diff --git a/AGENTS.md b/AGENTS.md index 72c03dd..f79349d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,10 +28,10 @@ - `extensions/plugin-init.ts`:注册用户显式触发的 `/plugin_init`,在确认当前项目后只初始化缺失的 CodeGraph 状态,并在全部成功后调用 Pi 官方热重载。 - `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。 - `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。 -- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`;只在目标缺失时把 `config/pi-permission-policy.jsonc` / `config/pi-permission-hard-guards.jsonc` 原子部署为 owner-only 全局用户配置,旧全局 `config.json` 仅复制迁移为 `policy.jsonc`。项目级 policy 不读取;已存在的 `policy.jsonc` 永不被覆盖,其内容完全由用户维护。 +- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,自身不部署、不播种、不迁移任何配置。`config/pi-permission-policy.jsonc` / `config/pi-permission-hard-guards.jsonc` 由 `install.sh` / `update.sh` 经 `scripts/rebuild-bundle-config.mjs` 部署到 `/my-pi/permission-system/`(policy.jsonc 与 hard-guards.jsonc 且权限 owner-only);旧 `extensions/pi-permission-system/` 位置的残留文件被忽略,不迁移也不清理。项目级 policy 不读取。 - `extensions/lsp.ts`:把组合包内 TypeScript Language Server CLI 物化为绝对命令路径,部署 TypeScript/Kotlin/JDT LS 全局配置并加载 `pi-lsp`。 - `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,在每轮开始前按激活工具追加简短路由规则,并提供 `/dump-system-prompt` 将扩展所见的有效提示词写入 `.pi-debug/effective-system-prompt.md`。 -- `extensions/system-prompt-cover.ts`:把 Pi 默认系统提示词的开头替换为组合包权威 `config/system-prompt-cover.md`(部署到 `~/.pi/agent/SYSTEM_COVER.md`),并用文本拼接保留其余全部分区及其原始顺序。必须注册在 `tool-routing.ts` **之前**:`tool-routing` 会以 `forceSystemPrompt` 压平提示词,在其后改 `systemPromptOptions` 将不再影响本轮请求。封面文件缺失或空白时不干预。`install.sh` / `update.sh` 从升级后组合包中的 `scripts/deploy-system-prompt-cover.mjs` 同步该文件,不保留备份、直接覆盖。 +- `extensions/system-prompt-cover.ts`:把 Pi 默认系统提示词的开头替换为组合包权威 `config/system-prompt-cover.md`(部署到 `~/.pi/agent/my-pi/system-prompt-cover.md`),并用文本拼接保留其余全部分区及其原始顺序。必须注册在 `tool-routing.ts` **之前**:`tool-routing` 会以 `forceSystemPrompt` 压平提示词,在其后改 `systemPromptOptions` 将不再影响本轮请求。封面文件缺失或空白时不干预。`install.sh` / `update.sh` 经 `scripts/rebuild-bundle-config.mjs` 与全部组合包配置一起整体重建该文件,不保留备份。 - `pi-chrome/`:从上游源码导入并由本仓库直接维护,通过用户安装的 unpacked MV3 扩展与 `127.0.0.1:17318` loopback bridge 接管当前已登录 Chrome;控制默认锁定,每个 Pi session 必须显式 `/chrome authorize`。 - 上游来源: - 初始导入快照:`017ff4b9a639f0b8b213e58a3f30613fc38edcc8`(`0.15.46`,该 commit 无对应 tag)。 @@ -62,8 +62,8 @@ - 上游来源二:,导入快照 `c4d6ca8fb484ba51684296ba8b4715f8c7b3ac91`(`0.1.4`)。 - `pi-compaction` 采用全新的 `pi-compaction` version 1 Session 记录和 `settings.json.piCompaction` 配置,不读取或迁移旧 `context-prune-*`、`contextPrune` 或 `openai-codex-native-compaction` 状态;切换后应使用新 Session。两份上游 MIT 许可证及来源差异保留在包内。 - 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0` 和 `typescript@6.0.3`;`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`pi-ask-user`、`pi-chrome`、`pi-compaction`、`pi-hermes-memory`、`pi-minimal-footer`、`pi-ssh` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖;`pi-subagents/` 仅保留源码,不作为根运行时依赖。 -- `install.sh`:先安装根组合包,再交互检查 Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。 -- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。不得在 `pi update` 前预清理安装缓存;仅当默认 Git 来源首次升级失败、Pi 的精确中断标记存在、canonical checkout 根目录和 `origin` 均通过身份校验时,才允许对该缓存执行最多 4 次连续 `git clean -fdx`,随后只重试一次 `pi update`,由 Pi 自己恢复依赖和清除标记;自定义来源、标记缺失或身份不匹配必须保持 fail-fast。升级成功后只能调用可定位的更新后组合包 Node helper 检查 Pi agent `settings.json`:只有 `toolSearch` 完整精确匹配某个已提交的旧默认快照时,才创建从生成起即为权限 `600` 的时间戳备份、以同目录临时文件原子迁移到当前默认值并写入当前 `bundleDefaultsVersion`;任何自定义或无效配置必须保留,提交前检测到内容变化必须中止。无法从 `PI_PACKAGE_SOURCE` 定位更新后 helper 时必须保留配置并报告失败,不能回退到可能陈旧的脚本目录 helper。其他组件升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。 +- `install.sh`:先安装根组合包,再通过 `scripts/rebuild-bundle-config.mjs` 整体重建 `/my-pi/` 受管配置目录,然后交互检查 Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。 +- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。不得在 `pi update` 前预清理安装缓存;`pi update` 失败时 fail-fast 停止——Pi 1.3.0 会在下一次 `pi update` 时依据其 `.pi-update-incomplete` 标记自行重跑 `git clean -fdx` 与依赖安装完成自愈,本脚本不做人工恢复。升级成功后调用可定位的更新后组合包 Node helper `scripts/rebuild-bundle-config.mjs` 整体重建 `/my-pi/`:先按 `config/bundle-manifest.json` 校验全部来源与目标,在 agent 目录内构建临时树,再删除旧 `my-pi/`(符号链接只删链接不跟随)并原子替换,不备份、不迁移、不合并不属于组合包的内容。无法从 `PI_PACKAGE_SOURCE` 定位更新后 helper 时必须报告失败,不能回退到可能陈旧的脚本目录 helper。其他组件升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。 - `uninstall.sh`:先移除根组合包,其他共享终端环境和机器工具不卸载。不得删除 `.pi/agent/pi-hermes-memory/`、`projects-memory/`、`.hippo/` 或任何用户记忆数据。 ## 当前职责与默认行为 @@ -78,7 +78,7 @@ - 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 或倾倒全部结果解决。 - `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具支持交互式 TUI 与具备 Pi extension UI bridge 的 RPC 客户端、顺序执行和最多 8 题;RPC 必须使用标准 `select` / `input` 对话框逐题收集,JSON/print 等无 UI 模式保持拒绝。取消或 abort 必须返回 `cancelled: true`,不得伪造答案,且 `/qna` 不属于该扩展。其成功 `details.answers` 必须保留 `question` / `answer` 字段,以匹配 AutoReview 已有的可信结构化用户交互边界。 -- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute`、六个记忆工具与 `context_tree_query`,以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。首次创建完整默认 `toolSearch` 段时必须通过唯一同目录临时文件以权限 `600` 原子写入并记录当前 `bundleDefaultsVersion`,已有自定义段只补缺失运行时字段且不得伪造迁移来源标记。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。 +- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute`、六个记忆工具与 `context_tree_query`,以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。配置从 `/my-pi/tool-search/config.json` 读取(由 rebuild 脚本部署 `config/tool-search.json`),文件缺失或无效时回退 `BUNDLE_TOOL_SEARCH_DEFAULTS`;扩展自身不播种、不迁移,也不再读写 `settings.json` 的 `toolSearch` 段。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。 - `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。授权撤销或过期时必须停用全部 21 个工具,包括 `chrome_find` / `chrome_inspect`;Tool Search 重新激活 schema 不能替代每次执行时的授权校验。 - 标准组合包工具全部命中预置目录时 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 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决,不能读写 active tool 列表。 @@ -112,13 +112,14 @@ - `skill_manage` 是 `pi-hermes-memory` 提供的能力,不是 Pi 宿主能力:Pi 只发现并加载 skill 目录(`~/.pi/agent/skills/` 加扩展通过 `resources_discover` 注册的目录),没有创建 skill 的工具。该扩展同时承担 skill 写入与目录注册;不加载它,`projects-memory/<项目>/skills/` 下的既有 skill 也不会被 Pi 发现。 - `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 时保留已经完成的组合包安装并报告失败。 -- `install.sh` 不检查默认不需要的 `rtk` CLI;`update.sh` 和 `uninstall.sh` 使用同一个 `PI_PACKAGE_SOURCE` 规则分别调用 `pi update` 和 `pi remove`。运行 `update.sh` 视为明确授权升级已安装组件及同步已有受管配置;其中 Tool Search 只能自动迁移完整匹配仓库已知默认快照的配置,不能逐字段猜测或覆盖自定义值。不得安装缺失组件或改写受管块之外的用户配置;卸载不得删除用户记忆数据,其他共享机器级工具保持不变。 +- `install.sh` 不检查默认不需要的 `rtk` CLI;`update.sh` 和 `uninstall.sh` 使用同一个 `PI_PACKAGE_SOURCE` 规则分别调用 `pi update` 和 `pi remove`。运行 `update.sh` 视为明确授权升级已安装组件并整体重建 `/my-pi/` 受管配置目录:重建按 `config/bundle-manifest.json` 全量替换,不逐字段迁移或合并用户改动;需要长期保留的自定义必须改仓库 `config/` 源文件。不得安装缺失组件或改写受管块之外的用户配置;卸载不得删除用户记忆数据,其他共享机器级工具保持不变。 +- `/my-pi/` 只存放组合包拥有、可随时重建的配置/日志/缓存;`install.sh` / `update.sh` 每次运行都整体删除后按 `config/bundle-manifest.json` 重建且不备份。需要长期保留的配置修改必须改仓库 `config/` 中的源文件;用户数据(记忆、会话、`auth.json`、`models.json`、`settings.json`、`search.env` 等)不得放入该目录。外部 npm 扩展固定路径的 `hashline.json`、`lsp.json` 位于 agent 根目录,是唯一例外;旧位置(`extensions/pi-permission-system/`、`settings.json` 的 `toolSearch` 段、agent 根 `SYSTEM_COVER.md`)的残留不迁移也不清理。 ## 修改边界 - 优先在目标扩展目录内完成改动;不要让一个扩展依赖另一个扩展的未公开内部实现。 - 保留原项目的 `LICENSE`、版权信息和必要的来源说明。 -- 扩展运行目录中的用户 `policy.jsonc` / `hard-guards.jsonc`、旧 `config.json`、日志、构建产物、覆盖率目录和依赖目录属于本地状态,不应提交;`config/pi-permission-policy.jsonc`、`config/pi-permission-hard-guards.jsonc`、`config/pi-hashline-edit.json` 与 `config/lsp.json` 分别是新安装权限种子、Hashline 默认行为和 LSP 后端的组合包权威源配置,必须提交并维护。 +- agent 目录下扩展运行时产生的数据——`/my-pi/` 受管重建目录、旧 `extensions/pi-permission-system/` 残留、日志、构建产物、覆盖率目录和依赖目录——属于本地状态,不应提交;`config/bundle-manifest.json`、`config/pi-permission-policy.jsonc`、`config/pi-permission-hard-guards.jsonc`、`config/tool-search.json`、`config/system-prompt-cover.md`、`config/pi-hashline-edit.json` 与 `config/lsp.json` 是组合包权威源配置,必须提交并维护。 - 外部 Pi 扩展依赖必须在根 `package.json` 中使用精确版本,自维护扩展必须使用本地 `file:` 依赖,并更新根 `package-lock.json`;不要用仓库级 `.pi/settings.json` 代替组合包依赖。 - 需要原生构建的依赖只按锁定版本加入根 `allowScripts`;当前 `better-sqlite3@12.11.1` 仍由 Context Mode 使用并保持精确放行。`pi-hermes-memory` 的 `better-sqlite3@13` 运行时使用包内 N-API 预编译二进制,npm 因 `binding.gyp` 推断出的 `node-gyp rebuild` 无需执行,不得为此新增放行条目。 - Pi 核心包只作为宿主 peer dependencies,不得在组合包内再安装或打包一套 Pi runtime;保留根 `.npmrc` 的 peer 安装策略。 @@ -129,7 +130,7 @@ - `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 和本文件。 -- 安装流程中的机器级依赖和用户终端配置必须保持逐项询问且默认拒绝,不得在没有用户明确确认的情况下自动安装或改写。用户主动运行 `update.sh` 只授权升级已安装项和同步已有受管配置;Tool Search 完整旧默认快照迁移属于该受管同步,但必须先备份并保持 fail-safe。缺失项仍必须跳过。`.zshrc` 修改必须局限于受管块并保留备份,卸载不得顺带删除或还原共享工具和用户终端配置。 +- 安装流程中的机器级依赖和用户终端配置必须保持逐项询问且默认拒绝,不得在没有用户明确确认的情况下自动安装或改写。用户主动运行 `update.sh` 只授权升级已安装项和整体重建 `my-pi/` 受管配置目录;重建必须先完成全部校验并在临时目录构建成功后一次性替换,任何失败不得留下半重建状态。缺失项仍必须跳过。`.zshrc` 修改必须局限于受管块并保留备份,卸载不得顺带删除或还原共享工具和用户终端配置。 - 未经明确要求,不执行发布、提交、推送、运行安装/卸载脚本或安装到用户 Pi 运行目录等外部写操作。 ## 根目录 TypeScript 与 LSP 验证约定 @@ -171,7 +172,7 @@ - 扩展入口是 `pi-hermes-memory/src/index.ts`,来源记录是 `pi-hermes-memory/UPSTREAM.md`;只从官方 `chandra447/pi-hermes-memory` 的明确 tag/commit 移植并保留官方 README、LICENSE 和版权。 - 根包必须通过 `file:./pi-hermes-memory` 提供依赖并直接加载仓库源码;不得同时安装或加载 npm 预编译入口,也不得保留第二个记忆扩展(`hippo-memory-pi` 已移除)。 - `package.json` 的 `pi.extensions` 必须把 `./pi-hermes-memory/src/index.ts` 排在 `./pi-compaction/index.ts` 之前,否则 `flushOnCompact` 会被 `pi-compaction` 的 `{ cancel: true }` 静默跳过;根 `tests/extension-load.test.ts` 必须断言该顺序。 -- 六个记忆工具与 `context_tree_query` 必须在 `pi-tool-search/extensions/config.ts` 的 `BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled` 中常驻,并由 `pi-tool-search/extensions/bundle-groups.ts` 的 `memory-recall` / `memory-management` 分组提供目录元数据(`context_tree_query` 留在 `memory-recall`)。新增、删除或重命名这些工具时必须同步 `bundle-groups.ts`、`BUNDLE_TOOL_SEARCH_DEFAULTS_VERSION`、`scripts/migrate-tool-search-settings.mjs` 的旧快照常量、Tool Search 测试、权限策略与 `pi-tool-search` 的 README / CHANGELOG。 +- 六个记忆工具与 `context_tree_query` 必须在 `pi-tool-search/extensions/config.ts` 的 `BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled` 中常驻,并由 `pi-tool-search/extensions/bundle-groups.ts` 的 `memory-recall` / `memory-management` 分组提供目录元数据(`context_tree_query` 留在 `memory-recall`)。新增、删除或重命名这些工具时必须同步 `bundle-groups.ts`、`BUNDLE_TOOL_SEARCH_DEFAULTS`、`config/tool-search.json`(二者必须完全一致)、Tool Search 测试、权限策略与 `pi-tool-search` 的 README / CHANGELOG。 - 依赖必须保持 `better-sqlite3@^13.0.3`:13.x 运行时使用包内 N-API 预编译二进制 `prebuilds/.node`,不依赖 node-gyp 编译产物,必须能由 npm 嵌套安装并与根 `better-sqlite3@12.11.1` 并存。不得把它降级回 12.x(Node 24 下会在进程退出时 SIGABRT),也不得让根包提升为单一版本。 - `devDependencies` 中的 `@earendil-works/*` 必须与根包当前版本精确一致。若写成 `^0.80.2` 这类与根不兼容的范围,npm 会在本目录嵌套安装第二套 Pi runtime,违反"不得在组合包内再安装一套 Pi runtime"的纪律;`typebox`、`tsx`、`typescript`、`@types/better-sqlite3` 同理,必须钉到根已有版本。 - 全局记忆、项目记忆与 skill 位于 `~/.pi/agent/pi-hermes-memory/` 与 `~/.pi/agent/projects-memory/`,配置位于 `~/.pi/agent/hermes-memory-config.json`;这些属于用户数据,安装、升级与卸载脚本不得创建、迁移或删除。 @@ -239,14 +240,14 @@ ## `extensions/system-prompt-cover.ts` 开发约定 -- 扩展入口是 `extensions/system-prompt-cover.ts`;纯 helper(`spliceCover` / `readCoverFile` / `resolveCoverPath` / `COVER_FILE_NAME`)位于 `extensions/system-prompt-cover-config.ts` 以便 `node --test` 单测;部署 helper 是 `scripts/deploy-system-prompt-cover.mjs`;组合包权威源是 `config/system-prompt-cover.md`。 +- 扩展入口是 `extensions/system-prompt-cover.ts`;纯 helper(`spliceCover` / `readCoverFile` / `resolveCoverPath` / `COVER_FILE_NAME`)位于 `extensions/system-prompt-cover-config.ts` 以便 `node --test` 单测;部署由 `scripts/rebuild-bundle-config.mjs` 按 `config/bundle-manifest.json` 统一完成;组合包权威源是 `config/system-prompt-cover.md`。 - 只能使用 Pi 的 `forceSystemPrompt` 投影路径(`before_agent_start` 返回 `{ systemPrompt }`)配合文本拼接来保持分区顺序;不得改用 `customPrompt` 选项。后者会抑制 `tools` / `rules` / `docs` 三个分区,而回填只能追加到末尾,无法恢复原顺序。 - 不得在 handler 中写 `customPrompt` 或依赖其生效:`forceSystemPrompt` 优先级更高,二者同时存在时 `customPrompt` 会被静默忽略而不报错。 - 分割边界必须是独占一行的裸标签(带 backreference 同名闭合),不能匹配带属性的标签:`` 嵌套在 `` 内,只有裸标签才是分区边界。 -- 封面文件必须叫 `SYSTEM_COVER.md`,不得改名为 `SYSTEM.md`:后者会被 Pi 先当成 `customPrompt` 读入,封面扩展拿到的提示词会缺少可回填的分区。 +- 封面文件是 `/my-pi/system-prompt-cover.md`,不得改名为 `SYSTEM.md`:后者会被 Pi 先当成 `customPrompt` 读入,封面扩展拿到的提示词会缺少可回填的分区。 - 文件缺失、不可读或为空白时必须静默 no-op,保持 Pi 默认封面;不得写入空 preamble,也不得因读取失败阻止其他扩展加载。 -- `install.sh` / `update.sh` 必须从**升级后**组合包内的 helper 同步封面,禁止回退到可能陈旧的脚本目录;脚本只在自身目录(`install.sh`)或 `updated_bundle_dir`(`update.sh`)内定位 helper,缺失时报告失败但继续处理其他组件。部署不保留备份并直接覆盖,与 `pi-hashline-edit.json` 的组合包权威配置语义一致。 -- 修改 `spliceCover` 语义、注册顺序、部署路径或脚本钩子后,必须运行 `node --test tests/system-prompt-cover.test.ts`、`node --test tests/extension-load.test.ts`、`sh -n install.sh`、`sh -n update.sh`,并确认 `npm pack --dry-run` 包含 `config/system-prompt-cover.md` 与 `scripts/deploy-system-prompt-cover.mjs`。 +- `install.sh` / `update.sh` 必须从**当前升级后**组合包内的 helper 重建 `my-pi/`(`update.sh` 只定位 `updated_bundle_dir` 内的 `scripts/rebuild-bundle-config.mjs`,禁止回退到可能陈旧的脚本目录;`install.sh` 使用自身脚本目录副本),helper 缺失时报告失败但继续处理其他组件。重建不保留备份,与 `pi-hashline-edit.json` 的组合包权威配置语义一致。 +- 修改 `spliceCover` 语义、注册顺序、部署路径或脚本钩子后,必须运行 `node --test tests/system-prompt-cover.test.ts`、`node --test tests/bundle-config-rebuild.test.ts`、`node --test tests/extension-load.test.ts`、`sh -n install.sh`、`sh -n update.sh`,并确认 `npm pack --dry-run` 包含 `config/system-prompt-cover.md`、`config/bundle-manifest.json` 与 `scripts/rebuild-bundle-config.mjs`。 ## `pi-ask-user` 开发约定 @@ -261,7 +262,7 @@ ## `pi-tool-search` 开发约定 -- 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `pi-tool-search/extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `pi-tool-search/extensions/catalog.ts`,配置部署位于 `pi-tool-search/extensions/config.ts`,测试保留在 `pi-tool-search/test/`。 +- 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `pi-tool-search/extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `pi-tool-search/extensions/catalog.ts`,配置读取与路径解析位于 `pi-tool-search/extensions/config.ts`(`/my-pi/tool-search/config.json`,由 rebuild 脚本部署),测试保留在 `pi-tool-search/test/`。 - 同步上游必须记录明确 tag/commit,保留 README、CHANGELOG、文档、作者和许可证说明;不得导入上游 `.pi`、嵌套 `.git`、`node_modules` 或构建产物。 - 修改 Tool Card 提示词、目录校验、分组、缓存、active-tool/LRU 生命周期、配置或工具结果时,必须同步更新包内 README、当前动态加载文档、CHANGELOG 和测试。 - 新增、删除或重命名组合包暴露工具时必须同步维护 `BUNDLE_GROUP_DEFINITIONS`;日常只读工具、provider 工具和高风险管理工具不得因名称相似而混入同一大组,根验证应确认标准组合包没有落入 unknown-tools 路径。 @@ -272,7 +273,7 @@ 组合包依赖或加载入口变化时,至少验证根 `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 描述与实际流程一致。Git 升级中断恢复必须用隔离 agent dir 和 mock `pi` / `git` 覆盖合法标记连续清理后成功、无标记保持失败、checkout 身份不匹配拒绝清理及单次 Pi 重试边界。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 命令验证分支行为。 +安装、升级、卸载、搜索或 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 描述与实际流程一致。组合包配置重建必须用隔离 agent dir 覆盖:`my-pi/` 全量替换且内容字节一致、权限 `600`/`700`、目录外文件不受影响、空或相对 agentDir 拒绝且不删除、manifest 目标越界拒绝且不删除、`my-pi/` 为符号链接时只替换链接且不触碰目标目录;`update.sh` 中 helper 无法定位时必须记录失败且不回退脚本目录。搜索配置测试只能使用虚拟 key 和隔离 HOME;SSH 配置测试必须使用隔离 HOME、虚拟凭据与 mock transport,不得连接真实服务器或把真实密码写入测试输出。涉及真实 `pi install`、`pi update`、`pi remove`、Homebrew、远程安装器、Git 克隆、真实 SSH 连接或真实用户终端配置的端到端验证属于外部写操作,未经明确要求不得执行;可以使用隔离的临时 HOME 和 mock 命令验证分支行为。 记忆扩展变化必须额外验证:`pi-hermes-memory` 在隔离临时 Pi agent 目录中可加载且注册 6 个工具、`pi.extensions` 中排在 `pi-compaction` 之前、Tool Search 目录把六个工具归入 `memory-recall` / `memory-management` 且全部常驻、权限策略默认 `allow`、`better-sqlite3@13` 嵌套安装与根 `12.11.1` 并存。不得在测试中连接真实数据库或修改真实 `~/.pi/agent/` 记忆数据。 diff --git a/README.md b/README.md index ee2adf9..c076d2a 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Powerlevel10k 主题/配置和各 Zsh 插件使用 `# >>> my-pi: >>>` 到 pi update git:git@bitbucket.org:siakitem/my-pi.git ``` -默认 Git 组合包若在 Pi 内部清理大型 `node_modules` 时因目录仍非空而留下 `.pi-update-incomplete` 标记,脚本不会在升级前破坏性预清理。首次 `pi update` 失败后,它只对默认来源推导出的缓存 checkout 进行恢复,并同时校验中断标记、canonical Git 根目录和精确 `origin`;校验通过后最多连续执行 4 次依赖清理,再只重试一次 `pi update`,由 Pi 自己重新安装依赖并清除标记。自定义来源、缺少标记或身份不匹配仍立即失败,不会清理猜测出的路径。 +脚本不会在升级前破坏性预清理安装缓存:`pi update` 失败时立即停止后续升级——Pi 1.3.0 会在下一次 `pi update` 时依据自己的 `.pi-update-incomplete` 标记自动重新清理并恢复未完成的升级,重新运行 `update.sh` 即可,脚本不做人工恢复。 随后只处理已经安装的项目,未安装项直接跳过且不会询问安装:Kitty 及已启用的 Solarized Dark、Oh My Zsh、Powerlevel10k、两个第三方 Zsh 插件、CodeGraph,以及由 @@ -86,8 +86,7 @@ Git checkout 在 `fetch` 后比较本地与上游提交,Homebrew 包在刷新 日志中的“Zsh 插件未安装,升级流程跳过”不是升级失败:升级脚本不会新增用户此前未选择的 插件。需要补装时重新运行 `./install.sh`,并在对应的逐项询问中明确选择 `Y`。 -升级时会优先从 `pi update` 后的已安装组合包目录读取最新配置。对于 Pi agent 目录中的 `settings.json`,只有 `toolSearch` 完整匹配旧组合包生成的 `3` 组 / `20` 工具默认快照时,才会备份并原子迁移到当前 `5` / `28` 默认值;任何字段不同、结构无效或存在自定义工具时都不会被覆盖。Powerlevel10k 受管副本只在内容不同时备份并原子同步;`.zshrc` 也只更新已有的 my-pi 受管块,不触碰块外配置。因此安装流程负责补齐缺失项,升级流程只升级已安装项并同步可明确识别的受管配置。 -首次获得这项迁移能力时需要执行已经包含该逻辑的新版 `update.sh`;旧脚本正在运行时不会因为 `pi update` 替换包内容而自动获得后半段迁移步骤。如果刚通过旧脚本升级到含迁移逻辑的版本,可再运行一次更新后的脚本。 +升级成功后,脚本会使用**更新后**组合包中的 `scripts/rebuild-bundle-config.mjs` 整体重建 `/my-pi/` 受管配置目录:按 `config/bundle-manifest.json` 校验来源与目标、在临时目录完成构建后一次性替换,不备份也不保留目录内的本地改动。Powerlevel10k 受管副本只在内容不同时备份并原子同步;`.zshrc` 也只更新已有的 my-pi 受管块,不触碰块外配置。因此安装流程负责补齐缺失项,升级流程只升级已安装项并整体刷新组合包拥有的配置。 卸载组合包: @@ -148,15 +147,15 @@ CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自 ### 系统提示词封面 -`extensions/system-prompt-cover.ts` 把 Pi 默认系统提示词的**开头**替换为组合包权威的 `config/system-prompt-cover.md`(部署到 `~/.pi/agent/SYSTEM_COVER.md`),同时**保留其余全部分区及其原始顺序**:``、``、``、``、``、`` 都不会被删除或重新排序。 +`extensions/system-prompt-cover.ts` 把 Pi 默认系统提示词的**开头**替换为组合包权威的 `config/system-prompt-cover.md`(部署到 `~/.pi/agent/my-pi/system-prompt-cover.md`),同时**保留其余全部分区及其原始顺序**:``、``、``、``、``、`` 都不会被删除或重新排序。 实现上使用 Pi 的 `forceSystemPrompt` 投影路径(每轮把渲染结果作为强制定本发送)而不是 `customPrompt` 选项:后者会连带抑制 `tools`/`rules`/`docs` 三个分区,且回填只能追加到末尾,无法保持顺序。封面只在**请求**时应用,不修改 transcript 中已持久化的结构化分区。 -封面每轮重新读取,因此修改 `~/.pi/agent/SYSTEM_COVER.md` 后在**下一轮**即生效,不需要重开会话;已经结束的历史对话不受影响。文件缺失或为空白时不干预,Pi 继续使用自身默认封面。 +封面每轮重新读取,因此修改 `~/.pi/agent/my-pi/system-prompt-cover.md` 后在**下一轮**即生效,不需要重开会话;已经结束的历史对话不受影响。文件缺失或为空白时不干预,Pi 继续使用自身默认封面。 -该文件由组合包直接维护,`install.sh` 与 `update.sh` 会在组合包安装/升级后从组合包副本同步过去,**不保留备份并且会覆盖**本地修改;要自定义请直接编辑仓库中的 `config/system-prompt-cover.md`。 +该文件由组合包直接维护,`install.sh` 与 `update.sh` 会在组合包安装/升级后连同整个 `my-pi/` 目录一起重建,**不保留备份并且会覆盖**本地修改;要自定义请直接编辑仓库中的 `config/system-prompt-cover.md`。 -`SYSTEM_COVER.md` 必须使用这个名字:不能改名为 `SYSTEM.md`,否则 Pi 会先把它当作 `customPrompt` 读入,封面扩展拿到的提示词就已经缺少可供回填的分区了。 +`system-prompt-cover.md` 必须使用这个名字:不能改名为 `SYSTEM.md`,否则 Pi 会先把它当作 `customPrompt` 读入,封面扩展拿到的提示词就已经缺少可供回填的分区了。 ### 接管当前 Chrome @@ -174,33 +173,37 @@ CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自 新 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` 会恢复预置目录,`/tool-search-status` 显示目录来源、容量、LRU 顺序和最近淘汰原因。 +标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具,并且当前调用不是精确加载已有分组时,才可使用当前已认证模型补充目录;精确已有分组始终立即按当前确定性目录激活,不等待无关未知工具的模型增强。模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `my-pi/tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会恢复预置目录,`/tool-search-status` 显示目录来源、容量、LRU 顺序和最近淘汰原因。 动态组默认最多同时加载 5 个、每个模型生成组最多 8 个工具、全部动态组最多 28 个工具。`tool_search.groups` 可在一次调用中原子加载最多 5 个精确组;完整请求本身超限时保持原状态。加载或调用组内工具会更新 LRU,超限时先卸载最久未使用且不在本次请求内的组。自然语言 query 对精确工具名和已提交 workflow alias 加权,弱匹配或近似同分只返回候选。首次纯增加载继续使用 Pi 原生增量传播;发生卸载和加载的替换由 Pi 自动走安全 fallback。 -Pi 的用户级配置文件通常是 `~/.pi/agent/settings.json`(准确位置为 Pi agent dir 下的 `settings.json`)。组合包首次加载仅在其中缺少对应字段时写入: +Tool Search 的用户级配置文件是 `~/.pi/agent/my-pi/tool-search/config.json`(准确位置为 Pi agent dir 下的 `my-pi/tool-search/config.json`),由 `install.sh` / `update.sh` 从仓库的 `config/tool-search.json` 部署: ```json { - "toolSearch": { - "alwaysEnabled": [ - "codegraph_explore", - "lsp_diagnostics", - "ctx_execute", - "ctx_execute_file", - "ctx_batch_execute" - ], - "showToolSearchFooterStatus": false, - "maxActiveGroups": 5, - "maxToolsPerGroup": 8, - "maxDynamicTools": 28, - "groupOverrides": {}, - "bundleDefaultsVersion": 2 - } + "alwaysEnabled": [ + "codegraph_explore", + "lsp_diagnostics", + "ctx_execute", + "ctx_execute_file", + "ctx_batch_execute", + "memory_search", + "session_search", + "memory_add", + "memory_replace", + "memory_remove", + "skill_manage", + "context_tree_query" + ], + "showToolSearchFooterStatus": false, + "maxActiveGroups": 5, + "maxToolsPerGroup": 8, + "maxDynamicTools": 28, + "groupOverrides": {} } ``` -扩展正常加载只补充缺失字段,不覆盖用户已有配置;首次创建完整 Tool Search 默认段时也写入 `bundleDefaultsVersion: 2`。用户显式运行新版 `update.sh` 时,脚本会在 `pi update` 成功后使用更新后组合包中的 Node helper 检查完整旧默认快照:只有精确匹配旧版 `3`/`20` 默认值才会创建权限 `600` 的时间戳备份并原子替换为当前配置;任何自定义或无效配置都会保留,提交前检测到文件内容变化也会中止。`alwaysEnabled` 中的精确工具名属于固定工具,不占动态组额度;`groupOverrides` 可把指定工具强制归入命名组。默认关闭状态行,避免与 minimal footer 叠加。 +扩展只读取该文件,从不写入:文件缺失、不可读或结构无效时回退内置的 `BUNDLE_TOOL_SEARCH_DEFAULTS`;每次运行 `install.sh` / `update.sh` 都会随整个 `my-pi/` 目录重建该文件,本地手改会被覆盖,长期自定义应改仓库 `config/tool-search.json`。`alwaysEnabled` 中的精确工具名属于固定工具,不占动态组额度;`groupOverrides` 可把指定工具强制归入命名组。默认关闭状态行,避免与 minimal footer 叠加。 ### 精确读写归 Hashline @@ -292,16 +295,16 @@ codegraph status `pi-permission-system/` 从上游 `pi-permission-system-v26.2.1` tag 导入,初始源码快照为 `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`。原 `pi-permission-auto-review` 的 reviewer、可信证据边界和 Codex Guardian 风格策略已整合进同一个扩展;其 MIT 许可和来源记录保留在 `pi-permission-system/LICENSES/` 与 `docs/auto-review/`。根组合包不再加载第二个 AutoReview 扩展。 -`extensions/permission-system.ts` 只在目标缺失时,以 `0600` 原子创建两份用户可编辑 JSONC: +`extensions/permission-system.ts` 只加载扩展本身,不做任何配置播种或迁移;两份用户可编辑 JSONC 由 `install.sh` / `update.sh` 经 `scripts/rebuild-bundle-config.mjs` 以 `0600` 部署: -- `~/.pi/agent/extensions/pi-permission-system/policy.jsonc`:唯一的全局普通 allow/ask/deny 策略,同时保存运行参数与 AutoReview 配置。项目级 `.pi/extensions/pi-permission-system/policy.jsonc` 不再读取。 -- `~/.pi/agent/extensions/pi-permission-system/hard-guards.jsonc`:仅全局生效的硬防御。硬 `deny` 先于 Session grant、per-agent 规则和 AutoReview,不能被后续层放宽。 +- `~/.pi/agent/my-pi/permission-system/policy.jsonc`:唯一的全局普通 allow/ask/deny 策略,同时保存运行参数与 AutoReview 配置。项目级 `.pi/extensions/pi-permission-system/policy.jsonc` 不再读取。 +- `~/.pi/agent/my-pi/permission-system/hard-guards.jsonc`:仅全局生效的硬防御。硬 `deny` 先于 Session grant、per-agent 规则和 AutoReview,不能被后续层放宽。 普通 pattern 不再按“最后一条命中”覆盖,而按具体度选择:精确匹配优先,其次字面字符更多、通配符更少、pattern 更长,只有完全同级时才以后声明者为准。因此具体 deny 会稳定压过宽泛的 `"*": "allow"`。 系统不再产生 `external_directory` 判断:项目外路径与项目内路径一样,只走用户配置的工具、`path` 和 Bash 规则。Tool Search 是唯一的工具显隐/激活控制者;权限系统只在工具被调用时做裁决。 -首次安装只部署缺失文件:`extensions/permission-system.ts` 在目标不存在时才把 `config/pi-permission-policy.jsonc` 与 `config/pi-permission-hard-guards.jsonc` 原子写入为 `0600`。已存在的文件永不被覆盖,因此对整个 `policy.jsonc` 的后续修改都由用户自行完成。旧全局 `config.json` 仍只在目标缺失时复制迁移为 `policy.jsonc`。 +`my-pi/` 目录由组合包整体拥有:每次 `install.sh` / `update.sh` 都会按 `config/bundle-manifest.json` 删除后重建,不备份也不迁移目录内的改动;要长期保留的自定义必须改仓库 `config/` 下的权威源。旧位置 `~/.pi/agent/extensions/pi-permission-system/` 的残留文件被忽略,不迁移也不清理。 默认普通策略对未匹配请求返回 `ask`,并通过 `authorizerChain: ["auto-review"]` 先交给内置 reviewer。高危 Hard Guard 直接终止;AutoReview 的 `allow` / `deny` 对任一普通策略 `ask`(包括 `path`)直接结束当前审批;模型、配置、认证、超时或输出异常统一 `defer` 到人工界面。 diff --git a/config/bundle-manifest.json b/config/bundle-manifest.json new file mode 100644 index 0000000..a3136bb --- /dev/null +++ b/config/bundle-manifest.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "directory": "my-pi", + "files": [ + { "source": "config/pi-permission-policy.jsonc", "target": "permission-system/policy.jsonc" }, + { "source": "config/pi-permission-hard-guards.jsonc", "target": "permission-system/hard-guards.jsonc" }, + { "source": "config/tool-search.json", "target": "tool-search/config.json" }, + { "source": "config/system-prompt-cover.md", "target": "system-prompt-cover.md" } + ] +} diff --git a/config/pi-permission-policy.jsonc b/config/pi-permission-policy.jsonc index ec744bd..bde4c9a 100644 --- a/config/pi-permission-policy.jsonc +++ b/config/pi-permission-policy.jsonc @@ -1,5 +1,5 @@ { - "debugLog": false, + "debugLog": true, "permissionReviewLog": true, "yoloMode": false, "doublePressToConfirm": true, diff --git a/config/tool-search.json b/config/tool-search.json new file mode 100644 index 0000000..357a049 --- /dev/null +++ b/config/tool-search.json @@ -0,0 +1,21 @@ +{ + "alwaysEnabled": [ + "codegraph_explore", + "lsp_diagnostics", + "ctx_execute", + "ctx_execute_file", + "ctx_batch_execute", + "memory_search", + "session_search", + "memory_add", + "memory_replace", + "memory_remove", + "skill_manage", + "context_tree_query" + ], + "showToolSearchFooterStatus": false, + "maxActiveGroups": 5, + "maxToolsPerGroup": 8, + "maxDynamicTools": 28, + "groupOverrides": {} +} diff --git a/extensions/permission-system.ts b/extensions/permission-system.ts index 039aca7..304db8b 100644 --- a/extensions/permission-system.ts +++ b/extensions/permission-system.ts @@ -1,49 +1,13 @@ import permissionSystemExtension from "../pi-permission-system/src/index.ts"; -import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -const BUNDLE_CONFIGS = [ - { - source: fileURLToPath(new URL("../config/pi-permission-policy.jsonc", import.meta.url)), - target: "policy.jsonc", - }, - { - source: fileURLToPath(new URL("../config/pi-permission-hard-guards.jsonc", import.meta.url)), - target: "hard-guards.jsonc", - }, -] as const; - -function seedBundlePermissionConfigs(): void { - const targetDir = join(getAgentDir(), "extensions", "pi-permission-system"); - mkdirSync(targetDir, { recursive: true, mode: 0o700 }); - - for (const entry of BUNDLE_CONFIGS) { - const targetPath = join(targetDir, entry.target); - if (existsSync(targetPath)) continue; - - const legacyPath = entry.target === "policy.jsonc" - ? join(targetDir, "config.json") - : undefined; - const sourcePath = legacyPath && existsSync(legacyPath) ? legacyPath : entry.source; - const temporaryPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(temporaryPath, readFileSync(sourcePath, "utf8"), { - encoding: "utf8", - mode: 0o600, - flag: "wx", - }); - try { - renameSync(temporaryPath, targetPath); - } catch (error) { - try { unlinkSync(temporaryPath); } catch { /* best effort */ } - if (!existsSync(targetPath)) throw error; - } - } -} - -/** Seed user-editable policy files before registering permission hooks. */ +/** + * Load the bundled permission system directly. + * + * Its bundle-owned config (policy.jsonc, hard-guards.jsonc) is deployed by + * install.sh / update.sh into `/my-pi/permission-system/`; the + * extension itself never seeds or migrates configuration. + */ export default function bundledPermissionSystemExtension(pi: ExtensionAPI): void { - seedBundlePermissionConfigs(); permissionSystemExtension(pi); } diff --git a/extensions/system-prompt-cover-config.ts b/extensions/system-prompt-cover-config.ts index e23d1f0..a4ea6c5 100644 --- a/extensions/system-prompt-cover-config.ts +++ b/extensions/system-prompt-cover-config.ts @@ -1,8 +1,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -/** File name of the bundle-managed system prompt cover inside the agent directory. */ -export const COVER_FILE_NAME = "SYSTEM_COVER.md"; +/** File name of the bundle-managed system prompt cover inside `/my-pi/`. */ +export const COVER_FILE_NAME = "system-prompt-cover.md"; /** * Matches the newline-delimited boundary before the first top-level prompt section. @@ -14,9 +14,9 @@ export const COVER_FILE_NAME = "SYSTEM_COVER.md"; */ const FIRST_SECTION_BOUNDARY = /\n<[a-z][a-z0-9_-]*>\n/; -/** Resolve the cover path inside a given agent directory. */ +/** Resolve the cover path inside a given agent directory's bundle-owned `my-pi/` tree. */ export function resolveCoverPath(agentDir: string): string { - return join(agentDir, COVER_FILE_NAME); + return join(agentDir, "my-pi", COVER_FILE_NAME); } /** diff --git a/extensions/system-prompt-cover.ts b/extensions/system-prompt-cover.ts index 2815063..836d5af 100644 --- a/extensions/system-prompt-cover.ts +++ b/extensions/system-prompt-cover.ts @@ -8,7 +8,7 @@ export { COVER_FILE_NAME, readCoverFile, resolveCoverPath, spliceCover } from ". * Replaces Pi's default system prompt preamble with the bundle-managed cover while * preserving every other prompt section in its original position. * - * The cover is read on each `before_agent_start`, so edits to `SYSTEM_COVER.md` take + * The cover is read on each `before_agent_start`, so edits to `system-prompt-cover.md` take * effect on the next turn of an already-running session rather than requiring a restart. * * Returning `systemPrompt` sets Pi's `forceSystemPrompt`, which is projected onto the diff --git a/install.sh b/install.sh index c49aedf..0815715 100755 --- a/install.sh +++ b/install.sh @@ -75,28 +75,26 @@ resolve_agent_dir() { printf '%s\n' "$HOME/.pi/agent" } -# 部署组合包权威的系统提示词封面;脚本目录内的组合包副本即为来源。 -deploy_system_prompt_cover() { - cover_helper=$SCRIPT_DIR/scripts/deploy-system-prompt-cover.mjs - if [ ! -f "$cover_helper" ]; then - warn "找不到 SYSTEM_COVER.md 部署辅助脚本,已跳过封面部署。" +# 整体删除并重建组合包拥有的 /my-pi/ 配置目录;脚本目录内的组合包副本即为来源。 +rebuild_bundle_config() { + rebuild_helper=$SCRIPT_DIR/scripts/rebuild-bundle-config.mjs + if [ ! -f "$rebuild_helper" ]; then + warn "找不到组合包配置重建辅助脚本,已跳过配置重建。" return 1 fi if ! command -v node >/dev/null 2>&1; then - warn "找不到 node,无法部署 SYSTEM_COVER.md;Pi 将使用默认系统提示词封面。" + warn "找不到 node,无法重建组合包配置目录。" return 1 fi - cover_agent_dir=$(resolve_agent_dir) || return 1 - cover_status=$(node "$cover_helper" "$cover_agent_dir" 2>&1) || { - warn "SYSTEM_COVER.md 部署失败:$cover_status" + rebuild_agent_dir=$(resolve_agent_dir) || return 1 + rebuild_status=$(node "$rebuild_helper" "$rebuild_agent_dir" 2>&1) || { + warn "组合包配置目录重建失败:$rebuild_status" return 1 } - case $cover_status in - created) say "✓ 已部署系统提示词封面:$cover_agent_dir/SYSTEM_COVER.md" ;; - updated) say "✓ 已同步系统提示词封面到组合包当前版本。" ;; - current) say "✓ 系统提示词封面已是当前组合包版本。" ;; + case $rebuild_status in + rebuilt:*) say "✓ 已重建组合包配置目录:${rebuild_status#rebuilt:}" ;; *) - warn "SYSTEM_COVER.md 部署返回未知状态:$cover_status" + warn "组合包配置重建返回未知状态:$rebuild_status" return 1 ;; esac @@ -373,7 +371,7 @@ fi say "Pi 组合包安装完成,开始部署受管配置。" -deploy_system_prompt_cover || record_install_failure "SYSTEM_COVER.md" +rebuild_bundle_config || record_install_failure "组合包配置重建" say "开始检查终端环境和机器级 CLI 依赖。" diff --git a/pi-permission-system/CHANGELOG.md b/pi-permission-system/CHANGELOG.md index 1c4c51d..49b2393 100644 --- a/pi-permission-system/CHANGELOG.md +++ b/pi-permission-system/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Cache approvals within one user message.** An `allow` is reused for the same action while the conversation's latest trusted entry is unchanged; a new user message invalidates the cache, and rejects and failures are never cached. - Bound the rendered transcript with a conservative token estimator (ASCII counts a quarter token per character, every non-ASCII codepoint a full token — `length / 4` under-counts CJK by roughly 2–4x), expose the budgets under `autoReview.transcript` (`messageTokens`, `toolTokens`, `maxUntrustedEntries`), and substitute pi-compaction tool-result summaries when they are strictly shorter. - Make `autoReview.reasoning` effective again (default `"low"`), passed only to reasoning-capable models; add `autoReview.timeoutMs` (default `30000`, total across attempts), `autoReview.maxAttempts` (default `2`), and `autoReview.maxOutputTokens` (default `4000`). +- **Move the global config directory into the bundle-owned rebuild tree.** Policy, hard guards, and logs now live under `/my-pi/permission-system/` instead of `/extensions/pi-permission-system/`; the bundle's install/update scripts delete and redeploy that whole directory on every run, so the extension itself never seeds or migrates configuration. Files left at the old location are ignored. ### Removed diff --git a/pi-permission-system/README.md b/pi-permission-system/README.md index c31264d..7364e63 100644 --- a/pi-permission-system/README.md +++ b/pi-permission-system/README.md @@ -34,7 +34,7 @@ pi install npm:@gotgenes/pi-permission-system ## Quick Start -1. Create ordinary policy at `~/.pi/agent/extensions/pi-permission-system/policy.jsonc`: +1. Create ordinary policy at `~/.pi/agent/my-pi/permission-system/policy.jsonc`: ```jsonc { @@ -51,7 +51,7 @@ pi install npm:@gotgenes/pi-permission-system } ``` -2. Create independent hard guards at `~/.pi/agent/extensions/pi-permission-system/hard-guards.jsonc`: +2. Create independent hard guards at `~/.pi/agent/my-pi/permission-system/hard-guards.jsonc`: ```jsonc { @@ -86,12 +86,12 @@ The integrated extension uses two independent global JSONC files: | Purpose | Scope | Path | | ------- | ----- | ---- | -| Ordinary policy, runtime knobs, AutoReview | Global only | `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` | -| Non-overridable hard guards | Global only | `~/.pi/agent/extensions/pi-permission-system/hard-guards.jsonc` | +| Ordinary policy, runtime knobs, AutoReview | Global only | `~/.pi/agent/my-pi/permission-system/policy.jsonc` | +| Non-overridable hard guards | Global only | `~/.pi/agent/my-pi/permission-system/hard-guards.jsonc` | Project-level `policy.jsonc` files are ignored. Project agent Markdown remains a separate per-agent restriction mechanism. -The bundle seeds missing files with mode `0600`. During `update.sh`, only the exact known legacy default policy is backed up and replaced by the current bundled seed. Any customized or invalid policy is preserved and reported instead of being guessed or overwritten. +The bundle deploys both files with mode `0600` via `scripts/rebuild-bundle-config.mjs`: every `install.sh` / `update.sh` run rebuilds the whole `/my-pi/` tree, overwriting local edits without a backup — keep durable changes in the repository's `config/` sources. Files left at the former `extensions/pi-permission-system/` location are ignored. Within a pattern map, the most specific match wins: exact patterns, then more literal characters, fewer wildcards, and longer patterns. Declaration order is only a tie-breaker. diff --git a/pi-permission-system/docs/configuration.md b/pi-permission-system/docs/configuration.md index 5a5dd49..49bc470 100644 --- a/pi-permission-system/docs/configuration.md +++ b/pi-permission-system/docs/configuration.md @@ -6,8 +6,8 @@ Pi Permission System reads two user-level JSONC files: | Purpose | Scope | Path | | ------- | ----- | ---- | -| Ordinary policy + runtime | Global only | `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` | -| Hard guards | Global only | `~/.pi/agent/extensions/pi-permission-system/hard-guards.jsonc` | +| Ordinary policy + runtime | Global only | `~/.pi/agent/my-pi/permission-system/policy.jsonc` | +| Hard guards | Global only | `~/.pi/agent/my-pi/permission-system/hard-guards.jsonc` | `PI_CODING_AGENT_DIR` replaces `~/.pi/agent` when set. Project-level `policy.jsonc` files are not loaded. Project agent Markdown may still provide per-agent restrictions; it is not a second ordinary policy file. @@ -141,7 +141,7 @@ Each review outcome is written to the permission review log — the resolved mod | `toolTextSummaryMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. | | `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). | -Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`. +Both logs write to `~/.pi/agent/my-pi/permission-system/logs/`. No debug output is printed to the terminal. ### Inline permission dialog (TUI) diff --git a/pi-permission-system/docs/opencode-compatibility.md b/pi-permission-system/docs/opencode-compatibility.md index 4062eaa..61b5cb1 100644 --- a/pi-permission-system/docs/opencode-compatibility.md +++ b/pi-permission-system/docs/opencode-compatibility.md @@ -49,7 +49,7 @@ If your OpenCode config uses these features, the equivalent works in this extens | `mcp` surface | Not a documented permission surface | First-class with server/tool-level granularity | | Top-level string shorthand | `"permission": "allow"` sets all surfaces | Not supported; must use an object | | Per-agent config location | `agent` key in config JSON or YAML frontmatter | YAML frontmatter in agent `.md` files only | -| Config file paths | `~/.config/opencode/opencode.json` | `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` plus global `hard-guards.jsonc` | +| Config file paths | `~/.config/opencode/opencode.json` | `~/.pi/agent/my-pi/permission-system/policy.jsonc` plus global `hard-guards.jsonc` | | Subagent prompt forwarding | Not documented | `ask` policies work in non-UI subagent contexts | | Infrastructure auto-allow | N/A | Read-only tools to Pi infra dirs bypass the gate | | Permission review log | No equivalent documented | Writes decisions to a JSONL audit log | diff --git a/pi-permission-system/docs/troubleshooting.md b/pi-permission-system/docs/troubleshooting.md index 255a10e..1d4db22 100644 --- a/pi-permission-system/docs/troubleshooting.md +++ b/pi-permission-system/docs/troubleshooting.md @@ -4,7 +4,7 @@ | Problem | Cause | Solution | | ----------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| Config not applied (everything asks) | File not found or parse error | Verify the global policy at `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` (respects `PI_CODING_AGENT_DIR`); check JSONC syntax | +| Config not applied (everything asks) | File not found or parse error | Verify the global policy at `~/.pi/agent/my-pi/permission-system/policy.jsonc` (respects `PI_CODING_AGENT_DIR`); check JSONC syntax | | Per-agent override not applied | Frontmatter parsing issue | Ensure `---` delimiters at file top; keep YAML simple; restart session | | Tool blocked as unregistered | Unknown tool name | Use a registered `mcp` tool for server tools: `{ "tool": "server:tool" }` | | `/skill:` blocked | Deny policy or confirmation unavailable | Check merged `skill` policy (global/project/agent layers). `ask` still requires UI or forwarded confirmation. | @@ -22,7 +22,7 @@ This makes it easy to verify which files the extension actually loaded: ```jsonc { "event": "config.resolved", - "globalConfigPath": "/…/.pi/agent/extensions/pi-permission-system/policy.jsonc", + "globalConfigPath": "/…/.pi/agent/my-pi/permission-system/policy.jsonc", "globalConfigExists": true, "projectConfigPath": "/…/my-project/.pi/extensions/pi-permission-system/policy.jsonc", "projectConfigExists": false, diff --git a/pi-permission-system/schemas/permissions.schema.json b/pi-permission-system/schemas/permissions.schema.json index 86760ae..6ff6476 100644 --- a/pi-permission-system/schemas/permissions.schema.json +++ b/pi-permission-system/schemas/permissions.schema.json @@ -266,7 +266,7 @@ "additionalProperties": false, "title": "PI Permission System Configuration", "description": "Ordinary JSONC policy combining runtime knobs, AutoReview settings, and flat permission rules for pi-permission-system.", - "markdownDescription": "Global ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nPlace at `~/.pi/agent/extensions/pi-permission-system/policy.jsonc`. Project-level policy files are not loaded. Global-only hard guards use the separate `hard-guards.jsonc` schema.", + "markdownDescription": "Global ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nPlace at `~/.pi/agent/my-pi/permission-system/policy.jsonc`. Project-level policy files are not loaded. Global-only hard guards use the separate `hard-guards.jsonc` schema.", "$defs": { "permissionState": { "anyOf": [ diff --git a/pi-permission-system/src/config-paths.ts b/pi-permission-system/src/config-paths.ts index b8f8979..ee9450d 100644 --- a/pi-permission-system/src/config-paths.ts +++ b/pi-permission-system/src/config-paths.ts @@ -2,11 +2,20 @@ import { join } from "node:path"; const EXTENSION_ID = "pi-permission-system"; +/** + * Bundle-owned config tree under the agent directory: install.sh / update.sh + * delete and rebuild `my-pi/` wholesale on every run, so policy, hard guards, + * and logs all live beneath it. Files at the former + * `extensions/pi-permission-system/` location are ignored — never migrated. + */ +const BUNDLE_CONFIG_DIR = "my-pi"; +const BUNDLE_PERMISSION_DIR = "permission-system"; + export const DEBUG_LOG_FILENAME = `${EXTENSION_ID}-debug.jsonl`; export const REVIEW_LOG_FILENAME = `${EXTENSION_ID}-permission-review.jsonl`; export function getGlobalConfigDir(agentDir: string): string { - return join(agentDir, "extensions", EXTENSION_ID); + return join(agentDir, BUNDLE_CONFIG_DIR, BUNDLE_PERMISSION_DIR); } export function getGlobalConfigPath(agentDir: string): string { diff --git a/pi-permission-system/src/config-schema.ts b/pi-permission-system/src/config-schema.ts index 474850e..b65bec1 100644 --- a/pi-permission-system/src/config-schema.ts +++ b/pi-permission-system/src/config-schema.ts @@ -262,7 +262,7 @@ export const unifiedConfigSchema = z description: "Ordinary JSONC policy combining runtime knobs, AutoReview settings, and flat permission rules for pi-permission-system.", markdownDescription: - "Global ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nPlace at `~/.pi/agent/extensions/pi-permission-system/policy.jsonc`. Project-level policy files are not loaded. Global-only hard guards use the separate `hard-guards.jsonc` schema.", + "Global ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nPlace at `~/.pi/agent/my-pi/permission-system/policy.jsonc`. Project-level policy files are not loaded. Global-only hard guards use the separate `hard-guards.jsonc` schema.", }); /** A permission decision. */ diff --git a/pi-permission-system/test/config-loader.test.ts b/pi-permission-system/test/config-loader.test.ts index db0794c..6ccd694 100644 --- a/pi-permission-system/test/config-loader.test.ts +++ b/pi-permission-system/test/config-loader.test.ts @@ -687,7 +687,7 @@ describe("loadAndMergeConfigs", () => { }); function writeGlobal(content: Record): void { - const dir = join(agentDir, "extensions", "pi-permission-system"); + const dir = join(agentDir, "my-pi", "permission-system"); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "policy.jsonc"), JSON.stringify(content)); } @@ -727,7 +727,7 @@ describe("loadAndMergeConfigs", () => { const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot); expect(result.issues).toHaveLength(1); expect(result.issues[0]).toContain("pi-permissions.jsonc"); - expect(result.issues[0]).toContain("extensions/pi-permission-system"); + expect(result.issues[0]).toContain("my-pi/permission-system"); // Legacy file has no flat-format permission key — no rules extracted expect(result.merged.permission).toBeUndefined(); }); diff --git a/pi-permission-system/test/config-paths.test.ts b/pi-permission-system/test/config-paths.test.ts index 2e00957..8ea7078 100644 --- a/pi-permission-system/test/config-paths.test.ts +++ b/pi-permission-system/test/config-paths.test.ts @@ -10,15 +10,15 @@ import { describe("config-paths", () => { const agentDir = "/home/user/.pi/agent"; - it("places the global policy at extensions/pi-permission-system/policy.jsonc under the agent dir", () => { + it("places the global policy at my-pi/permission-system/policy.jsonc under the agent dir", () => { expect(getGlobalConfigPath(agentDir)).toBe( - join(agentDir, "extensions", "pi-permission-system", "policy.jsonc"), + join(agentDir, "my-pi", "permission-system", "policy.jsonc"), ); }); it("places logs next to the global config dir", () => { expect(getGlobalLogsDir(agentDir)).toBe( - join(agentDir, "extensions", "pi-permission-system", "logs"), + join(agentDir, "my-pi", "permission-system", "logs"), ); }); diff --git a/pi-permission-system/test/config-pipeline.test.ts b/pi-permission-system/test/config-pipeline.test.ts index d57adee..1794bcc 100644 --- a/pi-permission-system/test/config-pipeline.test.ts +++ b/pi-permission-system/test/config-pipeline.test.ts @@ -36,7 +36,7 @@ describe("config pipeline seam", () => { }); function writeGlobal(content: Record): void { - const dir = join(agentDir, "extensions", "pi-permission-system"); + const dir = join(agentDir, "my-pi", "permission-system"); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "policy.jsonc"), JSON.stringify(content)); } diff --git a/pi-permission-system/test/config-reporter.test.ts b/pi-permission-system/test/config-reporter.test.ts index a194a3f..3aae14e 100644 --- a/pi-permission-system/test/config-reporter.test.ts +++ b/pi-permission-system/test/config-reporter.test.ts @@ -15,7 +15,7 @@ import { PermissionManager } from "#src/permission-manager"; test("buildResolvedConfigLogEntry reports global and agent policy paths", () => { const policyPaths: ResolvedPolicyPaths = { - globalConfigPath: "/home/user/.pi/agent/extensions/pi-permission-system/policy.jsonc", + globalConfigPath: "/home/user/.pi/agent/my-pi/permission-system/policy.jsonc", globalConfigExists: true, agentsDir: "/home/user/.pi/agent/agents", agentsDirExists: true, diff --git a/pi-permission-system/test/helpers/manager-harness.ts b/pi-permission-system/test/helpers/manager-harness.ts index a3ac7b3..2049721 100644 --- a/pi-permission-system/test/helpers/manager-harness.ts +++ b/pi-permission-system/test/helpers/manager-harness.ts @@ -6,7 +6,7 @@ */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { getGlobalConfigPath } from "#src/config-paths"; import { PermissionManager, type PolicyLoader } from "#src/permission-manager"; @@ -232,7 +232,7 @@ export function createAgentDirHarness(opts: { const cwd = join(baseDir, "project"); const globalConfigPath = getGlobalConfigPath(agentDir); - mkdirSync(join(agentDir, "extensions", "pi-permission-system"), { + mkdirSync(dirname(globalConfigPath), { recursive: true, }); writeFileSync( diff --git a/pi-rtk-optimizer/CHANGELOG.md b/pi-rtk-optimizer/CHANGELOG.md index 4797688..29d34dd 100644 --- a/pi-rtk-optimizer/CHANGELOG.md +++ b/pi-rtk-optimizer/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Treat `ssh_bash` as a Bash output-compaction alias for ANSI stripping, build/test/Git/linter aggregation, and bounded truncation without enabling remote command rewriting; keep `ssh_find`, `ssh_grep`, and `ssh_read` outside RTK processing. +### Changed +- Move the global config directory to `/my-pi/rtk-optimizer/` under the bundle-owned rebuild tree (`config.json` is still created on demand by `ensureConfigExists`; the old `extensions/pi-rtk-optimizer/` location is ignored). + ## [0.9.0] - 2026-07-03 ### Changed diff --git a/pi-rtk-optimizer/README.md b/pi-rtk-optimizer/README.md index 9d0830f..e087a41 100644 --- a/pi-rtk-optimizer/README.md +++ b/pi-rtk-optimizer/README.md @@ -109,8 +109,8 @@ Use ←/→ to switch tabs, ↑/↓ to navigate settings in the active tab, type Configuration is stored at: ```text -Default global path: ~/.pi/agent/extensions/pi-rtk-optimizer/config.json -Actual global path: $PI_CODING_AGENT_DIR/extensions/pi-rtk-optimizer/config.json when PI_CODING_AGENT_DIR is set +Default global path: ~/.pi/agent/my-pi/rtk-optimizer/config.json +Actual global path: $PI_CODING_AGENT_DIR/my-pi/rtk-optimizer/config.json when PI_CODING_AGENT_DIR is set ``` A starter template is included at `config/config.example.json`. diff --git a/pi-rtk-optimizer/src/constants.ts b/pi-rtk-optimizer/src/constants.ts index 499b5b3..471667f 100644 --- a/pi-rtk-optimizer/src/constants.ts +++ b/pi-rtk-optimizer/src/constants.ts @@ -2,5 +2,5 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { join } from "node:path"; export const EXTENSION_NAME = "pi-rtk-optimizer"; -export const CONFIG_DIR = join(getAgentDir(), "extensions", EXTENSION_NAME); +export const CONFIG_DIR = join(getAgentDir(), "my-pi", "rtk-optimizer"); export const CONFIG_PATH = join(CONFIG_DIR, "config.json"); diff --git a/pi-tool-search/CHANGELOG.md b/pi-tool-search/CHANGELOG.md index 2bf59bb..d86276d 100644 --- a/pi-tool-search/CHANGELOG.md +++ b/pi-tool-search/CHANGELOG.md @@ -18,6 +18,10 @@ - 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. - 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. +- Moved user configuration from the `settings.json` `toolSearch` section to the bundle-owned `/my-pi/tool-search/config.json`, deployed by the bundle's rebuild script; a missing or invalid file falls back to `BUNDLE_TOOL_SEARCH_DEFAULTS`. Moved the model-enriched catalog cache to `/my-pi/tool-search/catalog-v1.json`. + +### Removed +- Removed `ensureToolSearchDefaults`, `BUNDLE_TOOL_SEARCH_DEFAULTS_VERSION`, `bundleDefaultsVersion` handling, and all `settings.json` reads/writes; the bundle deploys config via `scripts/rebuild-bundle-config.mjs` instead of extension-side seeding or snapshot migration. - Pinned all six Hermes memory tools in the bundle defaults and raised `bundleDefaultsVersion` to 3. The injected memory policy expects these schemas every turn, and pinning also keeps the request prefix cache-stable instead of paying a `tool_search` round trip plus a cache invalidation. `scripts/migrate-tool-search-settings.mjs` migrates the complete version-2 snapshot with an owner-only backup, atomic replacement, and fail-safe preservation of custom or invalid settings. - Pinned `context_tree_query` alongside the memory tools and raised `bundleDefaultsVersion` to 4. Recovering pruned tool evidence must not depend on a `tool_search` activation, and the added name keeps the request prefix stable. The migration helper now accepts the complete version-3 snapshot in addition to version 2. - 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. diff --git a/pi-tool-search/README.md b/pi-tool-search/README.md index bb16d7e..990eb63 100644 --- a/pi-tool-search/README.md +++ b/pi-tool-search/README.md @@ -14,7 +14,7 @@ Generated metadata never replaces executable schemas. Exact names, parameter typ 2. Hidden tool definitions are hashed with the grouping constraints. 3. Every tool exposed by the standard `my-pi` bundle is matched against the checked-in catalog in `extensions/bundle-groups.ts`. If all tools are recognized, no model call and no user cache are needed. 4. User `groupOverrides` take priority over checked-in assignments. Optional or unavailable bundle tools are simply filtered out of their predefined groups. -5. Additional user or third-party tools receive deterministic prefix/source groups immediately. A matching private model-enriched cache at `/tool-search/catalog-v1.json` is reused when present. +5. Additional user or third-party tools receive deterministic prefix/source groups immediately. A matching private model-enriched cache at `/my-pi/tool-search/catalog-v1.json` is reused when present. 6. Only when unrecognized tools exist and the call does not name an exact existing group may `tool_search` ask the current authenticated model to enrich the complete catalog. Exact group activation always uses the current deterministic catalog immediately. Validation requires all checked-in bundle tools to remain in their predefined groups before a `0600` cache is accepted. 7. Missing authentication, invalid JSON, timeout, cancellation, changed bundle assignments, or cache errors leave the checked-in plus deterministic hybrid catalog usable. @@ -74,33 +74,29 @@ The first loads are purely additive and retain Pi's native deferred-loading prop ## Configuration -Pi reads this configuration from `/settings.json` (normally `~/.pi/agent/settings.json`). The bundle adds fields only when missing and never replaces explicit user choices: +Pi reads this configuration from `/my-pi/tool-search/config.json` (normally `~/.pi/agent/my-pi/tool-search/config.json`), deployed by the bundle's `install.sh` / `update.sh` from `config/tool-search.json`. A missing, unreadable, or invalid file falls back to the built-in `BUNDLE_TOOL_SEARCH_DEFAULTS`; the extension never writes the file: ```json { - "toolSearch": { - "alwaysEnabled": [ - "codegraph_explore", - "lsp_diagnostics", - "ctx_execute", - "ctx_execute_file", - "ctx_batch_execute", - "memory_search", - "session_search", - "memory_add", - "memory_replace", - "memory_remove", - "skill_manage", - "context_tree_query" - ], - ], - "showToolSearchFooterStatus": false, - "maxActiveGroups": 5, - "maxToolsPerGroup": 8, - "maxDynamicTools": 28, - "groupOverrides": {}, - "bundleDefaultsVersion": 4 - } + "alwaysEnabled": [ + "codegraph_explore", + "lsp_diagnostics", + "ctx_execute", + "ctx_execute_file", + "ctx_batch_execute", + "memory_search", + "session_search", + "memory_add", + "memory_replace", + "memory_remove", + "skill_manage", + "context_tree_query" + ], + "showToolSearchFooterStatus": false, + "maxActiveGroups": 5, + "maxToolsPerGroup": 8, + "maxDynamicTools": 28, + "groupOverrides": {} } ``` @@ -112,22 +108,19 @@ Pi reads this configuration from `/settings.json` (normally `~/.pi/ag | `maxToolsPerGroup` | `8` | Maximum size accepted from model-generated groups | | `maxDynamicTools` | `28` | Hard cap across active dynamic group tools | | `groupOverrides` | `{}` | Exact tool lists that override checked-in, generated, or fallback assignments | -| `bundleDefaultsVersion` | `4` on a newly created default section | Bundle provenance for controlled default migrations; ignored by runtime routing | Example override: ```json { - "toolSearch": { - "groupOverrides": { - "web-tavily": ["tavily_web_search", "tavily_web_fetch"], - "lsp": ["lsp_definition", "lsp_references", "lsp_hover", "lsp_symbols", "lsp_diagnostics"] - } + "groupOverrides": { + "web-tavily": ["tavily_web_search", "tavily_web_fetch"], + "lsp": ["lsp_definition", "lsp_references", "lsp_hover", "lsp_symbols", "lsp_diagnostics"] } } ``` -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. +An explicit override larger than `maxDynamicTools` remains visible but cannot be activated until the limit or override is corrected. Every `install.sh` / `update.sh` run rebuilds the file together with the rest of the `my-pi/` tree, so hand edits are overwritten — change `config/tool-search.json` in the bundle repository for durable customization. ## Current host integration @@ -138,7 +131,7 @@ This version directly targets `@earendil-works/pi-coding-agent`, current `typebo - `extensions/index.ts` — Pi lifecycle, model call, activation, and group LRU - `extensions/catalog.ts` — hashing, strict catalog validation, fallback grouping, cache, and ranking - `extensions/bundle-groups.ts` — checked-in authoritative groups for tools exposed by `my-pi` -- `extensions/config.ts` — safe bundle-default deployment and config parsing +- `extensions/config.ts` — bundle-deployed config path and config parsing - `test/` — catalog, config, model-generation, and active-set tests - `docs/dynamic-tool-loading.md` — current Pi propagation and replacement behavior - `UPSTREAM.md` — imported snapshot and local divergence diff --git a/pi-tool-search/extensions/config.ts b/pi-tool-search/extensions/config.ts index 6c8609b..cf47c7c 100644 --- a/pi-tool-search/extensions/config.ts +++ b/pi-tool-search/extensions/config.ts @@ -1,17 +1,21 @@ -import { - chmodSync, - closeSync, - fsyncSync, - mkdirSync, - openSync, - readFileSync, - renameSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { dirname, join } from "node:path"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; -export type ToolSearchDefaultResult = "updated" | "unchanged" | "skipped-invalid"; +const TOOL_SEARCH_DIR = join("my-pi", "tool-search"); + +/** + * Path of the bundle-managed Tool Search config inside the agent directory. + * `scripts/rebuild-bundle-config.mjs` deploys `config/tool-search.json` here; + * this extension never writes or migrates it. + */ +export function getToolSearchConfigPath(agentDir: string): string { + return join(agentDir, TOOL_SEARCH_DIR, "config.json"); +} + +/** Catalog cache path under the same bundle-owned `my-pi/tool-search/` tree. */ +export function getToolSearchCatalogPath(agentDir: string): string { + return join(agentDir, TOOL_SEARCH_DIR, "catalog-v1.json"); +} export const BUNDLE_TOOL_SEARCH_DEFAULTS = { alwaysEnabled: [ @@ -35,8 +39,6 @@ export const BUNDLE_TOOL_SEARCH_DEFAULTS = { groupOverrides: {}, } as const; -export const BUNDLE_TOOL_SEARCH_DEFAULTS_VERSION = 4; - export interface ToolSearchConfig { alwaysEnabled: string[]; showToolSearchFooterStatus: boolean; @@ -80,12 +82,16 @@ export function bundleFallbackConfig(): ToolSearchConfig { }; } +/** + * Read the Tool Search config from `/my-pi/tool-search/config.json`. + * A missing, unreadable, or non-object file falls back to the bundle defaults. + */ export function readToolSearchConfig(agentDir: string): ToolSearchConfig { const fallback = bundleFallbackConfig(); try { - const parsed: unknown = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf8")); - if (!isObject(parsed) || !isObject(parsed.toolSearch)) return fallback; - const config = parsed.toolSearch; + const parsed: unknown = JSON.parse(readFileSync(getToolSearchConfigPath(agentDir), "utf8")); + if (!isObject(parsed)) return fallback; + const config = parsed; return { alwaysEnabled: Array.isArray(config.alwaysEnabled) ? stringList(config.alwaysEnabled) : fallback.alwaysEnabled, showToolSearchFooterStatus: @@ -101,52 +107,3 @@ export function readToolSearchConfig(agentDir: string): ToolSearchConfig { return fallback; } } - -/** Add bundle defaults without replacing explicit user choices. */ -export function ensureToolSearchDefaults(agentDir: string): ToolSearchDefaultResult { - const targetPath = join(agentDir, "settings.json"); - let settings: Record = {}; - - try { - const parsed: unknown = JSON.parse(readFileSync(targetPath, "utf8")); - if (!isObject(parsed)) return "skipped-invalid"; - settings = parsed; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") return "skipped-invalid"; - } - - const existing = settings.toolSearch; - if (existing !== undefined && !isObject(existing)) return "skipped-invalid"; - - const toolSearch = { ...(existing ?? {}) }; - let changed = false; - for (const [key, value] of Object.entries(BUNDLE_TOOL_SEARCH_DEFAULTS)) { - if (Object.hasOwn(toolSearch, key)) continue; - toolSearch[key] = Array.isArray(value) ? [...value] : isObject(value) ? { ...value } : value; - changed = true; - } - if (existing === undefined) { - toolSearch.bundleDefaultsVersion = BUNDLE_TOOL_SEARCH_DEFAULTS_VERSION; - changed = true; - } - if (!changed) return "unchanged"; - - settings.toolSearch = toolSearch; - mkdirSync(dirname(targetPath), { recursive: true }); - const temporaryPath = `${targetPath}.my-pi-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`; - let descriptor: number | undefined; - 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"; -} diff --git a/pi-tool-search/extensions/index.ts b/pi-tool-search/extensions/index.ts index fac5b72..445926e 100644 --- a/pi-tool-search/extensions/index.ts +++ b/pi-tool-search/extensions/index.ts @@ -6,7 +6,6 @@ */ import { randomUUID } from "node:crypto"; import { unlinkSync } from "node:fs"; -import { join } from "node:path"; import { getAgentDir, type ExtensionAPI, type ExtensionContext, type ToolInfo } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; @@ -29,7 +28,7 @@ import { createBundleCatalog, type BundleCatalogResult, } from "./bundle-groups.ts"; -import { ensureToolSearchDefaults, readToolSearchConfig } from "./config.ts"; +import { getToolSearchCatalogPath, readToolSearchConfig } from "./config.ts"; const TOOL_SEARCH_NAME = "tool_search"; const POWERSHELL_TOOL_NAME = "powershell"; @@ -113,11 +112,7 @@ function groupDescription(catalog: ToolCatalog, source: CatalogSource): string { export default function toolSearchExtension(pi: ExtensionAPI): void { const agentDir = getAgentDir(); - const cachePath = join(agentDir, "tool-search", "catalog-v1.json"); - const defaultResult = ensureToolSearchDefaults(agentDir); - if (defaultResult === "skipped-invalid") { - console.warn("my-pi: skipped pi-tool-search defaults because settings.json or toolSearch is invalid"); - } + const cachePath = getToolSearchCatalogPath(agentDir); let config = readToolSearchConfig(agentDir); let tools: ToolInfo[] = []; diff --git a/pi-tool-search/test/config.test.ts b/pi-tool-search/test/config.test.ts index 96e79b5..22e6f72 100644 --- a/pi-tool-search/test/config.test.ts +++ b/pi-tool-search/test/config.test.ts @@ -1,10 +1,16 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import test from "node:test"; -import { BUNDLE_TOOL_SEARCH_DEFAULTS, ensureToolSearchDefaults, readToolSearchConfig } from "../extensions/config.ts"; +import { + BUNDLE_TOOL_SEARCH_DEFAULTS, + bundleFallbackConfig, + getToolSearchCatalogPath, + getToolSearchConfigPath, + readToolSearchConfig, +} from "../extensions/config.ts"; async function withAgentDir(run: (agentDir: string) => Promise): Promise { const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-")); @@ -15,71 +21,73 @@ async function withAgentDir(run: (agentDir: string) => Promise): Promise { - await withAgentDir(async (agentDir) => { - assert.equal(ensureToolSearchDefaults(agentDir), "updated"); - assert.deepEqual(BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled, [ - "codegraph_explore", - "lsp_diagnostics", - "ctx_execute", - "ctx_execute_file", - "ctx_batch_execute", - "memory_search", - "session_search", - "memory_add", - "memory_replace", - "memory_remove", - "skill_manage", - "context_tree_query", - ]); - assert.deepEqual(JSON.parse(await readFile(join(agentDir, "settings.json"), "utf8")), { - toolSearch: { - alwaysEnabled: [...BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled], - showToolSearchFooterStatus: false, - maxActiveGroups: 5, - maxToolsPerGroup: 8, - maxDynamicTools: 28, - groupOverrides: {}, - bundleDefaultsVersion: 4, - }, - }); - assert.equal((await stat(join(agentDir, "settings.json"))).mode & 0o777, 0o600); +async function writeConfig(agentDir: string, content: string): Promise { + const path = getToolSearchConfigPath(agentDir); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content, "utf8"); +} + +test("bundle defaults keep the committed tool list", () => { + assert.deepEqual(BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled, [ + "codegraph_explore", + "lsp_diagnostics", + "ctx_execute", + "ctx_execute_file", + "ctx_batch_execute", + "memory_search", + "session_search", + "memory_add", + "memory_replace", + "memory_remove", + "skill_manage", + "context_tree_query", + ]); + assert.deepEqual(bundleFallbackConfig(), { + alwaysEnabled: [...BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled], + showToolSearchFooterStatus: false, + maxActiveGroups: 5, + maxToolsPerGroup: 8, + maxDynamicTools: 28, + groupOverrides: {}, }); }); -test("fills missing defaults and preserves explicit settings", async () => { +test("config and catalog live under the bundle-owned my-pi tree", async () => { await withAgentDir(async (agentDir) => { - const path = join(agentDir, "settings.json"); - await writeFile(path, JSON.stringify({ theme: "dark", toolSearch: { alwaysEnabled: ["multi_grep"], maxActiveGroups: 2 } }), "utf8"); - assert.equal(ensureToolSearchDefaults(agentDir), "updated"); - assert.deepEqual(JSON.parse(await readFile(path, "utf8")), { - theme: "dark", - toolSearch: { - alwaysEnabled: ["multi_grep"], - maxActiveGroups: 2, - showToolSearchFooterStatus: false, - maxToolsPerGroup: 8, - maxDynamicTools: 28, - groupOverrides: {}, - }, - }); + assert.equal( + getToolSearchConfigPath(agentDir), + join(agentDir, "my-pi", "tool-search", "config.json"), + ); + assert.equal( + getToolSearchCatalogPath(agentDir), + join(agentDir, "my-pi", "tool-search", "catalog-v1.json"), + ); + }); +}); + +test("falls back to bundle defaults when the config file is absent or invalid", async () => { + await withAgentDir(async (agentDir) => { + assert.deepEqual(readToolSearchConfig(agentDir), bundleFallbackConfig()); + + await writeConfig(agentDir, "{not-json"); + assert.deepEqual(readToolSearchConfig(agentDir), bundleFallbackConfig()); + + await writeConfig(agentDir, '"a string, not an object"'); + assert.deepEqual(readToolSearchConfig(agentDir), bundleFallbackConfig()); }); }); test("normalizes invalid runtime values without overwriting the file", async () => { await withAgentDir(async (agentDir) => { - const path = join(agentDir, "settings.json"); const original = JSON.stringify({ - toolSearch: { - alwaysEnabled: ["one", "one", 3], - showToolSearchFooterStatus: "no", - maxActiveGroups: 0, - maxToolsPerGroup: 4, - maxDynamicTools: -1, - groupOverrides: { web: ["search", 2], empty: [] }, - }, + alwaysEnabled: ["one", "one", 3], + showToolSearchFooterStatus: "no", + maxActiveGroups: 0, + maxToolsPerGroup: 4, + maxDynamicTools: -1, + groupOverrides: { web: ["search", 2], empty: [] }, }); - await writeFile(path, original, "utf8"); + await writeConfig(agentDir, original); assert.deepEqual(readToolSearchConfig(agentDir), { alwaysEnabled: ["one"], showToolSearchFooterStatus: false, @@ -88,34 +96,29 @@ test("normalizes invalid runtime values without overwriting the file", async () maxDynamicTools: 28, groupOverrides: { web: ["search"] }, }); - assert.equal(await readFile(path, "utf8"), original); + assert.equal(await readFile(getToolSearchConfigPath(agentDir), "utf8"), original); }); }); -test("preserves a complete explicit configuration including former defaults", async () => { +test("reads an explicit configuration verbatim", async () => { await withAgentDir(async (agentDir) => { - const path = join(agentDir, "settings.json"); const original = `${JSON.stringify({ - toolSearch: { - alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"], - showToolSearchFooterStatus: false, - maxActiveGroups: 3, - maxToolsPerGroup: 8, - maxDynamicTools: 20, - groupOverrides: {}, - }, + alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"], + showToolSearchFooterStatus: true, + maxActiveGroups: 3, + maxToolsPerGroup: 8, + maxDynamicTools: 20, + groupOverrides: { custom: ["some_tool"] }, }, null, 2)}\n`; - await writeFile(path, original, "utf8"); - assert.equal(ensureToolSearchDefaults(agentDir), "unchanged"); - assert.equal(await readFile(path, "utf8"), original); - }); -}); - -test("does not overwrite malformed settings", async () => { - await withAgentDir(async (agentDir) => { - const path = join(agentDir, "settings.json"); - await writeFile(path, "{not-json", "utf8"); - assert.equal(ensureToolSearchDefaults(agentDir), "skipped-invalid"); - assert.equal(await readFile(path, "utf8"), "{not-json"); + await writeConfig(agentDir, original); + assert.deepEqual(readToolSearchConfig(agentDir), { + alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"], + showToolSearchFooterStatus: true, + maxActiveGroups: 3, + maxToolsPerGroup: 8, + maxDynamicTools: 20, + groupOverrides: { custom: ["some_tool"] }, + }); + assert.equal(await readFile(getToolSearchConfigPath(agentDir), "utf8"), original); }); }); diff --git a/pi-tool-search/test/extension.test.ts b/pi-tool-search/test/extension.test.ts index 9599820..56db30f 100644 --- a/pi-tool-search/test/extension.test.ts +++ b/pi-tool-search/test/extension.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -52,17 +52,17 @@ test("loads whole groups and evicts the least-recently-used group", async () => process.env.PI_CODING_AGENT_DIR = agentDir; try { + const configDir = join(agentDir, "my-pi", "tool-search"); + await mkdir(configDir, { recursive: true }); await writeFile( - join(agentDir, "settings.json"), + join(configDir, "config.json"), JSON.stringify({ - toolSearch: { - alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"], - showToolSearchFooterStatus: false, - maxActiveGroups: 2, - maxToolsPerGroup: 4, - maxDynamicTools: 8, - groupOverrides: {}, - }, + alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"], + showToolSearchFooterStatus: false, + maxActiveGroups: 2, + maxToolsPerGroup: 4, + maxDynamicTools: 8, + groupOverrides: {}, }), ); const handlers = new Map unknown>(); @@ -229,7 +229,7 @@ test("builds and caches a validated catalog with the current model on first sear assert.equal(result?.details.catalogSource, "model"); assert.deepEqual(result?.usage, usage); assert.match(prompts[0] ?? "", /Search the public web with filters and return sources/); - const cache = JSON.parse(await readFile(join(agentDir, "tool-search", "catalog-v1.json"), "utf8")); + const cache = JSON.parse(await readFile(join(agentDir, "my-pi", "tool-search", "catalog-v1.json"), "utf8")); assert.equal(cache.catalog.groups[0].id, "web-research"); } finally { if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; @@ -300,7 +300,7 @@ test("uses the precomputed bundle groups without calling a model", async () => { assert.equal(result?.details.loadedGroup, "web-tavily"); assert.equal(result?.details.catalogSource, "bundle"); assert.equal(modelCalls, 0); - await assert.rejects(readFile(join(agentDir, "tool-search", "catalog-v1.json"), "utf8"), /ENOENT/); + await assert.rejects(readFile(join(agentDir, "my-pi", "tool-search", "catalog-v1.json"), "utf8"), /ENOENT/); } finally { if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousAgentDir; diff --git a/scripts/deploy-system-prompt-cover.mjs b/scripts/deploy-system-prompt-cover.mjs deleted file mode 100644 index 5bd3d9b..0000000 --- a/scripts/deploy-system-prompt-cover.mjs +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env node - -/** - * Deploys the bundle-authoritative system prompt cover into the Pi agent directory. - * - * The cover is bundle-owned configuration: the repository copy under - * `config/system-prompt-cover.md` is authoritative, and this helper synchronizes it to - * `/SYSTEM_COVER.md`. There is no user-owned variant, so an existing file is - * replaced atomically without keeping a backup. - */ - -import { readFileSync, realpathSync } from "node:fs"; -import { mkdir, open, rename, rm } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -export const COVER_FILE_NAME = "SYSTEM_COVER.md"; - -const scriptPath = fileURLToPath(import.meta.url); -const bundleRoot = dirname(dirname(scriptPath)); -export const BUNDLED_COVER_PATH = join(bundleRoot, "config", "system-prompt-cover.md"); - -/** - * Resolve the Pi agent directory, mirroring Pi's own `getAgentDir()`. - * - * Implemented locally so this helper stays runnable from `install.sh` / `update.sh` - * without loading the Pi runtime. - * - * @param {NodeJS.ProcessEnv} [env] - * @param {string} homedir - * @returns {string} - */ -export function resolveAgentDir(env = process.env, homedir) { - const configured = env.PI_CODING_AGENT_DIR; - if (configured) { - return configured.startsWith("~/") ? join(homedir, configured.slice(2)) : configured; - } - return join(homedir, ".pi", "agent"); -} - -/** - * Compare the bundle cover against the deployed copy and replace it when they differ. - * - * Returns `{ status }`: - * - `current` — deployed copy already matches the bundle. - * - `updated` — deployed copy was replaced. - * - `created` — no deployed copy existed. - * - * Read or write failures throw so callers can report a real failure instead of - * silently leaving a stale cover in place. - * - * @param {{ agentDir?: string, bundledCoverPath?: string }} options - * @returns {Promise<{ status: "current" | "updated" | "created", targetPath: string }>} - */ -export async function deploySystemPromptCover({ agentDir, bundledCoverPath = BUNDLED_COVER_PATH } = {}) { - if (!agentDir) { - throw new Error("agentDir is required"); - } - - let bundledCover; - try { - bundledCover = readFileSync(bundledCoverPath, "utf8"); - } catch (error) { - throw new Error( - `cannot read bundled system prompt cover at ${bundledCoverPath}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - - const targetPath = join(agentDir, COVER_FILE_NAME); - - let existing; - let existed = false; - try { - existing = readFileSync(targetPath, "utf8"); - existed = true; - } catch (error) { - if (error?.code !== "ENOENT") { - throw new Error( - `cannot read deployed system prompt cover at ${targetPath}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - - if (existed && existing === bundledCover) { - return { status: "current", targetPath }; - } - - await mkdir(agentDir, { recursive: true }); - const temporaryPath = join( - agentDir, - `.${COVER_FILE_NAME}.my-pi-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`, - ); - - try { - const handle = await open(temporaryPath, "wx", 0o644); - try { - await handle.writeFile(bundledCover, "utf8"); - await handle.sync(); - } finally { - await handle.close(); - } - await rename(temporaryPath, targetPath); - } catch (error) { - await rm(temporaryPath, { force: true }).catch(() => {}); - throw error; - } - - return { status: existed ? "updated" : "created", targetPath }; -} - -function isMainModule() { - if (!process.argv[1]) return false; - try { - return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]); - } catch { - return false; - } -} - -async function main() { - const agentDir = process.argv[2] || resolveAgentDir(process.env, process.env.HOME); - const { status } = await deploySystemPromptCover({ agentDir }); - process.stdout.write(`${status}\n`); -} - -if (isMainModule()) { - main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - }); -} diff --git a/scripts/migrate-tool-search-settings.mjs b/scripts/migrate-tool-search-settings.mjs deleted file mode 100755 index 15b3b73..0000000 --- a/scripts/migrate-tool-search-settings.mjs +++ /dev/null @@ -1,233 +0,0 @@ -#!/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 PREVIOUS_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 PREVIOUS_TOOL_SEARCH_DEFAULTS = { - ...PREVIOUS_TOOL_SEARCH_RUNTIME_DEFAULTS, - bundleDefaultsVersion: 2, -}; - -export const VERSION_3_TOOL_SEARCH_RUNTIME_DEFAULTS = { - alwaysEnabled: [ - "codegraph_explore", - "lsp_diagnostics", - "ctx_execute", - "ctx_execute_file", - "ctx_batch_execute", - "memory_search", - "session_search", - "memory_add", - "memory_replace", - "memory_remove", - "skill_manage", - ], - showToolSearchFooterStatus: false, - maxActiveGroups: 5, - maxToolsPerGroup: 8, - maxDynamicTools: 28, - groupOverrides: {}, -}; - -export const VERSION_3_TOOL_SEARCH_DEFAULTS = { - ...VERSION_3_TOOL_SEARCH_RUNTIME_DEFAULTS, - bundleDefaultsVersion: 3, -}; - -export const CURRENT_TOOL_SEARCH_RUNTIME_DEFAULTS = { - alwaysEnabled: [ - "codegraph_explore", - "lsp_diagnostics", - "ctx_execute", - "ctx_execute_file", - "ctx_batch_execute", - "memory_search", - "session_search", - "memory_add", - "memory_replace", - "memory_remove", - "skill_manage", - "context_tree_query", - ], - showToolSearchFooterStatus: false, - maxActiveGroups: 5, - maxToolsPerGroup: 8, - maxDynamicTools: 28, - groupOverrides: {}, -}; - -export const CURRENT_TOOL_SEARCH_DEFAULTS = { - ...CURRENT_TOOL_SEARCH_RUNTIME_DEFAULTS, - bundleDefaultsVersion: 4, -}; - -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" }; - const matchesPreviousSnapshot = exactJsonValue(settings.toolSearch, PREVIOUS_TOOL_SEARCH_DEFAULTS) - || exactJsonValue(settings.toolSearch, PREVIOUS_TOOL_SEARCH_RUNTIME_DEFAULTS) - || exactJsonValue(settings.toolSearch, VERSION_3_TOOL_SEARCH_DEFAULTS) - || exactJsonValue(settings.toolSearch, VERSION_3_TOOL_SEARCH_RUNTIME_DEFAULTS); - if (!exactJsonValue(settings.toolSearch, LEGACY_TOOL_SEARCH_DEFAULTS) && !matchesPreviousSnapshot) { - 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 "); - 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; - }); -} diff --git a/scripts/rebuild-bundle-config.mjs b/scripts/rebuild-bundle-config.mjs new file mode 100644 index 0000000..9fa2ff0 --- /dev/null +++ b/scripts/rebuild-bundle-config.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node + +/** + * Rebuilds the bundle-owned config directory inside the Pi agent directory. + * + * Every piece of bundle-owned, rebuildable state lives under + * `/my-pi/` (policy files, Tool Search config, the system prompt + * cover, permission logs). `config/bundle-manifest.json` is the authoritative + * source→target list: this helper builds the full tree in a sibling temp + * directory, deletes any existing `my-pi/` wholesale — no backup — and renames + * the fresh tree into place, so a rebuild can never half-apply. + * + * Nothing outside `my-pi/` is read or removed; leftovers at the old locations + * are ignored rather than migrated. + */ + +import { realpathSync } from "node:fs"; +import { chmod, copyFile, lstat, mkdir, readFile, rename, rm, unlink } from "node:fs/promises"; +import { dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +const bundleRoot = dirname(dirname(scriptPath)); +export const BUNDLE_ROOT = bundleRoot; +export const MANIFEST_PATH = join(bundleRoot, "config", "bundle-manifest.json"); + +/** Thrown for an unusable agentDir argument; maps to CLI exit code 2. */ +export class RebuildUsageError extends Error {} + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * Load and validate the manifest. + * + * Every source must be a readable repo-relative file and every target a + * relative path that stays inside the bundle directory — a `..` or absolute + * target would escape `my-pi/` during the rebuild, so it is rejected before + * anything is touched. + * + * @param {string} manifestPath + * @returns {Promise<{ directory: string, files: Array<{ source: string, target: string }> }>} + */ +export async function loadManifest(manifestPath) { + let manifest; + try { + manifest = JSON.parse(await readFile(manifestPath, "utf8")); + } catch (error) { + throw new Error( + `cannot read bundle manifest at ${manifestPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (!isObject(manifest) || manifest.directory !== "my-pi" || !Array.isArray(manifest.files)) { + throw new Error(`bundle manifest at ${manifestPath} is malformed`); + } + + const files = manifest.files.map((entry, index) => { + if (!isObject(entry) || typeof entry.source !== "string" || typeof entry.target !== "string") { + throw new Error(`bundle manifest entry ${index} lacks source/target strings`); + } + const directoryBase = resolve(manifest.directory); + const resolved = resolve(directoryBase, entry.target); + if ( + isAbsolute(entry.target) || + resolved === directoryBase || + !resolved.startsWith(directoryBase + sep) + ) { + throw new Error( + `bundle manifest target ${entry.target} escapes the ${manifest.directory}/ directory`, + ); + } + const sourcePath = join(bundleRoot, entry.source); + return { source: entry.source, sourcePath, target: entry.target }; + }); + + // Fail before deleting anything: a missing source must not wipe the live tree. + for (const file of files) { + try { + await readFile(file.sourcePath); + } catch (error) { + throw new Error( + `cannot read manifest source ${file.source}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + return { directory: manifest.directory, files }; +} + +/** + * Rebuild `/my-pi` from the bundle manifest. + * + * The tree is staged in `/.my-pi.rebuild--` (dirs 0700, + * files 0600), then the existing `my-pi` is removed — a symlink is unlinked, + * never followed — and the stage is renamed into place. Any failure removes + * the stage and throws. + * + * @param {string} agentDir - Absolute Pi agent directory. + * @param {{ manifestPath?: string }} [options] + * @returns {Promise<{ targetDir: string }>} + */ +export async function rebuildBundleConfig(agentDir, { manifestPath = MANIFEST_PATH } = {}) { + if (typeof agentDir !== "string" || agentDir.length === 0 || !isAbsolute(agentDir)) { + throw new RebuildUsageError("rebuild-bundle-config requires an absolute agentDir argument"); + } + const manifest = await loadManifest(manifestPath); + + const targetDir = join(agentDir, manifest.directory); + const stageDir = join( + agentDir, + `.${manifest.directory}.rebuild-${process.pid}-${Math.random().toString(16).slice(2)}`, + ); + + try { + await mkdir(agentDir, { recursive: true, mode: 0o700 }); + await mkdir(stageDir, { mode: 0o700 }); + for (const file of manifest.files) { + const destination = join(stageDir, file.target); + await mkdir(dirname(destination), { recursive: true, mode: 0o700 }); + // copyFile keeps the source mode on some platforms; pin owner-only. + await copyFile(file.sourcePath, destination); + await chmod(destination, 0o600); + } + + // Never follow a symlink: unlink the link itself, never its target. + try { + const stats = await lstat(targetDir); + if (stats.isSymbolicLink()) { + await unlink(targetDir); + } else { + await rm(targetDir, { recursive: true, force: true }); + } + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + + await rename(stageDir, targetDir); + return { targetDir }; + } catch (error) { + await rm(stageDir, { recursive: true, force: true }).catch(() => {}); + throw error; + } +} + +function isMainModule() { + if (!process.argv[1]) return false; + try { + return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]); + } catch { + return false; + } +} + +async function main() { + const agentDir = process.argv[2] ?? ""; + if (agentDir.length === 0 || !isAbsolute(agentDir)) { + console.error("usage: rebuild-bundle-config.mjs "); + process.exitCode = 2; + return; + } + const { targetDir } = await rebuildBundleConfig(agentDir); + process.stdout.write(`rebuilt:${targetDir}\n`); +} + +if (isMainModule()) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = error instanceof RebuildUsageError ? 2 : 1; + }); +} diff --git a/tests/bundle-config-rebuild.test.ts b/tests/bundle-config-rebuild.test.ts new file mode 100644 index 0000000..b503301 --- /dev/null +++ b/tests/bundle-config-rebuild.test.ts @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import { lstat, mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, 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 { + getGlobalConfigPath, + getGlobalLogsDir, + getHardGuardConfigPath, +} from "../pi-permission-system/src/config-paths.ts"; +import { + BUNDLE_TOOL_SEARCH_DEFAULTS, + getToolSearchCatalogPath, + getToolSearchConfigPath, +} from "../pi-tool-search/extensions/config.ts"; +import { resolveCoverPath } from "../extensions/system-prompt-cover-config.ts"; +import { rebuildBundleConfig } from "../scripts/rebuild-bundle-config.mjs"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const rebuildScript = join(repositoryRoot, "scripts", "rebuild-bundle-config.mjs"); +const manifestPath = join(repositoryRoot, "config", "bundle-manifest.json"); + +interface ManifestFile { + source: string; + target: string; +} + +const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { + version: number; + directory: string; + files: ManifestFile[]; +}; + +async function makeTempDir() { + return mkdtemp(join(tmpdir(), "my-pi-rebuild-")); +} + +function modeOf(path: string): Promise { + return stat(path).then((info) => info.mode & 0o777); +} + +async function readSnapshot(path: string): Promise { + return readFile(path, "utf8"); +} + +test("rebuild replaces the my-pi tree with byte-identical manifest targets and leaves outside files alone", async () => { + const dir = await makeTempDir(); + try { + // Pre-existing junk inside the managed directory must disappear entirely. + await mkdir(join(dir, "my-pi", "permission-system"), { recursive: true }); + await writeFile(join(dir, "my-pi", "permission-system", "policy.jsonc"), "STALE POLICY", "utf8"); + await writeFile(join(dir, "my-pi", "leftover.txt"), "JUNK", "utf8"); + await mkdir(join(dir, "my-pi", "stray", "deep"), { recursive: true }); + + // Files outside my-pi/ must survive byte-identical — the script owns only my-pi/. + const outside = [ + join(dir, "settings.json"), + join(dir, "sessions", "abc", "session.jsonl"), + join(dir, "extensions", "pi-permission-system", "policy.jsonc"), + ]; + for (const path of outside) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `outside ${path}\n`, "utf8"); + } + const before = new Map(await Promise.all(outside.map(async (p) => [p, await readSnapshot(p)]))); + + await rebuildBundleConfig(dir); + + const myPi = join(dir, "my-pi"); + const expectedFiles = new Set(manifest.files.map((file) => file.target)); + const actualFiles: string[] = []; + const walk = async (root: string, rel: string) => { + for (const entry of await readdir(root, { withFileTypes: true })) { + const next = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) await walk(join(root, entry.name), next); + else actualFiles.push(next); + } + }; + await walk(myPi, ""); + assert.deepEqual(actualFiles.sort(), [...expectedFiles].sort(), "my-pi must contain exactly the manifest targets"); + + for (const file of manifest.files) { + const target = join(myPi, file.target); + assert.equal(await readFile(target, "utf8"), await readFile(join(repositoryRoot, file.source), "utf8")); + assert.equal(await modeOf(target), 0o600, `${file.target} must be owner-only`); + assert.equal(await modeOf(dirname(target)), 0o700, `${dirname(file.target)} must be owner-only`); + } + + for (const [path, content] of before) { + assert.equal(await readSnapshot(path), content, `outside file ${path} must be untouched`); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("rejects an empty or relative agentDir without deleting anything", async () => { + const dir = await makeTempDir(); + try { + const sentinel = join(dir, "keep.txt"); + await writeFile(sentinel, "keep", "utf8"); + + await assert.rejects(rebuildBundleConfig(""), /absolute/); + await assert.rejects(rebuildBundleConfig("relative/dir"), /absolute/); + assert.equal(await readFile(sentinel, "utf8"), "keep"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("CLI rejects a relative agentDir and reports the failure", async () => { + const dir = await makeTempDir(); + try { + await mkdir(join(dir, "my-pi"), { recursive: true }); + await writeFile(join(dir, "my-pi", "sentinel.txt"), "still here", "utf8"); + + const failure = spawnSync(process.execPath, [rebuildScript, "relative/dir"], { + encoding: "utf8", + }); + assert.equal(failure.status, 2); + assert.match(failure.stderr ?? "", /absolute/); + assert.equal(await readFile(join(dir, "my-pi", "sentinel.txt"), "utf8"), "still here"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("CLI prints rebuilt: on success", async () => { + const dir = await makeTempDir(); + try { + const result = spawnSync(process.execPath, [rebuildScript, dir], { encoding: "utf8" }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(result.stdout.trim(), `rebuilt:${join(dir, "my-pi")}`); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("a manifest target escaping the directory is rejected without deleting my-pi", async () => { + const dir = await makeTempDir(); + try { + await mkdir(join(dir, "my-pi"), { recursive: true }); + await writeFile(join(dir, "my-pi", "sentinel.txt"), "still here", "utf8"); + + const badManifestPath = join(dir, "bad-manifest.json"); + await writeFile( + badManifestPath, + JSON.stringify({ + version: 1, + directory: "my-pi", + files: [{ source: "config/tool-search.json", target: "../escape.txt" }], + }), + "utf8", + ); + await assert.rejects( + rebuildBundleConfig(dir, { manifestPath: badManifestPath }), + /escapes/, + ); + assert.equal(await readFile(join(dir, "my-pi", "sentinel.txt"), "utf8"), "still here"); + await assert.rejects(readFile(join(dir, "escape.txt"), "utf8"), /ENOENT/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("a symlinked my-pi is replaced, never followed", async () => { + const dir = await makeTempDir(); + const elsewhere = await makeTempDir(); + try { + const targetContent = join(elsewhere, "precious.txt"); + await writeFile(targetContent, "do not touch", "utf8"); + await symlink(elsewhere, join(dir, "my-pi"), "dir"); + + await rebuildBundleConfig(dir); + + // The link itself must be replaced by a real directory… + const rebuilt = await stat(join(dir, "my-pi")); + assert.ok(rebuilt.isDirectory()); + assert.ok(!(await lstat(join(dir, "my-pi"))).isSymbolicLink(), "my-pi must be a real directory now"); + + // …and the link target's contents must survive untouched. + assert.equal(await readFile(targetContent, "utf8"), "do not touch"); + } finally { + await rm(dir, { recursive: true, force: true }); + await rm(elsewhere, { recursive: true, force: true }); + } +}); + +test("manifest targets match the paths the extensions actually read", () => { + const targets = new Map(manifest.files.map((file) => [file.target, file.source])); + const agentDir = "/agent"; + const base = `${agentDir}/${manifest.directory}`; + + const expectTarget = (source: string, resolved: string, suffix: string) => { + assert.ok(resolved.startsWith(`${base}/`), `${resolved} must live under ${base}`); + assert.equal( + targets.get(resolved.slice(base.length + 1)), + source, + `${resolved} must be a manifest target backed by ${source}`, + ); + assert.ok(resolved.endsWith(suffix), `${resolved} must end with ${suffix}`); + }; + + expectTarget("config/pi-permission-policy.jsonc", getGlobalConfigPath(agentDir), "my-pi/permission-system/policy.jsonc"); + expectTarget("config/pi-permission-hard-guards.jsonc", getHardGuardConfigPath(agentDir), "my-pi/permission-system/hard-guards.jsonc"); + expectTarget("config/tool-search.json", getToolSearchConfigPath(agentDir), "my-pi/tool-search/config.json"); + expectTarget("config/system-prompt-cover.md", resolveCoverPath(agentDir), "my-pi/system-prompt-cover.md"); + + // Runtime state that is created rather than deployed must still live under my-pi/. + assert.ok(getGlobalLogsDir(agentDir).endsWith("my-pi/permission-system/logs")); + assert.ok(getToolSearchCatalogPath(agentDir).endsWith("my-pi/tool-search/catalog-v1.json")); +}); + +test("deployed tool-search config is exactly BUNDLE_TOOL_SEARCH_DEFAULTS", async () => { + const deployed = JSON.parse(await readFile(join(repositoryRoot, "config", "tool-search.json"), "utf8")); + assert.deepEqual(deployed, BUNDLE_TOOL_SEARCH_DEFAULTS); +}); diff --git a/tests/extension-load.test.ts b/tests/extension-load.test.ts index 60c0810..c65db64 100644 --- a/tests/extension-load.test.ts +++ b/tests/extension-load.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -201,34 +201,4 @@ test("all package extensions load together without global registration conflicts assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension|conflicts with/u); - const deployedPermissionPolicy = await readFile( - join(home, ".pi-agent", "extensions", "pi-permission-system", "policy.jsonc"), - "utf8", - ); - const deployedHardGuards = await readFile( - join(home, ".pi-agent", "extensions", "pi-permission-system", "hard-guards.jsonc"), - "utf8", - ); - assert.equal(deployedPermissionPolicy, permissionPolicySource, "the seeded policy must match the bundle source"); - assert.equal(deployedHardGuards, hardGuardSource, "the seeded hard guards must match the bundle source"); - - const deployedConfigDir = join(home, ".pi-agent", "extensions", "pi-permission-system"); - const deployedPolicyPath = join(deployedConfigDir, "policy.jsonc"); - const deployedHardGuardPath = join(deployedConfigDir, "hard-guards.jsonc"); - const customPolicy = `{ "permission": { "*": "deny" } }\n`; - const customHardGuards = `{ "version": 1, "permission": { "*": "allow" } }\n`; - await writeFile(deployedPolicyPath, customPolicy, "utf8"); - await writeFile(deployedHardGuardPath, customHardGuards, "utf8"); - const preserveResult = runBundle(); - assert.equal(preserveResult.status, 0, `${preserveResult.stdout}\n${preserveResult.stderr}`); - assert.equal(await readFile(deployedPolicyPath, "utf8"), customPolicy, "reload must preserve user policy edits"); - assert.equal(await readFile(deployedHardGuardPath, "utf8"), customHardGuards, "reload must preserve user hard guards"); - - await rm(deployedPolicyPath); - const legacyPolicy = `{ "authorizerChain": ["auto-review"], "permission": { "*": "ask" } }\n`; - await writeFile(join(deployedConfigDir, "config.json"), legacyPolicy, "utf8"); - const migrationResult = runBundle(); - assert.equal(migrationResult.status, 0, `${migrationResult.stdout}\n${migrationResult.stderr}`); - assert.equal(await readFile(deployedPolicyPath, "utf8"), legacyPolicy, "legacy config.json must seed policy.jsonc once"); - assert.equal(await readFile(deployedHardGuardPath, "utf8"), customHardGuards, "legacy migration must not replace hard guards"); }); diff --git a/tests/system-prompt-cover.test.ts b/tests/system-prompt-cover.test.ts index b1b2041..2deb0f1 100644 --- a/tests/system-prompt-cover.test.ts +++ b/tests/system-prompt-cover.test.ts @@ -1,16 +1,10 @@ import assert from "node:assert/strict"; -import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -import { - COVER_FILE_NAME, - deploySystemPromptCover, - resolveAgentDir, -} from "../scripts/deploy-system-prompt-cover.mjs"; import { readCoverFile, resolveCoverPath, @@ -149,8 +143,10 @@ test("readCoverFile returns undefined when the file is absent or blank", async ( const dir = await makeTempDir(); try { const coverPath = resolveCoverPath(dir); + assert.equal(coverPath, join(dir, "my-pi", "system-prompt-cover.md")); assert.equal(readCoverFile(coverPath), undefined); + await mkdir(dirname(coverPath), { recursive: true }); await writeFile(coverPath, " \n\n", "utf8"); assert.equal(readCoverFile(coverPath), undefined); @@ -160,58 +156,3 @@ test("readCoverFile returns undefined when the file is absent or blank", async ( await rm(dir, { recursive: true, force: true }); } }); - -test("resolveAgentDir honours PI_CODING_AGENT_DIR and defaults to ~/.pi/agent", () => { - assert.equal(resolveAgentDir({}, "/home/u"), "/home/u/.pi/agent"); - assert.equal(resolveAgentDir({ PI_CODING_AGENT_DIR: "/custom/agent" }, "/home/u"), "/custom/agent"); - assert.equal(resolveAgentDir({ PI_CODING_AGENT_DIR: "~/other" }, "/home/u"), "/home/u/other"); -}); - -test("deploySystemPromptCover creates, is idempotent, and overwrites without a backup", async () => { - const dir = await makeTempDir(); - try { - const target = join(dir, COVER_FILE_NAME); - - assert.equal((await deploySystemPromptCover({ agentDir: dir })).status, "created"); - assert.equal(await readFile(target, "utf8"), await readFile(bundledCoverPath, "utf8")); - - assert.equal((await deploySystemPromptCover({ agentDir: dir })).status, "current"); - - await writeFile(target, "USER EDITED COVER\n", "utf8"); - assert.equal((await deploySystemPromptCover({ agentDir: dir })).status, "updated"); - assert.equal(await readFile(target, "utf8"), await readFile(bundledCoverPath, "utf8")); - - // No backup and no temporary file may survive an overwrite. - assert.deepEqual(await readdir(dir), [COVER_FILE_NAME]); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("deploySystemPromptCover fails loudly when the bundled cover is unreadable", async () => { - const dir = await makeTempDir(); - try { - await assert.rejects( - deploySystemPromptCover({ agentDir: dir, bundledCoverPath: join(dir, "missing.md") }), - /cannot read bundled system prompt cover/, - ); - // The failure must not leave a partial file behind. - assert.deepEqual(await readdir(dir), []); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("deploy-system-prompt-cover.mjs prints a status line and exits zero", async () => { - const dir = await makeTempDir(); - try { - const helper = join(repositoryRoot, "scripts", "deploy-system-prompt-cover.mjs"); - const first = execFileSync(process.execPath, [helper, dir], { encoding: "utf8" }).trim(); - const second = execFileSync(process.execPath, [helper, dir], { encoding: "utf8" }).trim(); - - assert.equal(first, "created"); - assert.equal(second, "current"); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); diff --git a/tests/tool-search-settings-migration.test.ts b/tests/tool-search-settings-migration.test.ts deleted file mode 100644 index 0673bcf..0000000 --- a/tests/tool-search-settings-migration.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -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, - PREVIOUS_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("migrates the previous versioned default snapshot and pins the Hermes memory tools", async (t) => { - const { settingsPath } = await fixture(t); - await writeFile(settingsPath, JSON.stringify({ toolSearch: PREVIOUS_TOOL_SEARCH_DEFAULTS }), "utf8"); - - const result = await migrateToolSearchSettings(settingsPath); - assert.equal(result.status, "migrated"); - const migrated = JSON.parse(await readFile(settingsPath, "utf8")).toolSearch; - assert.deepEqual(migrated, CURRENT_TOOL_SEARCH_DEFAULTS); - assert.equal(migrated.bundleDefaultsVersion, 4); - for (const name of ["memory_search", "session_search", "memory_add", "memory_replace", "memory_remove", "skill_manage"]) { - assert.ok(migrated.alwaysEnabled.includes(name), `${name} must be pinned after migration`); - } -}); - -test("preserves custom settings that keep the previous capacities but pin extra tools", async (t) => { - const { root, settingsPath } = await fixture(t); - const custom = { - toolSearch: { - ...PREVIOUS_TOOL_SEARCH_DEFAULTS, - alwaysEnabled: [...PREVIOUS_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("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", "pi-hashline-edit.json"), - join(updatedBundle, "config", "pi-hashline-edit.json"), - ); - await copyFile( - join(repositoryRoot, "scripts", "migrate-tool-search-settings.mjs"), - join(updatedBundle, "scripts", "migrate-tool-search-settings.mjs"), - ); - // A released bundle also ships the system prompt cover and its deploy helper; update.sh - // resolves both from the upgraded bundle directory. - await copyFile( - join(repositoryRoot, "config", "system-prompt-cover.md"), - join(updatedBundle, "config", "system-prompt-cover.md"), - ); - await copyFile( - join(repositoryRoot, "scripts", "deploy-system-prompt-cover.mjs"), - join(updatedBundle, "scripts", "deploy-system-prompt-cover.mjs"), - ); - await copyFile( - join(repositoryRoot, "config", "pi-permission-policy.jsonc"), - join(updatedBundle, "config", "pi-permission-policy.jsonc"), - ); - 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/); -}); diff --git a/tests/update-git-clean-recovery.test.ts b/tests/update-git-clean-recovery.test.ts deleted file mode 100644 index 64e4604..0000000 --- a/tests/update-git-clean-recovery.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import assert from "node:assert/strict"; -import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { spawnSync } from "node:child_process"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; - -const repositoryRoot = dirname(dirname(fileURLToPath(import.meta.url))); - -async function fixture( - t: test.TestContext, - options: { createMarker?: boolean; origin?: string; secondPiStatus?: number } = {}, -) { - const root = await mkdtemp(join(tmpdir(), "my-pi-update-clean-recovery-")); - 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 target = join(agentDir, "git", "bitbucket.org", "siakitem", "my-pi"); - const marker = join(agentDir, "git", "bitbucket.org", "siakitem", ".my-pi.pi-update-incomplete"); - const piLog = join(root, "pi.log"); - const piCount = join(root, "pi.count"); - const gitLog = join(root, "git.log"); - const cleanCount = join(root, "clean.count"); - - await mkdir(join(target, ".git"), { recursive: true }); - await mkdir(join(target, "config"), { recursive: true }); - await copyFile(join(repositoryRoot, "config", "p10k.zsh"), join(target, "config", "p10k.zsh")); - await copyFile( - join(repositoryRoot, "config", "pi-hashline-edit.json"), - join(target, "config", "pi-hashline-edit.json"), - ); - // A real released bundle also ships `scripts/`; the update flow locates its helpers - // there rather than falling back to a possibly stale script directory. - await mkdir(join(target, "scripts"), { recursive: true }); - await copyFile( - join(repositoryRoot, "config", "system-prompt-cover.md"), - join(target, "config", "system-prompt-cover.md"), - ); - await copyFile( - join(repositoryRoot, "scripts", "deploy-system-prompt-cover.mjs"), - join(target, "scripts", "deploy-system-prompt-cover.mjs"), - ); - await mkdir(bin, { recursive: true }); - - await writeFile( - join(bin, "pi"), - `#!/bin/sh -count=0 -[ ! -f "$MOCK_PI_COUNT" ] || count=$(cat "$MOCK_PI_COUNT") -count=$((count + 1)) -printf '%s\n' "$count" > "$MOCK_PI_COUNT" -printf 'pi %s\n' "$*" >> "$MOCK_PI_LOG" -if [ "$count" -eq 1 ]; then - if [ "$MOCK_CREATE_MARKER" = 1 ]; then - : > "$MOCK_MARKER" - fi - exit 1 -fi -if [ "$MOCK_SECOND_PI_STATUS" -eq 0 ]; then - rm -f "$MOCK_MARKER" -fi -exit "$MOCK_SECOND_PI_STATUS" -`, - "utf8", - ); - await chmod(join(bin, "pi"), 0o755); - - await writeFile( - join(bin, "git"), - `#!/bin/sh -printf 'git %s\n' "$*" >> "$MOCK_GIT_LOG" -case "$*" in - *" rev-parse --show-toplevel") - printf '%s\n' "$MOCK_GIT_TARGET" - ;; - *" remote get-url origin") - printf '%s\n' "$MOCK_GIT_ORIGIN" - ;; - *" clean -fdx") - count=0 - [ ! -f "$MOCK_CLEAN_COUNT" ] || count=$(cat "$MOCK_CLEAN_COUNT") - count=$((count + 1)) - printf '%s\n' "$count" > "$MOCK_CLEAN_COUNT" - if [ "$count" -eq 1 ]; then - printf '%s\n' 'warning: failed to remove node_modules/: Directory not empty' >&2 - exit 1 - fi - ;; -esac -`, - "utf8", - ); - await chmod(join(bin, "git"), 0o755); - - await writeFile(join(bin, "uname"), "#!/bin/sh\nprintf '%s\\n' Linux\n", "utf8"); - await chmod(join(bin, "uname"), 0o755); - - const env = { - ...process.env, - HOME: home, - XDG_CONFIG_HOME: join(home, ".config"), - PI_CODING_AGENT_DIR: agentDir, - MOCK_CREATE_MARKER: options.createMarker === false ? "0" : "1", - MOCK_MARKER: marker, - MOCK_PI_COUNT: piCount, - MOCK_PI_LOG: piLog, - MOCK_SECOND_PI_STATUS: String(options.secondPiStatus ?? 0), - MOCK_GIT_LOG: gitLog, - MOCK_CLEAN_COUNT: cleanCount, - MOCK_GIT_TARGET: target, - MOCK_GIT_ORIGIN: options.origin ?? "git@bitbucket.org:siakitem/my-pi.git", - PATH: `${bin}:${dirname(process.execPath)}:/usr/bin:/bin`, - ZSH: "", - ZSH_CUSTOM: "", - ZDOTDIR: home, - }; - delete env.PI_PACKAGE_SOURCE; - - return { cleanCount, env, gitLog, marker, piCount, piLog }; -} - -test("update.sh retries an identity-checked interrupted Git cleanup before retrying pi update", async (t) => { - const f = await fixture(t); - const result = spawnSync("sh", [join(repositoryRoot, "update.sh")], { - cwd: repositoryRoot, - env: f.env, - encoding: "utf8", - timeout: 30_000, - }); - - assert.equal(result.status, 0, result.stderr || result.stdout); - assert.equal((await readFile(f.piCount, "utf8")).trim(), "2"); - assert.equal((await readFile(f.cleanCount, "utf8")).trim(), "2"); - assert.match(result.stdout, /检测到默认组合包的 Pi Git 升级中断/); - assert.match(result.stdout, /第 2 次尝试后完成/); - await assert.rejects(readFile(f.marker, "utf8"), { code: "ENOENT" }); -}); - -test("update.sh keeps the original fail-fast behavior when no update marker exists", async (t) => { - const f = await fixture(t, { createMarker: false }); - const result = spawnSync("sh", [join(repositoryRoot, "update.sh")], { - cwd: repositoryRoot, - env: f.env, - encoding: "utf8", - timeout: 30_000, - }); - - assert.equal(result.status, 1); - assert.equal((await readFile(f.piCount, "utf8")).trim(), "1"); - await assert.rejects(readFile(f.gitLog, "utf8"), { code: "ENOENT" }); - assert.match(result.stderr, /pi update 执行失败/); -}); - -test("update.sh refuses cleanup when the cached checkout origin does not match", async (t) => { - const f = await fixture(t, { origin: "git@example.invalid:other/package.git" }); - const result = spawnSync("sh", [join(repositoryRoot, "update.sh")], { - cwd: repositoryRoot, - env: f.env, - encoding: "utf8", - timeout: 30_000, - }); - - assert.equal(result.status, 1); - assert.equal((await readFile(f.piCount, "utf8")).trim(), "1"); - const gitLog = await readFile(f.gitLog, "utf8"); - assert.doesNotMatch(gitLog, /clean -fdx/); - assert.match(result.stderr, /checkout 身份校验失败/); -}); diff --git a/update.sh b/update.sh index 861253d..062c2c9 100755 --- a/update.sh +++ b/update.sh @@ -57,91 +57,6 @@ select_updated_bundle_config() { fi } -recover_incomplete_bundle_update() { - recovery_applicable=0 - case $PACKAGE_SOURCE in - git:git@bitbucket.org:siakitem/my-pi.git) ;; - *) return 1 ;; - esac - - agent_dir=${PI_CODING_AGENT_DIR:-$HOME/.pi/agent} - recovery_target=$agent_dir/git/bitbucket.org/siakitem/my-pi - recovery_marker=$agent_dir/git/bitbucket.org/siakitem/.my-pi.pi-update-incomplete - if [ ! -f "$recovery_marker" ] || [ -L "$recovery_marker" ]; then - return 1 - fi - if ! command -v git >/dev/null 2>&1 || [ ! -d "$recovery_target/.git" ]; then - warn "检测到 Pi Git 升级中断标记,但无法验证已安装 checkout,已跳过自动恢复。" - return 1 - fi - - recovery_target=$(CDPATH=; cd "$recovery_target" 2>/dev/null && pwd -P) || return 1 - recovery_top=$(git -C "$recovery_target" rev-parse --show-toplevel 2>/dev/null) || return 1 - recovery_top=$(CDPATH=; cd "$recovery_top" 2>/dev/null && pwd -P) || return 1 - recovery_origin=$(git -C "$recovery_target" remote get-url origin 2>/dev/null) || return 1 - if [ "$recovery_top" != "$recovery_target" ] || \ - [ "$recovery_origin" != "git@bitbucket.org:siakitem/my-pi.git" ]; then - warn "检测到 Pi Git 升级中断标记,但 checkout 身份校验失败,拒绝自动清理。" - return 1 - fi - - recovery_applicable=1 - say "检测到默认组合包的 Pi Git 升级中断,正在有限重试依赖清理。" - recovery_attempt=1 - while [ "$recovery_attempt" -le 4 ]; do - if git -C "$recovery_target" clean -fdx; then - say "✓ Pi Git 缓存清理在第 ${recovery_attempt} 次尝试后完成。" - return 0 - fi - warn "Pi Git 缓存第 ${recovery_attempt} 次清理未完成。" - recovery_attempt=$((recovery_attempt + 1)) - done - warn "Pi Git 缓存清理在 4 次尝试后仍未完成;将交由最后一次 pi update 恢复依赖并报告结果。" - return 1 -} - -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 旧版默认配置已迁移到当前组合包默认值。" - 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_agent_dir() { if [ -n "${PI_CODING_AGENT_DIR:-}" ]; then case $PI_CODING_AGENT_DIR in @@ -153,28 +68,27 @@ resolve_agent_dir() { printf '%s\n' "$HOME/.pi/agent" } -# 把组合包权威的系统提示词封面同步到 agent 目录;未受管的自定义封面会被覆盖。 -deploy_system_prompt_cover() { - if [ -z "${updated_bundle_dir:-}" ] || [ ! -f "$updated_bundle_dir/scripts/deploy-system-prompt-cover.mjs" ]; then - warn "无法定位升级后组合包中的 SYSTEM_COVER.md 部署辅助脚本,已保留现有封面。" +# 整体删除并重建组合包拥有的 /my-pi/ 配置目录;helper 必须来自升级后的 +# 组合包,缺失时报告失败且绝不回退到脚本目录中可能陈旧的副本。 +rebuild_bundle_config() { + if [ -z "${updated_bundle_dir:-}" ] || [ ! -f "$updated_bundle_dir/scripts/rebuild-bundle-config.mjs" ]; then + warn "无法定位升级后组合包中的配置重建辅助脚本,已保留现有配置。" return 1 fi if ! command -v node >/dev/null 2>&1; then - warn "找不到 node,无法同步 SYSTEM_COVER.md;Pi 将使用默认系统提示词封面。" + warn "找不到 node,无法重建组合包配置目录。" return 1 fi - cover_helper=$updated_bundle_dir/scripts/deploy-system-prompt-cover.mjs - cover_agent_dir=$(resolve_agent_dir) || return 1 - cover_status=$(node "$cover_helper" "$cover_agent_dir" 2>&1) || { - warn "SYSTEM_COVER.md 同步失败:$cover_status" + rebuild_helper=$updated_bundle_dir/scripts/rebuild-bundle-config.mjs + rebuild_agent_dir=$(resolve_agent_dir) || return 1 + rebuild_status=$(node "$rebuild_helper" "$rebuild_agent_dir" 2>&1) || { + warn "组合包配置目录重建失败:$rebuild_status" return 1 } - case $cover_status in - created) say "✓ 已部署系统提示词封面:$cover_agent_dir/SYSTEM_COVER.md" ;; - updated) say "✓ 已同步系统提示词封面到组合包当前版本。" ;; - current) say "✓ 系统提示词封面已是当前组合包版本。" ;; + case $rebuild_status in + rebuilt:*) say "✓ 已重建组合包配置目录:${rebuild_status#rebuilt:}" ;; *) - warn "SYSTEM_COVER.md 部署返回未知状态:$cover_status" + warn "组合包配置重建返回未知状态:$rebuild_status" return 1 ;; esac @@ -452,31 +366,12 @@ if ! command -v pi >/dev/null 2>&1; then fi say "正在升级 Pi 组合包:$PACKAGE_SOURCE" -if pi update "$PACKAGE_SOURCE"; then - : -else - recovery_applicable=0 - recovery_cleaned=0 - if recover_incomplete_bundle_update; then - recovery_cleaned=1 - fi - if [ "$recovery_applicable" -ne 1 ]; then - warn "pi update 执行失败,已停止后续升级。" - exit 1 - fi - if [ "$recovery_cleaned" -eq 1 ]; then - say "正在重试 Pi 组合包升级,由 Pi 完成依赖安装并清除中断标记。" - else - warn "正在执行最后一次 pi update,以恢复可能被部分清理的依赖。" - fi - if ! pi update "$PACKAGE_SOURCE"; then - warn "pi update 自动恢复后仍然失败,已停止后续升级。" - exit 1 - fi +if ! pi update "$PACKAGE_SOURCE"; then + warn "pi update 执行失败,已停止后续升级。重新运行 update.sh 即可,Pi 会自行恢复未完成的升级。" + exit 1 fi select_updated_bundle_config || exit 1 -migrate_tool_search_settings || record_update_failure "Tool Search 用户配置" -deploy_system_prompt_cover || record_update_failure "SYSTEM_COVER.md" +rebuild_bundle_config || record_update_failure "组合包配置重建" say "组合包升级完成,开始升级已安装的终端环境和机器级 CLI;未安装项将直接跳过。" update_kitty || record_update_failure "Kitty"