Compare commits

..
5 Commits
Author SHA1 Message Date
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 87940da51a feat(prompt): 重新注册 bash,把"不要用 bash 搜索"写进工具描述
真实会话里模型仍习惯性先调 bash grep,被拒后才改用 grep 工具;系统提示词里已有 4 处
提醒,但模型下调用决定时看的是工具 schema 描述,而 Pi 内置 bash 描述对此只字未提。

- 新增 extensions/bash-override.ts:用 Pi 的 createBashToolDefinition 重新注册 bash,
  执行、输出结构与 codemode 说明完全沿用上游;shellPath/commandPrefix 按 ctx.cwd
  经 SettingsManager 惰性解析,与内置一致
- tools.jsonc 新增 descriptionAppend 字段,由 prompt-guard 注册时拼接到上游描述后
- bash 增加命令→工具对照规则(grep -n / grep -rl / find -name),并提示
  文件清单用 $(git ls-files) 传参、不要接 xargs(会触发权限审核)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 01:20:52 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2b21163a18 fix(pi-permission-system): bash 命令枚举下降进入控制流与赋值载荷
for/while/if/case/{ }/函数体此前只按整段文本匹配,
"for d in ...; do n=$(find ...); done" 中的 find 绕过了 find* deny(真实会话复现);
x=$(grep ...)、export X=$(find ...)、! grep ... 同样绕过。

控制流语句仍整段判定,并逐条枚举条件与体内命令;do/else/case 分支容器只下降不单独成单元;
其余语句兜底额外枚举其中的 $(…)。结果只会更严,hard guard 共享同一枚举。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 00:14:49 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> dae1a01f23 feat(prompt): 搜索引导改为意图→工具的正向指引,multi_grep 按上游保持关闭
真实会话中模型仍用 bash grep -o | sort -u 做抽取,被拒后直接放弃核对;
原提示词只说"不要",没告诉它抽取/计数、给程序传文件清单、过滤命令输出时该用什么。

- routing:按已知信息选 grep/find(多变体用 grep 正则 alternation),
  codemode + tools.grep 做聚合,bash 文件清单用 git ls-files,
  overflow_query 过滤输出,被拒后改用替代方案继续
- 权限策略:rg/grep/git grep/find/fd 的 deny 附带指向替代工具的 reason
- tools.jsonc:grep/find 新 snippet,补上 codegraph_explore snippet
- FFF 上游标注 multi_grep 有害:wrapper 删除 PI_FFF_MULTIGREP,清理全部引用
- JSONC 测试改用字符串感知的注释剥离(reason 中含 'src/**/*.ts')

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 00:14:49 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 833b9514d4 feat(update): update.sh 先同步 Pi 最新 release 再升级组合包
pi update <source> 只更新单个包,Pi 本体不会随之升级;改为先执行
pi update --self(版本比较由 Pi 自身完成,已是最新时跳过,失败记录后继续),
再升级组合包。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 23:26:43 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f9a7f743dd feat: 接入 Pi 内置 codemode,并修复全面审查发现的问题
- codemode:Tool Search 常驻(alwaysEnabled)、权限 allow(嵌套调用仍逐个过权限门)、
  tools.jsonc 登记使用规则;RTK 跳过 parentToolCallId 嵌套调用,避免丢失 bash
  structuredContent;Tool Search 目录过滤 hidden / codemode(script-only)exposure;
  扩展加载测试加载 builtin:codemode 并断言其常驻激活
- 搜索 key 不再经命令行参数传递:search-auth.mjs 改为 --set-stdin(先读校验再加锁),
  configure.sh 交互输入经管道、非交互用 --<provider> -
- CodeGraph 客户端监听子进程 stdin error,避免 EPIPE 未处理导致崩溃
- 移除 PI_TOOL_SEARCH/HERMES_MEMORY/RTK_CONFIG_PATH 环境变量入口,测试改用工厂 configPath 注入
- pi-subagents 固定 vitest / coverage-istanbul 4.1.10,修复 npm ls 版本冲突
- 补 pi-web-search / pi-worktree UPSTREAM.md 与 pi-minimal-footer AGENTS.md,
  files 白名单补齐并加测试;.codegraph/.gitignore 移出跟踪
