mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 07:23:06 +00:00
feat(tool-search): expand dynamic group activation
This commit is contained in:
@@ -65,7 +65,7 @@
|
|||||||
- `pi-condense@2.9.1`:总结已完成的工具调用批次,以短 stub 替换历史原始输出,并通过 `context_tree_query` 按需恢复;组合包在用户尚未配置 `contextPrune.enabled` 时默认开启。
|
- `pi-condense@2.9.1`:总结已完成的工具调用批次,以短 stub 替换历史原始输出,并通过 `context_tree_query` 按需恢复;组合包在用户尚未配置 `contextPrune.enabled` 时默认开启。
|
||||||
- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-chrome@0.15.46`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`@tintinweb/pi-subagents`、`pi-ask-user`、`pi-minimal-footer`、`pi-ssh` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。
|
- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-chrome@0.15.46`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`@tintinweb/pi-subagents`、`pi-ask-user`、`pi-minimal-footer`、`pi-ssh` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。
|
||||||
- `install.sh`:先安装根组合包,再交互检查 Hippo Memory CLI、Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Hippo 项目目录与 `hippo init` 始终由用户自行决定。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。
|
- `install.sh`:先安装根组合包,再交互检查 Hippo Memory CLI、Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Hippo 项目目录与 `hippo init` 始终由用户自行决定。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。
|
||||||
- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
|
- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。升级成功后只能调用可定位的更新后组合包 Node helper 检查 Pi agent `settings.json`:只有 `toolSearch` 完整精确匹配旧版 `3` 组 / `20` 工具默认快照时,才创建从生成起即为权限 `600` 的时间戳备份、以同目录临时文件原子迁移到 `5` / `28` 并写入 `bundleDefaultsVersion: 2`;任何自定义或无效配置必须保留,提交前检测到内容变化必须中止。无法从 `PI_PACKAGE_SOURCE` 定位更新后 helper 时必须保留配置并报告失败,不能回退到可能陈旧的脚本目录 helper。其他组件升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
|
||||||
- `uninstall.sh`:先移除根组合包;若检测到 Hippo Memory CLI,再明确询问是否卸载确认属于 npm 全局安装的 `hippo-memory`,默认保留且始终不删除 `.hippo/` 或用户记忆数据。其他共享终端环境和机器工具不卸载。
|
- `uninstall.sh`:先移除根组合包;若检测到 Hippo Memory CLI,再明确询问是否卸载确认属于 npm 全局安装的 `hippo-memory`,默认保留且始终不删除 `.hippo/` 或用户记忆数据。其他共享终端环境和机器工具不卸载。
|
||||||
|
|
||||||
## 当前职责与默认行为
|
## 当前职责与默认行为
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
- Hashline 默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 和 `replaceText` 均关闭;它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
|
- Hashline 默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 和 `replaceText` 均关闭;它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
|
||||||
- 工具路由规则不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索按场景只先选一个服务:广泛发现、新闻和候选来源用 Tavily,官方/技术/论文和高确定性来源用 Exa,中文、站点限定和日期筛选用 Keenable;只有必要时才跨服务复核。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。
|
- 工具路由规则不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索按场景只先选一个服务:广泛发现、新闻和候选来源用 Tavily,官方/技术/论文和高确定性来源用 Exa,中文、站点限定和日期筛选用 Keenable;只有必要时才跨服务复核。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。
|
||||||
- `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具仅支持交互式 TUI、顺序执行和最多 8 题;取消或 abort 必须返回 `cancelled: true`,不得伪造答案,且 `/qna` 不属于该扩展。其成功 `details.answers` 必须保留 `question` / `answer` 字段,以匹配 AutoReview 已有的可信结构化用户交互边界。
|
- `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具仅支持交互式 TUI、顺序执行和最多 8 题;取消或 abort 必须返回 `cancelled: true`,不得伪造答案,且 `/qna` 不属于该扩展。其成功 `details.answers` 必须保留 `question` / `answer` 字段,以匹配 AutoReview 已有的可信结构化用户交互边界。
|
||||||
- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics` 与 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 3 个动态组、模型组最多 8 个工具、动态工具总数最多 20,加载或组内调用更新 LRU,超限先移除最久未用组。
|
- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` 与 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。首次创建完整默认 `toolSearch` 段时必须通过唯一同目录临时文件以权限 `600` 原子写入并记录 `bundleDefaultsVersion: 2`,已有自定义段只补缺失运行时字段且不得伪造迁移来源标记。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。
|
||||||
- `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。Tool Search 重新激活 schema 不能替代上游每次执行时的授权校验。
|
- `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。Tool Search 重新激活 schema 不能替代上游每次执行时的授权校验。
|
||||||
- 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
|
- 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
|
||||||
- Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。
|
- Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
- `uninstall.sh` 只在根包移除成功后询问是否卸载 npm 全局 Hippo CLI,默认拒绝;不得删除项目 `.hippo/`、全局记忆或旧 Hermes 数据。
|
- `uninstall.sh` 只在根包移除成功后询问是否卸载 npm 全局 Hippo CLI,默认拒绝;不得删除项目 `.hippo/`、全局记忆或旧 Hermes 数据。
|
||||||
- `install.sh` 默认从 `git:git@bitbucket.org:siakitem/my-pi.git` 安装,并允许用 `PI_PACKAGE_SOURCE` 覆盖来源;组合包安装失败时立即停止,机器级依赖安装失败时继续检查其余依赖并最终返回非零状态。
|
- `install.sh` 默认从 `git:git@bitbucket.org:siakitem/my-pi.git` 安装,并允许用 `PI_PACKAGE_SOURCE` 覆盖来源;组合包安装失败时立即停止,机器级依赖安装失败时继续检查其余依赖并最终返回非零状态。
|
||||||
- CodeGraph 的可选安装使用官方远程安装脚本;macOS Kitty 使用 Homebrew cask,其他系统使用 Kitty 官方安装器;Kotlin LSP、Java 21 和 JDT LS 使用 Homebrew;Oh My Zsh 使用官方安装器,Powerlevel10k 与两个第三方 Zsh 插件从各自官方 Git 仓库克隆。升级时 Git checkout、Homebrew 包、Kitty 官方安装和 CodeGraph 都必须先比较本地与远端版本,版本一致时不得重新下载安装;无法可靠比较时保留现状并报告。缺少 Homebrew、curl 或 git 时保留已经完成的组合包安装并报告失败。
|
- CodeGraph 的可选安装使用官方远程安装脚本;macOS Kitty 使用 Homebrew cask,其他系统使用 Kitty 官方安装器;Kotlin LSP、Java 21 和 JDT LS 使用 Homebrew;Oh My Zsh 使用官方安装器,Powerlevel10k 与两个第三方 Zsh 插件从各自官方 Git 仓库克隆。升级时 Git checkout、Homebrew 包、Kitty 官方安装和 CodeGraph 都必须先比较本地与远端版本,版本一致时不得重新下载安装;无法可靠比较时保留现状并报告。缺少 Homebrew、curl 或 git 时保留已经完成的组合包安装并报告失败。
|
||||||
- `install.sh` 不检查默认不需要的 `rtk` CLI;`update.sh` 和 `uninstall.sh` 使用同一个 `PI_PACKAGE_SOURCE` 规则分别调用 `pi update` 和 `pi remove`。运行 `update.sh` 视为明确授权升级已安装组件及同步已有受管配置,但不得安装缺失组件或改写受管块之外的用户配置;卸载仅可在用户当次明确确认后移除 npm 全局 Hippo CLI,其他共享机器级工具保持不变。
|
- `install.sh` 不检查默认不需要的 `rtk` CLI;`update.sh` 和 `uninstall.sh` 使用同一个 `PI_PACKAGE_SOURCE` 规则分别调用 `pi update` 和 `pi remove`。运行 `update.sh` 视为明确授权升级已安装组件及同步已有受管配置;其中 Tool Search 只能自动迁移完整匹配仓库旧默认快照的配置,不能逐字段猜测或覆盖自定义值。不得安装缺失组件或改写受管块之外的用户配置;卸载仅可在用户当次明确确认后移除 npm 全局 Hippo CLI,其他共享机器级工具保持不变。
|
||||||
|
|
||||||
## 修改边界
|
## 修改边界
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
- `pi-notify/` 的根依赖必须保持 `file:./pi-notify`,组合包直接加载 `pi-notify/src/index.ts`;不得同时安装或加载 npm 预编译入口,否则会重复通知。
|
- `pi-notify/` 的根依赖必须保持 `file:./pi-notify`,组合包直接加载 `pi-notify/src/index.ts`;不得同时安装或加载 npm 预编译入口,否则会重复通知。
|
||||||
- 上游仅作为参考来源。同步上游改动时先核对本仓库已有修改,再按明确范围移植;不要直接覆盖本地实现。
|
- 上游仅作为参考来源。同步上游改动时先核对本仓库已有修改,再按明确范围移植;不要直接覆盖本地实现。
|
||||||
- `install.sh`、`update.sh`、`uninstall.sh` 和 `search_config.sh` 必须保持 POSIX `sh` 兼容和可执行权限,并包含在根 `package.json` 的 `files` 中;修改脚本行为时同步更新 README 和本文件。
|
- `install.sh`、`update.sh`、`uninstall.sh` 和 `search_config.sh` 必须保持 POSIX `sh` 兼容和可执行权限,并包含在根 `package.json` 的 `files` 中;修改脚本行为时同步更新 README 和本文件。
|
||||||
- 安装流程中的机器级依赖和用户终端配置必须保持逐项询问且默认拒绝,不得在没有用户明确确认的情况下自动安装或改写。用户主动运行 `update.sh` 只授权升级已安装项和同步已有受管配置;缺失项仍必须跳过。`.zshrc` 修改必须局限于受管块并保留备份,卸载不得顺带删除或还原共享工具和用户终端配置。
|
- 安装流程中的机器级依赖和用户终端配置必须保持逐项询问且默认拒绝,不得在没有用户明确确认的情况下自动安装或改写。用户主动运行 `update.sh` 只授权升级已安装项和同步已有受管配置;Tool Search 完整旧默认快照迁移属于该受管同步,但必须先备份并保持 fail-safe。缺失项仍必须跳过。`.zshrc` 修改必须局限于受管块并保留备份,卸载不得顺带删除或还原共享工具和用户终端配置。
|
||||||
- 未经明确要求,不执行发布、提交、推送、运行安装/卸载脚本或安装到用户 Pi 运行目录等外部写操作。
|
- 未经明确要求,不执行发布、提交、推送、运行安装/卸载脚本或安装到用户 Pi 运行目录等外部写操作。
|
||||||
|
|
||||||
## 根目录 TypeScript 与 LSP 验证约定
|
## 根目录 TypeScript 与 LSP 验证约定
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
|
|
||||||
组合包依赖或加载入口变化时,至少验证根 `npm install` 幂等、锁文件有效、MCP adapter 唯一所有者不变量测试,以及全部扩展可在隔离的临时 Pi agent 目录加载。权限配置变化时使用仓库内固定快照的 `pi-permission-system` schema 校验,并验证包装入口部署后的文件与仓库源配置一致。
|
组合包依赖或加载入口变化时,至少验证根 `npm install` 幂等、锁文件有效、MCP adapter 唯一所有者不变量测试,以及全部扩展可在隔离的临时 Pi agent 目录加载。权限配置变化时使用仓库内固定快照的 `pi-permission-system` schema 校验,并验证包装入口部署后的文件与仓库源配置一致。
|
||||||
|
|
||||||
安装、升级、卸载、搜索或 SSH 配置脚本变化时,至少运行 `sh -n install.sh`、`sh -n update.sh`、`sh -n uninstall.sh`、`sh -n search_config.sh`、`sh -n ssh_config.sh` 和 ShellCheck,并核对脚本仍具有可执行权限、仍包含在根 `package.json` 的 `files` 中、README 描述与实际流程一致。搜索配置测试只能使用虚拟 key 和隔离 HOME;SSH 配置测试必须使用隔离 HOME、虚拟凭据与 mock transport,不得连接真实服务器或把真实密码写入测试输出。涉及真实 `pi install`、`pi update`、`pi remove`、Homebrew、远程安装器、Git 克隆、真实 SSH 连接或真实用户终端配置的端到端验证属于外部写操作,未经明确要求不得执行;可以使用隔离的临时 HOME 和 mock 命令验证分支行为。
|
安装、升级、卸载、搜索或 SSH 配置脚本变化时,至少运行 `sh -n install.sh`、`sh -n update.sh`、`sh -n uninstall.sh`、`sh -n search_config.sh`、`sh -n ssh_config.sh` 和 ShellCheck,并核对脚本仍具有可执行权限、仍包含在根 `package.json` 的 `files` 中、README 描述与实际流程一致。Tool Search settings 迁移必须用隔离 agent dir 覆盖完整旧快照迁移、当前值幂等、自定义配置保留、无效 JSON fail-safe、备份/权限、`pi update` 失败不迁移和 `PI_CODING_AGENT_DIR`。搜索配置测试只能使用虚拟 key 和隔离 HOME;SSH 配置测试必须使用隔离 HOME、虚拟凭据与 mock transport,不得连接真实服务器或把真实密码写入测试输出。涉及真实 `pi install`、`pi update`、`pi remove`、Homebrew、远程安装器、Git 克隆、真实 SSH 连接或真实用户终端配置的端到端验证属于外部写操作,未经明确要求不得执行;可以使用隔离的临时 HOME 和 mock 命令验证分支行为。
|
||||||
|
|
||||||
Hippo 脚本变化必须额外用隔离 HOME/PATH 和 mock `pi`、`npm`、`hippo` 验证:安装缺失 CLI 但不调用 `hippo init`、相同版本不升级、不同版本精确升级、未知来源 CLI 不替换、卸载默认保留、明确确认后只卸载 npm 全局包且保留数据。不得在测试中执行真实全局 npm 写入或修改真实 `.hippo/`。
|
Hippo 脚本变化必须额外用隔离 HOME/PATH 和 mock `pi`、`npm`、`hippo` 验证:安装缺失 CLI 但不调用 `hippo init`、相同版本不升级、不同版本精确升级、未知来源 CLI 不替换、卸载默认保留、明确确认后只卸载 npm 全局包且保留数据。不得在测试中执行真实全局 npm 写入或修改真实 `.hippo/`。
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
- 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 后通知,并支持点击精确聚焦来源 Kitty 窗口。
|
- 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 后通知,并支持点击精确聚焦来源 Kitty 窗口。
|
||||||
- `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。
|
- `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。
|
||||||
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`,用一个顺序 TUI 同时支持单题、多题、选择题、文本题、自定义回答与提交前复核。
|
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`,用一个顺序 TUI 同时支持单题、多题、选择题、文本题、自定义回答与提交前复核。
|
||||||
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,以最多 3 个动态组的 LRU 策略按组加载原始完整 schema。
|
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。
|
||||||
- `pi-chrome@0.15.46`:通过显式会话授权接入用户当前已登录的 Chrome Profile,并由 Tool Search 按导航观察、页面交互和调试上传三个工作流按需加载 21 个 `chrome_*` 工具。
|
- `pi-chrome@0.15.46`:通过显式会话授权接入用户当前已登录的 Chrome Profile,并由 Tool Search 按导航观察、页面交互和调试上传三个工作流按需加载 21 个 `chrome_*` 工具。
|
||||||
- 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。
|
- 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。
|
||||||
- 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入,负责工具、路径、MCP、硬拒绝和兜底权限基线。
|
- 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入,负责工具、路径、MCP、硬拒绝和兜底权限基线。
|
||||||
@@ -87,9 +87,8 @@ Git checkout 在 `fetch` 后比较本地与上游提交,Homebrew 包在刷新
|
|||||||
日志中的“Zsh 插件未安装,升级流程跳过”不是升级失败:升级脚本不会新增用户此前未选择的
|
日志中的“Zsh 插件未安装,升级流程跳过”不是升级失败:升级脚本不会新增用户此前未选择的
|
||||||
插件。需要补装时重新运行 `./install.sh`,并在对应的逐项询问中明确选择 `Y`。
|
插件。需要补装时重新运行 `./install.sh`,并在对应的逐项询问中明确选择 `Y`。
|
||||||
|
|
||||||
升级时会优先从 `pi update` 后的已安装组合包目录读取最新 `config/p10k.zsh`,再与受管副本比较,
|
升级时会优先从 `pi update` 后的已安装组合包目录读取最新配置。对于 Pi agent 目录中的 `settings.json`,只有 `toolSearch` 完整匹配旧组合包生成的 `3` 组 / `20` 工具默认快照时,才会备份并原子迁移到当前 `5` / `28` 默认值;任何字段不同、结构无效或存在自定义工具时都不会被覆盖。Powerlevel10k 受管副本只在内容不同时备份并原子同步;`.zshrc` 也只更新已有的 my-pi 受管块,不触碰块外配置。因此安装流程负责补齐缺失项,升级流程只升级已安装项并同步可明确识别的受管配置。
|
||||||
只在内容不同时备份并原子同步;`.zshrc` 也只更新已有的 my-pi 受管块,不触碰块外配置。因此安装流程负责补齐缺失项,升级流程只升级
|
首次获得这项迁移能力时需要执行已经包含该逻辑的新版 `update.sh`;旧脚本正在运行时不会因为 `pi update` 替换包内容而自动获得后半段迁移步骤。如果刚通过旧脚本升级到含迁移逻辑的版本,可再运行一次更新后的脚本。
|
||||||
已安装项并同步已受管配置。
|
|
||||||
|
|
||||||
卸载组合包:
|
卸载组合包:
|
||||||
|
|
||||||
@@ -152,28 +151,35 @@ CodeGraph 本体不由组合包安装。需要使用 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` 和 `tool_search`;Windows 上额外把内置 `powershell` 与 `bash` 作为同类本地 Shell 核心工具固定启用,非 Windows 平台则从目录中过滤它。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、Chrome 导航/交互/调试、用户交互、CodeGraph/LSP、Tavily、Exa、Keenable、Context Mode 执行/知识库/观测/管理、Memory 查询/维护、Skill 和 MCP 管理。不可用或尚未授权而未注册的可选工具会自动从组中滤除,固定工具也不占动态组额度。
|
新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics`、`ctx_execute`、`ctx_execute_file`、`ctx_batch_execute` 和 `tool_search`;Windows 上额外把内置 `powershell` 与 `bash` 作为同类本地 Shell 核心工具固定启用,非 Windows 平台则从目录中过滤它。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、Chrome 导航/交互/调试、用户交互、CodeGraph/LSP、Tavily、Exa、Keenable、Context Mode 执行/知识库/观测/管理、Memory 查询/维护、Skill 和 MCP 管理。不可用、已固定或尚未授权而未注册的可选工具会自动从动态组中滤除。
|
||||||
|
|
||||||
标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具,并且当前调用不是精确加载已有分组时,才可使用当前已认证模型补充目录;精确已有分组始终立即按当前确定性目录激活,不等待无关未知工具的模型增强。模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会立即恢复预置目录,只有仍存在额外工具时才可能在后续未知分组或 query 搜索中惰性增强。
|
标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具,并且当前调用不是精确加载已有分组时,才可使用当前已认证模型补充目录;精确已有分组始终立即按当前确定性目录激活,不等待无关未知工具的模型增强。模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会恢复预置目录,`/tool-search-status` 显示目录来源、容量、LRU 顺序和最近淘汰原因。
|
||||||
|
|
||||||
动态组默认最多同时加载 3 个、每个模型生成组最多 8 个工具、全部动态组最多 20 个工具。加载或调用组内工具会更新 LRU;加载第 4 组或超出工具总量时先卸载最久未使用的非固定组。首次纯增加载继续使用 Pi 原生增量传播;发生卸载和加载的替换不是纯增量,Pi 会自动走安全 fallback。
|
动态组默认最多同时加载 5 个、每个模型生成组最多 8 个工具、全部动态组最多 28 个工具。`tool_search.groups` 可在一次调用中原子加载最多 5 个精确组;完整请求本身超限时保持原状态。加载或调用组内工具会更新 LRU,超限时先卸载最久未使用且不在本次请求内的组。自然语言 query 对精确工具名和已提交 workflow alias 加权,弱匹配或近似同分只返回候选。首次纯增加载继续使用 Pi 原生增量传播;发生卸载和加载的替换由 Pi 自动走安全 fallback。
|
||||||
|
|
||||||
首次加载仅在 `settings.json` 缺少对应字段时写入:
|
Pi 的用户级配置文件通常是 `~/.pi/agent/settings.json`(准确位置为 Pi agent dir 下的 `settings.json`)。组合包首次加载仅在其中缺少对应字段时写入:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"toolSearch": {
|
"toolSearch": {
|
||||||
"alwaysEnabled": ["codegraph_explore", "lsp_diagnostics"],
|
"alwaysEnabled": [
|
||||||
|
"codegraph_explore",
|
||||||
|
"lsp_diagnostics",
|
||||||
|
"ctx_execute",
|
||||||
|
"ctx_execute_file",
|
||||||
|
"ctx_batch_execute"
|
||||||
|
],
|
||||||
"showToolSearchFooterStatus": false,
|
"showToolSearchFooterStatus": false,
|
||||||
"maxActiveGroups": 3,
|
"maxActiveGroups": 5,
|
||||||
"maxToolsPerGroup": 8,
|
"maxToolsPerGroup": 8,
|
||||||
"maxDynamicTools": 20,
|
"maxDynamicTools": 28,
|
||||||
"groupOverrides": {}
|
"groupOverrides": {},
|
||||||
|
"bundleDefaultsVersion": 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
用户已有配置不会被覆盖;结构无效时也不会重写原文件。`alwaysEnabled` 中的精确工具名属于固定工具,不占动态组额度;`groupOverrides` 可把指定工具强制归入命名组。默认关闭状态行,避免与 minimal footer 叠加。
|
扩展正常加载只补充缺失字段,不覆盖用户已有配置;首次创建完整 Tool Search 默认段时也写入 `bundleDefaultsVersion: 2`。用户显式运行新版 `update.sh` 时,脚本会在 `pi update` 成功后使用更新后组合包中的 Node helper 检查完整旧默认快照:只有精确匹配旧版 `3`/`20` 默认值才会创建权限 `600` 的时间戳备份并原子替换为当前配置;任何自定义或无效配置都会保留,提交前检测到文件内容变化也会中止。`alwaysEnabled` 中的精确工具名属于固定工具,不占动态组额度;`groupOverrides` 可把指定工具强制归入命名组。默认关闭状态行,避免与 minimal footer 叠加。
|
||||||
|
|
||||||
### 精确读写归 Hashline
|
### 精确读写归 Hashline
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,15 @@
|
|||||||
- Added sequential `ssh_cd` to the authoritative remote-shell workflow and direct the model to wait for a successful workspace change before dependent remote calls, avoiding both the unknown-tools fallback and cwd races.
|
- Added sequential `ssh_cd` to the authoritative remote-shell workflow and direct the model to wait for a successful workspace change before dependent remote calls, avoiding both the unknown-tools fallback and cwd races.
|
||||||
- Added the authoritative `user-interaction` group for the maintained `ask_user_question` TUI tool.
|
- Added the authoritative `user-interaction` group for the maintained `ask_user_question` TUI tool.
|
||||||
- Added authoritative `chrome-navigation`, `chrome-interaction`, and `chrome-debugging` groups for all 21 tools registered by the pinned `pi-chrome` bridge after explicit session authorization.
|
- Added authoritative `chrome-navigation`, `chrome-interaction`, and `chrome-debugging` groups for all 21 tools registered by the pinned `pi-chrome` bridge after explicit session authorization.
|
||||||
|
- Raised the bundle defaults to five active groups and 28 dynamic tools, and pinned the high-frequency Context Mode execution trio alongside CodeGraph exploration and LSP diagnostics.
|
||||||
|
- Added atomic multi-group activation through `tool_search.groups`, bounded to five exact group ids and rejected without state changes when the complete workflow cannot fit.
|
||||||
|
- Weighted exact tool names, group metadata, and common SSH/Chrome/Context aliases during query routing; weak or ambiguous matches now return candidates instead of activating arbitrarily.
|
||||||
|
- Added `/tool-search-status` for catalog source, capacity, active-group LRU order, and latest eviction diagnostics.
|
||||||
|
- Added an explicit `update.sh` migration for the complete former `3`/`20` bundle-default snapshot, with owner-only backup, atomic replacement, provenance versioning, and fail-safe preservation of custom or invalid user settings.
|
||||||
### Fixed
|
### Fixed
|
||||||
- Treat Pi's optional `powershell` definition as the same pinned local-shell/core class as `bash` on Windows, while excluding it from non-Windows catalogs so it cannot become an unknown-tool model group.
|
- Treat Pi's optional `powershell` definition as the same pinned local-shell/core class as `bash` on Windows, while excluding it from non-Windows catalogs so it cannot become an unknown-tool model group.
|
||||||
- Activate exact existing groups from the current catalog before optional unknown-tool model enrichment, preventing unrelated catalog generation from delaying known group loads.
|
- Activate exact existing groups from the current catalog before optional unknown-tool model enrichment, preventing unrelated catalog generation from delaying known group loads.
|
||||||
|
- Include pinned tools in the optional footer's total-tool denominator.
|
||||||
|
|
||||||
|
|
||||||
## [0.3.6] - 2026-04-24
|
## [0.3.6] - 2026-04-24
|
||||||
|
|||||||
+29
-13
@@ -20,7 +20,7 @@ Generated metadata never replaces executable schemas. Exact names, parameter typ
|
|||||||
|
|
||||||
A standard bundle installation therefore sends no tool definitions to a model during catalog setup. Complete definitions are sent to the selected provider only when extra unrecognized tools require enrichment during an unknown-group or query lookup; exact existing group loads never wait for that nested call. Any nested-call usage is attached to the `tool_search` result.
|
A standard bundle installation therefore sends no tool definitions to a model during catalog setup. Complete definitions are sent to the selected provider only when extra unrecognized tools require enrichment during an unknown-group or query lookup; exact existing group loads never wait for that nested call. Any nested-call usage is attached to the `tool_search` result.
|
||||||
|
|
||||||
Run `/tool-search-rebuild` to remove model enrichment. Standard bundle tools immediately return to the checked-in catalog; extra tools can be enriched lazily on the next search.
|
Run `/tool-search-status` to inspect the current catalog source, pinned count, dynamic capacity, LRU order, and latest eviction reason. Run `/tool-search-rebuild` to remove model enrichment; standard bundle tools immediately return to the checked-in catalog and extra tools can be enriched lazily on the next search.
|
||||||
|
|
||||||
## Precomputed my-pi groups
|
## Precomputed my-pi groups
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ Run `/tool-search-rebuild` to remove model enrichment. Standard bundle tools imm
|
|||||||
| `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 |
|
||||||
| `mcp-management` | Shared MCP adapter management |
|
| `mcp-management` | Shared MCP adapter management |
|
||||||
|
|
||||||
Pinned tools are removed from these dynamic groups at runtime. For example, the default-pinned `codegraph_explore` and `lsp_diagnostics` do not consume the `code-intelligence` group budget.
|
Pinned tools are removed from these dynamic groups at runtime. The defaults pin `codegraph_explore`, `lsp_diagnostics`, and the three high-frequency Context Mode execution tools, so none of them consume dynamic group or tool capacity.
|
||||||
|
|
||||||
## Loading and eviction
|
## Loading and eviction
|
||||||
|
|
||||||
@@ -58,37 +58,53 @@ Call `tool_search` with an exact catalog group id when possible:
|
|||||||
{ "group": "web-tavily" }
|
{ "group": "web-tavily" }
|
||||||
```
|
```
|
||||||
|
|
||||||
A natural-language `query` can rank checked-in, generated, and deterministic group metadata when the group is unclear. Loading a group exposes the original full schemas for all tools in that group on Pi's next model request.
|
A workflow can atomically activate up to five exact groups in one call. The complete request must fit the configured group and tool limits; otherwise no active state is changed:
|
||||||
|
|
||||||
Pinned tools do not consume the dynamic group budget. Dynamic groups update their recency when loaded or when any member starts executing. Loading beyond `maxActiveGroups` or `maxDynamicTools` evicts least-recently-used, non-pinned groups before activating the new group.
|
```json
|
||||||
|
{
|
||||||
|
"groups": ["ssh-connection", "ssh-remote-shell", "ssh-remote-files", "ssh-remote-search"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A natural-language `query` uses weighted group, alias, and exact-tool-name metadata when the group is unclear. Weak or closely tied matches return ranked candidates without activating an arbitrary group. Loading exposes the original full schemas on Pi's next model request.
|
||||||
|
|
||||||
|
Pinned tools do not consume the dynamic budget. Dynamic groups update their recency when loaded or when a member starts executing. Loading beyond `maxActiveGroups` or `maxDynamicTools` atomically evicts least-recently-used non-requested groups before activation.
|
||||||
|
|
||||||
The first loads are purely additive and retain Pi's native deferred-loading propagation. An eviction necessarily removes and adds schemas in one transition, so Pi uses its safe fallback rather than treating that transition as purely additive.
|
The first loads are purely additive and retain Pi's native deferred-loading propagation. An eviction necessarily removes and adds schemas in one transition, so Pi uses its safe fallback rather than treating that transition as purely additive.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The bundle adds these fields only when missing and never replaces explicit user choices:
|
Pi reads this configuration from `<agent-dir>/settings.json` (normally `~/.pi/agent/settings.json`). The bundle adds fields only when missing and never replaces explicit user choices:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"toolSearch": {
|
"toolSearch": {
|
||||||
"alwaysEnabled": ["codegraph_explore", "lsp_diagnostics"],
|
"alwaysEnabled": [
|
||||||
|
"codegraph_explore",
|
||||||
|
"lsp_diagnostics",
|
||||||
|
"ctx_execute",
|
||||||
|
"ctx_execute_file",
|
||||||
|
"ctx_batch_execute"
|
||||||
|
],
|
||||||
"showToolSearchFooterStatus": false,
|
"showToolSearchFooterStatus": false,
|
||||||
"maxActiveGroups": 3,
|
"maxActiveGroups": 5,
|
||||||
"maxToolsPerGroup": 8,
|
"maxToolsPerGroup": 8,
|
||||||
"maxDynamicTools": 20,
|
"maxDynamicTools": 28,
|
||||||
"groupOverrides": {}
|
"groupOverrides": {},
|
||||||
|
"bundleDefaultsVersion": 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Key | Bundle default | Description |
|
| Key | Bundle default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `alwaysEnabled` | `codegraph_explore`, `lsp_diagnostics` | Exact names pinned alongside Pi core tools |
|
| `alwaysEnabled` | CodeGraph explore, LSP diagnostics, Context execution trio | Exact names pinned alongside Pi core tools |
|
||||||
| `showToolSearchFooterStatus` | `false` | Show active tool/group counts |
|
| `showToolSearchFooterStatus` | `false` | Show active tool/group counts |
|
||||||
| `maxActiveGroups` | `3` | 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 |
|
||||||
| `maxDynamicTools` | `20` | Hard cap across active dynamic group tools |
|
| `maxDynamicTools` | `28` | Hard cap across active dynamic group tools |
|
||||||
| `groupOverrides` | `{}` | Exact tool lists that override checked-in, generated, or fallback assignments |
|
| `groupOverrides` | `{}` | Exact tool lists that override checked-in, generated, or fallback assignments |
|
||||||
|
| `bundleDefaultsVersion` | `2` on a newly created default section | Bundle provenance for controlled default migrations; ignored by runtime routing |
|
||||||
|
|
||||||
Example override:
|
Example override:
|
||||||
|
|
||||||
@@ -103,7 +119,7 @@ Example override:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
An explicit override larger than `maxDynamicTools` remains visible but cannot be activated until the limit or override is corrected. Invalid `settings.json` or an invalid `toolSearch` value is never overwritten.
|
An explicit override larger than `maxDynamicTools` remains visible but cannot be activated until the limit or override is corrected. Normal extension loading preserves existing values and writes owner-only settings through a unique same-directory temporary file. When the user explicitly runs the bundle's updated `update.sh`, its post-update migration helper changes only the complete former `3`/`20` default snapshot: it creates an owner-only timestamped backup, atomically writes the current defaults, and records `bundleDefaultsVersion: 2`. Customized or invalid settings are preserved, and a content change detected immediately before commit aborts the migration.
|
||||||
|
|
||||||
## Current host integration
|
## Current host integration
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,21 @@
|
|||||||
|
|
||||||
`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. On Windows, `powershell` is pinned beside `bash` as a seventh local-shell/core tool; on non-Windows hosts it is excluded from the catalog. These pinned tools do not consume the dynamic-group 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, and the three high-frequency Context Mode execution 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.
|
||||||
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.
|
||||||
5. Only a hybrid catalog with unrecognized tools can call `ModelRegistry.complete()`, and an exact existing group skips that enrichment so activation cannot wait on an unrelated unknown tool. Unknown-group and query lookups may still enrich the catalog. The prompt marks bundle assignments as fixed; validation rejects any response that moves them.
|
5. Only a hybrid catalog with unrecognized tools can call `ModelRegistry.complete()`, and an exact existing group skips that enrichment so activation cannot wait on an unrelated unknown tool. Unknown-group and query lookups may still enrich the catalog. The prompt marks bundle assignments as fixed; validation rejects any response that moves them.
|
||||||
6. Code validation still requires every exact tool name exactly once and rejects unknown names, duplicates, oversized generated groups, omissions, and tool-card/group mismatches. Generated text never changes executable schemas.
|
6. Code validation still requires every exact tool name exactly once and rejects unknown names, duplicates, oversized generated groups, omissions, and tool-card/group mismatches. Generated text never changes executable schemas.
|
||||||
7. Loading a group calls `setActiveTools()` with its original full tool definitions. Pi supplies newly added schemas on the next model request.
|
7. Loading one exact group or an atomic `groups` batch calls `setActiveTools()` once with the original full tool definitions. A batch is rejected without changing active state if its requested groups alone exceed either capacity limit. Pi supplies newly added schemas on the next model request.
|
||||||
8. Group load and member execution update an in-session LRU counter. Loading past `maxActiveGroups` or `maxDynamicTools` removes the least-recently-used dynamic groups.
|
8. Group load and member execution update an in-session LRU counter. Loading past the five-group or 28-tool defaults removes least-recently-used non-requested dynamic groups. `/tool-search-status` reports current capacity, LRU order, and the latest eviction reason.
|
||||||
9. `turn_start` notices tool or grouping-configuration changes by hash, resets stale dynamic groups, and reconstructs the checked-in/hybrid catalog.
|
9. Natural-language lookup gives exact tool names and workflow aliases more weight than descriptive overlap. Weak or closely tied matches return candidates without activation.
|
||||||
|
10. `turn_start` notices tool or grouping-configuration changes by hash, resets stale dynamic groups, and reconstructs the checked-in/hybrid catalog.
|
||||||
|
|
||||||
The standard bundle assigns the official Hippo Pi extension's five `hippo_*` tools to recall and management groups, and assigns `Agent`, `get_subagent_result`, and `steer_subagent` to the `subagents` orchestration group. `context_tree_query` remains in `memory-recall` because it recovers condensed tool output; retired Hermes names and third-party `tff-memory_*` names are not seeded.
|
The standard bundle assigns the official Hippo Pi extension's five `hippo_*` tools to recall and management groups, and assigns `Agent`, `get_subagent_result`, and `steer_subagent` to the `subagents` orchestration group. `context_tree_query` remains in `memory-recall` because it recovers condensed tool output; retired Hermes names and third-party `tff-memory_*` names are not seeded.
|
||||||
The maintained `pi-ssh` tools are also split by workflow: sequential reviewed agent-controlled connection lives in `ssh-connection`, structured `ssh_read`/`ssh_write`/`ssh_edit` file operations live in `ssh-remote-files`, bounded `ssh_find`/`ssh_grep` discovery lives in `ssh-remote-search`, and sequential reviewed workspace changes plus reviewed command execution live in `ssh-remote-shell`. The workflows tell the model to call `ssh_connect` or `ssh_cd` separately and wait for success before issuing remote calls that depend on the new connection or cwd.
|
The maintained `pi-ssh` tools are also split by workflow: sequential reviewed agent-controlled connection lives in `ssh-connection`, structured `ssh_read`/`ssh_write`/`ssh_edit` file operations live in `ssh-remote-files`, bounded `ssh_find`/`ssh_grep` discovery lives in `ssh-remote-search`, and sequential reviewed workspace changes plus reviewed command execution live in `ssh-remote-shell`. The workflows tell the model to call `ssh_connect` or `ssh_cd` separately and wait for success before issuing remote calls that depend on the new connection or cwd.
|
||||||
The maintained `ask_user_question` tool has its own `user-interaction` group so the model can load the complete interactive schema only when a missing decision or preference must come from the user.
|
The maintained `ask_user_question` tool has its own `user-interaction` group so the model can load the complete interactive schema only when a missing decision or preference must come from the user.
|
||||||
The pinned `pi-chrome` bridge registers its 21 `chrome_*` tools only after explicit per-session authorization. Once registered, catalog refresh assigns them to `chrome-navigation`, `chrome-interaction`, and `chrome-debugging`; revocation still remains enforced inside every upstream tool execution even if a schema is later reactivated by Tool Search.
|
The pinned `pi-chrome` bridge registers its 21 `chrome_*` tools only after explicit per-session authorization. Once registered, catalog refresh assigns them to `chrome-navigation`, `chrome-interaction`, and `chrome-debugging`; the 28-tool default allows all three groups to coexist. Revocation remains enforced inside every upstream tool execution even if a schema is later reactivated by Tool Search.
|
||||||
|
|
||||||
A purely additive first load receives Pi's native dynamic-tool result propagation. A replacement that evicts one group while adding another is intentionally non-additive; current Pi detects the removal and uses its safe next-request fallback instead of attaching an invalid additive-only hint.
|
A purely additive first load receives Pi's native dynamic-tool result propagation. A replacement that evicts one group while adding another is intentionally non-additive; current Pi detects the removal and uses its safe next-request fallback instead of attaching an invalid additive-only hint.
|
||||||
|
|
||||||
|
|||||||
@@ -334,6 +334,17 @@ export function writeCachedCatalog(cachePath: string, catalog: ToolCatalog): voi
|
|||||||
renameSync(temporaryPath, cachePath);
|
renameSync(temporaryPath, cachePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GROUP_QUERY_ALIASES: Record<string, string[]> = {
|
||||||
|
"context-execution": ["context mode", "run command", "test build logs", "执行命令", "运行测试", "分析日志"],
|
||||||
|
"ssh-connection": ["ssh connect", "remote server connection", "连接服务器", "远程连接"],
|
||||||
|
"ssh-remote-shell": ["ssh shell", "remote command", "remote terminal", "远程命令", "远程终端"],
|
||||||
|
"ssh-remote-files": ["ssh file", "remote read write edit", "远程文件", "远程读写"],
|
||||||
|
"ssh-remote-search": ["ssh search", "remote find grep", "远程搜索", "远程查找"],
|
||||||
|
"chrome-navigation": ["chrome browser navigation", "browser tabs pages", "浏览器导航", "网页导航"],
|
||||||
|
"chrome-interaction": ["chrome browser interaction", "click type form", "浏览器交互", "点击输入"],
|
||||||
|
"chrome-debugging": ["chrome browser debugging", "console network screenshot", "浏览器调试", "控制台网络"],
|
||||||
|
};
|
||||||
|
|
||||||
function queryTerms(value: string): Set<string> {
|
function queryTerms(value: string): Set<string> {
|
||||||
const normalized = value.toLowerCase();
|
const normalized = value.toLowerCase();
|
||||||
const terms = new Set(normalized.match(/[\p{L}\p{N}]+/gu) ?? []);
|
const terms = new Set(normalized.match(/[\p{L}\p{N}]+/gu) ?? []);
|
||||||
@@ -343,24 +354,92 @@ function queryTerms(value: string): Set<string> {
|
|||||||
return terms;
|
return terms;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rankGroups(catalog: ToolCatalog, query: string): Array<{ group: GroupCard; score: number }> {
|
const GENERIC_QUERY_TERMS = new Set([
|
||||||
const querySet = queryTerms(query);
|
"edit",
|
||||||
|
"execute",
|
||||||
|
"fetch",
|
||||||
|
"file",
|
||||||
|
"files",
|
||||||
|
"find",
|
||||||
|
"get",
|
||||||
|
"list",
|
||||||
|
"read",
|
||||||
|
"run",
|
||||||
|
"search",
|
||||||
|
"show",
|
||||||
|
"tool",
|
||||||
|
"tools",
|
||||||
|
"web",
|
||||||
|
"write",
|
||||||
|
"列出",
|
||||||
|
"写入",
|
||||||
|
"工具",
|
||||||
|
"执行",
|
||||||
|
"搜索",
|
||||||
|
"文件",
|
||||||
|
"显示",
|
||||||
|
"查找",
|
||||||
|
"获取",
|
||||||
|
"编辑",
|
||||||
|
"网页",
|
||||||
|
"读取",
|
||||||
|
"运行",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function addTermWeights(target: Map<string, number>, querySet: Set<string>, value: string, weight: number): void {
|
||||||
|
const terms = queryTerms(value);
|
||||||
|
for (const term of querySet) {
|
||||||
|
if (terms.has(term)) target.set(term, Math.max(target.get(term) ?? 0, weight));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RankedGroup {
|
||||||
|
group: GroupCard;
|
||||||
|
score: number;
|
||||||
|
matchedTerms: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rankGroups(catalog: ToolCatalog, query: string): RankedGroup[] {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const querySet = queryTerms(normalizedQuery);
|
||||||
return catalog.groups
|
return catalog.groups
|
||||||
.map((group) => {
|
.map((group) => {
|
||||||
const cards = catalog.tools.filter((tool) => tool.primaryGroup === group.id);
|
const cards = catalog.tools.filter((tool) => tool.primaryGroup === group.id);
|
||||||
const searchable = [
|
const aliases = GROUP_QUERY_ALIASES[group.id] ?? [];
|
||||||
group.id,
|
const termWeights = new Map<string, number>();
|
||||||
group.title,
|
addTermWeights(termWeights, querySet, group.id, 8);
|
||||||
group.summary,
|
addTermWeights(termWeights, querySet, group.title, 6);
|
||||||
...group.useWhen,
|
addTermWeights(termWeights, querySet, group.summary, 2);
|
||||||
...group.avoidWhen,
|
addTermWeights(termWeights, querySet, group.useWhen.join(" "), 3);
|
||||||
...cards.flatMap((tool) => [tool.name, tool.summary, ...tool.useWhen, ...tool.keywords]),
|
addTermWeights(termWeights, querySet, group.avoidWhen.join(" "), 1);
|
||||||
].join(" ");
|
addTermWeights(termWeights, querySet, aliases.join(" "), 5);
|
||||||
const terms = queryTerms(searchable);
|
for (const card of cards) {
|
||||||
let score = 0;
|
addTermWeights(termWeights, querySet, card.name, 10);
|
||||||
for (const term of querySet) if (terms.has(term)) score += term.length > 1 ? 3 : 1;
|
addTermWeights(termWeights, querySet, card.summary, 2);
|
||||||
if (group.id === query.trim().toLowerCase()) score += 100;
|
addTermWeights(termWeights, querySet, card.useWhen.join(" "), 3);
|
||||||
return { group, score };
|
addTermWeights(termWeights, querySet, card.keywords.join(" "), 5);
|
||||||
|
}
|
||||||
|
let score = [...termWeights].reduce(
|
||||||
|
(total, [term, weight]) => total + (term.length > 1 ? 3 : 1) * weight,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (cards.some((card) => normalizedQuery === card.name.toLowerCase())) score += 800;
|
||||||
|
if (group.id === normalizedQuery) score += 1_000;
|
||||||
|
if (aliases.some((alias) => alias.toLowerCase() === normalizedQuery)) score += 500;
|
||||||
|
return { group, score, matchedTerms: [...termWeights.keys()] };
|
||||||
})
|
})
|
||||||
.sort((left, right) => right.score - left.score || left.group.id.localeCompare(right.group.id));
|
.sort((left, right) => right.score - left.score || left.group.id.localeCompare(right.group.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Avoid auto-activating a generic, weak, or ambiguous natural-language match. */
|
||||||
|
export function selectConfidentGroup(ranked: RankedGroup[], _query: string): GroupCard | undefined {
|
||||||
|
const [first, second] = ranked;
|
||||||
|
if (!first || first.score < 24) return undefined;
|
||||||
|
if (first.score >= 500) return first.group;
|
||||||
|
const informativeTerms = first.matchedTerms.filter(
|
||||||
|
(term) => term.length > 1 && !GENERIC_QUERY_TERMS.has(term),
|
||||||
|
);
|
||||||
|
if (informativeTerms.length === 0) return undefined;
|
||||||
|
if (second && first.score - second.score < 12) return undefined;
|
||||||
|
return first.group;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,35 @@
|
|||||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
import {
|
||||||
|
chmodSync,
|
||||||
|
closeSync,
|
||||||
|
fsyncSync,
|
||||||
|
mkdirSync,
|
||||||
|
openSync,
|
||||||
|
readFileSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
export type ToolSearchDefaultResult = "updated" | "unchanged" | "skipped-invalid";
|
export type ToolSearchDefaultResult = "updated" | "unchanged" | "skipped-invalid";
|
||||||
|
|
||||||
export const BUNDLE_TOOL_SEARCH_DEFAULTS = {
|
export const BUNDLE_TOOL_SEARCH_DEFAULTS = {
|
||||||
alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"],
|
alwaysEnabled: [
|
||||||
|
"codegraph_explore",
|
||||||
|
"lsp_diagnostics",
|
||||||
|
"ctx_execute",
|
||||||
|
"ctx_execute_file",
|
||||||
|
"ctx_batch_execute",
|
||||||
|
],
|
||||||
showToolSearchFooterStatus: false,
|
showToolSearchFooterStatus: false,
|
||||||
maxActiveGroups: 3,
|
maxActiveGroups: 5,
|
||||||
maxToolsPerGroup: 8,
|
maxToolsPerGroup: 8,
|
||||||
maxDynamicTools: 20,
|
maxDynamicTools: 28,
|
||||||
groupOverrides: {},
|
groupOverrides: {},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const BUNDLE_TOOL_SEARCH_DEFAULTS_VERSION = 2;
|
||||||
|
|
||||||
export interface ToolSearchConfig {
|
export interface ToolSearchConfig {
|
||||||
alwaysEnabled: string[];
|
alwaysEnabled: string[];
|
||||||
showToolSearchFooterStatus: boolean;
|
showToolSearchFooterStatus: boolean;
|
||||||
@@ -100,12 +118,28 @@ export function ensureToolSearchDefaults(agentDir: string): ToolSearchDefaultRes
|
|||||||
toolSearch[key] = Array.isArray(value) ? [...value] : isObject(value) ? { ...value } : value;
|
toolSearch[key] = Array.isArray(value) ? [...value] : isObject(value) ? { ...value } : value;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
if (existing === undefined) {
|
||||||
|
toolSearch.bundleDefaultsVersion = BUNDLE_TOOL_SEARCH_DEFAULTS_VERSION;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
if (!changed) return "unchanged";
|
if (!changed) return "unchanged";
|
||||||
|
|
||||||
settings.toolSearch = toolSearch;
|
settings.toolSearch = toolSearch;
|
||||||
mkdirSync(dirname(targetPath), { recursive: true });
|
mkdirSync(dirname(targetPath), { recursive: true });
|
||||||
const temporaryPath = `${targetPath}.my-pi.tmp`;
|
const temporaryPath = `${targetPath}.my-pi-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`;
|
||||||
writeFileSync(temporaryPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
let descriptor: number | undefined;
|
||||||
renameSync(temporaryPath, targetPath);
|
try {
|
||||||
|
descriptor = openSync(temporaryPath, "wx", 0o600);
|
||||||
|
writeFileSync(descriptor, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
||||||
|
fsyncSync(descriptor);
|
||||||
|
closeSync(descriptor);
|
||||||
|
descriptor = undefined;
|
||||||
|
renameSync(temporaryPath, targetPath);
|
||||||
|
chmodSync(targetPath, 0o600);
|
||||||
|
} catch (error) {
|
||||||
|
if (descriptor !== undefined) closeSync(descriptor);
|
||||||
|
rmSync(temporaryPath, { force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return "updated";
|
return "updated";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
parseGeneratedCatalog,
|
parseGeneratedCatalog,
|
||||||
rankGroups,
|
rankGroups,
|
||||||
readCachedCatalog,
|
readCachedCatalog,
|
||||||
|
selectConfidentGroup,
|
||||||
writeCachedCatalog,
|
writeCachedCatalog,
|
||||||
type CatalogConstraints,
|
type CatalogConstraints,
|
||||||
type GroupCard,
|
type GroupCard,
|
||||||
@@ -32,6 +33,7 @@ import { ensureToolSearchDefaults, 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";
|
||||||
|
const MAX_BATCH_GROUPS = 5;
|
||||||
const CORE_TOOLS = ["read", "write", "edit", "bash", "grep", "find"];
|
const CORE_TOOLS = ["read", "write", "edit", "bash", "grep", "find"];
|
||||||
|
|
||||||
type CatalogSource = "bundle" | "cache" | "fallback" | "hybrid" | "model";
|
type CatalogSource = "bundle" | "cache" | "fallback" | "hybrid" | "model";
|
||||||
@@ -102,7 +104,7 @@ function groupDescription(catalog: ToolCatalog, source: CatalogSource): string {
|
|||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
return [
|
return [
|
||||||
"Activate a complete tool group for the current task. Prefer an exact group id from this catalog; use query only when no id clearly matches.",
|
"Activate one or more complete tool groups for the current task. Prefer exact group ids from this catalog; use query only when no id clearly matches.",
|
||||||
`Catalog source: ${sourceLabel}.`,
|
`Catalog source: ${sourceLabel}.`,
|
||||||
"Available groups:",
|
"Available groups:",
|
||||||
groups || " (no hidden tool groups)",
|
groups || " (no hidden tool groups)",
|
||||||
@@ -131,6 +133,7 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
|||||||
const activeGroups = new Map<string, number>();
|
const activeGroups = new Map<string, number>();
|
||||||
const pinnedTools = new Set<string>();
|
const pinnedTools = new Set<string>();
|
||||||
let bundleState: BundleCatalogResult = { catalog, coveredNames: new Set(), unknownTools: [] };
|
let bundleState: BundleCatalogResult = { catalog, coveredNames: new Set(), unknownTools: [] };
|
||||||
|
let lastEviction = "none";
|
||||||
|
|
||||||
function constraints(): CatalogConstraints {
|
function constraints(): CatalogConstraints {
|
||||||
return {
|
return {
|
||||||
@@ -171,12 +174,32 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
|||||||
return catalog.groups.find((group) => group.id === id);
|
return catalog.groups.find((group) => group.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dynamicToolCount(): number {
|
function dynamicToolCount(groupIds: Iterable<string> = activeGroups.keys()): number {
|
||||||
return unique([...activeGroups.keys()].flatMap((id) => groupById(id)?.tools ?? [])).length;
|
return unique([...groupIds].flatMap((id) => groupById(id)?.tools ?? [])).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function leastRecentlyUsedGroup(): string | undefined {
|
function leastRecentlyUsedGroup(
|
||||||
return [...activeGroups].sort((left, right) => left[1] - right[1] || left[0].localeCompare(right[0]))[0]?.[0];
|
groups: Map<string, number> = activeGroups,
|
||||||
|
protectedGroups: ReadonlySet<string> = new Set(),
|
||||||
|
): string | undefined {
|
||||||
|
return [...groups]
|
||||||
|
.filter(([id]) => !protectedGroups.has(id))
|
||||||
|
.sort((left, right) => left[1] - right[1] || left[0].localeCompare(right[0]))[0]?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentStatusText(): string {
|
||||||
|
const lru = [...activeGroups]
|
||||||
|
.sort((left, right) => left[1] - right[1] || left[0].localeCompare(right[0]))
|
||||||
|
.map(([id]) => id);
|
||||||
|
return [
|
||||||
|
"Tool Search status",
|
||||||
|
`Catalog: ${catalogSource}`,
|
||||||
|
`Pinned tools: ${pinnedTools.size}`,
|
||||||
|
`Dynamic groups: ${activeGroups.size}/${config.maxActiveGroups}`,
|
||||||
|
`Dynamic tools: ${dynamicToolCount()}/${config.maxDynamicTools}`,
|
||||||
|
`LRU oldest → newest: ${lru.join(" → ") || "none"}`,
|
||||||
|
`Last eviction: ${lastEviction}`,
|
||||||
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyActiveTools(): void {
|
function applyActiveTools(): void {
|
||||||
@@ -189,7 +212,7 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
|||||||
ctx.ui.setStatus(
|
ctx.ui.setStatus(
|
||||||
"tool-search",
|
"tool-search",
|
||||||
config.showToolSearchFooterStatus
|
config.showToolSearchFooterStatus
|
||||||
? `${activeToolCount} / ${tools.length + 1} tools · ${activeGroups.size} / ${config.maxActiveGroups} groups`
|
? `${activeToolCount} / ${tools.length + pinnedTools.size + 1} tools · ${activeGroups.size} / ${config.maxActiveGroups} groups`
|
||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -199,71 +222,147 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
|||||||
name: TOOL_SEARCH_NAME,
|
name: TOOL_SEARCH_NAME,
|
||||||
label: "Tool Search",
|
label: "Tool Search",
|
||||||
description: groupDescription(catalog, catalogSource),
|
description: groupDescription(catalog, catalogSource),
|
||||||
promptSnippet: `Activate relevant tool groups on demand; at most ${config.maxActiveGroups} dynamic groups remain active`,
|
promptSnippet: `Activate relevant tool groups on demand; up to ${config.maxActiveGroups} dynamic groups can remain active. Use groups for workflows that need several groups together`,
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
group: Type.Optional(Type.String({ description: "Exact group id from the tool_search catalog" })),
|
group: Type.Optional(Type.String({ description: "Exact group id from the tool_search catalog" })),
|
||||||
|
groups: Type.Optional(
|
||||||
|
Type.Array(Type.String({ description: "Exact group id from the tool_search catalog" }), {
|
||||||
|
description: `Exact group ids to activate atomically (maximum ${MAX_BATCH_GROUPS})`,
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: MAX_BATCH_GROUPS,
|
||||||
|
}),
|
||||||
|
),
|
||||||
query: Type.Optional(Type.String({ description: "Natural-language task used to rank groups when an exact id is unclear" })),
|
query: Type.Optional(Type.String({ description: "Natural-language task used to rank groups when an exact id is unclear" })),
|
||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||||
const requestedId = params.group?.trim().toLowerCase();
|
const singleGroup = typeof params.group === "string" ? params.group.trim() : "";
|
||||||
let selected = requestedId ? groupById(requestedId) : undefined;
|
const batchGroups = Array.isArray(params.groups) ? params.groups : [];
|
||||||
const generation: CatalogGenerationResult = selected
|
const query = typeof params.query === "string" ? params.query.trim() : "";
|
||||||
? {}
|
const hasSingle = singleGroup.length > 0;
|
||||||
: await ensureModelCatalog(ctx, signal);
|
const hasBatch = batchGroups.length > 0;
|
||||||
|
const hasQuery = query.length > 0;
|
||||||
|
const modes = Number(hasSingle) + Number(hasBatch) + Number(hasQuery);
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
const emptyDetails = (candidates: Array<{ id: string; score: number }> = []) => ({
|
||||||
|
loadedGroup: undefined,
|
||||||
|
loadedGroups: [] as string[],
|
||||||
|
evictedGroups: [] as string[],
|
||||||
|
activeGroups: [...activeGroups.keys()],
|
||||||
|
candidates,
|
||||||
|
catalogSource,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (modes !== 1) {
|
||||||
|
lines.push("Provide exactly one of group, groups, or query.");
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n") }], details: emptyDetails() };
|
||||||
|
}
|
||||||
|
if (batchGroups.length > MAX_BATCH_GROUPS) {
|
||||||
|
lines.push(`groups accepts at most ${MAX_BATCH_GROUPS} group ids.`);
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n") }], details: emptyDetails() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedIds = unique(
|
||||||
|
(hasBatch ? batchGroups : hasSingle ? [singleGroup] : [])
|
||||||
|
.map((id) => id.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
let selectedGroups = requestedIds.map(groupById).filter((group): group is GroupCard => group !== undefined);
|
||||||
|
const needsCatalog = hasQuery || selectedGroups.length !== requestedIds.length;
|
||||||
|
const generation: CatalogGenerationResult = needsCatalog ? await ensureModelCatalog(ctx, signal) : {};
|
||||||
if (generation.notice) lines.push(generation.notice);
|
if (generation.notice) lines.push(generation.notice);
|
||||||
|
|
||||||
if (!selected && requestedId) selected = groupById(requestedId);
|
selectedGroups = requestedIds.map(groupById).filter((group): group is GroupCard => group !== undefined);
|
||||||
const ranked = params.query ? rankGroups(catalog, params.query).slice(0, 3) : [];
|
const ranked = hasQuery ? rankGroups(catalog, query).slice(0, 3) : [];
|
||||||
if (!selected && params.query && (ranked[0]?.score ?? 0) > 0) selected = ranked[0]?.group;
|
if (hasQuery) {
|
||||||
|
const selected = selectConfidentGroup(ranked, query);
|
||||||
|
if (selected) selectedGroups = [selected];
|
||||||
|
}
|
||||||
|
|
||||||
if (!selected) {
|
const missingIds = requestedIds.filter((id) => !groupById(id));
|
||||||
|
if (selectedGroups.length === 0 || missingIds.length > 0) {
|
||||||
const candidates = ranked.length > 0 ? ranked : catalog.groups.slice(0, 5).map((group) => ({ group, score: 0 }));
|
const candidates = ranked.length > 0 ? ranked : catalog.groups.slice(0, 5).map((group) => ({ group, score: 0 }));
|
||||||
lines.push(
|
lines.push(
|
||||||
params.group ? `Unknown group: ${params.group}` : "No group was activated. Provide an exact group id from the catalog.",
|
missingIds.length > 0
|
||||||
|
? `Unknown groups: ${missingIds.join(", ")}`
|
||||||
|
: hasQuery
|
||||||
|
? "No group was activated because the query match was weak or ambiguous. Use an exact group id from the candidates."
|
||||||
|
: "No group was activated. Provide an exact group id from the catalog.",
|
||||||
`Candidates: ${candidates.map(({ group }) => group.id).join(", ") || "none"}`,
|
`Candidates: ${candidates.map(({ group }) => group.id).join(", ") || "none"}`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: lines.join("\n") }],
|
content: [{ type: "text", text: lines.join("\n") }],
|
||||||
details: { loadedGroup: undefined, evictedGroups: [], activeGroups: [...activeGroups.keys()], candidates: candidates.map(({ group, score }) => ({ id: group.id, score })), catalogSource },
|
details: emptyDetails(candidates.map(({ group, score }) => ({ id: group.id, score }))),
|
||||||
...(generation.usage ? { usage: generation.usage } : {}),
|
...(generation.usage ? { usage: generation.usage } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupTools = unique(selected.tools);
|
const selectedIds = selectedGroups.map((group) => group.id);
|
||||||
if (groupTools.length > config.maxDynamicTools) {
|
const selectedTools = unique(selectedGroups.flatMap((group) => group.tools));
|
||||||
lines.push(`Group ${selected.id} has ${groupTools.length} tools, exceeding maxDynamicTools=${config.maxDynamicTools}.`);
|
if (selectedIds.length > config.maxActiveGroups || selectedTools.length > config.maxDynamicTools) {
|
||||||
|
lines.push(
|
||||||
|
`Requested workflow needs ${selectedIds.length} groups and ${selectedTools.length} tools, exceeding the configured limits of ${config.maxActiveGroups} groups and ${config.maxDynamicTools} tools.`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: lines.join("\n") }],
|
content: [{ type: "text", text: lines.join("\n") }],
|
||||||
details: { loadedGroup: undefined, evictedGroups: [], activeGroups: [...activeGroups.keys()], candidates: [], catalogSource },
|
details: emptyDetails(),
|
||||||
...(generation.usage ? { usage: generation.usage } : {}),
|
...(generation.usage ? { usage: generation.usage } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestedSet = new Set(selectedIds);
|
||||||
|
const nextActive = new Map(activeGroups);
|
||||||
|
for (const id of selectedIds) if (!nextActive.has(id)) nextActive.set(id, Number.MAX_SAFE_INTEGER);
|
||||||
const evictedGroups: string[] = [];
|
const evictedGroups: string[] = [];
|
||||||
if (!activeGroups.has(selected.id)) {
|
const evictionReasons = new Set<string>();
|
||||||
while (
|
while (nextActive.size > config.maxActiveGroups || dynamicToolCount(nextActive.keys()) > config.maxDynamicTools) {
|
||||||
activeGroups.size >= config.maxActiveGroups ||
|
if (nextActive.size > config.maxActiveGroups) evictionReasons.add(`group cap ${config.maxActiveGroups}`);
|
||||||
(activeGroups.size > 0 && dynamicToolCount() + groupTools.length > config.maxDynamicTools)
|
if (dynamicToolCount(nextActive.keys()) > config.maxDynamicTools) evictionReasons.add(`tool cap ${config.maxDynamicTools}`);
|
||||||
) {
|
const evicted = leastRecentlyUsedGroup(nextActive, requestedSet);
|
||||||
const evicted = leastRecentlyUsedGroup();
|
if (!evicted) break;
|
||||||
if (!evicted) break;
|
nextActive.delete(evicted);
|
||||||
activeGroups.delete(evicted);
|
evictedGroups.push(evicted);
|
||||||
evictedGroups.push(evicted);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
activeGroups.set(selected.id, ++clock);
|
|
||||||
applyActiveTools();
|
|
||||||
registerToolSearch();
|
|
||||||
updateStatus(ctx);
|
|
||||||
|
|
||||||
lines.push(`Loaded group: ${selected.id} (${groupTools.join(", ")})`);
|
const previousActive = new Map(activeGroups);
|
||||||
|
const previousClock = clock;
|
||||||
|
const previousLastEviction = lastEviction;
|
||||||
|
activeGroups.clear();
|
||||||
|
for (const [id, recency] of nextActive) activeGroups.set(id, recency);
|
||||||
|
for (const id of selectedIds) activeGroups.set(id, ++clock);
|
||||||
|
if (evictedGroups.length > 0) {
|
||||||
|
lastEviction = `${evictedGroups.join(", ")} (${[...evictionReasons].join("; ")})`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
applyActiveTools();
|
||||||
|
registerToolSearch();
|
||||||
|
updateStatus(ctx);
|
||||||
|
} catch (error) {
|
||||||
|
activeGroups.clear();
|
||||||
|
for (const [id, recency] of previousActive) activeGroups.set(id, recency);
|
||||||
|
clock = previousClock;
|
||||||
|
lastEviction = previousLastEviction;
|
||||||
|
try {
|
||||||
|
applyActiveTools();
|
||||||
|
registerToolSearch();
|
||||||
|
updateStatus(ctx);
|
||||||
|
} catch {
|
||||||
|
// Preserve the original host activation failure while retaining rolled-back internal state.
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedGroups.length === 1) {
|
||||||
|
lines.push(`Loaded group: ${selectedGroups[0]?.id} (${selectedTools.join(", ")})`);
|
||||||
|
} else {
|
||||||
|
lines.push(`Loaded groups: ${selectedIds.join(", ")} (${selectedTools.length} unique tools)`);
|
||||||
|
}
|
||||||
if (evictedGroups.length > 0) lines.push(`Evicted least-recently-used: ${evictedGroups.join(", ")}`);
|
if (evictedGroups.length > 0) lines.push(`Evicted least-recently-used: ${evictedGroups.join(", ")}`);
|
||||||
lines.push(`Active groups: ${[...activeGroups.keys()].join(", ")}`);
|
lines.push(`Active groups: ${[...activeGroups.keys()].join(", ")}`);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: lines.join("\n") }],
|
content: [{ type: "text", text: lines.join("\n") }],
|
||||||
details: {
|
details: {
|
||||||
loadedGroup: selected.id,
|
loadedGroup: selectedIds.at(-1),
|
||||||
|
loadedGroups: selectedIds,
|
||||||
evictedGroups,
|
evictedGroups,
|
||||||
activeGroups: [...activeGroups.keys()],
|
activeGroups: [...activeGroups.keys()],
|
||||||
candidates: ranked.map(({ group, score }) => ({ id: group.id, score })),
|
candidates: ranked.map(({ group, score }) => ({ id: group.id, score })),
|
||||||
@@ -379,17 +478,23 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
|||||||
if (catalogChanged) {
|
if (catalogChanged) {
|
||||||
activeGroups.clear();
|
activeGroups.clear();
|
||||||
clock = 0;
|
clock = 0;
|
||||||
|
lastEviction = "none";
|
||||||
attemptedGenerationHash = undefined;
|
attemptedGenerationHash = undefined;
|
||||||
installBaseCatalog(true);
|
installBaseCatalog(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
let capacityChanged = false;
|
const refreshEvictions: string[] = [];
|
||||||
|
const refreshReasons = new Set<string>();
|
||||||
while (activeGroups.size > config.maxActiveGroups || dynamicToolCount() > config.maxDynamicTools) {
|
while (activeGroups.size > config.maxActiveGroups || dynamicToolCount() > config.maxDynamicTools) {
|
||||||
|
if (activeGroups.size > config.maxActiveGroups) refreshReasons.add(`group cap ${config.maxActiveGroups}`);
|
||||||
|
if (dynamicToolCount() > config.maxDynamicTools) refreshReasons.add(`tool cap ${config.maxDynamicTools}`);
|
||||||
const evicted = leastRecentlyUsedGroup();
|
const evicted = leastRecentlyUsedGroup();
|
||||||
if (!evicted) break;
|
if (!evicted) break;
|
||||||
activeGroups.delete(evicted);
|
activeGroups.delete(evicted);
|
||||||
capacityChanged = true;
|
refreshEvictions.push(evicted);
|
||||||
}
|
}
|
||||||
|
const capacityChanged = refreshEvictions.length > 0;
|
||||||
|
if (capacityChanged) lastEviction = `${refreshEvictions.join(", ")} (${[...refreshReasons].join("; ")}; settings refresh)`;
|
||||||
if (catalogChanged || policyChanged || capacityChanged) {
|
if (catalogChanged || policyChanged || capacityChanged) {
|
||||||
registerToolSearch();
|
registerToolSearch();
|
||||||
applyActiveTools();
|
applyActiveTools();
|
||||||
@@ -397,6 +502,13 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
|||||||
updateStatus(ctx);
|
updateStatus(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pi.registerCommand("tool-search-status", {
|
||||||
|
description: "Show Tool Search catalog, capacity, active-group LRU order, and the latest eviction reason",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
if (ctx.hasUI) ctx.ui.notify(currentStatusText(), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
pi.registerCommand("tool-search-rebuild", {
|
pi.registerCommand("tool-search-rebuild", {
|
||||||
description: "Invalidate the generated tool-group catalog; rebuild lazily on the next tool_search call",
|
description: "Invalidate the generated tool-group catalog; rebuild lazily on the next tool_search call",
|
||||||
handler: async (_args, ctx) => {
|
handler: async (_args, ctx) => {
|
||||||
@@ -407,6 +519,7 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
|||||||
}
|
}
|
||||||
activeGroups.clear();
|
activeGroups.clear();
|
||||||
attemptedGenerationHash = undefined;
|
attemptedGenerationHash = undefined;
|
||||||
|
lastEviction = "none";
|
||||||
installBaseCatalog(false);
|
installBaseCatalog(false);
|
||||||
registerToolSearch();
|
registerToolSearch();
|
||||||
applyActiveTools();
|
applyActiveTools();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import test from "node:test";
|
|||||||
|
|
||||||
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
|
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
|
||||||
import { BUNDLE_GROUP_DEFINITIONS, createBundleCatalog } from "../extensions/bundle-groups.ts";
|
import { BUNDLE_GROUP_DEFINITIONS, createBundleCatalog } from "../extensions/bundle-groups.ts";
|
||||||
import { buildManifestHash } from "../extensions/catalog.ts";
|
import { buildManifestHash, rankGroups, selectConfidentGroup } from "../extensions/catalog.ts";
|
||||||
|
|
||||||
function tool(name: string): ToolInfo {
|
function tool(name: string): ToolInfo {
|
||||||
return {
|
return {
|
||||||
@@ -38,6 +38,14 @@ test("bundle seed names are unique and cover the declared my-pi tools", () => {
|
|||||||
assert.equal(result.catalog.tools.find((card) => card.name === "chrome_snapshot")?.primaryGroup, "chrome-navigation");
|
assert.equal(result.catalog.tools.find((card) => card.name === "chrome_snapshot")?.primaryGroup, "chrome-navigation");
|
||||||
assert.equal(result.catalog.tools.find((card) => card.name === "chrome_click")?.primaryGroup, "chrome-interaction");
|
assert.equal(result.catalog.tools.find((card) => card.name === "chrome_click")?.primaryGroup, "chrome-interaction");
|
||||||
assert.equal(result.catalog.tools.find((card) => card.name === "chrome_list_network_requests")?.primaryGroup, "chrome-debugging");
|
assert.equal(result.catalog.tools.find((card) => card.name === "chrome_list_network_requests")?.primaryGroup, "chrome-debugging");
|
||||||
|
for (const query of ["read", "read banana", "search", "web search", "查找", "运行", "查找香蕉"]) {
|
||||||
|
assert.equal(selectConfidentGroup(rankGroups(result.catalog, query), query), undefined);
|
||||||
|
}
|
||||||
|
assert.equal(selectConfidentGroup(rankGroups(result.catalog, "ssh_read"), "ssh_read")?.id, "ssh-remote-files");
|
||||||
|
assert.equal(
|
||||||
|
selectConfidentGroup(rankGroups(result.catalog, "remote find grep"), "remote find grep")?.id,
|
||||||
|
"ssh-remote-search",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("user overrides take priority and unknown tools retain deterministic fallback groups", () => {
|
test("user overrides take priority and unknown tools retain deterministic fallback groups", () => {
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
parseGeneratedCatalog,
|
parseGeneratedCatalog,
|
||||||
rankGroups,
|
rankGroups,
|
||||||
readCachedCatalog,
|
readCachedCatalog,
|
||||||
|
selectConfidentGroup,
|
||||||
writeCachedCatalog,
|
writeCachedCatalog,
|
||||||
|
type ToolCatalog,
|
||||||
} from "../extensions/catalog.ts";
|
} from "../extensions/catalog.ts";
|
||||||
|
|
||||||
function tool(name: string, description: string): ToolInfo {
|
function tool(name: string, description: string): ToolInfo {
|
||||||
@@ -62,6 +64,29 @@ test("validates exact generated assignments and ranks generated metadata", () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("prefers exact tool names and refuses weak or ambiguous query activation", () => {
|
||||||
|
const catalog: ToolCatalog = {
|
||||||
|
version: 1,
|
||||||
|
manifestHash: "test",
|
||||||
|
generatedAt: new Date(0).toISOString(),
|
||||||
|
groups: [
|
||||||
|
{ id: "remote-files", title: "Remote files", summary: "Read remote files", useWhen: [], avoidWhen: [], tools: ["ssh_read"] },
|
||||||
|
{ id: "web-reader", title: "Web reader", summary: "Read web pages", useWhen: [], avoidWhen: [], tools: ["web_read"] },
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
{ name: "ssh_read", summary: "Read a remote file", useWhen: [], avoidWhen: [], keywords: ["SSH"], primaryGroup: "remote-files" },
|
||||||
|
{ name: "web_read", summary: "Read a web page", useWhen: [], avoidWhen: [], keywords: ["web"], primaryGroup: "web-reader" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(selectConfidentGroup(rankGroups(catalog, "ssh_read"), "ssh_read")?.id, "remote-files");
|
||||||
|
assert.equal(selectConfidentGroup(rankGroups(catalog, "read"), "read"), undefined);
|
||||||
|
assert.equal(
|
||||||
|
selectConfidentGroup(rankGroups(catalog, "completely unrelated capability"), "completely unrelated capability"),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("writes a private cache and rejects a stale manifest hash", async () => {
|
test("writes a private cache and rejects a stale manifest hash", async () => {
|
||||||
const directory = await mkdtemp(join(tmpdir(), "tool-search-catalog-"));
|
const directory = await mkdtemp(join(tmpdir(), "tool-search-catalog-"));
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, readFile, rm, 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";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
@@ -18,16 +18,25 @@ async function withAgentDir(run: (agentDir: string) => Promise<void>): Promise<v
|
|||||||
test("writes bundle defaults when toolSearch is absent", async () => {
|
test("writes bundle defaults when toolSearch is absent", async () => {
|
||||||
await withAgentDir(async (agentDir) => {
|
await withAgentDir(async (agentDir) => {
|
||||||
assert.equal(ensureToolSearchDefaults(agentDir), "updated");
|
assert.equal(ensureToolSearchDefaults(agentDir), "updated");
|
||||||
|
assert.deepEqual(BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled, [
|
||||||
|
"codegraph_explore",
|
||||||
|
"lsp_diagnostics",
|
||||||
|
"ctx_execute",
|
||||||
|
"ctx_execute_file",
|
||||||
|
"ctx_batch_execute",
|
||||||
|
]);
|
||||||
assert.deepEqual(JSON.parse(await readFile(join(agentDir, "settings.json"), "utf8")), {
|
assert.deepEqual(JSON.parse(await readFile(join(agentDir, "settings.json"), "utf8")), {
|
||||||
toolSearch: {
|
toolSearch: {
|
||||||
alwaysEnabled: [...BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled],
|
alwaysEnabled: [...BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled],
|
||||||
showToolSearchFooterStatus: false,
|
showToolSearchFooterStatus: false,
|
||||||
maxActiveGroups: 3,
|
maxActiveGroups: 5,
|
||||||
maxToolsPerGroup: 8,
|
maxToolsPerGroup: 8,
|
||||||
maxDynamicTools: 20,
|
maxDynamicTools: 28,
|
||||||
groupOverrides: {},
|
groupOverrides: {},
|
||||||
|
bundleDefaultsVersion: 2,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
assert.equal((await stat(join(agentDir, "settings.json"))).mode & 0o777, 0o600);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,7 +52,7 @@ test("fills missing defaults and preserves explicit settings", async () => {
|
|||||||
maxActiveGroups: 2,
|
maxActiveGroups: 2,
|
||||||
showToolSearchFooterStatus: false,
|
showToolSearchFooterStatus: false,
|
||||||
maxToolsPerGroup: 8,
|
maxToolsPerGroup: 8,
|
||||||
maxDynamicTools: 20,
|
maxDynamicTools: 28,
|
||||||
groupOverrides: {},
|
groupOverrides: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -67,26 +76,26 @@ test("normalizes invalid runtime values without overwriting the file", async ()
|
|||||||
assert.deepEqual(readToolSearchConfig(agentDir), {
|
assert.deepEqual(readToolSearchConfig(agentDir), {
|
||||||
alwaysEnabled: ["one"],
|
alwaysEnabled: ["one"],
|
||||||
showToolSearchFooterStatus: false,
|
showToolSearchFooterStatus: false,
|
||||||
maxActiveGroups: 3,
|
maxActiveGroups: 5,
|
||||||
maxToolsPerGroup: 4,
|
maxToolsPerGroup: 4,
|
||||||
maxDynamicTools: 20,
|
maxDynamicTools: 28,
|
||||||
groupOverrides: { web: ["search"] },
|
groupOverrides: { web: ["search"] },
|
||||||
});
|
});
|
||||||
assert.equal(await readFile(path, "utf8"), original);
|
assert.equal(await readFile(path, "utf8"), original);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preserves explicit complete configuration", async () => {
|
test("preserves a complete explicit configuration including former defaults", async () => {
|
||||||
await withAgentDir(async (agentDir) => {
|
await withAgentDir(async (agentDir) => {
|
||||||
const path = join(agentDir, "settings.json");
|
const path = join(agentDir, "settings.json");
|
||||||
const original = `${JSON.stringify({
|
const original = `${JSON.stringify({
|
||||||
toolSearch: {
|
toolSearch: {
|
||||||
alwaysEnabled: [],
|
alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"],
|
||||||
showToolSearchFooterStatus: true,
|
showToolSearchFooterStatus: false,
|
||||||
maxActiveGroups: 1,
|
maxActiveGroups: 3,
|
||||||
maxToolsPerGroup: 2,
|
maxToolsPerGroup: 8,
|
||||||
maxDynamicTools: 2,
|
maxDynamicTools: 20,
|
||||||
groupOverrides: { custom: ["one"] },
|
groupOverrides: {},
|
||||||
},
|
},
|
||||||
}, null, 2)}\n`;
|
}, null, 2)}\n`;
|
||||||
await writeFile(path, original, "utf8");
|
await writeFile(path, original, "utf8");
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import toolSearchExtension, { platformToolPolicy } from "../extensions/index.ts"
|
|||||||
interface ToolSearchResult {
|
interface ToolSearchResult {
|
||||||
details: {
|
details: {
|
||||||
loadedGroup?: string;
|
loadedGroup?: string;
|
||||||
|
loadedGroups: string[];
|
||||||
evictedGroups: string[];
|
evictedGroups: string[];
|
||||||
activeGroups: string[];
|
activeGroups: string[];
|
||||||
catalogSource: string;
|
catalogSource: string;
|
||||||
@@ -20,7 +21,7 @@ interface ToolSearchResult {
|
|||||||
interface RegisteredTool {
|
interface RegisteredTool {
|
||||||
execute(
|
execute(
|
||||||
id: string,
|
id: string,
|
||||||
params: { group?: string; query?: string },
|
params: { group?: string; groups?: string[]; query?: string },
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
onUpdate: undefined,
|
onUpdate: undefined,
|
||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
@@ -67,6 +68,9 @@ test("loads whole groups and evicts the least-recently-used group", async () =>
|
|||||||
const handlers = new Map<string, (...args: any[]) => unknown>();
|
const handlers = new Map<string, (...args: any[]) => unknown>();
|
||||||
const registered = new Map<string, RegisteredTool>();
|
const registered = new Map<string, RegisteredTool>();
|
||||||
const activeCalls: string[][] = [];
|
const activeCalls: string[][] = [];
|
||||||
|
const commands = new Map<string, (...args: any[]) => unknown>();
|
||||||
|
const notifications: string[] = [];
|
||||||
|
let failActivation = false;
|
||||||
const sourceTools = [
|
const sourceTools = [
|
||||||
sourceTool("read", "Read a file"),
|
sourceTool("read", "Read a file"),
|
||||||
sourceTool("bash", "Run a command"),
|
sourceTool("bash", "Run a command"),
|
||||||
@@ -85,13 +89,22 @@ test("loads whole groups and evicts the least-recently-used group", async () =>
|
|||||||
const tool = definition as RegisteredTool & { name: string };
|
const tool = definition as RegisteredTool & { name: string };
|
||||||
registered.set(tool.name, tool);
|
registered.set(tool.name, tool);
|
||||||
},
|
},
|
||||||
registerCommand: () => {},
|
registerCommand: (name: string, definition: { handler: (...args: any[]) => unknown }) => commands.set(name, definition.handler),
|
||||||
setActiveTools: (names: string[]) => activeCalls.push([...names]),
|
setActiveTools: (names: string[]) => {
|
||||||
|
if (failActivation) throw new Error("simulated setActiveTools failure");
|
||||||
|
activeCalls.push([...names]);
|
||||||
|
},
|
||||||
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);
|
toolSearchExtension(api);
|
||||||
const ctx = testContext();
|
const ctx = testContext({
|
||||||
|
hasUI: true,
|
||||||
|
ui: {
|
||||||
|
setStatus: () => {},
|
||||||
|
notify: (message: string) => notifications.push(message),
|
||||||
|
} as unknown as ExtensionContext["ui"],
|
||||||
|
});
|
||||||
handlers.get("session_start")?.({}, ctx);
|
handlers.get("session_start")?.({}, ctx);
|
||||||
|
|
||||||
assert.ok(activeCalls.at(-1)?.includes("tool_search"));
|
assert.ok(activeCalls.at(-1)?.includes("tool_search"));
|
||||||
@@ -113,6 +126,36 @@ test("loads whole groups and evicts the least-recently-used group", async () =>
|
|||||||
assert.ok(activeCalls.at(-1)?.includes("alpha_one"));
|
assert.ok(activeCalls.at(-1)?.includes("alpha_one"));
|
||||||
assert.ok(activeCalls.at(-1)?.includes("gamma_one"));
|
assert.ok(activeCalls.at(-1)?.includes("gamma_one"));
|
||||||
assert.ok(!activeCalls.at(-1)?.includes("beta_one"));
|
assert.ok(!activeCalls.at(-1)?.includes("beta_one"));
|
||||||
|
|
||||||
|
const batch = await tool?.execute("call-4", { groups: ["beta", "gamma"] }, undefined, undefined, ctx);
|
||||||
|
assert.deepEqual(batch?.details.loadedGroups, ["beta", "gamma"]);
|
||||||
|
assert.deepEqual(batch?.details.evictedGroups, ["alpha"]);
|
||||||
|
assert.deepEqual(batch?.details.activeGroups, ["gamma", "beta"]);
|
||||||
|
|
||||||
|
const rejected = await tool?.execute(
|
||||||
|
"call-5",
|
||||||
|
{ groups: ["alpha", "beta", "gamma"] },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.deepEqual(rejected?.details.loadedGroups, []);
|
||||||
|
assert.deepEqual(rejected?.details.activeGroups, ["gamma", "beta"]);
|
||||||
|
|
||||||
|
await commands.get("tool-search-status")?.("", ctx);
|
||||||
|
assert.match(notifications.at(-1) ?? "", /Dynamic groups: 2\/2/);
|
||||||
|
assert.match(notifications.at(-1) ?? "", /LRU oldest → newest: beta → gamma/);
|
||||||
|
assert.match(notifications.at(-1) ?? "", /Last eviction: alpha \(group cap 2\)/);
|
||||||
|
|
||||||
|
failActivation = true;
|
||||||
|
await assert.rejects(
|
||||||
|
tool!.execute("call-6", { groups: ["alpha", "gamma"] }, undefined, undefined, ctx),
|
||||||
|
/simulated setActiveTools failure/,
|
||||||
|
);
|
||||||
|
failActivation = false;
|
||||||
|
await commands.get("tool-search-status")?.("", ctx);
|
||||||
|
assert.match(notifications.at(-1) ?? "", /LRU oldest → newest: beta → gamma/);
|
||||||
|
assert.match(notifications.at(-1) ?? "", /Last eviction: alpha \(group cap 2\)/);
|
||||||
} 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;
|
||||||
@@ -203,11 +246,15 @@ test("uses the precomputed bundle groups without calling a model", async () => {
|
|||||||
try {
|
try {
|
||||||
const handlers = new Map<string, (...args: any[]) => unknown>();
|
const handlers = new Map<string, (...args: any[]) => unknown>();
|
||||||
const registered = new Map<string, RegisteredTool>();
|
const registered = new Map<string, RegisteredTool>();
|
||||||
|
const activeCalls: string[][] = [];
|
||||||
let modelCalls = 0;
|
let modelCalls = 0;
|
||||||
const sourceTools = [
|
const sourceTools = [
|
||||||
sourceTool("read", "Read a file"),
|
sourceTool("read", "Read a file"),
|
||||||
sourceTool("tavily_web_search", "Search broadly for current web sources"),
|
sourceTool("tavily_web_search", "Search broadly for current web sources"),
|
||||||
sourceTool("tavily_web_fetch", "Fetch a selected Tavily source"),
|
sourceTool("tavily_web_fetch", "Fetch a selected Tavily source"),
|
||||||
|
sourceTool("ctx_execute", "Run commands in a context sandbox"),
|
||||||
|
sourceTool("ctx_execute_file", "Analyze a file in a context sandbox"),
|
||||||
|
sourceTool("ctx_batch_execute", "Run a bounded command batch"),
|
||||||
];
|
];
|
||||||
const api = {
|
const api = {
|
||||||
getAllTools: () => sourceTools,
|
getAllTools: () => sourceTools,
|
||||||
@@ -216,7 +263,7 @@ test("uses the precomputed bundle groups without calling a model", async () => {
|
|||||||
registered.set(tool.name, tool);
|
registered.set(tool.name, tool);
|
||||||
},
|
},
|
||||||
registerCommand: () => {},
|
registerCommand: () => {},
|
||||||
setActiveTools: () => {},
|
setActiveTools: (names: string[]) => activeCalls.push([...names]),
|
||||||
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);
|
toolSearchExtension(api);
|
||||||
@@ -231,10 +278,21 @@ test("uses the precomputed bundle groups without calling a model", async () => {
|
|||||||
} as unknown as ExtensionContext["modelRegistry"],
|
} as unknown as ExtensionContext["modelRegistry"],
|
||||||
});
|
});
|
||||||
handlers.get("session_start")?.({}, ctx);
|
handlers.get("session_start")?.({}, ctx);
|
||||||
|
assert.ok(["ctx_execute", "ctx_execute_file", "ctx_batch_execute"].every((name) => activeCalls.at(-1)?.includes(name)));
|
||||||
|
|
||||||
|
const ambiguous = await registered.get("tool_search")?.execute(
|
||||||
|
"call-1",
|
||||||
|
{ query: "web search" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.equal(ambiguous?.details.loadedGroup, undefined);
|
||||||
|
assert.deepEqual(ambiguous?.details.activeGroups, []);
|
||||||
|
|
||||||
const result = await registered.get("tool_search")?.execute(
|
const result = await registered.get("tool_search")?.execute(
|
||||||
"call-1",
|
"call-2",
|
||||||
{ group: "web-tavily" },
|
{ query: "tavily_web_search" },
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
Executable
+174
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { constants, realpathSync } from "node:fs";
|
||||||
|
import { chmod, open, rename, rm } from "node:fs/promises";
|
||||||
|
import { basename, dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export const LEGACY_TOOL_SEARCH_DEFAULTS = {
|
||||||
|
alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"],
|
||||||
|
showToolSearchFooterStatus: false,
|
||||||
|
maxActiveGroups: 3,
|
||||||
|
maxToolsPerGroup: 8,
|
||||||
|
maxDynamicTools: 20,
|
||||||
|
groupOverrides: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CURRENT_TOOL_SEARCH_RUNTIME_DEFAULTS = {
|
||||||
|
alwaysEnabled: [
|
||||||
|
"codegraph_explore",
|
||||||
|
"lsp_diagnostics",
|
||||||
|
"ctx_execute",
|
||||||
|
"ctx_execute_file",
|
||||||
|
"ctx_batch_execute",
|
||||||
|
],
|
||||||
|
showToolSearchFooterStatus: false,
|
||||||
|
maxActiveGroups: 5,
|
||||||
|
maxToolsPerGroup: 8,
|
||||||
|
maxDynamicTools: 28,
|
||||||
|
groupOverrides: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CURRENT_TOOL_SEARCH_DEFAULTS = {
|
||||||
|
...CURRENT_TOOL_SEARCH_RUNTIME_DEFAULTS,
|
||||||
|
bundleDefaultsVersion: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isObject(value) {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactJsonValue(left, right) {
|
||||||
|
if (Array.isArray(left) || Array.isArray(right)) {
|
||||||
|
return Array.isArray(left)
|
||||||
|
&& Array.isArray(right)
|
||||||
|
&& left.length === right.length
|
||||||
|
&& left.every((value, index) => exactJsonValue(value, right[index]));
|
||||||
|
}
|
||||||
|
if (isObject(left) || isObject(right)) {
|
||||||
|
if (!isObject(left) || !isObject(right)) return false;
|
||||||
|
const leftKeys = Object.keys(left).sort();
|
||||||
|
const rightKeys = Object.keys(right).sort();
|
||||||
|
return exactJsonValue(leftKeys, rightKeys)
|
||||||
|
&& leftKeys.every((key) => exactJsonValue(left[key], right[key]));
|
||||||
|
}
|
||||||
|
return Object.is(left, right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function backupTimestamp(now) {
|
||||||
|
return now.toISOString().replace(/[:.]/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFileSnapshot(path) {
|
||||||
|
let handle;
|
||||||
|
try {
|
||||||
|
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
||||||
|
const metadata = await handle.stat();
|
||||||
|
if (!metadata.isFile()) throw new Error(`settings path is not a regular file: ${path}`);
|
||||||
|
return { metadata, content: await handle.readFile("utf8") };
|
||||||
|
} finally {
|
||||||
|
if (handle) await handle.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateToolSearchSettings(settingsPath, options = {}) {
|
||||||
|
const now = options.now ?? new Date();
|
||||||
|
let initial;
|
||||||
|
try {
|
||||||
|
initial = await readFileSnapshot(settingsPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ENOENT") return { status: "missing" };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const { metadata, content: original } = initial;
|
||||||
|
|
||||||
|
let settings;
|
||||||
|
try {
|
||||||
|
settings = JSON.parse(original);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`settings.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
if (!isObject(settings)) throw new Error("settings.json root must be a JSON object");
|
||||||
|
if (!("toolSearch" in settings)) return { status: "missing-section" };
|
||||||
|
if (!isObject(settings.toolSearch)) return { status: "custom" };
|
||||||
|
if (
|
||||||
|
exactJsonValue(settings.toolSearch, CURRENT_TOOL_SEARCH_DEFAULTS)
|
||||||
|
|| exactJsonValue(settings.toolSearch, CURRENT_TOOL_SEARCH_RUNTIME_DEFAULTS)
|
||||||
|
) return { status: "current" };
|
||||||
|
if (!exactJsonValue(settings.toolSearch, LEGACY_TOOL_SEARCH_DEFAULTS)) return { status: "custom" };
|
||||||
|
|
||||||
|
const updated = `${JSON.stringify({ ...settings, toolSearch: CURRENT_TOOL_SEARCH_DEFAULTS }, null, 2)}\n`;
|
||||||
|
const directory = dirname(settingsPath);
|
||||||
|
const backupPath = `${settingsPath}.bak.${backupTimestamp(now)}`;
|
||||||
|
const temporaryPath = join(
|
||||||
|
directory,
|
||||||
|
`.${basename(settingsPath)}.my-pi-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let backup;
|
||||||
|
let backupCreated = false;
|
||||||
|
try {
|
||||||
|
backup = await open(backupPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
||||||
|
backupCreated = true;
|
||||||
|
await backup.writeFile(original, "utf8");
|
||||||
|
await backup.sync();
|
||||||
|
await backup.close();
|
||||||
|
backup = undefined;
|
||||||
|
} catch (error) {
|
||||||
|
if (backup) await backup.close().catch(() => {});
|
||||||
|
if (backupCreated) await rm(backupPath, { force: true }).catch(() => {});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
let temporary;
|
||||||
|
try {
|
||||||
|
temporary = await open(temporaryPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
||||||
|
await temporary.writeFile(updated, "utf8");
|
||||||
|
await temporary.sync();
|
||||||
|
await temporary.close();
|
||||||
|
temporary = undefined;
|
||||||
|
|
||||||
|
if (options.beforeCommit) await options.beforeCommit();
|
||||||
|
const latest = await readFileSnapshot(settingsPath);
|
||||||
|
if (
|
||||||
|
latest.metadata.dev !== metadata.dev
|
||||||
|
|| latest.metadata.ino !== metadata.ino
|
||||||
|
|| latest.content !== original
|
||||||
|
) throw new Error("settings.json changed during migration; original file was preserved");
|
||||||
|
await rename(temporaryPath, settingsPath);
|
||||||
|
await chmod(settingsPath, 0o600);
|
||||||
|
} catch (error) {
|
||||||
|
if (temporary) await temporary.close().catch(() => {});
|
||||||
|
await rm(temporaryPath, { force: true }).catch(() => {});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "migrated", backupPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const settingsPath = process.argv[2];
|
||||||
|
if (!settingsPath) {
|
||||||
|
console.error("usage: migrate-tool-search-settings.mjs <settings.json>");
|
||||||
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await migrateToolSearchSettings(settingsPath);
|
||||||
|
if (result.status === "migrated") console.log(`migrated:${result.backupPath}`);
|
||||||
|
else console.log(result.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMainModule() {
|
||||||
|
if (!process.argv[1]) return false;
|
||||||
|
try {
|
||||||
|
return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMainModule()) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CURRENT_TOOL_SEARCH_DEFAULTS,
|
||||||
|
LEGACY_TOOL_SEARCH_DEFAULTS,
|
||||||
|
migrateToolSearchSettings,
|
||||||
|
} from "../scripts/migrate-tool-search-settings.mjs";
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
|
||||||
|
async function fixture(t: test.TestContext): Promise<{ root: string; settingsPath: string }> {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "my-pi-tool-search-migration-"));
|
||||||
|
t.after(() => rm(root, { recursive: true, force: true }));
|
||||||
|
return { root, settingsPath: join(root, "settings.json") };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("migrates only the complete legacy default snapshot with a private backup", async (t) => {
|
||||||
|
const { root, settingsPath } = await fixture(t);
|
||||||
|
const original = {
|
||||||
|
theme: "dark",
|
||||||
|
toolSearch: LEGACY_TOOL_SEARCH_DEFAULTS,
|
||||||
|
unrelated: { keep: true },
|
||||||
|
};
|
||||||
|
await writeFile(settingsPath, `${JSON.stringify(original, null, 2)}\n`, "utf8");
|
||||||
|
await chmod(settingsPath, 0o644);
|
||||||
|
|
||||||
|
const result = await migrateToolSearchSettings(settingsPath, { now: new Date("2026-08-28T12:34:56.789Z") });
|
||||||
|
assert.equal(result.status, "migrated");
|
||||||
|
assert.equal(result.backupPath, `${settingsPath}.bak.2026-08-28T12-34-56-789Z`);
|
||||||
|
assert.deepEqual(JSON.parse(await readFile(settingsPath, "utf8")), {
|
||||||
|
...original,
|
||||||
|
toolSearch: CURRENT_TOOL_SEARCH_DEFAULTS,
|
||||||
|
});
|
||||||
|
assert.deepEqual(JSON.parse(await readFile(result.backupPath!, "utf8")), original);
|
||||||
|
assert.equal((await stat(settingsPath)).mode & 0o777, 0o600);
|
||||||
|
assert.equal((await stat(result.backupPath!)).mode & 0o777, 0o600);
|
||||||
|
assert.deepEqual((await readdir(root)).sort(), [
|
||||||
|
"settings.json",
|
||||||
|
"settings.json.bak.2026-08-28T12-34-56-789Z",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not remove a pre-existing backup when the timestamp collides", async (t) => {
|
||||||
|
const { settingsPath } = await fixture(t);
|
||||||
|
const now = new Date("2026-08-28T12:34:56.789Z");
|
||||||
|
const backupPath = `${settingsPath}.bak.2026-08-28T12-34-56-789Z`;
|
||||||
|
const original = JSON.stringify({ toolSearch: LEGACY_TOOL_SEARCH_DEFAULTS });
|
||||||
|
await writeFile(settingsPath, original, "utf8");
|
||||||
|
await writeFile(backupPath, "existing backup\n", "utf8");
|
||||||
|
await assert.rejects(migrateToolSearchSettings(settingsPath, { now }), { code: "EEXIST" });
|
||||||
|
assert.equal(await readFile(backupPath, "utf8"), "existing backup\n");
|
||||||
|
assert.equal(await readFile(settingsPath, "utf8"), original);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves custom Tool Search settings even when they retain the old capacities", async (t) => {
|
||||||
|
const { root, settingsPath } = await fixture(t);
|
||||||
|
const custom = {
|
||||||
|
toolSearch: {
|
||||||
|
...LEGACY_TOOL_SEARCH_DEFAULTS,
|
||||||
|
alwaysEnabled: [...LEGACY_TOOL_SEARCH_DEFAULTS.alwaysEnabled, "custom_tool"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const original = `${JSON.stringify(custom, null, 2)}\n`;
|
||||||
|
await writeFile(settingsPath, original, "utf8");
|
||||||
|
|
||||||
|
assert.deepEqual(await migrateToolSearchSettings(settingsPath), { status: "custom" });
|
||||||
|
assert.equal(await readFile(settingsPath, "utf8"), original);
|
||||||
|
assert.deepEqual(await readdir(root), ["settings.json"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recognizes current defaults and skips files without a Tool Search section", async (t) => {
|
||||||
|
const current = await fixture(t);
|
||||||
|
await writeFile(current.settingsPath, JSON.stringify({ toolSearch: CURRENT_TOOL_SEARCH_DEFAULTS }), "utf8");
|
||||||
|
assert.deepEqual(await migrateToolSearchSettings(current.settingsPath), { status: "current" });
|
||||||
|
|
||||||
|
const unversioned = await fixture(t);
|
||||||
|
const { bundleDefaultsVersion: _version, ...runtimeDefaults } = CURRENT_TOOL_SEARCH_DEFAULTS;
|
||||||
|
await writeFile(unversioned.settingsPath, JSON.stringify({ toolSearch: runtimeDefaults }), "utf8");
|
||||||
|
assert.deepEqual(await migrateToolSearchSettings(unversioned.settingsPath), { status: "current" });
|
||||||
|
|
||||||
|
const absent = await fixture(t);
|
||||||
|
await writeFile(absent.settingsPath, JSON.stringify({ theme: "dark" }), "utf8");
|
||||||
|
assert.deepEqual(await migrateToolSearchSettings(absent.settingsPath), { status: "missing-section" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails safely for invalid JSON and reports a missing settings file", async (t) => {
|
||||||
|
const invalid = await fixture(t);
|
||||||
|
await writeFile(invalid.settingsPath, "{ invalid\n", "utf8");
|
||||||
|
await assert.rejects(migrateToolSearchSettings(invalid.settingsPath), /invalid JSON/);
|
||||||
|
assert.equal(await readFile(invalid.settingsPath, "utf8"), "{ invalid\n");
|
||||||
|
|
||||||
|
const missing = await fixture(t);
|
||||||
|
assert.deepEqual(await migrateToolSearchSettings(missing.settingsPath), { status: "missing" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("aborts when settings change before the atomic replacement", async (t) => {
|
||||||
|
const { root, settingsPath } = await fixture(t);
|
||||||
|
await writeFile(settingsPath, JSON.stringify({ toolSearch: LEGACY_TOOL_SEARCH_DEFAULTS }), "utf8");
|
||||||
|
const competing = `${JSON.stringify({ toolSearch: { ...LEGACY_TOOL_SEARCH_DEFAULTS, maxActiveGroups: 4 } })}\n`;
|
||||||
|
await assert.rejects(
|
||||||
|
migrateToolSearchSettings(settingsPath, {
|
||||||
|
beforeCommit: () => writeFile(settingsPath, competing, "utf8"),
|
||||||
|
}),
|
||||||
|
/changed during migration/,
|
||||||
|
);
|
||||||
|
assert.equal(await readFile(settingsPath, "utf8"), competing);
|
||||||
|
const files = await readdir(root);
|
||||||
|
assert.equal(files.filter((name) => name.includes(".bak.")).length, 1);
|
||||||
|
assert.equal(files.some((name) => name.endsWith(".tmp")), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function updateFixture(t: test.TestContext, piExitStatus = 0) {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "my-pi-tool-search-update-"));
|
||||||
|
t.after(() => rm(root, { recursive: true, force: true }));
|
||||||
|
const home = join(root, "home");
|
||||||
|
const bin = join(root, "bin");
|
||||||
|
const agentDir = join(home, ".pi", "agent");
|
||||||
|
const settingsPath = join(agentDir, "settings.json");
|
||||||
|
const log = join(root, "pi.log");
|
||||||
|
const updatedBundle = join(root, "updated-bundle");
|
||||||
|
await mkdir(bin, { recursive: true });
|
||||||
|
await mkdir(agentDir, { recursive: true });
|
||||||
|
await mkdir(join(updatedBundle, "config"), { recursive: true });
|
||||||
|
await mkdir(join(updatedBundle, "scripts"), { recursive: true });
|
||||||
|
await copyFile(join(repositoryRoot, "config", "p10k.zsh"), join(updatedBundle, "config", "p10k.zsh"));
|
||||||
|
await copyFile(
|
||||||
|
join(repositoryRoot, "config", "hippo-memory-version"),
|
||||||
|
join(updatedBundle, "config", "hippo-memory-version"),
|
||||||
|
);
|
||||||
|
await copyFile(
|
||||||
|
join(repositoryRoot, "scripts", "migrate-tool-search-settings.mjs"),
|
||||||
|
join(updatedBundle, "scripts", "migrate-tool-search-settings.mjs"),
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(bin, "pi"),
|
||||||
|
`#!/bin/sh\nprintf 'pi %s\\n' "$*" >> "$MOCK_PI_LOG"\nexit ${piExitStatus}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await chmod(join(bin, "pi"), 0o755);
|
||||||
|
await writeFile(settingsPath, JSON.stringify({ toolSearch: LEGACY_TOOL_SEARCH_DEFAULTS }), "utf8");
|
||||||
|
return {
|
||||||
|
settingsPath,
|
||||||
|
log,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
HOME: home,
|
||||||
|
XDG_CONFIG_HOME: join(home, ".config"),
|
||||||
|
PI_CODING_AGENT_DIR: agentDir,
|
||||||
|
PI_PACKAGE_SOURCE: updatedBundle,
|
||||||
|
MOCK_PI_LOG: log,
|
||||||
|
PATH: `${bin}:${dirname(process.execPath)}:/usr/bin:/bin`,
|
||||||
|
ZSH: "",
|
||||||
|
ZSH_CUSTOM: "",
|
||||||
|
ZDOTDIR: home,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("update.sh migrates the exact legacy snapshot after a successful package update", async (t) => {
|
||||||
|
const fixture = await updateFixture(t);
|
||||||
|
const result = spawnSync("sh", [join(repositoryRoot, "update.sh")], {
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
env: fixture.env,
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||||
|
assert.match(await readFile(fixture.log, "utf8"), /pi update .*updated-bundle/);
|
||||||
|
assert.match(result.stdout, /旧版默认配置已迁移/);
|
||||||
|
assert.deepEqual(JSON.parse(await readFile(fixture.settingsPath, "utf8")).toolSearch, CURRENT_TOOL_SEARCH_DEFAULTS);
|
||||||
|
assert.equal((await stat(fixture.settingsPath)).mode & 0o777, 0o600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update.sh does not migrate settings when pi update fails", async (t) => {
|
||||||
|
const fixture = await updateFixture(t, 1);
|
||||||
|
const before = await readFile(fixture.settingsPath, "utf8");
|
||||||
|
const result = spawnSync("sh", [join(repositoryRoot, "update.sh")], {
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
env: fixture.env,
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.equal(await readFile(fixture.settingsPath, "utf8"), before);
|
||||||
|
assert.doesNotMatch(result.stdout, /旧版默认配置已迁移/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update.sh preserves settings when the updated bundle helper cannot be located", async (t) => {
|
||||||
|
const fixture = await updateFixture(t);
|
||||||
|
const before = await readFile(fixture.settingsPath, "utf8");
|
||||||
|
const result = spawnSync("sh", [join(repositoryRoot, "update.sh")], {
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
env: { ...fixture.env, PI_PACKAGE_SOURCE: "unsupported:my-pi" },
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.equal(await readFile(fixture.settingsPath, "utf8"), before);
|
||||||
|
assert.match(result.stderr, /无法定位升级后组合包中的 Tool Search/);
|
||||||
|
});
|
||||||
@@ -59,6 +59,48 @@ select_updated_bundle_config() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrate_tool_search_settings() {
|
||||||
|
settings_file=${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/settings.json
|
||||||
|
if [ ! -e "$settings_file" ]; then
|
||||||
|
say "- Pi settings.json 尚不存在;Tool Search 将在下次加载时写入当前默认值。"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${updated_bundle_dir:-}" ] || [ ! -f "$updated_bundle_dir/scripts/migrate-tool-search-settings.mjs" ]; then
|
||||||
|
warn "无法定位升级后组合包中的 Tool Search settings.json 迁移辅助脚本,已保留现有配置。"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
migration_helper=$updated_bundle_dir/scripts/migrate-tool-search-settings.mjs
|
||||||
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
|
warn "迁移 Tool Search settings.json 需要 Node.js,已保留现有配置。"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
migration_result=$(node "$migration_helper" "$settings_file") || return 1
|
||||||
|
case $migration_result in
|
||||||
|
migrated:*)
|
||||||
|
say "✓ Tool Search 旧版默认配置已迁移到 5 个动态组 / 28 个动态工具。"
|
||||||
|
say " 原配置备份:${migration_result#migrated:}"
|
||||||
|
;;
|
||||||
|
current)
|
||||||
|
say "✓ Tool Search 用户配置已是当前组合包默认版本。"
|
||||||
|
;;
|
||||||
|
custom)
|
||||||
|
say "- 检测到自定义 Tool Search 用户配置,已完整保留。"
|
||||||
|
;;
|
||||||
|
missing-section)
|
||||||
|
say "- settings.json 尚未包含 Tool Search 配置;下次加载时将补充当前默认值。"
|
||||||
|
;;
|
||||||
|
missing)
|
||||||
|
say "- settings.json 在迁移前已被移除;下次加载时将写入当前默认值。"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
warn "Tool Search settings.json 迁移返回未知状态,已停止该项同步。"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
resolve_zsh_paths() {
|
resolve_zsh_paths() {
|
||||||
oh_my_zsh_dir=${ZSH:-$HOME/.oh-my-zsh}
|
oh_my_zsh_dir=${ZSH:-$HOME/.oh-my-zsh}
|
||||||
zsh_custom_dir=${ZSH_CUSTOM:-$oh_my_zsh_dir/custom}
|
zsh_custom_dir=${ZSH_CUSTOM:-$oh_my_zsh_dir/custom}
|
||||||
@@ -393,6 +435,7 @@ if ! pi update "$PACKAGE_SOURCE"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
select_updated_bundle_config || exit 1
|
select_updated_bundle_config || exit 1
|
||||||
|
migrate_tool_search_settings || record_update_failure "Tool Search 用户配置"
|
||||||
|
|
||||||
say "组合包升级完成,开始升级已安装的终端环境和机器级 CLI;未安装项将直接跳过。"
|
say "组合包升级完成,开始升级已安装的终端环境和机器级 CLI;未安装项将直接跳过。"
|
||||||
update_kitty || record_update_failure "Kitty"
|
update_kitty || record_update_failure "Kitty"
|
||||||
|
|||||||
Reference in New Issue
Block a user