- detect_kitty 优先识别官方安装目录

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 23:18:34 -07:00
65 changed files with 1292 additions and 267 deletions
-5
View File
@@ -1,5 +0,0 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+3 -2
View File
@@ -20,7 +20,8 @@
| `pi-notify/` | Kitty OSC 99 完成与权限确认通知,精确聚焦来源窗口 | | `pi-notify/` | Kitty OSC 99 完成与权限确认通知,精确聚焦来源窗口 |
| `pi-compaction/` | 压力触发的观察遮蔽(Pi context_edit)、阈值整段摘要、effective token、native compaction | | `pi-compaction/` | 压力触发的观察遮蔽(Pi context_edit)、阈值整段摘要、effective token、native compaction |
| `pi-overflow/` | 新工具结果统一截断:超阈值原文落盘 + 有界视图 + `overflow_query` | | `pi-overflow/` | 新工具结果统一截断:超阈值原文落盘 + 有界视图 + `overflow_query` |
| `extensions/fff-override.ts` | FFF `override` 模式接管 `find`/`grep`/`multi_grep` 与 `@` 补全 | | `extensions/fff-override.ts` | FFF `override` 模式接管 `find`/`grep` 与 `@` 补全,`multi_grep` 按上游保持关闭 |
| `extensions/bash-override.ts` | 用 Pi 的 `createBashToolDefinition` 重新注册 `bash`,仅替换模型可见描述(追加 `tools.jsonc` `descriptionAppend`);执行逻辑、输出结构、codemode 说明不变 |
| `extensions/codegraph.ts` | 原生 `codegraph_explore`,自有 stdio JSON-RPC 直连 `codegraph serve`(`codegraph-client.ts`) | | `extensions/codegraph.ts` | 原生 `codegraph_explore`,自有 stdio JSON-RPC 直连 `codegraph serve`(`codegraph-client.ts`) |
| `extensions/hashline.ts` | 部署 `config/pi-hashline-edit.json` 后加载 pi-hashline-edit 覆盖 `read`/`edit`(`hashline-config.ts`) | | `extensions/hashline.ts` | 部署 `config/pi-hashline-edit.json` 后加载 pi-hashline-edit 覆盖 `read`/`edit`(`hashline-config.ts`) |
| `extensions/permission-system.ts` | 直接加载仓库内 `pi-permission-system/src/index.ts` | | `extensions/permission-system.ts` | 直接加载仓库内 `pi-permission-system/src/index.ts` |
@@ -41,7 +42,7 @@
- 组合包不加载任何 MCP adapter/SDK:Exa 与 CodeGraph 均用自有直连客户端。 - 组合包不加载任何 MCP adapter/SDK:Exa 与 CodeGraph 均用自有直连客户端。
- 导入/同步上游:只从官方 Git 仓库的明确 tag/commit 取源码快照,禁止 npm tarball/缓存/`node_modules`/`dist/` 等预编译产物作导入源;保留上游 LICENSE、作者、测试与来源说明,移除嵌套 `.git`、`.pi` 状态、`node_modules` 与构建产物;同步前先核对本地改动再按明确范围移植,不得整体覆盖。各目录的维护约定见 `AGENTS.md`,每个自维护目录必须有中文 `UPSTREAM.md` 记录来源,上游原文文件不翻译不改写。 - 导入/同步上游:只从官方 Git 仓库的明确 tag/commit 取源码快照,禁止 npm tarball/缓存/`node_modules`/`dist/` 等预编译产物作导入源;保留上游 LICENSE、作者、测试与来源说明,移除嵌套 `.git`、`.pi` 状态、`node_modules` 与构建产物;同步前先核对本地改动再按明确范围移植,不得整体覆盖。各目录的维护约定见 `AGENTS.md`,每个自维护目录必须有中文 `UPSTREAM.md` 记录来源,上游原文文件不翻译不改写。
- 脚本:`install.sh`、`update.sh`、`uninstall.sh`、`configure.sh` 与 `scripts/lib.sh` 保持 POSIX `sh` 兼容并留在 `files` 中,行为变化同步 README;安装默认来源 `git:git@bitbucket.org:siakitem/my-pi.git`,可由 `PI_PACKAGE_SOURCE` 覆盖(update/uninstall 同规则);组合包安装失败立即停止,机器级依赖失败继续检查其余项并最终返回非零。 - 脚本:`install.sh`、`update.sh`、`uninstall.sh`、`configure.sh` 与 `scripts/lib.sh` 保持 POSIX `sh` 兼容并留在 `files` 中,行为变化同步 README;安装默认来源 `git:git@bitbucket.org:siakitem/my-pi.git`,可由 `PI_PACKAGE_SOURCE` 覆盖(update/uninstall 同规则);组合包安装失败立即停止,机器级依赖失败继续检查其余项并最终返回非零。
- 安装/升级纪律:机器级依赖与终端配置逐项询问、默认拒绝;`update.sh` 只升级已安装项、缺失项跳过,组件升级先比较本地与远端版本、一致不重装,成功升级后把 git 来源的受管 checkout `git reset --hard` 还原到已发布版本(本地路径来源不 reset、不 `git clean`);`pi update` 失败 fail-fast——Pi 1.3.0 会据 `.pi-update-incomplete` 标记在下次 update 自愈,不做预清理或人工恢复。`pi update` 只在有新版本时整体覆盖已安装副本(含 `config/`),不逐字段迁移本机改动;需长期保留的自定义必须改仓库 `config/` 源文件。`.zshrc` 只改 `# >>> my-pi:<id> >>>` 受管块并先建时间戳备份,不覆盖用户 `~/.p10k.zsh`;卸载不得删除用户记忆数据(`.pi/agent/pi-hermes-memory/`、`projects-memory/`、`.hippo/`)或共享机器级工具。 - 安装/升级纪律:机器级依赖与终端配置逐项询问、默认拒绝;`update.sh` 只升级已安装项、缺失项跳过,组件升级先比较本地与远端版本、一致不重装,成功升级后把 git 来源的受管 checkout `git reset --hard` 还原到已发布版本(本地路径来源不 reset、不 `git clean`);`update.sh` 先 `pi update --self` 同步 Pi 最新 release(版本比较由 Pi 自身完成,失败记录后继续),再升级组合包;组合包 `pi update` 失败 fail-fast——Pi 1.3.0 会据 `.pi-update-incomplete` 标记在下次 update 自愈,不做预清理或人工恢复。`pi update` 只在有新版本时整体覆盖已安装副本(含 `config/`),不逐字段迁移本机改动;需长期保留的自定义必须改仓库 `config/` 源文件。`.zshrc` 只改 `# >>> my-pi:<id> >>>` 受管块并先建时间戳备份,不覆盖用户 `~/.p10k.zsh`;卸载不得删除用户记忆数据(`.pi/agent/pi-hermes-memory/`、`projects-memory/`、`.hippo/`)或共享机器级工具。
- 未经明确要求不执行发布、提交、推送、运行安装/卸载脚本、安装到用户 Pi 目录等外部写操作;凭据不进入命令参数、日志、会话或仓库(`auth.json` 权限 `600`、不提交)。 - 未经明确要求不执行发布、提交、推送、运行安装/卸载脚本、安装到用户 Pi 目录等外部写操作;凭据不进入命令参数、日志、会话或仓库(`auth.json` 权限 `600`、不提交)。
- 优先在目标扩展目录内完成改动;保留原项目 LICENSE 与版权;不让一个扩展依赖另一个扩展的未公开内部实现。 - 优先在目标扩展目录内完成改动;保留原项目 LICENSE 与版权;不让一个扩展依赖另一个扩展的未公开内部实现。
+16 -9
View File
@@ -2,7 +2,7 @@
个人维护的 Pi 扩展组合包。安装一次即可加载并配置: 个人维护的 Pi 扩展组合包。安装一次即可加载并配置:
- `@ff-labs/pi-fff@0.10.5`:使用官方 `override` 模式接管 `find`、`grep` 和 `multi_grep`。 - `@ff-labs/pi-fff@0.10.5`:使用官方 `override` 模式接管 `find` 和 `grep`;上游默认关闭的 `multi_grep` 保持关闭。
- 本仓库维护的 `pi-rtk-optimizer`:只处理 Bash/read 等输出压缩,默认不改写命令,也不处理搜索工具结果。 - 本仓库维护的 `pi-rtk-optimizer`:只处理 Bash/read 等输出压缩,默认不改写命令,也不处理搜索工具结果。
- 本仓库自维护的 `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 serve --mcp` 子进程,由组合包自有 stdio JSON-RPC 客户端直连(无 MCP adapter/SDK);serve 的文件监听自动同步索引,无结果缓存;未初始化项目经确认后才 `codegraph init`,拒绝记入会话不再重复询问。 - `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性启动常驻 `codegraph serve --mcp` 子进程,由组合包自有 stdio JSON-RPC 客户端直连(无 MCP adapter/SDK);serve 的文件监听自动同步索引,无结果缓存;未初始化项目经确认后才 `codegraph init`,拒绝记入会话不再重复询问。
@@ -68,13 +68,14 @@ Powerlevel10k 主题/配置和各 Zsh 插件使用 `# >>> my-pi:<id> >>>` 到
./update.sh ./update.sh
``` ```
升级脚本先执行: 升级脚本先把 Pi 本体同步到最新发布版本,再升级组合包:
```bash ```bash
pi update --self
pi update git:git@bitbucket.org:siakitem/my-pi.git pi update git:git@bitbucket.org:siakitem/my-pi.git
``` ```
脚本不会在升级前破坏性预清理安装缓存:`pi update` 失败时立即停止后续升级——Pi 1.3.0 会在下一次 `pi update` 时依据自己的 `.pi-update-incomplete` 标记自动重新清理并恢复未完成的升级,重新运行 `update.sh` 即可,脚本不做人工恢复。 `pi update --self` 由 Pi 自己比较本地与最新 release,已是最新时直接跳过;它失败只记录为升级失败,不阻断组合包升级。脚本不会在升级前破坏性预清理安装缓存:组合包的 `pi update` 失败时立即停止后续升级——Pi 1.3.0 会在下一次 `pi update` 时依据自己的 `.pi-update-incomplete` 标记自动重新清理并恢复未完成的升级,重新运行 `update.sh` 即可,脚本不做人工恢复。
随后只处理已经安装的项目,未安装项直接跳过且不会询问安装:Kitty 及已启用的 随后只处理已经安装的项目,未安装项直接跳过且不会询问安装:Kitty 及已启用的
Solarized Dark、Oh My Zsh、Powerlevel10k、两个第三方 Zsh 插件、CodeGraph,以及由 Solarized Dark、Oh My Zsh、Powerlevel10k、两个第三方 Zsh 插件、CodeGraph,以及由
@@ -137,7 +138,7 @@ CodeGraph 的更新或遥测设置。
### 工具与搜索路由 ### 工具与搜索路由
`config/prompt/routing.jsonc` 定义跨工具路由规则,由 `extensions/system-prompt.ts` 在每轮开始前按当前激活工具渲染为 `<tool_routing>` 分区。代码结构、调用关系和待修改 symbol 优先使用 CodeGraph,定义、引用、类型和修改后诊断优先使用 LSP;仓库字面搜索先用 FFF `find` 缩小文件或目录范围,再在已收敛的路径中用 `grep`/`multi_grep` 获取行号。宽泛搜索命中大量结果、发生截断或达到上限时,应继续缩小路径、glob 或 pattern,而不是提高 limit 或输出全部结果。 `config/prompt/routing.jsonc` 定义跨工具路由规则,由 `extensions/system-prompt.ts` 在每轮开始前按当前激活工具渲染为 `<tool_routing>` 分区。代码结构、调用关系和待修改 symbol 优先使用 CodeGraph,定义、引用、类型和修改后诊断优先使用 LSP;本地字面搜索按已知信息直接选工具:确切标识符或文本用 `grep` 字面模式并限定 path/exclude,多个命名变体合并为一次 `grep` 正则 alternation,文件名、路径片段或功能概念用 FFF `find` 模糊全路径匹配,然后只 `read` 命中区域。对搜索结果做提取、去重、计数或聚合时,在 codemode 脚本中调用 `tools.grep` / `tools.find` 并用 JavaScript 后处理;bash 中不得运行 `grep`/`rg`/`find`/`fd`/`git grep`(策略 deny 的 reason 指明替代工具),程序需要文件清单时传 `$(git ls-files '<glob>')`。宽泛搜索命中大量结果、发生截断或达到上限时,应继续缩小路径、glob 或 pattern,而不是提高 limit 或输出全部结果。
在线搜索默认按任务只选一个服务:关键词不确定、新闻/近期话题或需要较多候选来源时优先 Tavily;官方/API 文档、版本信息、论文和高确定性事实确认优先 Exa;中文政策、政府公告、中文网页、站点限定和日期筛选优先 Keenable。只有来源质量或不确定性确实需要时才跨服务复核,避免默认把三套结果全部塞进上下文。 在线搜索默认按任务只选一个服务:关键词不确定、新闻/近期话题或需要较多候选来源时优先 Tavily;官方/API 文档、版本信息、论文和高确定性事实确认优先 Exa;中文政策、政府公告、中文网页、站点限定和日期筛选优先 Keenable。只有来源质量或不确定性确实需要时才跨服务复核,避免默认把三套结果全部塞进上下文。
@@ -167,7 +168,7 @@ CodeGraph 的更新或遥测设置。
本地源码直接使用 `@earendil-works/pi-coding-agent`、当前 `typebox`、`ModelRegistry.complete()` 与 `setActiveTools()`,不安装、别名映射或加载旧 `@mariozechner` runtime,也不使用 provider payload 改写、代理执行或隐藏 `sendMessage` 循环。 本地源码直接使用 `@earendil-works/pi-coding-agent`、当前 `typebox`、`ModelRegistry.complete()` 与 `setActiveTools()`,不安装、别名映射或加载旧 `@mariozechner` runtime,也不使用 provider payload 改写、代理执行或隐藏 `sendMessage` 循环。
新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics`、六个记忆工具、`context_tree_query`、`overflow_query` 和 `tool_search`;Windows 上额外把内置 `powershell` 与 `bash` 作为同类本地 Shell 核心工具固定启用,非 Windows 平台则从目录中过滤它。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、Chrome 导航/交互/调试、用户交互、CodeGraph/LSP、Tavily、Exa、Keenable、Memory 查询/维护与 Skill 管理。不可用、已固定或尚未授权而未注册的可选工具会自动从动态组中滤除。 新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics`、六个记忆工具、`context_tree_query`、`overflow_query`、Pi 内置 `codemode`(exposure `model-only`)和 `tool_search`;Windows 上额外把内置 `powershell` 与 `bash` 作为同类本地 Shell 核心工具固定启用,非 Windows 平台则从目录中过滤它。exposure 为 `hidden` 或 `codemode`(script-only)的工具一律不进入目录。`codemode` 在权限策略中为 `allow`:脚本本身无副作用,脚本发起的每个嵌套工具调用仍携带 `parentToolCallId` 经过同一权限门裁决;RTK 跳过所有嵌套调用,保证 bash 的 `structuredContent` 原样传给脚本。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、Chrome 导航/交互/调试、用户交互、CodeGraph/LSP、Tavily、Exa、Keenable、Memory 查询/维护与 Skill 管理。不可用、已固定或尚未授权而未注册的可选工具会自动从动态组中滤除。
标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具,并且当前调用不是精确加载已有分组时,才可使用当前已认证模型补充目录;精确已有分组始终立即按当前确定性目录激活,不等待无关未知工具的模型增强。模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `my-pi/tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会恢复预置目录,`/tool-search-status` 显示目录来源、容量、LRU 顺序和最近淘汰原因。 标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具,并且当前调用不是精确加载已有分组时,才可使用当前已认证模型补充目录;精确已有分组始终立即按当前确定性目录激活,不等待无关未知工具的模型增强。模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `my-pi/tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会恢复预置目录,`/tool-search-status` 显示目录来源、容量、LRU 顺序和最近淘汰原因。
@@ -187,7 +188,8 @@ Tool Search 的配置文件是已安装组合包内的 `config/tool-search.json`
"memory_remove", "memory_remove",
"skill_manage", "skill_manage",
"context_tree_query", "context_tree_query",
"overflow_query" "overflow_query",
"codemode"
], ],
"showToolSearchFooterStatus": false, "showToolSearchFooterStatus": false,
"maxActiveGroups": 5, "maxActiveGroups": 5,
@@ -209,9 +211,14 @@ Hashline 不负责压缩大文件;每行锚点本身还会增加少量 token
`extensions/fff-override.ts` 强制设置 FFF 官方的 `PI_FFF_MODE=override`(显式传入 `extensions/fff-override.ts` 强制设置 FFF 官方的 `PI_FFF_MODE=override`(显式传入
`--fff-mode` 仍按 FFF 官方优先级覆盖它)。FFF 替换 `--fff-mode` 仍按 FFF 官方优先级覆盖它)。FFF 替换
Pi 的 `find` 和 `grep`,并提供 `multi_grep`;RTK 对所有 `grep` 工具结果直接跳过, Pi 的 `find` 和 `grep`;`multi_grep` 上游标注为有害、默认关闭,wrapper 在加载前
删除 `PI_FFF_MULTIGREP` 环境变量,使其永远无法注册。RTK 对所有 `grep` 工具结果直接跳过,
也默认关闭 Bash 命令改写,因此不会把 `rg`、`grep`、`find` 或 `fd` 改写到 RTK。 也默认关闭 Bash 命令改写,因此不会把 `rg`、`grep`、`find` 或 `fd` 改写到 RTK。
`extensions/bash-override.ts` 用 Pi 的 `createBashToolDefinition` 重新注册内置 `bash`,
把「不要用 bash 搜索」的警告经 `tools.jsonc` 的 `descriptionAppend` 写进工具 schema,
让模型在调用时就看到替代工具;执行逻辑、输出结构和 codemode 的 `tools.bash` 说明完全沿用上游定义。
### 输出压缩归 RTK ### 输出压缩归 RTK
RTK 保留 Bash、read、build、test、lint 和 Git 输出压缩。默认 `read` 的有损压缩 RTK 保留 Bash、read、build、test、lint 和 Git 输出压缩。默认 `read` 的有损压缩
@@ -241,10 +248,10 @@ RTK 保留 Bash、read、build、test、lint 和 Git 输出压缩。默认 `read
### 在线搜索服务 ### 在线搜索服务
运行 `./configure.sh search` 可用隐藏输入依次配置 Tavily、Exa 和 Keenable;也可一次无交互配置: 运行 `./configure.sh search` 可用隐藏输入依次配置 Tavily、Exa 和 Keenable;key 不得出现在命令行参数中,非交互方式从标准输入读取(每次调用最多一个 provider):
```bash ```bash
./configure.sh search --tavily '...' --exa '...' --keenable '...' printf '%s' "$TAVILY_API_KEY" | ./configure.sh search --tavily -
``` ```
只传部分参数时保留其他服务的已有值。凭据写入 Pi 自己的 只传部分参数时保留其他服务的已有值。凭据写入 Pi 自己的
+8 -6
View File
@@ -37,7 +37,6 @@
"edit": "allow", "edit": "allow",
"grep": "allow", "grep": "allow",
"find": "allow", "find": "allow",
"multi_grep": "allow",
"ls": "allow", "ls": "allow",
"codegraph_explore": "allow", "codegraph_explore": "allow",
"lsp_diagnostics": "allow", "lsp_diagnostics": "allow",
@@ -82,6 +81,9 @@
// pi-overflow's inspector reads only the extension's own stored output // pi-overflow's inspector reads only the extension's own stored output
// directory (id-validated); pinned in Tool Search's alwaysEnabled set. // directory (id-validated); pinned in Tool Search's alwaysEnabled set.
"overflow_query": "allow", "overflow_query": "allow",
// A codemode script does nothing by itself: every nested tool call it makes
// runs through this same gate (Pi emits tool_call with parentToolCallId).
"codemode": "allow",
// Model-chosen URLs can exfiltrate data — same reason curl/wget ask. // Model-chosen URLs can exfiltrate data — same reason curl/wget ask.
"web_fetch": "ask", "web_fetch": "ask",
@@ -100,10 +102,10 @@
}, },
"bash": { "bash": {
"*": "allow", "*": "allow",
"rg*": "deny", "rg*": { "action": "deny", "reason": "Bash content search is disabled. Locally use the grep tool (pattern + path/exclude; regex alternation for several names); to extract/dedupe/count matches, call tools.grep in codemode and process in JS; to filter command output, use overflow_query on the stored result. Over SSH use ssh_grep." },
"grep*": "deny", "grep*": { "action": "deny", "reason": "Bash content search is disabled. Locally use the grep tool (pattern + path/exclude; regex alternation for several names); to extract/dedupe/count matches, call tools.grep in codemode and process in JS; to filter command output, use overflow_query on the stored result. Over SSH use ssh_grep." },
"find*": "deny", "find*": { "action": "deny", "reason": "Bash file search is disabled. Locally use the find tool (fuzzy path, or a glob in path such as 'src/**/*.ts'); to pass a file list to a program use $(git ls-files '<glob>'). Over SSH use ssh_find." },
"fd*": "deny", "fd*": { "action": "deny", "reason": "Bash file search is disabled. Locally use the find tool (fuzzy path, or a glob in path such as 'src/**/*.ts'); to pass a file list to a program use $(git ls-files '<glob>'). Over SSH use ssh_find." },
"git *": "ask", "git *": "ask",
"git status*": "allow", "git status*": "allow",
"git diff*": "allow", "git diff*": "allow",
@@ -111,7 +113,7 @@
"git show*": "allow", "git show*": "allow",
"git rev-parse*": "allow", "git rev-parse*": "allow",
"git ls-files*": "allow", "git ls-files*": "allow",
"git grep*": "deny", "git grep*": { "action": "deny", "reason": "Bash content search is disabled. Locally use the grep tool (pattern + path/exclude; regex alternation for several names); to extract/dedupe/count matches, call tools.grep in codemode and process in JS; to filter command output, use overflow_query on the stored result. Over SSH use ssh_grep." },
"git branch": "allow", "git branch": "allow",
"git branch --show-current*": "allow", "git branch --show-current*": "allow",
"git remote -v": "allow", "git remote -v": "allow",
+17 -5
View File
@@ -16,16 +16,25 @@
"text": "Use LSP for symbol definitions, references, hover/type information, and post-edit diagnostics when the language server supports the file." "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"], "whenAny": ["find", "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." "text": "Pick the local search tool by what you already know:\n - Exact identifier or text: grep with a literal pattern, constrained by path/exclude. Do not run find first just to locate it.\n - Several identifiers or naming variants: one grep with a regex alternation (e.g. user_id|userId|UserId) instead of several greps.\n - A filename, path fragment, or feature concept: find (fuzzy whole-path match; use a glob in path for exact names).\n - Then read only the matching region with offset/limit."
}, },
{ {
"whenAny": ["find", "grep", "multi_grep"], "whenAny": ["find", "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." "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."
}, },
{
"whenAll": ["codemode"],
"whenAny": ["find", "grep"],
"text": "To extract, deduplicate, count, or aggregate across search matches (what grep -o | sort -u | wc would do), call tools.grep / tools.find inside one codemode script and post-process the results in JavaScript."
},
{ {
"whenAny": ["bash"], "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." "text": "Use bash for builds, tests, git, package scripts, and other programs. Never run grep, rg, find, fd, or git grep in bash — standalone, piped, inside a loop, or in $(…). Search files with the grep/find tools; when a program needs a file list (wc, node, tsc), pass $(git ls-files '<glob>') as arguments rather than piping into xargs."
},
{
"whenAll": ["bash", "overflow_query"],
"text": "To filter a command's output, run it without | grep; if the result carries a [pi-overflow id=…] marker, search it with overflow_query instead of re-running."
}, },
{ {
"whenAny": ["ssh_connect"], "whenAny": ["ssh_connect"],
@@ -59,12 +68,15 @@
}, },
{ {
"whenAny": ["read"], "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." "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 source file merely to copy, compare, hash, count, or explore it. Reading project overview docs such as a README in full is fine."
}, },
{ {
"whenAll": ["read", "edit"], "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": "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": "When a tool call is denied by policy, use the alternative named in the denial and continue the subtask; do not drop it or present unverified facts as checked."
},
{ {
"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." "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."
} }
+1 -1
View File
@@ -14,7 +14,7 @@ You locate code and answer "where is X / what references Y" questions for the de
## Hard limits ## Hard limits
- Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp). - Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp).
- Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state. - Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state.
- Never search through bash (grep, rg, find, fd); use the find and grep tools. - Never search through bash (grep, rg, find, fd); use the find and grep tools. For several names use one grep with a regex alternation (a|b); when a program needs a file list, pass $(git ls-files '<glob>').
## Approach ## Approach
- Match the requested breadth: "quick" is one targeted lookup, "medium" covers a few related locations, "very thorough" searches multiple locations and naming conventions. - Match the requested breadth: "quick" is one targeted lookup, "medium" covers a few related locations, "very thorough" searches multiple locations and naming conventions.
+1 -1
View File
@@ -14,7 +14,7 @@ You explore the codebase and return an implementation plan to the delegating age
## Hard limits ## Hard limits
- Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp). - Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp).
- Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state. - Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state.
- Never search through bash (grep, rg, find, fd); use the find and grep tools. - Never search through bash (grep, rg, find, fd); use the find and grep tools. For several names use one grep with a regex alternation (a|b); when a program needs a file list, pass $(git ls-files '<glob>').
## Process ## Process
1. Restate the goal and the constraints you were given. Record anything ambiguous as an open question instead of guessing. 1. Restate the goal and the constraints you were given. Record anything ambiguous as an open question instead of guessing.
+26 -11
View File
@@ -2,23 +2,32 @@
// Bundle-authoritative tool guidance, owned by extensions/system-prompt.ts. // Bundle-authoritative tool guidance, owned by extensions/system-prompt.ts.
// //
// Every tool the bundle can expose must have an entry. Per field: // Every tool the bundle can expose must have an entry. Per field:
// "snippet" — replaces the tool's one-line <tools> entry (null hides it) // "snippet" — replaces the tool's one-line <tools> entry (null hides it)
// "guidelines" — replaces the tool's <rules> lines // "guidelines" — replaces the tool's <rules> lines
// "description" — replaces the tool description at registration; only // "description" — replaces the tool description at registration
// applied to third-party tools loaded through a bundle // "descriptionAppend" — appended to the description at registration (on top
// adapter (in-repo tools edit their own source instead) // of "description" when both are set)
// "description"/"descriptionAppend" only apply to tools registered through a
// bundle adapter (prompt-guard), which includes the bash override and the
// third-party wrappers; other in-repo tools edit their own source instead.
// An absent field keeps the upstream text. "inherit": true means the // An absent field keeps the upstream text. "inherit": true means the
// upstream text was reviewed against this bundle and is kept as-is. // upstream text was reviewed against this bundle and is kept as-is.
"version": 1, "version": 1,
"tools": { "tools": {
"bash": { "bash": {
"snippet": "Run shell commands for builds, tests, git, package scripts, and other programs; search files with the find/grep tools, not bash" "snippet": "Run shell commands for builds, tests, git, package scripts, and other programs; search files with the find/grep tools, not bash",
"descriptionAppend": "Not for searching files: use the grep tool for file contents and the find tool for paths (e.g. `grep -n X src/*.ts` → grep {pattern: \"X\", path: \"src/*.ts\"}); grep, rg, find, fd, and git grep are denied here. When a program needs many files, pass $(git ls-files '<glob>') as arguments instead of piping into xargs, which triggers a permission review.",
"guidelines": [
"You can inspect PI_* environment variables for current model and session details.",
"Search commands are denied in bash; call the tools instead: `grep -n X src/*.ts` → grep {pattern: \"X\", path: \"src/*.ts\"}; `grep -rl X src` → grep {pattern: \"X\", path: \"src/\"} and take the file names; `find . -name '*.ts'` → find {pattern: \"\", path: \"**/*.ts\"}.",
"When a program needs many files (wc, node, tsc), pass $(git ls-files '<glob>') as arguments; piping into xargs triggers a permission review."
]
}, },
"read": { "inherit": true }, "read": { "inherit": true },
"edit": { "edit": {
"guidelines": [ "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.", "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.", "find/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.", "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.", "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." "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."
@@ -28,12 +37,18 @@
"write": { "inherit": true }, "write": { "inherit": true },
"ls": { "inherit": true }, "ls": { "inherit": true },
"powershell": { "inherit": true }, "powershell": { "inherit": true },
"grep": { "inherit": true }, "grep": { "snippet": "Search file contents via the FFF index (git-aware, smart-case, frecency-ranked, regex or literal); replaces bash grep/rg" },
"find": { "inherit": true }, "find": { "snippet": "Find files by fuzzy path or glob via the FFF index; replaces bash find/fd" },
"multi_grep": { "inherit": true },
"tool_search": { "inherit": true }, "tool_search": { "inherit": true },
"codemode": {
"guidelines": [
"Use codemode to batch two or more independent tool calls (Promise.allSettled), chain dependent lookups, or filter large output down to what you need; call a single tool directly instead of wrapping it in a script.",
"Call edit, write, ssh_connect, ssh_cd, ask_user_question, and Agent directly, never from a codemode script.",
"Inside scripts, search with tools.grep or tools.find and do extraction, dedup, and counting in JavaScript; never run grep, find, rg, or fd through tools.bash."
]
},
"codegraph_explore": { "inherit": true }, "codegraph_explore": { "snippet": "Explore code structure from the CodeGraph index: symbols, relationships, call flows, and their source" },
"lsp_diagnostics": { "inherit": true }, "lsp_diagnostics": { "inherit": true },
"lsp_definition": { "inherit": true }, "lsp_definition": { "inherit": true },
"lsp_references": { "inherit": true }, "lsp_references": { "inherit": true },
+2 -1
View File
@@ -9,7 +9,8 @@
"memory_remove", "memory_remove",
"skill_manage", "skill_manage",
"context_tree_query", "context_tree_query",
"overflow_query" "overflow_query",
"codemode"
], ],
"showToolSearchFooterStatus": false, "showToolSearchFooterStatus": false,
"maxActiveGroups": 5, "maxActiveGroups": 5,
+21 -13
View File
@@ -24,24 +24,25 @@ search_usage() {
cat <<'EOF' cat <<'EOF'
用法: 用法:
./configure.sh search ./configure.sh search
./configure.sh search --tavily KEY --exa KEY --keenable KEY ./configure.sh search --tavily - 从标准输入读取 key
./configure.sh search --migrate [search.env 路径] ./configure.sh search --migrate [search.env 路径]
./configure.sh search --list ./configure.sh search --list
选项: 选项:
--tavily KEY 配置 tavily api_key 凭据 --tavily - 从标准输入读取 tavily api_key 凭据
--exa KEY 配置 exa api_key 凭据 --exa - 从标准输入读取 exa api_key 凭据
--keenable KEY 配置 keenable api_key 凭据 --keenable - 从标准输入读取 keenable api_key 凭据
--migrate 从旧 search.env 导入缺失的 key --migrate 从旧 search.env 导入缺失的 key
--list 显示各 provider 配置状态(不输出 key) --list 显示各 provider 配置状态(不输出 key)
--auth FILE 覆盖 auth.json 路径 --auth FILE 覆盖 auth.json 路径
-h, --help 显示帮助 -h, --help 显示帮助
不传 key 参数时进入隐藏输入的交互模式;传入任意 key 参数时不交互, key 不得出现在命令行参数中:非交互方式用 `--<provider> -` 从标准输入
未指定的 provider 保留已有值。凭据写入 Pi 的 auth.json 读取,每次调用最多一个 provider 可经标准输入配置;不传参数时进入
($PI_CODING_AGENT_DIR/auth.json 或 ~/.pi/agent/auth.json,权限 600)。 隐藏输入的交互模式,未指定的 provider 保留已有值。凭据写入 Pi 的
key 支持 auth.json 原生语义:字面量、$ENV_VAR / ${ENV_VAR} 插值、 auth.json($PI_CODING_AGENT_DIR/auth.json 或 ~/.pi/agent/auth.json,
以及 !command(执行命令取 stdout,如 "!op read op://Vault/Tavily/key")。 权限 600)。key 支持 auth.json 原生语义:字面量、$ENV_VAR / ${ENV_VAR}
插值、以及 !command(执行命令取 stdout,如 "!op read op://Vault/Tavily/key")。
EOF EOF
} }
@@ -52,6 +53,7 @@ configure_search() {
SEARCH_AUTH_FILE= SEARCH_AUTH_FILE=
search_mode=interactive search_mode=interactive
search_did_work=0 search_did_work=0
search_stdin_provider=
run_search_auth() { run_search_auth() {
if [ -n "$SEARCH_AUTH_FILE" ]; then if [ -n "$SEARCH_AUTH_FILE" ]; then
@@ -69,7 +71,7 @@ configure_search() {
prompt_label=$1 prompt_label=$1
prompt_configured=$2 prompt_configured=$2
if [ ! -t 0 ]; then if [ ! -t 0 ]; then
warn "交互模式需要终端;请改用 --tavily/--exa/--keenable 参数。" warn "交互模式需要终端;请改用 '--<provider> -' 从标准输入提供 key。"
return 2 return 2
fi fi
if [ "$prompt_configured" -eq 1 ]; then if [ "$prompt_configured" -eq 1 ]; then
@@ -88,9 +90,15 @@ configure_search() {
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case $1 in case $1 in
--tavily|--exa|--keenable) --tavily|--exa|--keenable)
[ "$#" -ge 2 ] || { warn "$1 缺少 KEY"; exit 2; } [ "$#" -ge 2 ] || { warn "$1 缺少 KEY 或 -"; exit 2; }
if [ "$2" != "-" ]; then
warn "$1 不接受明文 key:key 不得出现在命令行参数中;请用交互模式或 '$1 -' 从标准输入提供。"
exit 2
fi
[ -z "$search_stdin_provider" ] || { warn "每次调用最多一个 provider 可经标准输入读取 key。"; exit 2; }
search_stdin_provider=${1#--}
search_mode=noninteractive; search_did_work=1 search_mode=noninteractive; search_did_work=1
run_search_auth --set "${1#--}=$2" || exit $? run_search_auth --set-stdin "$search_stdin_provider" || exit $?
shift 2 ;; shift 2 ;;
--migrate) --migrate)
search_mode=noninteractive; search_did_work=1 search_mode=noninteractive; search_did_work=1
@@ -121,7 +129,7 @@ configure_search() {
if search_has_key "$provider"; then configured=1; else configured=0; fi if search_has_key "$provider"; then configured=1; else configured=0; fi
value=$(search_prompt_key "$label" "$configured") || exit $? value=$(search_prompt_key "$label" "$configured") || exit $?
if [ -n "$value" ]; then if [ -n "$value" ]; then
run_search_auth --set "$provider=$value" || exit $? printf '%s' "$value" | run_search_auth --set-stdin "$provider" || exit $?
search_did_work=1 search_did_work=1
fi fi
done done
+2 -1
View File
@@ -24,7 +24,8 @@
## 各 wrapper 约定 ## 各 wrapper 约定
- `fff-override.ts`:强制 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 `@` 补全;FFF 独占本地字面搜索,远端服务器搜索由 `ssh_find` / `ssh_grep` 负责,RTK 不得接管搜索工具(见 `pi-rtk-optimizer/AGENTS.md`)。显式 CLI flag 仍遵循 FFF 官方优先级。 - `fff-override.ts`:强制 FFF 官方 `override` 模式,统一接管 `find`、`grep` 和 `@` 补全;`multi_grep` 按上游保持关闭(加载前删除 `PI_FFF_MULTIGREP` 环境变量)。FFF 独占本地字面搜索,远端服务器搜索由 `ssh_find` / `ssh_grep` 负责,RTK 不得接管搜索工具(见 `pi-rtk-optimizer/AGENTS.md`)。显式 CLI flag 仍遵循 FFF 官方优先级。
- `bash-override.ts`:用 `createBashToolDefinition` 重新注册内置 `bash`,目的只是让 `tools.jsonc` 的 `descriptionAppend` 落到模型可见描述里(prompt-guard 在注册时拼接)。ExtensionContext 不暴露 session settings,因此 `shellPath`/`commandPrefix` 在首次 execute 时按 `ctx.cwd` 经 `SettingsManager.create(ctx.cwd, getAgentDir())` 惰性解析并按 cwd 缓存,metadata 来自 `process.cwd()` 的定义;执行、输出结构与 codemode 说明完全委托上游定义,不得改动上游执行逻辑,也不得用 prepareLoadout 或 provider-payload 改写实现同样效果。
- `codegraph.ts` + `codegraph-client.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性常驻 `codegraph serve --mcp -p <root>` 子进程,经组合包自有 stdio 逐行 JSON-RPC 客户端直连(不经 shell、不带 pi-mcp-adapter 或 MCP SDK);serve 进程内置文件监听自动同步索引,无结果缓存。子进程 10 分钟无调用自动关闭、会话结束统一关闭;调用中死亡换新客户端透明重试一次;其启动的上游 daemon(`.codegraph/daemon.sock`)退出后可多驻留 ≤5 分钟,属上游行为,组合包不管理。 - `codegraph.ts` + `codegraph-client.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性常驻 `codegraph serve --mcp -p <root>` 子进程,经组合包自有 stdio 逐行 JSON-RPC 客户端直连(不经 shell、不带 pi-mcp-adapter 或 MCP SDK);serve 进程内置文件监听自动同步索引,无结果缓存。子进程 10 分钟无调用自动关闭、会话结束统一关闭;调用中死亡换新客户端透明重试一次;其启动的上游 daemon(`.codegraph/daemon.sock`)退出后可多驻留 ≤5 分钟,属上游行为,组合包不管理。
- CodeGraph 是机器级依赖,`install.sh`/`update.sh` 经官方安装器管理 `codegraph` CLI,组合包不内置其运行时。安装、升级和普通启动流程不得执行 `codegraph init`;只有 `codegraph_explore` 在未初始化项目上被调用、且用户在 `ctx.ui.confirm` 中明确确认后才可 `codegraph init` 当前目录,拒绝记入会话级 declined 不再重复询问;init 失败必须如实报告并让模型回退内置工具;不得改动 CodeGraph 更新或遥测设置。CLI 缺失、init 被拒或服务调用失败都不得抛出,必须以文本结果打回并引导模型改用 `find`/`grep`/`read`/LSP。已移除的 `plugin-init.ts`(原 `/plugin_init`)职责已并入本文件的惰性确认,不得恢复。 - CodeGraph 是机器级依赖,`install.sh`/`update.sh` 经官方安装器管理 `codegraph` CLI,组合包不内置其运行时。安装、升级和普通启动流程不得执行 `codegraph init`;只有 `codegraph_explore` 在未初始化项目上被调用、且用户在 `ctx.ui.confirm` 中明确确认后才可 `codegraph init` 当前目录,拒绝记入会话级 declined 不再重复询问;init 失败必须如实报告并让模型回退内置工具;不得改动 CodeGraph 更新或遥测设置。CLI 缺失、init 被拒或服务调用失败都不得抛出,必须以文本结果打回并引导模型改用 `find`/`grep`/`read`/LSP。已移除的 `plugin-init.ts`(原 `/plugin_init`)职责已并入本文件的惰性确认,不得恢复。
- `hashline.ts` + `hashline-config.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`;默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 与 `replaceText` 均关闭。它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。 - `hashline.ts` + `hashline-config.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`;默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 与 `replaceText` 均关闭。它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
+38
View File
@@ -0,0 +1,38 @@
import {
createBashToolDefinition,
type ExtensionAPI,
getAgentDir,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
import { guardExtensionApi } from "./prompt-guard.ts";
type BashDefinition = ReturnType<typeof createBashToolDefinition>;
/** Re-register Pi's bash so tools.jsonc can append to its model-facing description; execution stays upstream. */
export default function bashOverrideExtension(pi: ExtensionAPI): void {
const sessionDefinitions = new Map<string, BashDefinition>();
const sessionDefinition = (cwd: string): BashDefinition => {
let definition = sessionDefinitions.get(cwd);
if (definition === undefined) {
const settings = SettingsManager.create(cwd, getAgentDir());
definition = createBashToolDefinition(cwd, {
shellPath: settings.getShellPath(),
commandPrefix: settings.getShellCommandPrefix(),
});
sessionDefinitions.set(cwd, definition);
}
return definition;
};
guardExtensionApi(pi).registerTool({
...sessionDefinition(process.cwd()),
execute: (toolCallId, params, signal, onUpdate, ctx) =>
sessionDefinition(ctx?.cwd ?? process.cwd()).execute(
toolCallId,
params,
signal,
onUpdate,
ctx,
),
});
}
+2
View File
@@ -222,6 +222,8 @@ export class CodegraphRpcClient {
// ── internals ──────────────────────────────────────────────────────────── // ── internals ────────────────────────────────────────────────────────────
private attach(child: ChildProcess): void { private attach(child: ChildProcess): void {
// EPIPE on a write after the child died is reported via exit/close.
child.stdin?.on("error", () => {});
child.stdout?.setEncoding("utf8"); child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => { child.stdout?.on("data", (chunk: string) => {
this.stdoutBuffer += chunk; this.stdoutBuffer += chunk;
+1
View File
@@ -5,5 +5,6 @@ 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";
delete process.env.PI_FFF_MULTIGREP;
fffExtension(guardExtensionApi(pi)); fffExtension(guardExtensionApi(pi));
} }
+28 -7
View File
@@ -1,11 +1,28 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { loadToolCatalog, type ToolCatalog } from "./system-prompt-config.ts"; import {
loadToolCatalog,
type ToolCatalog,
type ToolCatalogEntry,
} from "./system-prompt-config.ts";
type AnyMessage = { role?: string; content?: unknown } & Record<string, unknown>; type AnyMessage = { role?: string; content?: unknown } & Record<string, unknown>;
type AnyHandler = (event: never, ctx: never) => unknown; type AnyHandler = (event: never, ctx: never) => unknown;
/**
* The description a tool registers with under its catalog entry: a `description`
* override replaces the upstream text, a `descriptionAppend` is joined onto
* whichever base wins (override first, upstream otherwise) with a blank line.
*/
export function catalogDescription(description: string, entry: ToolCatalogEntry): string {
if ("inherit" in entry) return description;
const base = typeof entry.description === "string" ? entry.description : description;
return typeof entry.descriptionAppend === "string"
? `${base.trimEnd()}\n\n${entry.descriptionAppend}`
: base;
}
export interface PromptGuardOptions { export interface PromptGuardOptions {
/** /**
* Post-process the messages a wrapped `context` handler leaves behind. * Post-process the messages a wrapped `context` handler leaves behind.
@@ -19,9 +36,11 @@ export interface PromptGuardOptions {
* Proxy an `ExtensionAPI` for third-party extensions so they cannot bypass the * Proxy an `ExtensionAPI` for third-party extensions so they cannot bypass the
* bundle's single system-prompt owner (`extensions/system-prompt.ts`): * bundle's single system-prompt owner (`extensions/system-prompt.ts`):
* *
* - `registerTool` applies the tool's `description` override from * - `registerTool` applies the tool's `description` override and
* `config/prompt/tools.jsonc` when the catalog carries one (lazily loaded; * `descriptionAppend` suffix from `config/prompt/tools.jsonc` when the
* a catalog error keeps the upstream description). * catalog carries them (lazily loaded; a catalog error keeps the upstream
* description). An append composes onto the override when both are set, or
* onto the upstream description when only the append is set.
* - `before_agent_start` handlers keep every returned field except * - `before_agent_start` handlers keep every returned field except
* `systemPrompt`, which is dropped. * `systemPrompt`, which is dropped.
* - `context` handlers are post-processed through `opts.contextFilter`. * - `context` handlers are post-processed through `opts.contextFilter`.
@@ -42,10 +61,12 @@ export function guardExtensionApi(pi: ExtensionAPI, opts: PromptGuardOptions = {
const registerTool: ExtensionAPI["registerTool"] = (tool) => { const registerTool: ExtensionAPI["registerTool"] = (tool) => {
const entry = catalog()?.tools[tool.name]; const entry = catalog()?.tools[tool.name];
if (entry !== undefined && !("inherit" in entry) && typeof entry.description === "string") { const description =
pi.registerTool({ ...tool, description: entry.description }); entry === undefined ? tool.description : catalogDescription(tool.description, entry);
} else { if (description === tool.description) {
pi.registerTool(tool); pi.registerTool(tool);
} else {
pi.registerTool({ ...tool, description });
} }
}; };
+15 -7
View File
@@ -10,7 +10,12 @@ import { fileURLToPath } from "node:url";
/** Entry in `config/prompt/tools.jsonc`. */ /** Entry in `config/prompt/tools.jsonc`. */
export type ToolCatalogEntry = export type ToolCatalogEntry =
| { inherit: true } | { inherit: true }
| { snippet?: string | null; guidelines?: string[]; description?: string }; | {
snippet?: string | null;
guidelines?: string[];
description?: string;
descriptionAppend?: string;
};
export interface ToolCatalog { export interface ToolCatalog {
version: 1; version: 1;
@@ -152,7 +157,7 @@ function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === "string"); return Array.isArray(value) && value.every((entry) => typeof entry === "string");
} }
const TOOL_ENTRY_KEYS = new Set(["inherit", "snippet", "guidelines", "description"]); const TOOL_ENTRY_KEYS = new Set(["inherit", "snippet", "guidelines", "description", "descriptionAppend"]);
function validateToolEntry(name: string, raw: unknown): ToolCatalogEntry { function validateToolEntry(name: string, raw: unknown): ToolCatalogEntry {
if (!isPlainObject(raw)) { if (!isPlainObject(raw)) {
@@ -170,9 +175,9 @@ function validateToolEntry(name: string, raw: unknown): ToolCatalogEntry {
return { inherit: true }; return { inherit: true };
} }
const hasField = const hasField =
"snippet" in raw || "guidelines" in raw || "description" in raw; "snippet" in raw || "guidelines" in raw || "description" in raw || "descriptionAppend" in raw;
if (!hasField) { if (!hasField) {
fail("tools.jsonc", `tool "${name}" needs "inherit": true or at least one of snippet/guidelines/description`); fail("tools.jsonc", `tool "${name}" needs "inherit": true or at least one of snippet/guidelines/description/descriptionAppend`);
} }
if ("snippet" in raw && raw.snippet !== null && typeof raw.snippet !== "string") { if ("snippet" in raw && raw.snippet !== null && typeof raw.snippet !== "string") {
fail("tools.jsonc", `tool "${name}".snippet must be a string or null`); fail("tools.jsonc", `tool "${name}".snippet must be a string or null`);
@@ -183,10 +188,13 @@ function validateToolEntry(name: string, raw: unknown): ToolCatalogEntry {
if ("description" in raw && typeof raw.description !== "string") { if ("description" in raw && typeof raw.description !== "string") {
fail("tools.jsonc", `tool "${name}".description must be a string`); fail("tools.jsonc", `tool "${name}".description must be a string`);
} }
if ("descriptionAppend" in raw && typeof raw.descriptionAppend !== "string") {
fail("tools.jsonc", `tool "${name}".descriptionAppend must be a string`);
}
return raw as ToolCatalogEntry; return raw as ToolCatalogEntry;
} }
function validateToolCatalog(raw: unknown): ToolCatalog { export function validateToolCatalog(raw: unknown): ToolCatalog {
if (!isPlainObject(raw) || raw.version !== 1 || !isPlainObject(raw.tools)) { if (!isPlainObject(raw) || raw.version !== 1 || !isPlainObject(raw.tools)) {
fail("tools.jsonc", 'expected { "version": 1, "tools": { ... } }'); fail("tools.jsonc", 'expected { "version": 1, "tools": { ... } }');
} }
@@ -321,8 +329,8 @@ export function isSubagentCustomPrompt(customPrompt: unknown): boolean {
* Apply the tool catalog to the mutable prompt options: a `snippet` string * Apply the tool catalog to the mutable prompt options: a `snippet` string
* replaces the tool's `<tools>` line (`null` hides it), `guidelines` replaces * replaces the tool's `<tools>` line (`null` hides it), `guidelines` replaces
* the tool's `<rules>` lines; `{inherit:true}` entries are left untouched. * the tool's `<rules>` lines; `{inherit:true}` entries are left untouched.
* `description` is not applied here — it is a registration-time concern handled * `description` and `descriptionAppend` are not applied here — they are
* by `extensions/prompt-guard.ts`. * registration-time concerns handled by `extensions/prompt-guard.ts`.
* *
* Returns the selected tool names that have no catalog entry at all. * Returns the selected tool names that have no catalog entry at all.
*/ */
+27 -27
View File
@@ -2763,31 +2763,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitest/coverage-istanbul": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.12.tgz",
"integrity": "sha512-f98TiCqF7IriBQEpm6usi56wyD4G1cRYabI15f3Jm7R355q9tlwqJ0j1oPf+tWZ7D0LPnCh3dU70duRt10erHg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.29.0",
"@istanbuljs/schema": "^0.1.3",
"@jridgewell/gen-mapping": "^0.3.13",
"@jridgewell/trace-mapping": "0.3.31",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "4.1.12"
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.1.10", "version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
@@ -5672,9 +5647,9 @@
"@earendil-works/pi-coding-agent": "latest", "@earendil-works/pi-coding-agent": "latest",
"@earendil-works/pi-tui": "latest", "@earendil-works/pi-tui": "latest",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@vitest/coverage-istanbul": "^4.1.10", "@vitest/coverage-istanbul": "4.1.10",
"typescript": "^6.0.0", "typescript": "^6.0.0",
"vitest": "^4.0.18" "vitest": "4.1.10"
}, },
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-ai": ">=0.81.0", "@earendil-works/pi-ai": ">=0.81.0",
@@ -5703,6 +5678,31 @@
"undici-types": ">=7.24.0 <7.24.7" "undici-types": ">=7.24.0 <7.24.7"
} }
}, },
"pi-subagents/node_modules/@vitest/coverage-istanbul": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.10.tgz",
"integrity": "sha512-AyNJ5pQRFqCX7pwB9PSTmoVKPaZ4H5IEVJfJsT+q1DYkXvZMEFYgJlyk5sfStmt9rVYRyYYRRsuBeImCOc39ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.29.0",
"@istanbuljs/schema": "^0.1.3",
"@jridgewell/gen-mapping": "^0.3.13",
"@jridgewell/trace-mapping": "0.3.31",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "4.1.10"
}
},
"pi-subagents/node_modules/nanoid": { "pi-subagents/node_modules/nanoid": {
"version": "5.1.16", "version": "5.1.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
+4
View File
@@ -94,11 +94,13 @@
"pi-web-search/package.json", "pi-web-search/package.json",
"pi-web-search/README.md", "pi-web-search/README.md",
"pi-web-search/LICENSE", "pi-web-search/LICENSE",
"pi-web-search/UPSTREAM.md",
"pi-worktree/index.ts", "pi-worktree/index.ts",
"pi-worktree/src", "pi-worktree/src",
"pi-worktree/package.json", "pi-worktree/package.json",
"pi-worktree/README.md", "pi-worktree/README.md",
"pi-worktree/LICENSE", "pi-worktree/LICENSE",
"pi-worktree/UPSTREAM.md",
"pi-permission-system/LICENSES", "pi-permission-system/LICENSES",
"pi-permission-system/docs/auto-review", "pi-permission-system/docs/auto-review",
"pi-permission-system/src", "pi-permission-system/src",
@@ -107,6 +109,7 @@
"pi-permission-system/README.md", "pi-permission-system/README.md",
"pi-permission-system/CHANGELOG.md", "pi-permission-system/CHANGELOG.md",
"pi-permission-system/LICENSE", "pi-permission-system/LICENSE",
"pi-permission-system/UPSTREAM.md",
"config", "config",
"scripts", "scripts",
"install.sh", "install.sh",
@@ -120,6 +123,7 @@
"extensions": [ "extensions": [
"./pi-overflow/capture.ts", "./pi-overflow/capture.ts",
"./extensions/fff-override.ts", "./extensions/fff-override.ts",
"./extensions/bash-override.ts",
"./pi-web-search/index.ts", "./pi-web-search/index.ts",
"./extensions/codegraph.ts", "./extensions/codegraph.ts",
"./pi-rtk-optimizer/index.ts", "./pi-rtk-optimizer/index.ts",
@@ -24,10 +24,10 @@ const emit = async (event, data = {}) => {
try { try {
process.env.PI_CODING_AGENT_DIR = root; process.env.PI_CODING_AGENT_DIR = root;
process.env.PI_HERMES_MEMORY_CONFIG_PATH = join(root, "hermes-memory-config.json"); const configPath = join(root, "hermes-memory-config.json");
await mkdir(globalDir); await mkdir(globalDir);
await mkdir(ctx.cwd); await mkdir(ctx.cwd);
await writeFile(process.env.PI_HERMES_MEMORY_CONFIG_PATH, JSON.stringify({ await writeFile(configPath, JSON.stringify({
lazyInitialization: lazy, memoryMode: "policy-only", lazyInitialization: lazy, memoryMode: "policy-only",
reviewEnabled: false, correctionDetection: false, reviewEnabled: false, correctionDetection: false,
flushOnShutdown: false, flushOnCompact: false, flushOnShutdown: false, flushOnCompact: false,
@@ -40,7 +40,10 @@ try {
} }
const started = performance.now(); const started = performance.now();
const { default: register } = await import(pathToFileURL(resolve(entry)).href); const module = await import(pathToFileURL(resolve(entry)).href);
const register = module.createHermesMemoryExtension
? module.createHermesMemoryExtension({ configPath })
: module.default;
const imported = performance.now(); const imported = performance.now();
register({ register({
on(event, handler) { on(event, handler) {
+1 -10
View File
@@ -89,16 +89,7 @@ export const DEFAULT_CONFIG_PATH = fileURLToPath(
new URL("../../config/pi-hermes-memory.json", import.meta.url), new URL("../../config/pi-hermes-memory.json", import.meta.url),
); );
/** export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
* Resolve the effective config path. `PI_HERMES_MEMORY_CONFIG_PATH` relocates
* the file (tests point it at a temp file; it is never a persisted config
* value).
*/
export function resolveConfigPath(): string {
return process.env.PI_HERMES_MEMORY_CONFIG_PATH ?? DEFAULT_CONFIG_PATH;
}
export function loadConfig(configPath = resolveConfigPath()): MemoryConfig {
try { try {
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
const raw = fs.readFileSync(configPath, "utf-8"); const raw = fs.readFileSync(configPath, "utf-8");
+16 -3
View File
@@ -52,7 +52,7 @@ import { registerPreviewContextCommand } from "./handlers/preview-context.js";
import { registerStandingPinCommand } from "./handlers/standing-pin.js"; import { registerStandingPinCommand } from "./handlers/standing-pin.js";
import { StandingInstructions } from "./store/standing-instructions.js"; import { StandingInstructions } from "./store/standing-instructions.js";
import { STANDING_FILE } from "./constants.js"; import { STANDING_FILE } from "./constants.js";
import { loadConfig } from "./config.js"; import { DEFAULT_CONFIG_PATH, loadConfig } from "./config.js";
import { shouldWarnAutoConsolidationFailure } from "./auto-consolidation-warning.js"; import { shouldWarnAutoConsolidationFailure } from "./auto-consolidation-warning.js";
import { detectProject, detectProjectSkills } from "./project.js"; import { detectProject, detectProjectSkills } from "./project.js";
import { buildPromptContext } from "./prompt-context.js"; import { buildPromptContext } from "./prompt-context.js";
@@ -90,8 +90,12 @@ export function registerProjectSkillDiscoveryHandler(
}); });
} }
export default function (pi: ExtensionAPI) { export interface HermesMemoryExtensionOptions {
const config = loadConfig(); configPath?: string;
}
function hermesMemoryExtension(pi: ExtensionAPI, configPath: string) {
const config = loadConfig(configPath);
const lazy = config.lazyInitialization === true && config.memoryMode === "policy-only"; const lazy = config.lazyInitialization === true && config.memoryMode === "policy-only";
let sessionContext: ExtensionContext | undefined; let sessionContext: ExtensionContext | undefined;
@@ -480,3 +484,12 @@ export default function (pi: ExtensionAPI) {
} }
}); });
} }
export function createHermesMemoryExtension(
options?: HermesMemoryExtensionOptions,
): (pi: ExtensionAPI) => void {
const configPath = options?.configPath ?? DEFAULT_CONFIG_PATH;
return (pi) => hermesMemoryExtension(pi, configPath);
}
export default createHermesMemoryExtension();
+4 -6
View File
@@ -8,10 +8,10 @@ import { after, beforeEach, afterEach, describe, it } from "node:test";
// Bind the entry point to a disposable agent root, never the user's memory. // Bind the entry point to a disposable agent root, never the user's memory.
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-lazy-startup-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-lazy-startup-"));
const previousRoot = process.env.PI_CODING_AGENT_DIR; const previousRoot = process.env.PI_CODING_AGENT_DIR;
const previousConfigPath = process.env.PI_HERMES_MEMORY_CONFIG_PATH;
process.env.PI_CODING_AGENT_DIR = root; process.env.PI_CODING_AGENT_DIR = root;
process.env.PI_HERMES_MEMORY_CONFIG_PATH = path.join(root, "hermes-memory-config.json"); const configPath = path.join(root, "hermes-memory-config.json");
const { default: registerExtension } = await import("../src/index.js"); const { createHermesMemoryExtension } = await import("../src/index.js");
const registerExtension = createHermesMemoryExtension({ configPath });
const { MemoryStore } = await import("../src/store/memory-store.js"); const { MemoryStore } = await import("../src/store/memory-store.js");
const { DatabaseManager } = await import("../src/store/db.js"); const { DatabaseManager } = await import("../src/store/db.js");
const globalDir = path.join(root, "pi-hermes-memory"); const globalDir = path.join(root, "pi-hermes-memory");
@@ -24,7 +24,7 @@ let notifications: string[];
let ctx: any; let ctx: any;
async function configure(overrides: Record<string, unknown> = {}) { async function configure(overrides: Record<string, unknown> = {}) {
await fs.writeFile(path.join(root, "hermes-memory-config.json"), JSON.stringify({ await fs.writeFile(configPath, JSON.stringify({
lazyInitialization: true, lazyInitialization: true,
reviewEnabled: false, correctionDetection: false, reviewEnabled: false, correctionDetection: false,
flushOnCompact: false, flushOnShutdown: false, flushOnCompact: false, flushOnShutdown: false,
@@ -87,8 +87,6 @@ afterEach(async () => {
after(async () => { after(async () => {
if (previousRoot === undefined) delete process.env.PI_CODING_AGENT_DIR; if (previousRoot === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousRoot; else process.env.PI_CODING_AGENT_DIR = previousRoot;
if (previousConfigPath === undefined) delete process.env.PI_HERMES_MEMORY_CONFIG_PATH;
else process.env.PI_HERMES_MEMORY_CONFIG_PATH = previousConfigPath;
await fs.rm(root, { recursive: true, force: true }); await fs.rm(root, { recursive: true, force: true });
}); });
@@ -18,11 +18,11 @@ describe("session project memory rebinding", () => {
const launchDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-launch-")); const launchDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-launch-"));
const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-target-")); const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-target-"));
const previousAgentRoot = process.env.PI_CODING_AGENT_DIR; const previousAgentRoot = process.env.PI_CODING_AGENT_DIR;
const previousConfigPath = process.env.PI_HERMES_MEMORY_CONFIG_PATH; const configPath = path.join(agentRoot, "hermes-memory-config.json");
const previousCwd = process.cwd(); const previousCwd = process.cwd();
try { try {
await fs.writeFile( await fs.writeFile(
path.join(agentRoot, "hermes-memory-config.json"), configPath,
JSON.stringify({ JSON.stringify({
memoryMode: "legacy-inject", memoryMode: "legacy-inject",
reviewEnabled: false, reviewEnabled: false,
@@ -47,10 +47,10 @@ describe("session project memory rebinding", () => {
); );
process.env.PI_CODING_AGENT_DIR = agentRoot; process.env.PI_CODING_AGENT_DIR = agentRoot;
process.env.PI_HERMES_MEMORY_CONFIG_PATH = path.join(agentRoot, "hermes-memory-config.json");
process.chdir(launchDir); process.chdir(launchDir);
// Import after setting PI_CODING_AGENT_DIR so AGENT_ROOT is test-local. // Import after setting PI_CODING_AGENT_DIR so AGENT_ROOT is test-local.
const { default: registerExtension } = await import("../src/index.js"); const { createHermesMemoryExtension } = await import("../src/index.js");
const registerExtension = createHermesMemoryExtension({ configPath });
const mockPi: MockPi = { const mockPi: MockPi = {
handlers: {}, handlers: {},
tools: {}, tools: {},
@@ -103,8 +103,6 @@ describe("session project memory rebinding", () => {
process.chdir(previousCwd); process.chdir(previousCwd);
if (previousAgentRoot === undefined) delete process.env.PI_CODING_AGENT_DIR; if (previousAgentRoot === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousAgentRoot; else process.env.PI_CODING_AGENT_DIR = previousAgentRoot;
if (previousConfigPath === undefined) delete process.env.PI_HERMES_MEMORY_CONFIG_PATH;
else process.env.PI_HERMES_MEMORY_CONFIG_PATH = previousConfigPath;
await fs.rm(agentRoot, { recursive: true, force: true }); await fs.rm(agentRoot, { recursive: true, force: true });
await fs.rm(launchDir, { recursive: true, force: true }); await fs.rm(launchDir, { recursive: true, force: true });
await fs.rm(targetDir, { recursive: true, force: true }); await fs.rm(targetDir, { recursive: true, force: true });
@@ -24,10 +24,10 @@ import { after, beforeEach, describe, it } from "node:test";
// Bind the entry point to a disposable agent root, never the user's memory. // Bind the entry point to a disposable agent root, never the user's memory.
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-prompt-injection-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-prompt-injection-"));
const previousRoot = process.env.PI_CODING_AGENT_DIR; const previousRoot = process.env.PI_CODING_AGENT_DIR;
const previousConfigPath = process.env.PI_HERMES_MEMORY_CONFIG_PATH;
process.env.PI_CODING_AGENT_DIR = root; process.env.PI_CODING_AGENT_DIR = root;
process.env.PI_HERMES_MEMORY_CONFIG_PATH = path.join(root, "hermes-memory-config.json"); const configPath = path.join(root, "hermes-memory-config.json");
const { default: registerExtension } = await import("../src/index.js"); const { createHermesMemoryExtension } = await import("../src/index.js");
const registerExtension = createHermesMemoryExtension({ configPath });
const globalDir = path.join(root, "pi-hermes-memory"); const globalDir = path.join(root, "pi-hermes-memory");
const cwd = path.join(root, "workspace"); const cwd = path.join(root, "workspace");
@@ -68,7 +68,7 @@ let handlers: Record<string, Array<(event: any, ctx: any) => any>>;
let ctx: any; let ctx: any;
async function configure(overrides: Record<string, unknown> = {}) { async function configure(overrides: Record<string, unknown> = {}) {
await fs.writeFile(path.join(root, "hermes-memory-config.json"), JSON.stringify({ await fs.writeFile(configPath, JSON.stringify({
memoryMode: "policy-only", memoryMode: "policy-only",
memoryPolicyStyle: "full", memoryPolicyStyle: "full",
lazyInitialization: true, lazyInitialization: true,
@@ -115,8 +115,6 @@ beforeEach(async () => {
after(async () => { after(async () => {
if (previousRoot === undefined) delete process.env.PI_CODING_AGENT_DIR; if (previousRoot === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousRoot; else process.env.PI_CODING_AGENT_DIR = previousRoot;
if (previousConfigPath === undefined) delete process.env.PI_HERMES_MEMORY_CONFIG_PATH;
else process.env.PI_HERMES_MEMORY_CONFIG_PATH = previousConfigPath;
try { try {
await fs.rm(root, { recursive: true, force: true }); await fs.rm(root, { recursive: true, force: true });
} catch { } catch {
+10
View File
@@ -0,0 +1,10 @@
# my-pi 维护约定(pi-minimal-footer)
- 扩展入口是 `pi-minimal-footer/index.ts`(`package.json` 的 `pi.extensions` 指向 `./index.ts`);`status.ts` 收集 `ctx.ui.setStatus()` 发布的非空扩展状态并渲染为 footer 片段,`settings.ts` 读取 footer 配置,`assets/` 为 provider 图标,测试在 `test/`。
- 持久配置只读组合包 `config/pi-minimal-footer.json`(`settings.ts` 的 `readFooterSettings` 解析 `showCwd` / `showBranch`,缺失或非法时回退默认 `true`);不读写 `settings.json`,不以环境变量作持久默认。
- 上下文仪表优先显示 pi-compaction 发布的 effective usage 快照(`globalThis[Symbol.for("my-pi.pi-compaction.service.v1")].getMetrics(sessionId)` 的 `percent` / `effectiveTokens`);无快照时回退宿主用量(`buildSessionContext` 取最后一条非 aborted assistant 消息的 `input + output + cacheRead + cacheWrite`)。
- 来源记录在 `UPSTREAM.md`:从 `ogulcancelik/pi-extensions` 的 `pi-minimal-footer@0.1.10`(commit `77bd1e1`)导入,保留上游 LICENSE 与作者;本地改动为扩展状态渲染与 bundle 配置文件读取。
## 验证
- 在本目录内运行 `npm test`(`node --experimental-strip-types --test test/*.test.ts`)与 `npm run check`(`node --experimental-strip-types --check index.ts`)。
+1
View File
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- **Descend into bash control-flow bodies when enumerating command units.** `for`/`while`/`until` loops, `if`/`elif`/`else` branches, `case` items, `{ … }` brace groups, function definitions, and `!`-negated commands were previously matched only as whole-statement text, so a denied command hidden in a loop body or behind `!` (e.g. `for d in */; do find "$d" …; done`, `! rm -rf x`) bypassed every bash pattern — as did a substitution inside a value (`x=$(grep foo f)`, `export X=$(find .)`). The enumerator still emits each enclosing statement as a unit and now also emits every condition, body, and negated command — plus substitutions hosted in value lists, case subjects, assignments, declarations, and other non-command positions — so the most restrictive decision wins; pure body containers (`do …; done`, `elif`/`else` clauses, `case` items) no longer produce their own units. Hard guards share the same enumeration and now reach those nested commands too.
- **Replace the AutoReview text-JSON chat reviewer with a conversational tool-call reviewer.** The reviewer makes one independent model call per ask — a fixed review prompt, the rendered transcript, and a bounded JSON description of the planned action — and reaches its verdict only by calling one of two tools, `approve` or `reject`, each carrying a `reason`. A reply with no tool call counts as no verdict; multiple calls, unknown tools, and missing reasons count as invalid responses; neither is retried. An experimental TypeSafe Jev classifier rewrite explored in the working tree was dropped before release and never shipped. - **Replace the AutoReview text-JSON chat reviewer with a conversational tool-call reviewer.** The reviewer makes one independent model call per ask — a fixed review prompt, the rendered transcript, and a bounded JSON description of the planned action — and reaches its verdict only by calling one of two tools, `approve` or `reject`, each carrying a `reason`. A reply with no tool call counts as no verdict; multiple calls, unknown tools, and missing reasons count as invalid responses; neither is retried. An experimental TypeSafe Jev classifier rewrite explored in the working tree was dropped before release and never shipped.
- **The review model follows the session by default.** With `autoReview.provider` and `autoReview.model` both unset, the reviewer calls the session's current model (`ctx.model`), resolved live per ask so a mid-session switch applies to the next request. Setting both pins a fixed reviewer; setting exactly one is a misconfiguration that warns once at `session_start` and defers every ask. - **The review model follows the session by default.** With `autoReview.provider` and `autoReview.model` both unset, the reviewer calls the session's current model (`ctx.model`), resolved live per ask so a mid-session switch applies to the next request. Setting both pins a fixed reviewer; setting exactly one is a misconfiguration that warns once at `session_start` and defers every ask.
- **A rejection defers instead of denying.** `reject` maps to `defer` and the model's `reason` is shown in the human permission prompt, labelled as model-generated. Every review failure — timeout, provider error, malformed or truncated reply, unresolved model — also defers with a fixed system-authored note. Pressing Esc during a review aborts it and denies outright, so no human dialog appears after the user already refused. - **A rejection defers instead of denying.** `reject` maps to `defer` and the model's `reason` is shown in the human permission prompt, labelled as model-generated. Every review failure — timeout, provider error, malformed or truncated reply, unresolved model — also defers with a fixed system-authored note. Pressing Esc during a review aborts it and denies outright, so no human dialog appears after the user already refused.
+4
View File
@@ -21,3 +21,7 @@ AutoReview 源码已整合进 `src/auto-review/`,不再保留或加载第二
## 本地维护 ## 本地维护
本目录不是 submodule,不保留嵌套 `.git`、上游 `.pi` 本地状态、`dist/` 或构建产物。同步任一上游时必须从其源码仓库的明确 tag/commit 移植并与本地改动对照审查,不得整体覆盖,也不得使用 npm 预编译产物作为导入源。 本目录不是 submodule,不保留嵌套 `.git`、上游 `.pi` 本地状态、`dist/` 或构建产物。同步任一上游时必须从其源码仓库的明确 tag/commit 移植并与本地改动对照审查,不得整体覆盖,也不得使用 npm 预编译产物作为导入源。
## 本地改动
- Bash 命令枚举在保留整条控制流语句为独立 command unit 的基础上递归下降:`for`/`while`/`until`、`if`/`elif`/`else`、`case` 分支、`{ … }` 组合语句、函数定义与 `!` 取反命令体内的命令也按 bash pattern 逐条判定;赋值与声明值(`x=$(…)`、`export X=$(…)`)、值列表、case 词与 `[[ … ]]` 测试中的 `$(…)` 替换同样枚举,而 `do …; done`、`elif`/`else`、`case` 分支等纯容器不再单独产生 unit。藏在循环体、取反前缀或赋值载荷里的 `find`/`grep`/`rm` 等不再绕过 deny 或 hard guard。
+4 -1
View File
@@ -370,7 +370,10 @@ This holds wherever the substitution appears, not only in argument position.
A substitution in a **redirect target** (`echo hi > $(rm *.txt)`, `cat < <(rm c)`, ``echo hi 2> `rm d` ``) and one in an **interpolating heredoc body** (`cat <<EOF` with `$(rm e)` in the body) are evaluated the same way. A substitution in a **redirect target** (`echo hi > $(rm *.txt)`, `cat < <(rm c)`, ``echo hi 2> `rm d` ``) and one in an **interpolating heredoc body** (`cat <<EOF` with `$(rm e)` in the body) are evaluated the same way.
A quoted heredoc delimiter (`<<'EOF'` or `<<"EOF"`) does not interpolate, so its body is literal text and nothing in it is evaluated as a command. A quoted heredoc delimiter (`<<'EOF'` or `<<"EOF"`) does not interpolate, so its body is literal text and nothing in it is evaluated as a command.
The enclosing command is still matched without its redirect, so a rule like `npm install` keeps matching `npm install > out.txt`. The enclosing command is still matched without its redirect, so a rule like `npm install` keeps matching `npm install > out.txt`.
Control-flow bodies (`if`/`while`/`for`/`case`) and `{ … }` brace groups are not descended into; their contents are matched as part of the enclosing statement's text. Control-flow statements (`for`/`while`/`until` loops, `if`/`elif`/`else` branches, `case` items, `{ … }` brace groups, and function definitions) are evaluated as a whole unit *and* descended into: every command in their conditions and bodies is matched against the bash patterns in addition to the enclosing statement's text.
So `for d in src docs; do find "$d" -name '*.ts'; done` evaluates both the whole loop and `find "$d" -name '*.ts'`; if `find *` is denied, the whole invocation is denied.
Non-command parts of a control-flow statement — the `for` value list, the `case` subject and patterns, `[[ … ]]` tests — are not matched as commands themselves, but substitutions nested inside them still are, so `for f in $(find …)` and `if [[ "$(rm e)" = x ]]` gate the inner command.
Negation and shell bookkeeping get the same treatment: `! grep -q x f` evaluates both `! grep -q x f` and the inner `grep -q x f`, and a substitution inside a value — `x=$(grep foo f)`, `export X=$(find .)`, `local y=$(rg z)` — is gated as a command substitution even though the assignment or declaration itself runs no command.
A leading environment-variable assignment prefix is stripped before matching, so the rule gates the underlying command rather than the prefix. A leading environment-variable assignment prefix is stripped before matching, so the rule gates the underlying command rather than the prefix.
So `AWS_PROFILE=prod aws ec2 …` is matched as `aws ec2 …` — a `aws *` rule applies even though the invocation begins with `AWS_PROFILE=`. So `AWS_PROFILE=prod aws ec2 …` is matched as `aws ec2 …` — a `aws *` rule applies even though the invocation begins with `AWS_PROFILE=`.
@@ -1,6 +1,7 @@
import { import {
EXECUTION_HOST_TYPES, EXECUTION_HOST_TYPES,
forEachNestedExecution, forEachNestedExecution,
NESTED_EXECUTION_CONTEXTS,
} from "#src/access-intent/bash/nested-execution"; } from "#src/access-intent/bash/nested-execution";
import type { TSNode } from "#src/access-intent/bash/parser"; import type { TSNode } from "#src/access-intent/bash/parser";
import { import {
@@ -72,6 +73,62 @@ const COMMAND_ENUM_DESCEND = new Set([
*/ */
const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]); const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
/**
* Compound statement node types whose bodies really run commands:
* `for`/`while`/`until` loops (tree-sitter-bash folds `until` into
* `while_statement`), `if`/`case` statements, `{ … }` brace groups
* (`compound_statement`), function definitions, and `!`-negated commands.
*
* These were once emitted whole without descending (#306 deferred that), which
* let a gated command hide inside a loop body — a `for …; do find …; done`
* matched only patterns starting with `for`. Each is now emitted whole AND
* enumerated child-by-child; a `negated_command` emits `! grep …` whole and
* then `grep …` so the `!` prefix cannot defeat a command pattern either.
*/
const CONTROL_FLOW_TYPES = new Set([
"for_statement",
"c_style_for_statement",
"while_statement",
"if_statement",
"case_statement",
"compound_statement",
"function_definition",
"negated_command",
]);
/**
* Control-flow nodes that are pure body containers rather than statements in
* their own right: `do …; done`, `elif`/`else` clauses, and `case` items.
* They are descended exactly like {@link CONTROL_FLOW_TYPES} members but are
* not emitted as whole units — nobody writes a command pattern against
* `do …; done` or `a) find . ;;`, so emitting them only adds noise to prompts
* and approval suggestions.
*/
const CONTROL_FLOW_CONTAINERS = new Set([
"do_group",
"elif_clause",
"else_clause",
"case_item",
]);
/**
* Named children of a control-flow node that are statements in their own
* right and get full command enumeration (whole-emit rules, wrapper
* classification, substitution descent). Any other named child — words,
* `variable_name`, a for-loop's value list, a case subject or pattern, a
* `test_command` `[[ … ]]`, a comment — contributes only the nested
* `$(…)`/`<(…)` executions it hosts.
*/
const CONTROL_FLOW_STATEMENT_TYPES = new Set([
"command",
"list",
"pipeline",
"redirected_statement",
"subshell",
...CONTROL_FLOW_TYPES,
...CONTROL_FLOW_CONTAINERS,
]);
/** /**
* Enumerate the command units of a bash program, in source order. * Enumerate the command units of a bash program, in source order.
* *
@@ -82,8 +139,19 @@ const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
* subshells (`( … )`) — emitting each inner command as its own unit *in * subshells (`( … )`) — emitting each inner command as its own unit *in
* addition to* the enclosing command, since those inner commands really execute * addition to* the enclosing command, since those inner commands really execute
* (#306). * (#306).
* Control-flow bodies and `{ … }` brace groups are emitted whole without * Control-flow statements (loops, `if`/`elif`/`else`, `case`, `{ … }` groups,
* descending (deferred). * function definitions, `!` negation) are likewise emitted whole and then
* descended child-by-child: statement children (including nested control flow
* and conditions like `if grep -q …; then`) are enumerated as commands, while
* non-statement children (the for-loop value list, case subject/patterns,
* `[[ … ]]` tests, names) contribute only the nested executions they host.
* Pure body containers (`do …; done`, `elif`/`else` clauses, `case` items)
* are descended without a whole-unit emit of their own.
* Body commands keep the enclosing execution context — a loop inside `$(…)`
* runs there, a top-level loop's body runs in the current shell.
* Remaining named statements (`variable_assignment`, `declaration_command`,
* `test_command`, …) emit whole and still report the nested executions they
* host, so `x=$(grep foo f)` and `export X=$(find .)` gate the inner command.
* *
* The enclosing command/subshell is always still emitted whole, so adding the * The enclosing command/subshell is always still emitted whole, so adding the
* nested units can only ever produce a more-restrictive decision, never weaker. * nested units can only ever produce a more-restrictive decision, never weaker.
@@ -135,9 +203,57 @@ function collectCommandsInto(
return; return;
} }
// Any other named statement (compound_statement `{ … }`, if/while/for/case, if (CONTROL_FLOW_TYPES.has(node.type)) {
// function_definition): emit whole, do not descend — deferred (#306). out.push(makeUnit(node.text, context)); // never-weaker whole emit
descendControlFlowChildren(node, context, out);
return;
}
if (CONTROL_FLOW_CONTAINERS.has(node.type)) {
// Pure body container — enumerate its contents, emit no unit of its own.
descendControlFlowChildren(node, context, out);
return;
}
// Any other named statement (variable_assignment, declaration_command,
// test_command, …) keeps the whole-emit fallback, but a substitution in its
// payload still really runs — `x=$(grep foo f)` or `export X=$(find .)` —
// so hosted executions are enumerated in addition to the whole text.
out.push(makeUnit(node.text, context)); out.push(makeUnit(node.text, context));
collectHostedCommands(node, out);
}
/**
* Enumerate inside a control-flow statement, keeping the enclosing context.
*
* Statement children (`command`, `list`, `pipeline`, `redirected_statement`,
* `subshell`, or nested control flow such as a `do_group`)
* get full enumeration. Other named children are scanned only for nested
* executions: a `command_substitution` child directly (the `$(…)` in
* `for f in $(…)` or `case $(…) in`) is a context node in its own right — the
* strict-subtree {@link forEachNestedExecution} walk would not see it — so it
* is descended with its own context, while words, `variable_name`, test and
* expression subtrees go through the hosted-execution search.
*/
function descendControlFlowChildren(
node: TSNode,
context: BashCommandContext | undefined,
out: BashCommand[],
): void {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child?.isNamed) continue;
if (CONTROL_FLOW_STATEMENT_TYPES.has(child.type)) {
collectCommandsInto(child, context, out);
continue;
}
const nestedContext = NESTED_EXECUTION_CONTEXTS.get(child.type);
if (nestedContext) {
descendCommandChildren(child, nestedContext, out);
} else {
collectHostedCommands(child, out);
}
}
} }
function makeUnit( function makeUnit(
@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
resetWarmBashParser,
warmBashParser,
} from "#src/access-intent/bash/parser";
import { parseBashCommandsSync } from "#src/access-intent/bash/sync-commands";
describe("collectCommands — control-flow descent", () => {
beforeEach(async () => {
resetWarmBashParser();
await warmBashParser();
});
afterEach(() => {
resetWarmBashParser();
});
it("emits a for-loop's body commands in addition to the whole statement", () => {
expect(
parseBashCommandsSync('for d in a b; do find "$d" -name x; done'),
).toEqual([
{ text: 'for d in a b; do find "$d" -name x; done' },
{ text: 'find "$d" -name x' },
]);
});
it("emits a command substitution in the for value list as a nested context", () => {
expect(
parseBashCommandsSync('for f in $(find . -name x); do echo "$f"; done'),
).toEqual([
{ text: 'for f in $(find . -name x); do echo "$f"; done' },
{ text: "find . -name x", context: "command_substitution" },
{ text: 'echo "$f"' },
]);
});
it("emits the condition and body commands of a redirected while loop", () => {
expect(
parseBashCommandsSync('while read l; do grep x "$l"; done < list'),
).toEqual([
{ text: 'while read l; do grep x "$l"; done' },
{ text: "read l" },
{ text: 'grep x "$l"' },
]);
});
it("emits an if's condition, branch, and else commands", () => {
expect(
parseBashCommandsSync("if grep -q x f; then echo y; else rm z; fi"),
).toEqual([
{ text: "if grep -q x f; then echo y; else rm z; fi" },
{ text: "grep -q x f" },
{ text: "echo y" },
{ text: "rm z" },
]);
});
it("emits a case item's commands", () => {
expect(parseBashCommandsSync("case $x in a) find . ;; esac")).toEqual([
{ text: "case $x in a) find . ;; esac" },
{ text: "find ." },
]);
});
it("emits a command substitution in the case subject as a nested context", () => {
expect(
parseBashCommandsSync("case $(rm x) in a) find . ;; esac"),
).toEqual([
{ text: "case $(rm x) in a) find . ;; esac" },
{ text: "rm x", context: "command_substitution" },
{ text: "find ." },
]);
});
it("descends a brace group", () => {
expect(parseBashCommandsSync("{ grep x f; }")).toEqual([
{ text: "{ grep x f; }" },
{ text: "grep x f" },
]);
});
it("descends a function definition's body", () => {
expect(parseBashCommandsSync("f() { grep x f; }")).toEqual([
{ text: "f() { grep x f; }" },
{ text: "{ grep x f; }" },
{ text: "grep x f" },
]);
});
it("descends nested loops", () => {
expect(
parseBashCommandsSync("for a in 1; do for b in 2; do find .; done; done"),
).toEqual([
{ text: "for a in 1; do for b in 2; do find .; done; done" },
{ text: "for b in 2; do find .; done" },
{ text: "find ." },
]);
});
it("descends a c-style for loop", () => {
expect(
parseBashCommandsSync("for ((i=0;i<3;i++)); do echo i; done"),
).toEqual([
{ text: "for ((i=0;i<3;i++)); do echo i; done" },
{ text: "echo i" },
]);
});
it("emits no container units for a clean loop body", () => {
expect(parseBashCommandsSync('for d in a b; do echo "$d"; done')).toEqual([
{ text: 'for d in a b; do echo "$d"; done' },
{ text: 'echo "$d"' },
]);
});
it("does not emit a [[ ]] test as a separate command unit", () => {
expect(parseBashCommandsSync("if [[ -f x ]]; then echo y; fi")).toEqual([
{ text: "if [[ -f x ]]; then echo y; fi" },
{ text: "echo y" },
]);
});
it("catches a substitution hosted inside a [[ ]] test", () => {
expect(
parseBashCommandsSync('if [[ "$(rm x)" = y ]]; then echo y; fi'),
).toEqual([
{ text: 'if [[ "$(rm x)" = y ]]; then echo y; fi' },
{ text: "rm x", context: "command_substitution" },
{ text: "echo y" },
]);
});
});
describe("collectCommands — assignment, declaration, and negation", () => {
beforeEach(async () => {
resetWarmBashParser();
await warmBashParser();
});
afterEach(() => {
resetWarmBashParser();
});
it("emits a substitution in a plain assignment value as a nested context", () => {
expect(parseBashCommandsSync("x=$(grep foo f)")).toEqual([
{ text: "x=$(grep foo f)" },
{ text: "grep foo f", context: "command_substitution" },
]);
});
it("emits a substitution in an export declaration value", () => {
expect(parseBashCommandsSync("export X=$(find .)")).toEqual([
{ text: "export X=$(find .)" },
{ text: "find .", context: "command_substitution" },
]);
});
it("emits a substitution in a local declaration inside a function body", () => {
expect(parseBashCommandsSync("f() { local y=$(rg z); }")).toEqual([
{ text: "f() { local y=$(rg z); }" },
{ text: "{ local y=$(rg z); }" },
{ text: "rg z", context: "command_substitution" },
]);
});
it("emits a negated command's inner command as a normal unit", () => {
expect(parseBashCommandsSync("! grep -q x f")).toEqual([
{ text: "! grep -q x f" },
{ text: "grep -q x f" },
]);
});
it("emits a negated condition's inner command inside an if", () => {
expect(parseBashCommandsSync("if ! grep -q x f; then :; fi")).toEqual([
{ text: "if ! grep -q x f; then :; fi" },
{ text: "! grep -q x f" },
{ text: "grep -q x f" },
{ text: ":" },
]);
});
});
@@ -14,10 +14,13 @@ import { BashProgram } from "#src/access-intent/bash/program";
import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command"; import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
import { pathFlavorForPlatform } from "#src/path/path-flavor"; import { pathFlavorForPlatform } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer"; import { PathNormalizer } from "#src/path-normalizer";
import { PermissionResolver } from "#src/permission-resolver";
import type { ScopedPermissionResolver } from "#src/permission-resolver"; import type { ScopedPermissionResolver } from "#src/permission-resolver";
import type { PermissionState } from "#src/types"; import { SessionRules } from "#src/session-rules";
import type { DenyWithReason, PermissionState } from "#src/types";
import { makeCheckResult } from "#test/helpers/handler-fixtures"; import { makeCheckResult } from "#test/helpers/handler-fixtures";
import { createInMemoryManager } from "#test/helpers/manager-harness";
/** Decision strength ordering: deny (2) > ask (1) > allow (0). */ /** Decision strength ordering: deny (2) > ask (1) > allow (0). */
const STRENGTH: Record<PermissionState, number> = { const STRENGTH: Record<PermissionState, number> = {
@@ -146,3 +149,103 @@ describe("bash command gate — nested execution hosts do not weaken", () => {
expect(await decide("cat <<'EOF'\n$(rm x)\nEOF", resolver)).toBe("allow"); expect(await decide("cat <<'EOF'\n$(rm x)\nEOF", resolver)).toBe("allow");
}); });
}); });
/**
* The same totality property for control-flow bodies.
*
* A `for`/`while`/`if`/`case`/function was once emitted whole without
* descending, so `for d in …; do find …; done` only ever matched patterns
* starting with `for` — a `find*: deny` rule was bypassed. The enumerator now
* emits the whole statement AND its body commands, so hosting a gated command
* inside control flow cannot weaken its decision.
*/
describe("bash command gate — control-flow bodies do not weaken", () => {
/** Real pattern matching over a bash map: `*` allow, the rest deny-with-reason. */
function makePolicyResolver(
bash: Record<string, PermissionState | DenyWithReason>,
): ScopedPermissionResolver {
const manager = createInMemoryManager({
global: { permission: { "*": "allow", bash } },
});
return new PermissionResolver(manager, new SessionRules());
}
async function checkWith(
command: string,
resolver: ScopedPermissionResolver,
) {
const program = await BashProgram.parse(
command,
new PathNormalizer(pathFlavorForPlatform(process.platform), "/cwd"),
);
return resolveBashCommandCheck(command, program.commands(), undefined, resolver);
}
it("denies the reported repro: find inside a for-loop command substitution", async () => {
const resolver = makePolicyResolver({
"*": "allow",
"find*": { action: "deny", reason: "use the find tool" },
"grep*": { action: "deny", reason: "use the grep tool" },
});
const result = await checkWith(
'cd /Users/sia/code/my-pi && ls -1 extensions/ && echo "=== LOC by ext ===" && for d in pi-*/; do n=$(find "$d" -name \'*.ts\' -not -path \'*/node_modules/*\' -not -path \'*/dist/*\' -not -path \'*/test*\' | xargs wc -l 2>/dev/null | tail -1 | awk \'{print $1}\'); echo "$d $n"; done && echo "=== extensions ts ===" && wc -l extensions/*.ts | tail -3',
resolver,
);
expect(result.state).toBe("deny");
expect(result.command).toBe(
'find "$d" -name \'*.ts\' -not -path \'*/node_modules/*\' -not -path \'*/dist/*\' -not -path \'*/test*\'',
);
expect(result.matchedPattern).toBe("find*");
expect(result.reason).toBe("use the find tool");
expect(result.commandContext).toBe("command_substitution");
});
it.each([
'for d in a b; do find "$d" -name x; done',
'while read l; do grep x "$l"; done < list',
"if grep -q x f; then echo y; else echo n; fi",
"case $x in a) find . ;; esac",
"{ grep x f; }",
"f() { grep x f; }",
"for a in 1; do for b in 2; do find .; done; done",
"x=$(grep foo f)",
"export X=$(find .)",
"f() { local y=$(rg z); }",
"! grep -q x f",
"if ! grep -q x f; then :; fi",
])("denies a gated command inside `%s`", async (command) => {
const resolver = makePolicyResolver({
"*": "allow",
"find*": "deny",
"grep*": "deny",
"rg*": "deny",
});
expect((await checkWith(command, resolver)).state).toBe("deny");
});
it("denies a hard-guard payload hidden in a loop body", async () => {
const resolver = makePolicyResolver({
"*": "allow",
"rm -rf*": "deny",
});
const result = await checkWith("for f in a; do rm -rf /; done", resolver);
expect(result.state).toBe("deny");
expect(result.matchedPattern).toBe("rm -rf*");
});
it("allows a control-flow statement whose body commands are allowed", async () => {
const resolver = makePolicyResolver({
"*": "allow",
"find*": "deny",
"grep*": "deny",
});
expect(
(await checkWith('for d in a b; do echo "$d"; done', resolver)).state,
).toBe("allow");
});
});
@@ -7,6 +7,7 @@ import {
autoReviewConfigFileSchema, autoReviewConfigFileSchema,
normalizeAutoReviewConfig, normalizeAutoReviewConfig,
} from "#src/auto-review/config"; } from "#src/auto-review/config";
import { stripJsonComments } from "#src/config-loader";
import { unifiedConfigSchema } from "#src/config-schema"; import { unifiedConfigSchema } from "#src/config-schema";
/** The bundle's authoritative policy seed, as shipped. */ /** The bundle's authoritative policy seed, as shipped. */
@@ -16,10 +17,10 @@ const SEED_PATH = fileURLToPath(
function readSeed(): Record<string, unknown> { function readSeed(): Record<string, unknown> {
const raw = readFileSync(SEED_PATH, "utf8"); const raw = readFileSync(SEED_PATH, "utf8");
const stripped = raw // Parse with the same string-aware stripper the loader uses: a naive regex
.replace(/^\s*\/\/.*$/gm, "") // strips `/*`-looking runs inside string values (e.g. a `'src/**/*.ts'`
.replace(/\/\*[\s\S]*?\*\//g, ""); // glob in a deny reason) and corrupts the JSON.
return JSON.parse(stripped) as Record<string, unknown>; return JSON.parse(stripJsonComments(raw)) as Record<string, unknown>;
} }
describe("bundled policy seed", () => { describe("bundled policy seed", () => {
+1
View File
@@ -5,6 +5,7 @@
- RTK 默认只压缩非搜索输出,包括本地 `bash` 与远端 `ssh_bash` 的 ANSI 清理、测试聚合、构建过滤、Git 压缩、Lint 聚合;兜底截断默认关闭,新结果的展示截断由 pi-overflow 统一负责;`ssh_bash` 只复用输出处理,远端命令不得进入 RTK rewrite。 - RTK 默认只压缩非搜索输出,包括本地 `bash` 与远端 `ssh_bash` 的 ANSI 清理、测试聚合、构建过滤、Git 压缩、Lint 聚合;兜底截断默认关闭,新结果的展示截断由 pi-overflow 统一负责;`ssh_bash` 只复用输出处理,远端命令不得进入 RTK rewrite。
- RTK 命令改写默认关闭(`commandRewritingEnabled` 默认 `false`);默认安装不依赖系统 `rtk` CLI,只有用户主动开启命令改写时才需要 `rtk rewrite`,其命令支持策略由已安装的 `rtk rewrite` 决定,不在扩展内重复维护规则。 - RTK 命令改写默认关闭(`commandRewritingEnabled` 默认 `false`);默认安装不依赖系统 `rtk` CLI,只有用户主动开启命令改写时才需要 `rtk rewrite`,其命令支持策略由已安装的 `rtk rewrite` 决定,不在扩展内重复维护规则。
- FFF override 独占搜索工具与搜索输出;RTK 不得处理本地或远端搜索工具的调用/结果(含 `grep` 工具结果),也不得通过默认命令改写接管 `rg`、`grep`、`find`、`fd`。 - FFF override 独占搜索工具与搜索输出;RTK 不得处理本地或远端搜索工具的调用/结果(含 `grep` 工具结果),也不得通过默认命令改写接管 `rg`、`grep`、`find`、`fd`。
- RTK 不处理带 `parentToolCallId` 的嵌套调用(codemode 脚本内调用),否则会丢失 bash 的 structuredContent。
- `readCompaction.enabled`、`sourceCodeFilteringEnabled` 和 `smartTruncate.enabled` 当前均默认 `false`,源码读取保持原样;未经用户明确决定,不因节省上下文而改变这些默认值。若以后开启 read 压缩,优先考虑 `readCompaction + smartTruncate`,源码过滤仍独立评估;必须保留精确 `offset/limit` 读取、短文件和行锚点的完整性。 - `readCompaction.enabled`、`sourceCodeFilteringEnabled` 和 `smartTruncate.enabled` 当前均默认 `false`,源码读取保持原样;未经用户明确决定,不因节省上下文而改变这些默认值。若以后开启 read 压缩,优先考虑 `readCompaction + smartTruncate`,源码过滤仍独立评估;必须保留精确 `offset/limit` 读取、短文件和行锚点的完整性。
- read 压缩属于有损能力。任何默认值调整都必须同时说明哪些内容可能被省略、精确读取如何恢复原文,以及对 `edit` 文本匹配和排障证据的影响。 - read 压缩属于有损能力。任何默认值调整都必须同时说明哪些内容可能被省略、精确读取如何恢复原文,以及对 `edit` 文本匹配和排障证据的影响。
- 工具输出压缩可能损失证据。排障和审计相关改动应优先保证原始输出可恢复,并覆盖锚点完整性和截断边界。 - 工具输出压缩可能损失证据。排障和审计相关改动应优先保证原始输出可恢复,并覆盖锚点完整性和截断边界。
+5 -5
View File
@@ -1,6 +1,6 @@
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { resolveConfigPath } from "./constants.js"; import { CONFIG_PATH } from "./constants.js";
import { toRecord } from "./record-utils.js"; import { toRecord } from "./record-utils.js";
import { import {
DEFAULT_RTK_INTEGRATION_CONFIG, DEFAULT_RTK_INTEGRATION_CONFIG,
@@ -151,7 +151,7 @@ export function normalizeRtkIntegrationConfig(raw: unknown): RtkIntegrationConfi
}; };
} }
export function ensureConfigExists(configPath = resolveConfigPath()): EnsureConfigResult { export function ensureConfigExists(configPath = CONFIG_PATH): EnsureConfigResult {
if (existsSync(configPath)) { if (existsSync(configPath)) {
return { created: false }; return { created: false };
} }
@@ -169,7 +169,7 @@ export function ensureConfigExists(configPath = resolveConfigPath()): EnsureConf
} }
} }
export function loadRtkIntegrationConfig(configPath = resolveConfigPath()): ConfigLoadResult { export function loadRtkIntegrationConfig(configPath = CONFIG_PATH): ConfigLoadResult {
if (!existsSync(configPath)) { if (!existsSync(configPath)) {
return { config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG) }; return { config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG) };
} }
@@ -189,7 +189,7 @@ export function loadRtkIntegrationConfig(configPath = resolveConfigPath()): Conf
export function saveRtkIntegrationConfig( export function saveRtkIntegrationConfig(
config: RtkIntegrationConfig, config: RtkIntegrationConfig,
configPath = resolveConfigPath(), configPath = CONFIG_PATH,
): ConfigSaveResult { ): ConfigSaveResult {
const normalized = normalizeRtkIntegrationConfig(config); const normalized = normalizeRtkIntegrationConfig(config);
const tmpPath = `${configPath}.tmp`; const tmpPath = `${configPath}.tmp`;
@@ -218,6 +218,6 @@ export function saveRtkIntegrationConfig(
} }
} }
export function getRtkIntegrationConfigPath(configPath = resolveConfigPath()): string { export function getRtkIntegrationConfigPath(configPath = CONFIG_PATH): string {
return configPath; return configPath;
} }
-8
View File
@@ -9,11 +9,3 @@ export const EXTENSION_NAME = "pi-rtk-optimizer";
export const CONFIG_PATH = fileURLToPath( export const CONFIG_PATH = fileURLToPath(
new URL("../../config/rtk-optimizer.json", import.meta.url), new URL("../../config/rtk-optimizer.json", import.meta.url),
); );
/**
* Resolve the effective config path. `PI_RTK_CONFIG_PATH` relocates the file
* (tests point it at a temp file; it is never a persisted config value).
*/
export function resolveConfigPath(): string {
return process.env.PI_RTK_CONFIG_PATH ?? CONFIG_PATH;
}
+65 -3
View File
@@ -36,16 +36,15 @@ mock.module("@earendil-works/pi-tui", {
const indexModule = await import("./index.ts"); const indexModule = await import("./index.ts");
const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote } = indexModule; const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote } = indexModule;
const rtkIntegrationExtension = indexModule.default;
const { DEFAULT_RTK_INTEGRATION_CONFIG } = await import("./types.ts"); const { DEFAULT_RTK_INTEGRATION_CONFIG } = await import("./types.ts");
// Tests must never write the bundle's config/ file: relocate the config to a // Tests must never write the bundle's config/ file: relocate the config to a
// throwaway temp file before any config-store call resolves its path. // throwaway temp file via the extension factory's configPath option.
const TEST_CONFIG_PATH = join( const TEST_CONFIG_PATH = join(
mkdtempSync(join(tmpdir(), "my-pi-rtk-test-")), mkdtempSync(join(tmpdir(), "my-pi-rtk-test-")),
"rtk-optimizer.json", "rtk-optimizer.json",
); );
process.env.PI_RTK_CONFIG_PATH = TEST_CONFIG_PATH; const rtkIntegrationExtension = indexModule.createRtkOptimizerExtension({ configPath: TEST_CONFIG_PATH });
function writeTestConfig(commandRewritingEnabled: boolean): void { function writeTestConfig(commandRewritingEnabled: boolean): void {
writeFileSync( writeFileSync(
@@ -401,6 +400,69 @@ await runTest("tool_result lifecycle merges compaction metadata with existing de
assert.equal(notifications.length, 0); assert.equal(notifications.length, 0);
}); });
await runTest("nested tool calls and results (parentToolCallId) are left untouched", async () => {
writeTestConfig(true);
const handlers: Record<string, ExtensionHandler> = {};
const notifications: Notification[] = [];
let rewriteCalls = 0;
rtkIntegrationExtension({
exec: async (_command: string, args: string[]) => {
if (args[0] === "--version") {
return { code: 0, stdout: "rtk 1.0.0", stderr: "" };
}
if (args[0] === "rewrite") {
rewriteCalls += 1;
return { code: 3, stdout: "rtk git status", stderr: "" };
}
return { code: 1, stdout: "", stderr: "unexpected" };
},
on(eventName: string, handler: ExtensionHandler) {
handlers[eventName] = handler;
},
registerCommand() {},
} as never);
const toolCallHandler = handlers.tool_call;
const toolResultHandler = handlers.tool_result;
assert.ok(toolCallHandler);
assert.ok(toolResultHandler);
// A codemode script's nested bash result must survive intact: rewriting
// content without structuredContent would drop bash's outputSchema payload.
const nestedResult = await toolResultHandler(
{
toolName: "bash",
parentToolCallId: "codemode-1",
input: { command: "printf TODO" },
content: [{ type: "text", text: "\x1B[31msrc/a.ts\n 1: TODO\x1B[0m\n" }],
},
createNotificationContext(notifications),
);
assert.ok(!nestedResult || !("content" in nestedResult));
const nestedCall = {
toolName: "bash",
parentToolCallId: "codemode-1",
input: { command: "git status" },
};
await toolCallHandler(nestedCall, createNotificationContext(notifications));
assert.equal(nestedCall.input.command, "git status");
assert.equal(rewriteCalls, 0);
const directResult = await toolResultHandler(
{
toolName: "bash",
input: { command: "printf TODO" },
content: [{ type: "text", text: "\x1B[31msrc/a.ts\n 1: TODO\x1B[0m\n" }],
},
createNotificationContext(notifications),
);
assert.ok(directResult && "content" in directResult);
assert.equal(firstText(directResult.content), "src/a.ts\n 1: TODO\n");
writeTestConfig(false);
});
await runTest("tool_call surfaces RTK rewrite errors through existing UI warning path", async () => { await runTest("tool_call surfaces RTK rewrite errors through existing UI warning path", async () => {
writeTestConfig(true); writeTestConfig(true);
const handlers: Record<string, (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>> = {}; const handlers: Record<string, (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>> = {};
+28 -7
View File
@@ -8,7 +8,7 @@ import {
} from "./config-store.js"; } from "./config-store.js";
import { computeRewriteDecision } from "./command-rewriter.js"; import { computeRewriteDecision } from "./command-rewriter.js";
import { registerRtkIntegrationCommand } from "./command-register.js"; import { registerRtkIntegrationCommand } from "./command-register.js";
import { EXTENSION_NAME } from "./constants.js"; import { CONFIG_PATH, EXTENSION_NAME } from "./constants.js";
import { createLazyModuleLoader } from "./lazy-module-loader.js"; import { createLazyModuleLoader } from "./lazy-module-loader.js";
import { clearOutputMetrics, getOutputMetricsSummary } from "./output-metrics.js"; import { clearOutputMetrics, getOutputMetricsSummary } from "./output-metrics.js";
import type { ToolResultCompactionMetadata } from "./output-compactor.js"; import type { ToolResultCompactionMetadata } from "./output-compactor.js";
@@ -103,8 +103,12 @@ export function createBoundedNoticeTracker(maxEntries: number): BoundedNoticeTra
}; };
} }
export default function rtkIntegrationExtension(pi: ExtensionAPI): void { export interface RtkOptimizerExtensionOptions {
const initialLoad = loadRtkIntegrationConfig(); configPath?: string;
}
function rtkIntegrationExtension(pi: ExtensionAPI, configPath: string): void {
const initialLoad = loadRtkIntegrationConfig(configPath);
let config: RtkIntegrationConfig = initialLoad.config; let config: RtkIntegrationConfig = initialLoad.config;
if (!config.enabled) { if (!config.enabled) {
return; return;
@@ -200,12 +204,12 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
}; };
const refreshConfig = async (ctx?: ExtensionContext | ExtensionCommandContext): Promise<void> => { const refreshConfig = async (ctx?: ExtensionContext | ExtensionCommandContext): Promise<void> => {
const ensured = ensureConfigExists(); const ensured = ensureConfigExists(configPath);
if (ensured.error && ctx) { if (ensured.error && ctx) {
warnOnce(ctx, ensured.error); warnOnce(ctx, ensured.error);
} }
const loaded = loadRtkIntegrationConfig(); const loaded = loadRtkIntegrationConfig(configPath);
config = loaded.config; config = loaded.config;
pendingLoadWarning = loaded.warning; pendingLoadWarning = loaded.warning;
await refreshRuntimeStatus(); await refreshRuntimeStatus();
@@ -218,7 +222,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
const setConfig = (next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void => { const setConfig = (next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void => {
config = normalizeRtkIntegrationConfig(next); config = normalizeRtkIntegrationConfig(next);
const saved = saveRtkIntegrationConfig(config); const saved = saveRtkIntegrationConfig(config, configPath);
if (!saved.success && saved.error) { if (!saved.success && saved.error) {
ctx.ui.notify(saved.error, "error"); ctx.ui.notify(saved.error, "error");
} }
@@ -310,7 +314,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
const controller = { const controller = {
getConfig: () => config, getConfig: () => config,
setConfig, setConfig,
getConfigPath: getRtkIntegrationConfigPath, getConfigPath: () => getRtkIntegrationConfigPath(configPath),
getRuntimeStatus: () => runtimeStatus, getRuntimeStatus: () => runtimeStatus,
refreshRuntimeStatus, refreshRuntimeStatus,
getMetricsSummary: getOutputMetricsSummary, getMetricsSummary: getOutputMetricsSummary,
@@ -396,6 +400,10 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
}); });
pi.on("tool_call", async (event, ctx) => { pi.on("tool_call", async (event, ctx) => {
if (event.parentToolCallId) {
return {};
}
if (!config.enabled || !config.commandRewritingEnabled) { if (!config.enabled || !config.commandRewritingEnabled) {
return {}; return {};
} }
@@ -455,6 +463,10 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
}); });
pi.on("tool_result", async (event, ctx) => { pi.on("tool_result", async (event, ctx) => {
if (event.parentToolCallId) {
return {};
}
if (!config.enabled || !config.outputCompaction.enabled) { if (!config.enabled || !config.outputCompaction.enabled) {
return {}; return {};
} }
@@ -485,3 +497,12 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
} }
}); });
} }
export function createRtkOptimizerExtension(
options?: RtkOptimizerExtensionOptions,
): (pi: ExtensionAPI) => void {
const configPath = options?.configPath ?? CONFIG_PATH;
return (pi) => rtkIntegrationExtension(pi, configPath);
}
export default createRtkOptimizerExtension();
+1
View File
@@ -76,6 +76,7 @@ declare module "@earendil-works/pi-coding-agent" {
input: Record<string, unknown>; input: Record<string, unknown>;
content: Array<Record<string, unknown>>; content: Array<Record<string, unknown>>;
details?: unknown; details?: unknown;
parentToolCallId?: string;
} }
export interface BashToolCallEvent { export interface BashToolCallEvent {
+2 -2
View File
@@ -19,9 +19,9 @@
"@earendil-works/pi-coding-agent": "latest", "@earendil-works/pi-coding-agent": "latest",
"@earendil-works/pi-tui": "latest", "@earendil-works/pi-tui": "latest",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@vitest/coverage-istanbul": "^4.1.10", "@vitest/coverage-istanbul": "4.1.10",
"typescript": "^6.0.0", "typescript": "^6.0.0",
"vitest": "^4.0.18" "vitest": "4.1.10"
}, },
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-ai": ">=0.81.0", "@earendil-works/pi-ai": ">=0.81.0",
+2 -2
View File
@@ -61,9 +61,9 @@
"@earendil-works/pi-coding-agent": "latest", "@earendil-works/pi-coding-agent": "latest",
"@earendil-works/pi-tui": "latest", "@earendil-works/pi-tui": "latest",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@vitest/coverage-istanbul": "^4.1.10", "@vitest/coverage-istanbul": "4.1.10",
"typescript": "^6.0.0", "typescript": "^6.0.0",
"vitest": "^4.0.18" "vitest": "4.1.10"
}, },
"pi": { "pi": {
"extensions": [ "extensions": [
+1 -1
View File
@@ -3,7 +3,7 @@
- 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `extensions/catalog.ts`,配置读取与路径解析位于 `extensions/config.ts`(直接读取组合包 `config/tool-search.json`;目录缓存仍在 `<agentDir>/my-pi/tool-search/catalog-v1.json`),测试保留在 `test/`。 - 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `extensions/catalog.ts`,配置读取与路径解析位于 `extensions/config.ts`(直接读取组合包 `config/tool-search.json`;目录缓存仍在 `<agentDir>/my-pi/tool-search/catalog-v1.json`),测试保留在 `test/`。
- 同步上游必须记录明确 tag/commit,保留 README、CHANGELOG、文档、作者和许可证说明;不得导入上游 `.pi`、嵌套 `.git`、`node_modules` 或构建产物。 - 同步上游必须记录明确 tag/commit,保留 README、CHANGELOG、文档、作者和许可证说明;不得导入上游 `.pi`、嵌套 `.git`、`node_modules` 或构建产物。
- 根包必须通过 `file:./pi-tool-search` 提供本地依赖并直接加载仓库源码,不得同时安装或加载 npm `pi-tool-search` 实现;同步上游时先核对本地增量动态工具改造,禁止用旧 npm 入口或上游旧宿主实现直接覆盖。 - 根包必须通过 `file:./pi-tool-search` 提供本地依赖并直接加载仓库源码,不得同时安装或加载 npm `pi-tool-search` 实现;同步上游时先核对本地增量动态工具改造,禁止用旧 npm 入口或上游旧宿主实现直接覆盖。
- 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、六个记忆工具与 `context_tree_query`、`overflow_query`,以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。配置从已安装组合包内的 `config/tool-search.json` 直接读取,文件缺失或无效时回退 `BUNDLE_TOOL_SEARCH_DEFAULTS`;扩展自身不播种、不迁移,也不再读写 `settings.json` 的 `toolSearch` 段。 - 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、六个记忆工具与 `context_tree_query`、`overflow_query`、内置 `codemode`(exposure `model-only`),以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。exposure 为 `hidden` 或 `codemode`(script-only、模型不可直达)的工具一律不得进入目录或常驻集合。配置从已安装组合包内的 `config/tool-search.json` 直接读取,文件缺失或无效时回退 `BUNDLE_TOOL_SEARCH_DEFAULTS`;扩展自身不播种、不迁移,也不再读写 `settings.json` 的 `toolSearch` 段。
- 其余组合包工具必须优先使用 `extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。 - 其余组合包工具必须优先使用 `extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。
- 标准组合包工具全部命中预置目录时不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。 - 标准组合包工具全部命中预置目录时不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
- 必须直接使用当前 `@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` 只能在调用时裁决。 - 必须直接使用当前 `@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` 只能在调用时裁决。
+3
View File
@@ -3,6 +3,9 @@
## Unreleased — my-pi maintained source ## Unreleased — my-pi maintained source
### Changed ### Changed
- Removed `multi_grep` from the `filesystem-navigation` group: the bundle wrapper keeps FFF's upstream `PI_FFF_MULTIGREP` opt-in disabled, so the tool is never registered and the group now covers only `ls`.
- Pinned Pi's built-in `codemode` (exposure `model-only`) in `alwaysEnabled` so batching several tool calls through one script never depends on a `tool_search` activation; `platformToolPolicy` now also drops tools with `hidden` or `codemode` (script-only) exposure so they can never enter the catalog or the unknown-tools path.
- Removed the `PI_TOOL_SEARCH_CONFIG_PATH` environment override; the extension reads the bundle `config/tool-search.json`, and tests inject a path through the `createToolSearchExtension({ configPath })` factory.
- Removed the retired context-mode integration: the `ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` pins left `alwaysEnabled` and the four `context-*` bundle groups were dropped. Large tool results are now bounded by pi-overflow and recoverable through the pinned `overflow_query`. - Removed the retired context-mode integration: the `ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` pins left `alwaysEnabled` and the four `context-*` bundle groups were dropped. Large tool results are now bounded by pi-overflow and recoverable through the pinned `overflow_query`.
- Imported upstream `v0.3.6` at `ddfb23646fd3957b791214de278e23aa393c9b13` for direct local maintenance. - Imported upstream `v0.3.6` at `ddfb23646fd3957b791214de278e23aa393c9b13` for direct local maintenance.
- Migrated runtime imports to `@earendil-works/pi-coding-agent` and current `typebox`. - Migrated runtime imports to `@earendil-works/pi-coding-agent` and current `typebox`.
+6 -5
View File
@@ -10,7 +10,7 @@ Generated metadata never replaces executable schemas. Exact names, parameter typ
## Catalog lifecycle ## Catalog lifecycle
1. `session_start` keeps `tool_search`, Pi core tools, and configured `alwaysEnabled` names active. PowerShell is filtered out on non-Windows hosts; on Windows it is pinned beside Bash as the same local-shell capability class. 1. `session_start` keeps `tool_search`, Pi core tools, and configured `alwaysEnabled` names active. PowerShell is filtered out on non-Windows hosts; on Windows it is pinned beside Bash as the same local-shell capability class. Tools whose exposure is `hidden` or `codemode` (script-only, unreachable by the model directly) are excluded entirely; `model-only` tools such as the built-in `codemode` stay reachable and can be pinned.
2. Hidden tool definitions are hashed with the grouping constraints. 2. Hidden tool definitions are hashed with the grouping constraints.
3. Every tool exposed by the standard `my-pi` bundle is matched against the checked-in catalog in `extensions/bundle-groups.ts`. If all tools are recognized, no model call and no user cache are needed. 3. Every tool exposed by the standard `my-pi` bundle is matched against the checked-in catalog in `extensions/bundle-groups.ts`. If all tools are recognized, no model call and no user cache are needed.
4. User `groupOverrides` take priority over checked-in assignments. Optional or unavailable bundle tools are simply filtered out of their predefined groups. 4. User `groupOverrides` take priority over checked-in assignments. Optional or unavailable bundle tools are simply filtered out of their predefined groups.
@@ -26,7 +26,7 @@ Run `/tool-search-status` to inspect the current catalog source, pinned count, d
| Group | Bundle tools | | Group | Bundle tools |
|---|---| |---|---|
| `filesystem-navigation` | `ls`, `multi_grep` | | `filesystem-navigation` | `ls` |
| `ssh-connection` | `ssh_connect` | | `ssh-connection` | `ssh_connect` |
| `ssh-remote-files` | `ssh_read`, `ssh_write`, `ssh_edit` | | `ssh-remote-files` | `ssh_read`, `ssh_write`, `ssh_edit` |
| `ssh-remote-search` | `ssh_find`, `ssh_grep` | | `ssh-remote-search` | `ssh_find`, `ssh_grep` |
@@ -41,7 +41,7 @@ Run `/tool-search-status` to inspect the current catalog source, pinned count, d
| `chrome-interaction` | Chrome click/type/fill/key/hover/drag/tap/scroll input | | `chrome-interaction` | Chrome click/type/fill/key/hover/drag/tap/scroll input |
| `chrome-debugging` | Chrome page evaluation, console/network diagnostics, response retrieval, and file upload | | `chrome-debugging` | Chrome page evaluation, console/network diagnostics, response retrieval, and file upload |
Pinned tools are removed from these dynamic groups at runtime. The defaults pin `codegraph_explore`, `lsp_diagnostics`, and all six Hermes memory tools, so none of them consume dynamic group or tool capacity. Pinned tools are removed from these dynamic groups at runtime. The defaults pin `codegraph_explore`, `lsp_diagnostics`, all six Hermes memory tools, `context_tree_query`, `overflow_query`, and `codemode`, so none of them consume dynamic group or tool capacity.
## Loading and eviction ## Loading and eviction
@@ -81,7 +81,8 @@ Pi reads this configuration directly from the installed bundle's `config/tool-se
"memory_remove", "memory_remove",
"skill_manage", "skill_manage",
"context_tree_query", "context_tree_query",
"overflow_query" "overflow_query",
"codemode"
], ],
"showToolSearchFooterStatus": false, "showToolSearchFooterStatus": false,
"maxActiveGroups": 5, "maxActiveGroups": 5,
@@ -93,7 +94,7 @@ Pi reads this configuration directly from the installed bundle's `config/tool-se
| Key | Bundle default | Description | | Key | Bundle default | Description |
|---|---|---| |---|---|---|
| `alwaysEnabled` | CodeGraph explore, LSP diagnostics, all six Hermes memory tools, `context_tree_query`, `overflow_query` | Exact names pinned alongside Pi core tools | | `alwaysEnabled` | CodeGraph explore, LSP diagnostics, all six Hermes memory tools, `context_tree_query`, `overflow_query`, `codemode` | Exact names pinned alongside Pi core tools |
| `showToolSearchFooterStatus` | `false` | Show active tool/group counts | | `showToolSearchFooterStatus` | `false` | Show active tool/group counts |
| `maxActiveGroups` | `5` | Maximum simultaneously active dynamic groups | | `maxActiveGroups` | `5` | Maximum simultaneously active dynamic groups |
| `maxToolsPerGroup` | `8` | Maximum size accepted from model-generated groups | | `maxToolsPerGroup` | `8` | Maximum size accepted from model-generated groups |
+1 -1
View File
@@ -2,7 +2,7 @@
`pi-tool-search` keeps full low-frequency schemas hidden behind a compact, validated group catalog. `pi-tool-search` keeps full low-frequency schemas hidden behind a compact, validated group catalog.
1. `session_start` activates `tool_search`, the six cross-platform Pi core tools, and configured `alwaysEnabled` names. The my-pi defaults also pin CodeGraph exploration, LSP diagnostics, and the six Hermes memory tools. On Windows, `powershell` is pinned beside `bash`; on non-Windows hosts it is excluded. Pinned tools do not consume the dynamic-group or dynamic-tool budget. 1. `session_start` activates `tool_search`, the six cross-platform Pi core tools, and configured `alwaysEnabled` names. The my-pi defaults also pin CodeGraph exploration, LSP diagnostics, the six Hermes memory tools, and Pi's built-in `codemode` (exposure `model-only`). On Windows, `powershell` is pinned beside `bash`; on non-Windows hosts it is excluded. Tools with `hidden` or `codemode` (script-only) exposure are filtered out before catalog and pin processing. Pinned tools do not consume the dynamic-group or dynamic-tool budget.
2. Hidden definitions are hashed with grouping constraints and matched against `extensions/bundle-groups.ts`. Available standard tools receive checked-in groups; unavailable optional tools are omitted. 2. Hidden definitions are hashed with grouping constraints and matched against `extensions/bundle-groups.ts`. Available standard tools receive checked-in groups; unavailable optional tools are omitted.
3. If every hidden tool is recognized, the bundle catalog is used directly. No model call or per-user cache is required. 3. If every hidden tool is recognized, the bundle catalog is used directly. No model call or per-user cache is required.
4. Additional tools receive immediate deterministic prefix/source groups. A matching model-enriched cache is reused only if it preserves all checked-in bundle assignments. 4. Additional tools receive immediate deterministic prefix/source groups. A matching model-enriched cache is reused only if it preserves all checked-in bundle assignments.
+4 -4
View File
@@ -25,11 +25,11 @@ export const BUNDLE_GROUP_DEFINITIONS: BundleGroupDefinition[] = [
{ {
id: "filesystem-navigation", id: "filesystem-navigation",
title: "Filesystem navigation", title: "Filesystem navigation",
summary: "Inspect directory layouts and run bounded multi-pattern literal searches.", summary: "Inspect directory layouts.",
useWhen: ["You need directory entries or several literal searches after narrowing a path"], useWhen: ["You need the alphabetical directory entries of an unknown path"],
avoidWhen: ["Code relationships or types are the real question"], avoidWhen: ["Code relationships or types are the real question"],
keywords: ["files", "directory", "list", "multi grep", "目录", "文件", "多模式搜索"], keywords: ["files", "directory", "list", "目录", "文件"],
tools: ["ls", "multi_grep"], tools: ["ls"],
}, },
{ {
id: "ssh-connection", id: "ssh-connection",
+1 -2
View File
@@ -11,8 +11,6 @@ const TOOL_SEARCH_DIR = join("my-pi", "tool-search");
* agent directory. * agent directory.
*/ */
export function getToolSearchConfigPath(): string { export function getToolSearchConfigPath(): string {
const override = process.env.PI_TOOL_SEARCH_CONFIG_PATH;
if (override) return override;
return fileURLToPath(new URL("../../config/tool-search.json", import.meta.url)); return fileURLToPath(new URL("../../config/tool-search.json", import.meta.url));
} }
@@ -33,6 +31,7 @@ export const BUNDLE_TOOL_SEARCH_DEFAULTS = {
"skill_manage", "skill_manage",
"context_tree_query", "context_tree_query",
"overflow_query", "overflow_query",
"codemode",
], ],
showToolSearchFooterStatus: false, showToolSearchFooterStatus: false,
maxActiveGroups: 5, maxActiveGroups: 5,
+26 -7
View File
@@ -28,7 +28,7 @@ import {
createBundleCatalog, createBundleCatalog,
type BundleCatalogResult, type BundleCatalogResult,
} from "./bundle-groups.ts"; } from "./bundle-groups.ts";
import { getToolSearchCatalogPath, readToolSearchConfig } from "./config.ts"; import { getToolSearchCatalogPath, getToolSearchConfigPath, readToolSearchConfig } from "./config.ts";
const TOOL_SEARCH_NAME = "tool_search"; const TOOL_SEARCH_NAME = "tool_search";
const POWERSHELL_TOOL_NAME = "powershell"; const POWERSHELL_TOOL_NAME = "powershell";
@@ -39,13 +39,19 @@ type CatalogSource = "bundle" | "cache" | "fallback" | "hybrid" | "model";
type ModelUsage = Awaited<ReturnType<ExtensionContext["modelRegistry"]["complete"]>>["usage"]; type ModelUsage = Awaited<ReturnType<ExtensionContext["modelRegistry"]["complete"]>>["usage"];
type CatalogGenerationResult = { notice?: string; usage?: ModelUsage }; type CatalogGenerationResult = { notice?: string; usage?: ModelUsage };
export function platformToolPolicy<T extends { name: string }>( export function platformToolPolicy<T extends { name: string; exposure?: string }>(
definitions: readonly T[], definitions: readonly T[],
platform: NodeJS.Platform, platform: NodeJS.Platform,
): { tools: T[]; coreTools: string[] } { ): { tools: T[]; coreTools: string[] } {
// Script-only and hidden tools are unreachable by the model directly and
// must never enter the catalog (or the pinned set); "model-only" tools like
// codemode stay reachable and can be pinned.
const reachable = definitions.filter(
(tool) => tool.exposure !== "hidden" && tool.exposure !== "codemode",
);
const tools = platform === "win32" const tools = platform === "win32"
? [...definitions] ? [...reachable]
: definitions.filter((tool) => tool.name !== POWERSHELL_TOOL_NAME); : reachable.filter((tool) => tool.name !== POWERSHELL_TOOL_NAME);
const coreTools = platform === "win32" const coreTools = platform === "win32"
? [...CORE_TOOLS, POWERSHELL_TOOL_NAME] ? [...CORE_TOOLS, POWERSHELL_TOOL_NAME]
: [...CORE_TOOLS]; : [...CORE_TOOLS];
@@ -110,11 +116,11 @@ function groupDescription(catalog: ToolCatalog, source: CatalogSource): string {
].join("\n"); ].join("\n");
} }
export default function toolSearchExtension(pi: ExtensionAPI): void { function toolSearchExtension(pi: ExtensionAPI, configPath: string): void {
const agentDir = getAgentDir(); const agentDir = getAgentDir();
const cachePath = getToolSearchCatalogPath(agentDir); const cachePath = getToolSearchCatalogPath(agentDir);
let config = readToolSearchConfig(); let config = readToolSearchConfig(configPath);
let tools: ToolInfo[] = []; let tools: ToolInfo[] = [];
let catalog: ToolCatalog = createFallbackCatalog([], "", { let catalog: ToolCatalog = createFallbackCatalog([], "", {
maxToolsPerGroup: Math.min(config.maxToolsPerGroup, config.maxDynamicTools), maxToolsPerGroup: Math.min(config.maxToolsPerGroup, config.maxDynamicTools),
@@ -440,7 +446,7 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
} }
function refreshState(ctx: Pick<ExtensionContext, "ui">, forceReset: boolean): void { function refreshState(ctx: Pick<ExtensionContext, "ui">, forceReset: boolean): void {
const nextConfig = readToolSearchConfig(); const nextConfig = readToolSearchConfig(configPath);
const registeredTools = pi.getAllTools().filter((tool) => tool.name !== TOOL_SEARCH_NAME); const registeredTools = pi.getAllTools().filter((tool) => tool.name !== TOOL_SEARCH_NAME);
const platformPolicy = platformToolPolicy(registeredTools, process.platform); const platformPolicy = platformToolPolicy(registeredTools, process.platform);
const allTools = platformPolicy.tools; const allTools = platformPolicy.tools;
@@ -542,3 +548,16 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
if (group && activeGroups.has(group)) activeGroups.set(group, ++clock); if (group && activeGroups.has(group)) activeGroups.set(group, ++clock);
}); });
} }
export interface ToolSearchExtensionOptions {
configPath?: string;
}
export function createToolSearchExtension(
options?: ToolSearchExtensionOptions,
): (pi: ExtensionAPI) => void {
const configPath = options?.configPath ?? getToolSearchConfigPath();
return (pi) => toolSearchExtension(pi, configPath);
}
export default createToolSearchExtension();
@@ -10,6 +10,7 @@ function tool(name: string): ToolInfo {
name, name,
description: `Full description for ${name}`, description: `Full description for ${name}`,
parameters: { type: "object", properties: {} } as ToolInfo["parameters"], parameters: { type: "object", properties: {} } as ToolInfo["parameters"],
exposure: "direct",
sourceInfo: { source: "extension", scope: "user", path: `/test/${name}.ts` } as unknown as ToolInfo["sourceInfo"], sourceInfo: { source: "extension", scope: "user", path: `/test/${name}.ts` } as unknown as ToolInfo["sourceInfo"],
}; };
} }
+1
View File
@@ -21,6 +21,7 @@ function tool(name: string, description: string): ToolInfo {
name, name,
description, description,
parameters: { type: "object", properties: { query: { type: "string" } } } as ToolInfo["parameters"], parameters: { type: "object", properties: { query: { type: "string" } } } as ToolInfo["parameters"],
exposure: "direct",
sourceInfo: { source: "extension", scope: "user", path: `/test/${name}.ts` } as unknown as ToolInfo["sourceInfo"], sourceInfo: { source: "extension", scope: "user", path: `/test/${name}.ts` } as unknown as ToolInfo["sourceInfo"],
}; };
} }
+1
View File
@@ -41,6 +41,7 @@ test("bundle defaults keep the committed tool list", () => {
"skill_manage", "skill_manage",
"context_tree_query", "context_tree_query",
"overflow_query", "overflow_query",
"codemode",
]); ]);
assert.deepEqual(bundleFallbackConfig(), { assert.deepEqual(bundleFallbackConfig(), {
alwaysEnabled: [...BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled], alwaysEnabled: [...BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled],
+55 -7
View File
@@ -5,7 +5,7 @@ import { join } from "node:path";
import test from "node:test"; import test from "node:test";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import toolSearchExtension, { platformToolPolicy } from "../extensions/index.ts"; import toolSearchExtension, { createToolSearchExtension, platformToolPolicy } from "../extensions/index.ts";
interface ToolSearchResult { interface ToolSearchResult {
details: { details: {
@@ -49,13 +49,12 @@ function testContext(overrides: Partial<ExtensionContext> = {}): ExtensionContex
test("loads whole groups and evicts the least-recently-used group", async () => { test("loads whole groups and evicts the least-recently-used group", async () => {
const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-extension-")); const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-extension-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR; const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
const previousConfigPath = process.env.PI_TOOL_SEARCH_CONFIG_PATH; const configPath = join(agentDir, "tool-search-config.json");
process.env.PI_CODING_AGENT_DIR = agentDir; process.env.PI_CODING_AGENT_DIR = agentDir;
process.env.PI_TOOL_SEARCH_CONFIG_PATH = join(agentDir, "tool-search-config.json");
try { try {
await writeFile( await writeFile(
process.env.PI_TOOL_SEARCH_CONFIG_PATH, configPath,
JSON.stringify({ JSON.stringify({
alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"], alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"],
showToolSearchFooterStatus: false, showToolSearchFooterStatus: false,
@@ -97,7 +96,7 @@ test("loads whole groups and evicts the least-recently-used group", async () =>
on: (event: string, handler: (...args: any[]) => unknown) => handlers.set(event, handler), on: (event: string, handler: (...args: any[]) => unknown) => handlers.set(event, handler),
} as unknown as ExtensionAPI; } as unknown as ExtensionAPI;
toolSearchExtension(api); createToolSearchExtension({ configPath })(api);
const ctx = testContext({ const ctx = testContext({
hasUI: true, hasUI: true,
ui: { ui: {
@@ -159,8 +158,6 @@ test("loads whole groups and evicts the least-recently-used group", async () =>
} finally { } finally {
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousAgentDir; else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
if (previousConfigPath === undefined) delete process.env.PI_TOOL_SEARCH_CONFIG_PATH;
else process.env.PI_TOOL_SEARCH_CONFIG_PATH = previousConfigPath;
await rm(agentDir, { recursive: true, force: true }); await rm(agentDir, { recursive: true, force: true });
} }
}); });
@@ -328,6 +325,57 @@ test("keeps PowerShell with Bash only on Windows", () => {
assert.ok(windows.coreTools.includes("powershell")); assert.ok(windows.coreTools.includes("powershell"));
}); });
test("drops hidden and script-only tools but keeps reachable exposures", () => {
const definitions = [
sourceTool("read", "Read a file"),
{ ...sourceTool("hidden_helper", "Internal helper"), exposure: "hidden" },
{ ...sourceTool("script_only", "Only callable from scripts"), exposure: "codemode" },
{ ...sourceTool("codemode", "Batch several tool calls in one script"), exposure: "model-only" },
{ ...sourceTool("lazy_tool", "Activated on demand"), exposure: "deferred" },
];
const { tools } = platformToolPolicy(definitions, "darwin");
assert.deepEqual(tools.map((tool) => tool.name), ["read", "codemode", "lazy_tool"]);
});
test("pins a model-only codemode tool instead of hiding it", async () => {
const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-codemode-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
try {
const handlers = new Map<string, (...args: any[]) => unknown>();
const registered = new Map<string, RegisteredTool>();
const activeCalls: string[][] = [];
const sourceTools = [
sourceTool("read", "Read a file"),
{ ...sourceTool("codemode", "Batch several tool calls in one script"), exposure: "model-only" },
];
const api = {
getAllTools: () => sourceTools,
registerTool: (definition: unknown) => {
const tool = definition as RegisteredTool & { name: string };
registered.set(tool.name, tool);
},
registerCommand: () => {},
setActiveTools: (names: string[]) => activeCalls.push([...names]),
on: (event: string, handler: (...args: any[]) => unknown) => handlers.set(event, handler),
} as unknown as ExtensionAPI;
// The bundle config pins "codemode" in alwaysEnabled; exposure "model-only"
// stays reachable, so it must land in the active set, not a dynamic group.
toolSearchExtension(api);
handlers.get("session_start")?.({}, testContext());
assert.ok(activeCalls.at(-1)?.includes("codemode"));
assert.ok(activeCalls.at(-1)?.includes("tool_search"));
assert.ok(activeCalls.at(-1)?.includes("read"));
} finally {
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
await rm(agentDir, { recursive: true, force: true });
}
});
test("loads an exact known group without waiting for unknown-tool model enrichment", async () => { test("loads an exact known group without waiting for unknown-tool model enrichment", async () => {
const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-fast-path-")); const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-fast-path-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR; const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
+1 -1
View File
@@ -5,4 +5,4 @@
- `web_fetch` 固定走 Exa `web_fetch_exa`,失败时静默降级 Keenable → Tavily;Exa 搜索统一走 `web_search_advanced_exa` 并显式约束 `numResults` 与 `textMaxCharacters`;Tavily 默认 `search_depth: basic` 且必须在 `auth.json` 存在 `tavily` api_key 凭据才参与路由;Keenable keyless 可用,`keenable` 凭据仅提高速率限制。 - `web_fetch` 固定走 Exa `web_fetch_exa`,失败时静默降级 Keenable → Tavily;Exa 搜索统一走 `web_search_advanced_exa` 并显式约束 `numResults` 与 `textMaxCharacters`;Tavily 默认 `search_depth: basic` 且必须在 `auth.json` 存在 `tavily` api_key 凭据才参与路由;Keenable keyless 可用,`keenable` 凭据仅提高速率限制。
- `clients/` 为纯 `fetch` 直连,不使用 `pi-mcp-adapter` 或任何 MCP SDK:Tavily 走 `api.tavily.com`(必须有 key);Keenable 复刻上游 client,保留 keyless `/public` 端点与 SSRF 防护;Exa 直接对 `https://mcp.exa.ai/mcp` 发 JSON-RPC——`initialize` 一次并缓存 `mcp-session-id`,随后 `tools/call`,HTTP 404 视为会话失效自动重建并重试一次;`exa` 凭据通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。 - `clients/` 为纯 `fetch` 直连,不使用 `pi-mcp-adapter` 或任何 MCP SDK:Tavily 走 `api.tavily.com`(必须有 key);Keenable 复刻上游 client,保留 keyless `/public` 端点与 SSRF 防护;Exa 直接对 `https://mcp.exa.ai/mcp` 发 JSON-RPC——`initialize` 一次并缓存 `mcp-session-id`,随后 `tools/call`,HTTP 404 视为会话失效自动重建并重试一次;`exa` 凭据通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。
- 搜索 key 统一保存在 Pi 的 `auth.json`(`tavily`/`exa`/`keenable` 各一条 `api_key` 凭据,文件权限 `600` 且不得提交);key 支持 `!command` 执行、`$VAR`/`${VAR}` 插值间接寻址,`$$`/`$!` 转义。credential `env` 优先于进程 env;显式进程环境变量不再直接读取,env 注入通过 `"key": "$VAR"` 表达。 - 搜索 key 统一保存在 Pi 的 `auth.json`(`tavily`/`exa`/`keenable` 各一条 `api_key` 凭据,文件权限 `600` 且不得提交);key 支持 `!command` 执行、`$VAR`/`${VAR}` 插值间接寻址,`$$`/`$!` 转义。credential `env` 优先于进程 env;显式进程环境变量不再直接读取,env 注入通过 `"key": "$VAR"` 表达。
- `configure.sh search` 与 `scripts/search-auth.mjs` 以交互或参数方式写入凭据:加锁后原子写、权限 `600`、保留全部其他条目,支持 `--migrate` 从旧 `search.env` 一次性导入;只输出配置状态,禁止回显 key。`auth.ts` 每次调用读取 `auth.json`。 - `configure.sh search` 与 `scripts/search-auth.mjs` 以交互或标准输入方式写入凭据,key 不得出现在命令参数中:加锁后原子写、权限 `600`、保留全部其他条目,支持 `--migrate` 从旧 `search.env` 一次性导入;只输出配置状态,禁止回显 key。`auth.ts` 每次调用读取 `auth.json`。
+4 -1
View File
@@ -30,7 +30,10 @@ Keys live in Pi's `auth.json` as one `api_key` credential per provider
(`tavily`, `exa`, `keenable`), read by `auth.ts` with upstream resolution (`tavily`, `exa`, `keenable`), read by `auth.ts` with upstream resolution
semantics — literal values, `$VAR`/`${VAR}` interpolation, and `!command` semantics — literal values, `$VAR`/`${VAR}` interpolation, and `!command`
indirection (e.g. `"!op read op://Vault/Tavily/key"`). `configure.sh search` indirection (e.g. `"!op read op://Vault/Tavily/key"`). `configure.sh search`
writes them (atomically, mode 600, preserving all other entries); process writes them via its hidden interactive prompt or `scripts/search-auth.mjs
--set-stdin` reading the key from stdin — a key is never accepted as a
command-line argument (atomically, mode 600, preserving all other entries);
process
environment variables are not read directly — use `"key": "$TAVILY_API_KEY"` environment variables are not read directly — use `"key": "$TAVILY_API_KEY"`
to indirect through env. `KEENABLE_API_URL` stays env-only, or can be stored to indirect through env. `KEENABLE_API_URL` stays env-only, or can be stored
in the keenable credential's `env` map. in the keenable credential's `env` map.
+9
View File
@@ -0,0 +1,9 @@
# 上游来源
- 本项目完全自维护,无上游;未从任何外部仓库或 npm 包导入源码。
- `clients/` 下 Tavily、Exa、Keenable client 是按上游语义复刻的纯 `fetch` 直连实现,遵循各自 upstream 的接口语义,未导入上游源码。
- 实现仅依赖 Pi 扩展宿主 API(`@earendil-works/pi-coding-agent`)与 `typebox`,两者均为组合包 peer 依赖。
## 本地改动记录
- 初始实现:`index.ts`(`web_search` / `web_fetch` 统一入口与工具注册)、`router.ts`(当前主模型按内置规则选择 provider)、`clients/`(Tavily / Exa / Keenable 直连)、`auth.ts`(auth.json 凭据解析)。
+8
View File
@@ -0,0 +1,8 @@
# 上游来源
- 本项目完全自维护,无上游;未从任何外部仓库或 npm 包导入源码。
- 实现仅依赖 Pi 扩展宿主 API(`@earendil-works/pi-coding-agent`、`@earendil-works/pi-tui`)与 `typebox`,均为组合包 peer 依赖;git 操作经 `execFile` argv 直接调用系统 `git`。
## 本地改动记录
- 初始实现:`index.ts`(`/wt` 斜杠命令入口)、`src/`(worktree 生命周期、状态登记、会话切换与环境复刻)。
+4 -4
View File
@@ -17,16 +17,16 @@ warn() {
detect_kitty() { detect_kitty() {
kitty_bin= kitty_bin=
if command -v kitty >/dev/null 2>&1; then
kitty_bin=$(command -v kitty)
return 0
fi
for candidate in /Applications/kitty.app/Contents/MacOS/kitty "$HOME/.local/kitty.app/bin/kitty"; do for candidate in /Applications/kitty.app/Contents/MacOS/kitty "$HOME/.local/kitty.app/bin/kitty"; do
if [ -x "$candidate" ]; then if [ -x "$candidate" ]; then
kitty_bin=$candidate kitty_bin=$candidate
return 0 return 0
fi fi
done done
if command -v kitty >/dev/null 2>&1; then
kitty_bin=$(command -v kitty)
return 0
fi
return 1 return 1
} }
+15 -14
View File
@@ -5,7 +5,7 @@
* writes atomically with a lock directory, and keeps the file mode 600. * writes atomically with a lock directory, and keeps the file mode 600.
* *
* Usage: * Usage:
* node search-auth.mjs --set tavily=KEY [--set exa=KEY] [--set keenable=KEY] * node search-auth.mjs --set-stdin tavily (reads the key from stdin)
* node search-auth.mjs --unset tavily [--unset exa] * node search-auth.mjs --unset tavily [--unset exa]
* node search-auth.mjs --migrate [search.env path] * node search-auth.mjs --migrate [search.env path]
* node search-auth.mjs --list * node search-auth.mjs --list
@@ -47,7 +47,7 @@ function die(message) {
function usage() { function usage() {
console.log(`用法: console.log(`用法:
node search-auth.mjs --set <provider>=<key> [--set ...] node search-auth.mjs --set-stdin <provider> (从标准输入读取 key,每次只能配置一个 provider)
node search-auth.mjs --unset <provider> [--unset ...] node search-auth.mjs --unset <provider> [--unset ...]
node search-auth.mjs --migrate [search.env 路径] node search-auth.mjs --migrate [search.env 路径]
node search-auth.mjs --list node search-auth.mjs --list
@@ -69,17 +69,16 @@ function defaultSearchEnvPath(env = process.env) {
} }
function parseArgs(argv) { function parseArgs(argv) {
const result = { sets: {}, unsets: new Set(), migrate: undefined, list: false, has: undefined, authPath: undefined }; const result = { setStdin: undefined, unsets: new Set(), migrate: undefined, list: false, has: undefined, authPath: undefined };
for (let i = 0; i < argv.length; i++) { for (let i = 0; i < argv.length; i++) {
const arg = argv[i]; const arg = argv[i];
if (arg === "--set") { if (arg === "--set") {
const spec = argv[++i]; die("--set 已移除:key 不得出现在命令行参数中;请改用 --set-stdin 从标准输入提供。");
const separator = spec ? spec.indexOf("=") : -1; } else if (arg === "--set-stdin") {
if (separator < 1) die("--set 需要 <provider>=<key>"); const provider = argv[++i];
const provider = spec.slice(0, separator);
const value = spec.slice(separator + 1);
if (!PROVIDERS.includes(provider)) die(`未知 provider:${provider}(支持 ${PROVIDERS.join("/")})`); if (!PROVIDERS.includes(provider)) die(`未知 provider:${provider}(支持 ${PROVIDERS.join("/")})`);
result.sets[provider] = value; if (result.setStdin) die("每次调用最多一个 --set-stdin。");
result.setStdin = provider;
} else if (arg === "--unset") { } else if (arg === "--unset") {
const provider = argv[++i]; const provider = argv[++i];
if (!PROVIDERS.includes(provider)) die(`未知 provider:${provider}(支持 ${PROVIDERS.join("/")})`); if (!PROVIDERS.includes(provider)) die(`未知 provider:${provider}(支持 ${PROVIDERS.join("/")})`);
@@ -208,12 +207,15 @@ if (args.has) {
process.exit(hasKey(readAuthData(authPath), args.has) ? 0 : 1); process.exit(hasKey(readAuthData(authPath), args.has) ? 0 : 1);
} }
const mutations = Object.keys(args.sets).length + args.unsets.size + (args.migrate ? 1 : 0); const mutations = (args.setStdin ? 1 : 0) + args.unsets.size + (args.migrate ? 1 : 0);
if (mutations === 0) { if (mutations === 0) {
usage(); usage();
process.exit(2); process.exit(2);
} }
const stdinKey = args.setStdin ? readFileSync(0, "utf8").trim() : undefined;
if (args.setStdin) validateKey(args.setStdin, stdinKey);
const release = acquireLock(authPath); const release = acquireLock(authPath);
try { try {
const data = readAuthData(authPath); const data = readAuthData(authPath);
@@ -225,10 +227,9 @@ try {
changed.push(`已移除 ${provider}`); changed.push(`已移除 ${provider}`);
} }
} }
for (const [provider, key] of Object.entries(args.sets)) { if (args.setStdin) {
validateKey(provider, key); setKey(data, args.setStdin, stdinKey);
setKey(data, provider, key); changed.push(`已写入 ${args.setStdin}`);
changed.push(`已写入 ${provider}`);
} }
if (args.migrate) { if (args.migrate) {
const source = args.migrate === true ? defaultSearchEnvPath() : args.migrate; const source = args.migrate === true ? defaultSearchEnvPath() : args.migrate;
+21 -33
View File
@@ -13,13 +13,13 @@ import {
getToolSearchCatalogPath, getToolSearchCatalogPath,
getToolSearchConfigPath, getToolSearchConfigPath,
} from "../pi-tool-search/extensions/config.ts"; } from "../pi-tool-search/extensions/config.ts";
import { import { CONFIG_PATH as RTK_CONFIG_PATH } from "../pi-rtk-optimizer/src/constants.ts";
CONFIG_PATH as RTK_CONFIG_PATH,
resolveConfigPath as resolveRtkConfigPath,
} 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 { resolvePreamblePath } from "../extensions/system-prompt-config.ts"; import {
resolvePreamblePath,
stripJsoncComments,
} 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");
@@ -51,9 +51,9 @@ const BUNDLE_CONFIG_FILES = [
"p10k.zsh", "p10k.zsh",
]; ];
/** Strip JSONC comments (line and block) — same approach as the seed test. */ /** Strip JSONC comments (line and block) without touching string literals. */
function stripJsonc(raw: string): string { function stripJsonc(raw: string): string {
return raw.replace(/^\s*\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); return stripJsoncComments(raw);
} }
test("every bundle config file exists and is non-empty", async () => { test("every bundle config file exists and is non-empty", async () => {
@@ -75,33 +75,21 @@ test("every bundle JSON/JSONC config file parses", async () => {
}); });
test("each extension resolves its config path inside the bundle config/ dir", () => { test("each extension resolves its config path inside the bundle config/ dir", () => {
const savedToolSearch = process.env.PI_TOOL_SEARCH_CONFIG_PATH; const inBundleConfig = (resolved: string, file: string) =>
const savedRtk = process.env.PI_RTK_CONFIG_PATH; assert.equal(
delete process.env.PI_TOOL_SEARCH_CONFIG_PATH; resolved,
delete process.env.PI_RTK_CONFIG_PATH; join(bundleConfigDir, file),
try { `expected the bundle config path for ${file}`,
const inBundleConfig = (resolved: string, file: string) => );
assert.equal(
resolved,
join(bundleConfigDir, file),
`expected the bundle config path for ${file}`,
);
inBundleConfig(getGlobalConfigPath(), "pi-permission-policy.jsonc"); inBundleConfig(getGlobalConfigPath(), "pi-permission-policy.jsonc");
inBundleConfig(getHardGuardConfigPath(), "pi-permission-hard-guards.jsonc"); inBundleConfig(getHardGuardConfigPath(), "pi-permission-hard-guards.jsonc");
inBundleConfig(getToolSearchConfigPath(), "tool-search.json"); inBundleConfig(getToolSearchConfigPath(), "tool-search.json");
inBundleConfig(RTK_CONFIG_PATH, "rtk-optimizer.json"); inBundleConfig(RTK_CONFIG_PATH, "rtk-optimizer.json");
inBundleConfig(resolveRtkConfigPath(), "rtk-optimizer.json"); 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(resolvePreamblePath(), "prompt/preamble.md");
inBundleConfig(resolvePreamblePath(), "prompt/preamble.md");
} finally {
if (savedToolSearch === undefined) delete process.env.PI_TOOL_SEARCH_CONFIG_PATH;
else process.env.PI_TOOL_SEARCH_CONFIG_PATH = savedToolSearch;
if (savedRtk === undefined) delete process.env.PI_RTK_CONFIG_PATH;
else process.env.PI_RTK_CONFIG_PATH = savedRtk;
}
}); });
test("disposable runtime state stays under the agent-dir my-pi tree", () => { test("disposable runtime state stays under the agent-dir my-pi tree", () => {
+34
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { import {
execFileSync, execFileSync,
spawn, spawn,
@@ -13,6 +14,7 @@ import {
} from "node:fs/promises"; } 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 { PassThrough } from "node:stream";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import test from "node:test"; import test from "node:test";
@@ -278,6 +280,38 @@ test("client close() terminates the child process", async () => {
} }
}); });
test("a stdin EPIPE after child death rejects pending requests without throwing", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
const child = new EventEmitter() as ChildProcess;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.exitCode = null;
child.signalCode = null;
const client = new CodegraphRpcClient({
root: dir,
spawn: () => child,
requestTimeoutMs: 5_000,
});
try {
const start = client.start();
await sleep(20);
// Without the stdin "error" listener this emit throws uncaught.
child.stdin.emit(
"error",
Object.assign(new Error("write EPIPE"), { code: "EPIPE" }),
);
child.exitCode = 1;
child.emit("exit", 1, null);
child.emit("close", 1, null);
await assert.rejects(start, /子进程退出/);
assert.equal(client.dead, true);
} finally {
await client.close();
await rm(dir, { recursive: true, force: true });
}
});
// ── handler ───────────────────────────────────────────────────────────────── // ── handler ─────────────────────────────────────────────────────────────────
test("indexed project explores through the serve child", async () => { test("indexed project explores through the serve child", async () => {
+110 -3
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile } from "node:fs/promises"; import { mkdir, mkdtemp, readFile, writeFile } 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";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -58,6 +58,11 @@ test("all package extensions load together without global registration conflicts
packageJson.pi.extensions.includes("./extensions/codegraph.ts"), packageJson.pi.extensions.includes("./extensions/codegraph.ts"),
"the bundle must expose the native codegraph_explore tool", "the bundle must expose the native codegraph_explore tool",
); );
assert.equal(
packageJson.pi.extensions.indexOf("./extensions/bash-override.ts"),
packageJson.pi.extensions.indexOf("./extensions/fff-override.ts") + 1,
"the bash description override must register right after fff-override",
);
assert.ok( assert.ok(
packageJson.pi.extensions.includes("./pi-web-search/index.ts"), packageJson.pi.extensions.includes("./pi-web-search/index.ts"),
"the bundle must load the unified pi-web-search tools", "the bundle must load the unified pi-web-search tools",
@@ -292,9 +297,61 @@ test("all package extensions load together without global registration conflicts
const home = await mkdtemp(join(tmpdir(), "my-pi-extension-load-")); const home = await mkdtemp(join(tmpdir(), "my-pi-extension-load-"));
const mockBin = join(home, "bin"); const mockBin = join(home, "bin");
await mkdir(mockBin, { recursive: true }); await mkdir(mockBin, { recursive: true });
const args = ["--no-extensions"]; // Probe loaded LAST: records every registered tool (name, exposure,
// description, source) and the final active set so the test can assert the
// pinned built-in codemode. Its async setup also runs the bash override's
// registered definition end to end — executing `echo hi` through the same
// delegation path a model call would use.
const probeOutput = join(home, "probe-tools.json");
const probePath = join(home, "probe.ts");
await writeFile(
probePath,
[
'import { writeFileSync } from "node:fs";',
`import bashOverrideExtension from ${JSON.stringify(join(repositoryRoot, "extensions", "bash-override.ts"))};`,
"export default async function (pi: any): Promise<void> {",
"\tlet executed: unknown;",
"\ttry {",
"\t\tlet registered: any;",
"\t\tbashOverrideExtension({ registerTool: (tool: any) => { registered = tool; } } as any);",
"\t\tconst result = await registered.execute(",
'\t\t\t"probe-1",',
'\t\t\t{ command: "echo hi" },',
"\t\t\tundefined,",
"\t\t\tundefined,",
`\t\t\t{ cwd: ${JSON.stringify(home)}, sessionManager: { getSessionId: () => "probe-session", getSessionFile: () => undefined } } as any,`,
"\t\t);",
"\t\texecuted = {",
"\t\t\ttext: result?.content?.[0]?.text,",
"\t\t\texitCode: result?.structuredContent?.exit_code,",
"\t\t\thasOutputSchema: result?.structuredContent !== undefined,",
"\t\t\tregisteredDescription: registered?.description,",
"\t\t};",
"\t} catch (error) {",
"\t\texecuted = { error: String(error) };",
"\t}",
'\tpi.on("session_start", () => {',
"\t\twriteFileSync(",
`\t\t\t${JSON.stringify(probeOutput)},`,
"\t\t\tJSON.stringify({",
"\t\t\t\tall: pi.getAllTools().map((tool: any) => ({",
"\t\t\t\t\tname: tool.name,",
"\t\t\t\t\texposure: tool.exposure,",
"\t\t\t\t\tdescription: tool.description,",
"\t\t\t\t\tsourcePath: tool.sourceInfo?.path,",
"\t\t\t\t})),",
"\t\t\t\tactive: pi.getActiveTools(),",
"\t\t\t\texecuted,",
"\t\t\t}),",
"\t\t);",
"\t});",
"}",
"",
].join("\n"),
);
const args = ["--no-extensions", "-e", "builtin:codemode"];
for (const entry of packageJson.pi.extensions) args.push("-e", resolve(repositoryRoot, entry)); for (const entry of packageJson.pi.extensions) args.push("-e", resolve(repositoryRoot, entry));
args.push("--mode", "rpc"); args.push("-e", probePath, "--mode", "rpc");
const loadEnvironment = { const loadEnvironment = {
...process.env, ...process.env,
@@ -313,4 +370,54 @@ test("all package extensions load together without global registration conflicts
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension|conflicts with/u); assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension|conflicts with/u);
const probe = JSON.parse(await readFile(probeOutput, "utf8")) as {
all: { name: string; exposure?: string; description?: string; sourcePath?: string }[];
active: string[];
executed?: {
text?: string;
exitCode?: number;
hasOutputSchema?: boolean;
registeredDescription?: string;
error?: string;
};
};
const codemode = probe.all.find((tool) => tool.name === "codemode");
assert.equal(codemode?.exposure, "model-only", "Pi's built-in codemode must load with model-only exposure");
assert.ok(probe.active.includes("codemode"), "Tool Search must pin codemode into the active set");
assert.ok(
probe.all.every((tool) => tool.name !== "multi_grep"),
"FFF's upstream-flagged multi_grep must stay unregistered (fff-override deletes PI_FFF_MULTIGREP)",
);
assert.ok(
!probe.active.includes("multi_grep"),
"multi_grep must not appear in the active tool set",
);
const bash = probe.all.find((tool) => tool.name === "bash");
assert.ok(bash, "the bundle must expose the bash tool");
assert.ok(
bash.description?.startsWith("Execute a bash command in the current working directory."),
"the bash override must keep Pi's upstream description prefix",
);
assert.ok(
bash.description?.endsWith("which triggers a permission review."),
"the registered bash description must end with the tools.jsonc descriptionAppend",
);
assert.ok(
(bash.sourcePath ?? "").endsWith("extensions/bash-override.ts"),
`bash must be registered by extensions/bash-override.ts, got ${bash.sourcePath}`,
);
const executed = probe.executed ?? {};
assert.equal(executed.error, undefined, `bash override execute must not throw: ${executed.error}`);
assert.equal(executed.exitCode, 0, "echo hi must exit 0 through the delegated definition");
assert.match(executed.text ?? "", /hi/);
assert.equal(
executed.hasOutputSchema,
true,
"the bash override must keep the upstream outputSchema (structuredContent for codemode)",
);
assert.ok(
executed.registeredDescription?.includes("Not for searching files"),
"the catalog descriptionAppend must reach the registered bash description",
);
}); });
+70
View File
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { accessSync, chmodSync, constants } from "node:fs";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const LIB_SH = fileURLToPath(new URL("../scripts/lib.sh", import.meta.url));
const SYSTEM_KITTY = "/Applications/kitty.app/Contents/MacOS/kitty";
function systemKittyPath(): string | undefined {
try {
accessSync(SYSTEM_KITTY, constants.X_OK);
return SYSTEM_KITTY;
} catch {
return undefined;
}
}
function runDetectKitty(home: string, pathDir: string) {
return spawnSync(
"sh",
["-c", `. "$1"; detect_kitty; status=$?; printf '%s' "$kitty_bin"; exit "$status"`, "sh", LIB_SH],
{
encoding: "utf8",
env: { ...process.env, HOME: home, PATH: `${pathDir}:${process.env.PATH}` },
},
);
}
test("detect_kitty prefers an official install over a PATH kitty", async () => {
const home = await mkdtemp(join(tmpdir(), "my-pi-libsh-"));
try {
const official = join(home, ".local", "kitty.app", "bin", "kitty");
const pathDir = join(home, "pathbin");
await mkdir(dirname(official), { recursive: true });
await mkdir(pathDir, { recursive: true });
await writeFile(official, "#!/bin/sh\n");
chmodSync(official, 0o755);
await symlink(official, join(pathDir, "kitty"));
const result = runDetectKitty(home, pathDir);
assert.equal(result.status, 0, result.stderr);
// A real /Applications kitty also wins over $HOME/.local and PATH.
assert.equal(result.stdout, systemKittyPath() ?? official);
} finally {
await rm(home, { recursive: true, force: true });
}
});
test("detect_kitty falls back to a PATH kitty when no official install exists", async () => {
const home = await mkdtemp(join(tmpdir(), "my-pi-libsh-"));
try {
const pathDir = join(home, "pathbin");
const pathKitty = join(pathDir, "kitty");
await mkdir(pathDir, { recursive: true });
await writeFile(pathKitty, "#!/bin/sh\n");
chmodSync(pathKitty, 0o755);
const result = runDetectKitty(home, pathDir);
assert.equal(result.status, 0, result.stderr);
// The isolated HOME has no ~/.local install; only a machine-level
// /Applications kitty (when present) still takes precedence.
assert.equal(result.stdout, systemKittyPath() ?? pathKitty);
} finally {
await rm(home, { recursive: true, force: true });
}
});
+13
View File
@@ -43,6 +43,19 @@ test("every declared extension entry and file: dependency manifest is inside the
} }
}); });
test("every file: dependency ships an UPSTREAM.md source record", () => {
for (const [name, specifier] of Object.entries(packageJson.dependencies)) {
const match = /^file:\.\/(.+)$/u.exec(specifier);
if (!match) continue;
const upstream = `${match[1]}/UPSTREAM.md`;
assert.ok(
readFileSync(join(repositoryRoot, upstream), "utf8").trim().length > 0,
`${name} is a file: dependency — ${upstream} must exist and be non-empty`,
);
assert.ok(packedFiles.has(upstream), `${name} is a file: dependency — ${upstream} must be packed`);
}
});
test("relative import specifiers inside packed sources resolve to packed files", () => { test("relative import specifiers inside packed sources resolve to packed files", () => {
const specifierPatterns = [ const specifierPatterns = [
/\bfrom\s+["'](\.{1,2}\/[^"']+)["']/gu, /\bfrom\s+["'](\.{1,2}\/[^"']+)["']/gu,
+66 -12
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; import { mkdtemp, readFile, stat, 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";
@@ -142,19 +143,25 @@ test("template parser handles env names, braces and escapes", () => {
const AUTH_MJS = new URL("../scripts/search-auth.mjs", import.meta.url).pathname; const AUTH_MJS = new URL("../scripts/search-auth.mjs", import.meta.url).pathname;
const CONFIGURE_SH = new URL("../configure.sh", import.meta.url).pathname; const CONFIGURE_SH = new URL("../configure.sh", import.meta.url).pathname;
function runMjs(args: string[], env: Record<string, string> = {}) { function runMjs(args: string[], env: Record<string, string> = {}, input?: string) {
return spawnSync("node", [AUTH_MJS, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); return spawnSync("node", [AUTH_MJS, ...args], {
encoding: "utf8",
env: { ...process.env, ...env },
...(input === undefined ? {} : { input }),
});
} }
test("search-auth.mjs --set writes api_key entries and preserves other providers", async () => { test("search-auth.mjs --set-stdin writes api_key entries and preserves other providers", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-")); const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json"); const authPath = join(dir, "auth.json");
await writeFile( await writeFile(
authPath, authPath,
JSON.stringify({ anthropic: { type: "oauth", access: "tok", refresh: "ref", expires: 42, accountId: "acc" } }), JSON.stringify({ anthropic: { type: "oauth", access: "tok", refresh: "ref", expires: 42, accountId: "acc" } }),
); );
const result = runMjs(["--auth", authPath, "--set", "tavily=tvly-new", "--set", "exa=!op read x"]); const first = runMjs(["--auth", authPath, "--set-stdin", "tavily"], {}, "tvly-new\n");
assert.equal(result.status, 0, result.stderr); assert.equal(first.status, 0, first.stderr);
const second = runMjs(["--auth", authPath, "--set-stdin", "exa"], {}, " !op read x \n");
assert.equal(second.status, 0, second.stderr);
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>; const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
assert.deepEqual(written.anthropic, { type: "oauth", access: "tok", refresh: "ref", expires: 42, accountId: "acc" }); assert.deepEqual(written.anthropic, { type: "oauth", access: "tok", refresh: "ref", expires: 42, accountId: "acc" });
assert.deepEqual(written.tavily, { type: "api_key", key: "tvly-new" }); assert.deepEqual(written.tavily, { type: "api_key", key: "tvly-new" });
@@ -163,14 +170,33 @@ test("search-auth.mjs --set writes api_key entries and preserves other providers
assert.equal(getSearchApiKey("tavily", { authPath }), "tvly-new"); assert.equal(getSearchApiKey("tavily", { authPath }), "tvly-new");
}); });
test("search-auth.mjs preserves an existing credential env map on --set", async () => { test("search-auth.mjs --set is rejected: keys never travel in argv", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json");
const result = runMjs(["--auth", authPath, "--set", "tavily=tvly-leak"]);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /命令行参数|--set-stdin/);
assert.equal(existsSync(authPath), false);
});
test("search-auth.mjs allows only one --set-stdin and rejects empty stdin", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json");
const dup = runMjs(["--auth", authPath, "--set-stdin", "tavily", "--set-stdin", "exa"], {}, "key");
assert.notEqual(dup.status, 0);
const empty = runMjs(["--auth", authPath, "--set-stdin", "tavily"], {}, " \n");
assert.notEqual(empty.status, 0);
assert.equal(existsSync(authPath), false);
});
test("search-auth.mjs preserves an existing credential env map on --set-stdin", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-")); const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json"); const authPath = join(dir, "auth.json");
await writeFile( await writeFile(
authPath, authPath,
JSON.stringify({ keenable: { type: "api_key", key: "old", env: { KEENABLE_API_URL: "https://k.example" } } }), JSON.stringify({ keenable: { type: "api_key", key: "old", env: { KEENABLE_API_URL: "https://k.example" } } }),
); );
const result = runMjs(["--auth", authPath, "--set", "keenable=new-key"]); const result = runMjs(["--auth", authPath, "--set-stdin", "keenable"], {}, "new-key");
assert.equal(result.status, 0, result.stderr); assert.equal(result.status, 0, result.stderr);
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>; const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
assert.deepEqual(written.keenable, { assert.deepEqual(written.keenable, {
@@ -183,7 +209,7 @@ test("search-auth.mjs preserves an existing credential env map on --set", async
test("search-auth.mjs creates a missing auth.json and honors PI_CODING_AGENT_DIR", async () => { test("search-auth.mjs creates a missing auth.json and honors PI_CODING_AGENT_DIR", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-")); const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const agentDir = join(dir, "agent"); const agentDir = join(dir, "agent");
const result = runMjs(["--set", "keenable=keen-1"], { PI_CODING_AGENT_DIR: agentDir }); const result = runMjs(["--set-stdin", "keenable"], { PI_CODING_AGENT_DIR: agentDir }, "keen-1");
assert.equal(result.status, 0, result.stderr); assert.equal(result.status, 0, result.stderr);
const written = JSON.parse(await readFile(join(agentDir, "auth.json"), "utf8")) as Record<string, any>; const written = JSON.parse(await readFile(join(agentDir, "auth.json"), "utf8")) as Record<string, any>;
assert.deepEqual(written.keenable, { type: "api_key", key: "keen-1" }); assert.deepEqual(written.keenable, { type: "api_key", key: "keen-1" });
@@ -204,7 +230,7 @@ test("search-auth.mjs refuses to clobber a malformed auth.json", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-")); const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json"); const authPath = join(dir, "auth.json");
await writeFile(authPath, "{oops"); await writeFile(authPath, "{oops");
const result = runMjs(["--auth", authPath, "--set", "tavily=x"]); const result = runMjs(["--auth", authPath, "--set-stdin", "tavily"], {}, "x");
assert.notEqual(result.status, 0); assert.notEqual(result.status, 0);
assert.match(result.stderr, /不是合法 JSON|abort|clobber/i); assert.match(result.stderr, /不是合法 JSON|abort|clobber/i);
assert.equal(await readFile(authPath, "utf8"), "{oops"); assert.equal(await readFile(authPath, "utf8"), "{oops");
@@ -238,15 +264,43 @@ test("search-auth.mjs --has and --list never print key material", async () => {
assert.doesNotMatch(list.stdout, /supersecret/); assert.doesNotMatch(list.stdout, /supersecret/);
}); });
test("configure.sh search writes keys through search-auth.mjs noninteractively", async () => { test("configure.sh search writes keys through search-auth.mjs via stdin", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-")); const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json"); const authPath = join(dir, "auth.json");
const result = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "tvly-sh", "--exa", "exa-sh"], { const first = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "-"], {
encoding: "utf8", encoding: "utf8",
input: "tvly-sh\n",
}); });
assert.equal(result.status, 0, result.stderr); assert.equal(first.status, 0, first.stderr);
const second = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--exa", "-"], {
encoding: "utf8",
input: "exa-sh",
});
assert.equal(second.status, 0, second.stderr);
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>; const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
assert.deepEqual(written.tavily, { type: "api_key", key: "tvly-sh" }); assert.deepEqual(written.tavily, { type: "api_key", key: "tvly-sh" });
assert.deepEqual(written.exa, { type: "api_key", key: "exa-sh" }); assert.deepEqual(written.exa, { type: "api_key", key: "exa-sh" });
assert.equal((await stat(authPath)).mode & 0o777, 0o600); assert.equal((await stat(authPath)).mode & 0o777, 0o600);
}); });
test("configure.sh search rejects a plaintext key argument", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json");
const result = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "tvly-leak"], {
encoding: "utf8",
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /命令行参数/);
assert.equal(existsSync(authPath), false);
});
test("configure.sh search allows at most one stdin provider per call", async () => {
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json");
const result = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "-", "--exa", "-"], {
encoding: "utf8",
input: "one-key",
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /最多一个/);
});
+55 -5
View File
@@ -21,6 +21,7 @@ import {
stripJsoncComments, stripJsoncComments,
validateRoutingConfig, validateRoutingConfig,
validateSectionsConfig, validateSectionsConfig,
validateToolCatalog,
type MutablePromptOptions, type MutablePromptOptions,
type RoutingRule, type RoutingRule,
} from "../extensions/system-prompt-config.ts"; } from "../extensions/system-prompt-config.ts";
@@ -28,7 +29,7 @@ import systemPromptExtension, {
createSystemPromptComposer, createSystemPromptComposer,
getSystemPromptDumpPath, getSystemPromptDumpPath,
} from "../extensions/system-prompt.ts"; } from "../extensions/system-prompt.ts";
import { guardExtensionApi } from "../extensions/prompt-guard.ts"; import { catalogDescription, guardExtensionApi } from "../extensions/prompt-guard.ts";
import { BUNDLE_GROUP_DEFINITIONS } from "../pi-tool-search/extensions/bundle-groups.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"; import { BUNDLE_TOOL_SEARCH_DEFAULTS } from "../pi-tool-search/extensions/config.ts";
@@ -93,6 +94,20 @@ test("routing scope validation: main/subagent accepted, other values rejected",
); );
}); });
test("tools.jsonc validation: descriptionAppend accepted as a string, rejected otherwise", () => {
assert.doesNotThrow(() =>
validateToolCatalog({ version: 1, tools: { bash: { descriptionAppend: "extra" } } }),
);
assert.throws(
() => validateToolCatalog({ version: 1, tools: { bash: { descriptionAppend: 5 } } }),
/descriptionAppend must be a string/,
);
assert.throws(
() => validateToolCatalog({ version: 1, tools: { bash: { descriptionAppend: true } } }),
/descriptionAppend must be a string/,
);
});
test("sections.jsonc validation: subagentExclude defaults to [] and rejects bad shapes", () => { test("sections.jsonc validation: subagentExclude defaults to [] and rejects bad shapes", () => {
assert.deepEqual(validateSectionsConfig({ version: 1, order: ["tool_routing"] }), { assert.deepEqual(validateSectionsConfig({ version: 1, order: ["tool_routing"] }), {
version: 1, version: 1,
@@ -255,7 +270,7 @@ test("bundled routing rules select the same guidance the retired tool-routing ex
const routing = loadRoutingRules(); const routing = loadRoutingRules();
const literal = renderRouting(routing.rules, ["find", "grep", "read"]); const literal = renderRouting(routing.rules, ["find", "grep", "read"]);
assert.match(literal, /Use find to identify likely files or directories/); assert.match(literal, /Pick the local search tool by what you already know/);
assert.match(literal, /narrow the path, glob, or pattern instead of increasing the limit/); 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"]); const ssh = renderRouting(routing.rules, ["ssh_connect", "ssh_cd", "ssh_bash"]);
@@ -267,7 +282,7 @@ test("bundled routing rules select the same guidance the retired tool-routing ex
const minimal = renderRouting(routing.rules, ["read"]); const minimal = renderRouting(routing.rules, ["read"]);
assert.doesNotMatch(minimal, /codegraph_explore first/); assert.doesNotMatch(minimal, /codegraph_explore first/);
assert.doesNotMatch(minimal, /For literal search/); assert.doesNotMatch(minimal, /Pick the local search tool/);
assert.doesNotMatch(minimal, /ssh_connect only when/); assert.doesNotMatch(minimal, /ssh_connect only when/);
assert.match(minimal, /Stop searching once the current evidence is sufficient/); assert.match(minimal, /Stop searching once the current evidence is sufficient/);
}); });
@@ -397,7 +412,6 @@ test("composer splices the preamble, applies the catalog, and orders sections",
read: "reads files", read: "reads files",
bash: "runs commands", bash: "runs commands",
edit: "edits", edit: "edits",
codegraph_explore: "explores",
tool_search: "searches", tool_search: "searches",
}, },
toolGuidelines: { edit: ["upstream edit rule"] }, toolGuidelines: { edit: ["upstream edit rule"] },
@@ -414,6 +428,11 @@ test("composer splices the preamble, applies the catalog, and orders sections",
"the bundle preamble replaces Pi's default preamble", "the bundle preamble replaces Pi's default preamble",
); );
assert.match(output, /<tool_routing>\n- Use codegraph_explore first/); assert.match(output, /<tool_routing>\n- Use codegraph_explore first/);
assert.match(
output,
/<tools>[\s\S]*?- codegraph_explore: Explore code structure from the CodeGraph index/,
"tools.jsonc must add a snippet for codegraph_explore, which has no upstream <tools> entry",
);
assert.match(output, /search files with the find\/grep tools, not bash/); assert.match(output, /search files with the find\/grep tools, not bash/);
assert.match(output, /LINE#HASH anchors/); assert.match(output, /LINE#HASH anchors/);
assert.ok( assert.ok(
@@ -604,7 +623,38 @@ test("prompt guard applies catalog description overrides at registration", () =>
const override = loadToolCatalog().tools.edit; const override = loadToolCatalog().tools.edit;
assert.ok(override && !("inherit" in override) && typeof override.description === "string"); assert.ok(override && !("inherit" in override) && typeof override.description === "string");
assert.equal(tools[0].description, override.description); assert.equal(tools[0].description, override.description);
assert.equal(tools[1].description, "upstream grep", "inherit entries keep the upstream description"); assert.equal(tools[1].description, "upstream grep", "entries without description fields keep the upstream description");
});
test("prompt guard appends the catalog descriptionAppend to the upstream description", () => {
const { pi, tools } = makeRecordingPi();
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI);
guarded.registerTool({ name: "bash", description: "upstream bash" } as never);
guarded.registerTool({ name: "read", description: "upstream read" } as never);
const bashEntry = loadToolCatalog().tools.bash;
assert.ok(bashEntry && !("inherit" in bashEntry) && typeof bashEntry.descriptionAppend === "string");
assert.equal(tools[0].description, `upstream bash\n\n${bashEntry.descriptionAppend}`);
assert.equal(tools[1].description, "upstream read", "inherit entries keep the upstream description");
});
test("catalogDescription composes override, append, and inherit", () => {
assert.equal(
catalogDescription("upstream", { description: "override", descriptionAppend: "append" }),
"override\n\nappend",
"an append sits on top of the description override when both are set",
);
assert.equal(
catalogDescription("upstream \n", { descriptionAppend: "append" }),
"upstream\n\nappend",
"trailing whitespace on the base is trimmed before the blank line",
);
assert.equal(
catalogDescription("upstream", { inherit: true }),
"upstream",
"inherit entries are untouched",
);
}); });
test("prompt guard strips a returned systemPrompt but keeps other fields", async () => { test("prompt guard strips a returned systemPrompt but keeps other fields", async () => {
+4
View File
@@ -302,6 +302,10 @@ if ! command -v pi >/dev/null 2>&1; then
exit 127 exit 127
fi fi
# Pi 自身比较本地与最新 release,一致时跳过;失败不阻断组合包升级。
say "正在同步 Pi 最新发布版本。"
pi update --self || record_update_failure "Pi"
say "正在升级 Pi 组合包:$PACKAGE_SOURCE" say "正在升级 Pi 组合包:$PACKAGE_SOURCE"
if ! pi update "$PACKAGE_SOURCE"; then if ! pi update "$PACKAGE_SOURCE"; then
warn "pi update 执行失败,已停止后续升级。重新运行 update.sh 即可,Pi 会自行恢复未完成的升级。" warn "pi update 执行失败,已停止后续升级。重新运行 update.sh 即可,Pi 会自行恢复未完成的升级。"