From dec48dab6d5752fa44d9c9ce01caa8f97a8a861a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E6=9C=8D=E5=8A=A1=E9=83=A8-=E5=8F=B6=E6=9E=97?= =?UTF-8?q?=E7=AB=8B?= <1361666059@qq.com> Date: Thu, 15 Oct 2026 18:02:03 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=B0=81=E9=9D=A2=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=B7=A5=E5=85=B7=E7=BB=93=E6=9E=9C=E6=8A=95?= =?UTF-8?q?=E5=BD=B1=E4=B8=8E=E5=B8=B8=E9=A9=BB=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 系统提示词封面(新增能力): - extensions/system-prompt-cover.ts:读取 ~/.pi/agent/SYSTEM_COVER.md, 在 before_agent_start 返回 { systemPrompt },用文本拼接替换 Pi 默认 系统提示词的开头,同时保留 tools / rules / docs / skills / project_context / cwd 全部分区及其原始顺序。 - 必须走 forceSystemPrompt 投影路径。customPrompt 选项会连带抑制 tools / rules / docs 三个分区,而回填只能追加到末尾,无法恢复原顺序; 且 forceSystemPrompt 优先级更高,二者同时存在时 customPrompt 会被 静默忽略,因此 handler 不得写 customPrompt。 - 分割边界要求独占一行的裸标签: 嵌套在 内,只有裸标签才是分区边界。 - 注册在 tool-routing 之前,否则提示词已被压平,改 systemPromptOptions 不再影响本轮请求。 - scripts/deploy-system-prompt-cover.mjs:同目录临时文件 + rename 原子 覆盖,不保留备份;install.sh 从脚本目录、update.sh 从升级后组合包 定位 helper,禁止回退到可能陈旧的脚本目录。 - 封面缺失、不可读或空白时静默 no-op,Pi 行为保持不变;封面每轮重新 读取,修改后在下一轮生效,不影响已结束的历史对话。 pi-compaction 投影修正: - 仅在「摘要 + Source 行」严格短于原文时才替换工具结果。此前命中即 无条件替换,小结果反而被包壳放大;未替换的结果完整保留原始 content parts(含非文本部分)。 - deterministic.maxStubTokens 由 256 提升到 1024。 pi-tool-search 常驻工具: - alwaysEnabled 增加 context_tree_query:恢复被投影的工具证据不应依赖 一次 tool_search 激活,常驻同时保持请求前缀稳定。 - BUNDLE_TOOL_SEARCH_DEFAULTS_VERSION 由 3 提升到 4; scripts/migrate-tool-search-settings.mjs 新增 version-3 快照常量, 使其与 version-2 一样可被完整精确匹配并迁移。 - 权限策略为 context_tree_query 显式 allow:它只读扩展自己的 segment 索引、不触碰工作区,常驻后不应每轮触发 AutoReview 往返。该种子仅在 全新安装时部署,已存在的用户 policy.jsonc 不会被覆盖。 文档与测试同步: - AGENTS.md:新增 system-prompt-cover 开发约定,修正加载顺序、 bundleDefaultsVersion、可迁移旧快照集合与常驻工具范围的描述。 - pi-tool-search 的 README / CHANGELOG 同步 version 4 与新增常驻工具。 - tests/system-prompt-cover.test.ts:覆盖顺序保持、尾部逐字节不变、 嵌套 project_instructions 不误判、部署幂等与覆盖、缺源时非零退出。 - 修正 pi-tool-search/test/config.test.ts 与 tests/tool-search-settings-migration.test.ts 中写死的 version 3 与 工具列表断言。 - update-git-clean-recovery / tool-search-settings-migration 的 mock 组合包补拷 scripts/,与真实发布包结构一致,避免 helper 定位失败。 测试:根 61/61、pi-compaction 36/36 + tsc、pi-tool-search 16/16 + tsc、 pi-permission-system 2577/2577;sh -n 与 git diff --check 通过。 --- AGENTS.md | 22 +- README.md | 12 + config/pi-permission-policy.jsonc | 4 + config/system-prompt-cover.md | 1 + extensions/system-prompt-cover-config.ts | 57 +++++ extensions/system-prompt-cover.ts | 28 +++ install.sh | 44 +++- package.json | 1 + pi-compaction/CHANGELOG.md | 3 + pi-compaction/README.md | 3 + pi-compaction/src/config.ts | 2 +- pi-compaction/src/segment-index.ts | 5 +- pi-compaction/test/extension-runtime.test.ts | 35 ++- pi-tool-search/CHANGELOG.md | 3 +- pi-tool-search/README.md | 10 +- pi-tool-search/extensions/config.ts | 3 +- pi-tool-search/test/config.test.ts | 3 +- scripts/deploy-system-prompt-cover.mjs | 135 ++++++++++++ scripts/migrate-tool-search-settings.mjs | 35 ++- tests/extension-load.test.ts | 11 + tests/system-prompt-cover.test.ts | 217 +++++++++++++++++++ tests/tool-search-settings-migration.test.ts | 12 +- tests/update-git-clean-recovery.test.ts | 11 + update.sh | 38 ++++ 24 files changed, 675 insertions(+), 20 deletions(-) create mode 100644 config/system-prompt-cover.md create mode 100644 extensions/system-prompt-cover-config.ts create mode 100644 extensions/system-prompt-cover.ts create mode 100644 scripts/deploy-system-prompt-cover.mjs create mode 100644 tests/system-prompt-cover.test.ts diff --git a/AGENTS.md b/AGENTS.md index 08cdfb5..d9427be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ - `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/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` 同步该文件,不保留备份、直接覆盖。 - `pi-chrome/`:从上游源码导入并由本仓库直接维护,通过用户安装的 unpacked MV3 扩展与 `127.0.0.1:17318` loopback bridge 接管当前已登录 Chrome;控制默认锁定,每个 Pi session 必须显式 `/chrome authorize`。 - 上游来源: - 初始导入快照:`017ff4b9a639f0b8b213e58a3f30613fc38edcc8`(`0.15.46`,该 commit 无对应 tag)。 @@ -62,7 +63,7 @@ - `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` 完整精确匹配旧版 `3` 组 / `20` 工具或上一版 `5` 组 / `28` 工具默认快照时,才创建从生成起即为权限 `600` 的时间戳备份、以同目录临时文件原子迁移到当前默认值并写入当前 `bundleDefaultsVersion`;任何自定义或无效配置必须保留,提交前检测到内容变化必须中止。无法从 `PI_PACKAGE_SOURCE` 定位更新后 helper 时必须保留配置并报告失败,不能回退到可能陈旧的脚本目录 helper。其他组件升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。 +- `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` 受管块一起按内容比较后增量同步。 - `uninstall.sh`:先移除根组合包,其他共享终端环境和机器工具不卸载。不得删除 `.pi/agent/pi-hermes-memory/`、`projects-memory/`、`.hippo/` 或任何用户记忆数据。 ## 当前职责与默认行为 @@ -77,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` 与 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。首次创建完整默认 `toolSearch` 段时必须通过唯一同目录临时文件以权限 `600` 原子写入并记录 `bundleDefaultsVersion: 2`,已有自定义段只补缺失运行时字段且不得伪造迁移来源标记。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。 +- 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 顺序与最近淘汰原因。 - `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 列表。 @@ -105,7 +106,7 @@ - 原生远程压缩限定为 `openai-codex` + `openai-codex-responses` 与 `sub-api` + `openai-responses`,默认在 turn boundary 达到 effective 下界 90% 时触发。`pi-compaction/src/native-provider.ts` 统一资格与传输选择:Codex 保留原 endpoint/JWT/account ID;SubAPI 从显式 `baseUrl` 解析公共 `/responses`,使用配置的 API key 和请求头,不解析 ChatGPT account ID、不硬编码网关域名,也不调用独立 `/responses/compact`。两者复用 projection、`compaction_trigger`、SSE 结果、native checkpoint 恢复和 portable 模型切换摘要;SubAPI 部署须支持原生 compaction v2。普通聊天支持不代表压缩支持,失败回退 Pi,不自动改写用户模型配置。 - SubAPI 原生压缩认证必须保留非空显式 `Authorization`(大小写不敏感)的优先级;没有该头时才使用非空 API key 生成 Bearer,均缺失时可接受非空 `cf-aig-authorization` 代理认证头。不得要求 header-only 配置额外提供假 API key,也不得生成 `Bearer undefined`;空白或缺失凭据在网络请求前拒绝并回退 Pi。Codex 仍必须提供有效 JWT,不能用请求头绕过。 - 记忆由 `pi-hermes-memory` 统一负责:默认 `policy-only` 模式只注入 `` 指令而不注入记忆正文,因此它不改变 system prompt 前缀、对 DeepSeek 前缀缓存友好;模型需要历史上下文时主动调用 `memory_search` / `session_search`,写入用 `memory_add` / `memory_replace` / `memory_remove`,过程记忆用 `skill_manage`。 -- 六个记忆工具必须由 `pi-tool-search` 的 `alwaysEnabled` 常驻,不得放入动态组:注入的 policy 会在普通工作中指示模型调用它们,模型看不到 schema 就会静默放弃而不是去 `tool_search`;常驻同时保持请求前缀稳定。它们只写扩展自己的 agent 目录数据,不触碰工作区,因此在权限策略中默认 `allow`,避免每轮 AutoReview 往返。 +- 六个记忆工具与 `context_tree_query` 必须由 `pi-tool-search` 的 `alwaysEnabled` 常驻,不得放入动态组:注入的 policy 会在普通工作中指示模型调用记忆工具,而恢复被投影的工具证据也不能依赖 `tool_search` 激活;常驻同时保持请求前缀稳定。它们只写扩展自己的 agent 目录数据或只读本地索引,不触碰工作区,因此在权限策略中默认 `allow`,避免每轮 AutoReview 往返。 - `pi-hermes-memory` 必须注册在 `pi-compaction` 之前。`ExtensionRunner.emit()` 在第一个返回 `{ cancel: true }` 的 `session_before_compact` handler 处立即停止,而 `pi-compaction` 在判定无需压缩时正是这样返回;注册在其后的 handler 会被跳过,从而静默禁用 `flushOnCompact`。 - `pi-hermes-memory` 依赖 `better-sqlite3@13`,运行时实际 `dlopen` 的是包内 N-API 预编译二进制 `prebuilds/.node`,不依赖 node-gyp 编译产物。npm 会因该包存在 `binding.gyp` 而推断出隐式 `node-gyp rebuild`,该步骤既无必要也不得批准;根 `allowScripts` 必须保持不新增条目,同时保留既有的 `better-sqlite3@12.11.1` 放行。 - `skill_manage` 是 `pi-hermes-memory` 提供的能力,不是 Pi 宿主能力:Pi 只发现并加载 skill 目录(`~/.pi/agent/skills/` 加扩展通过 `resources_discover` 注册的目录),没有创建 skill 的工具。该扩展同时承担 skill 写入与目录注册;不加载它,`projects-memory/<项目>/skills/` 下的既有 skill 也不会被 Pi 发现。 @@ -170,7 +171,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` 必须断言该顺序。 -- 六个记忆工具必须在 `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`、Tool Search 测试和权限策略。 +- 六个记忆工具与 `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。 - 依赖必须保持 `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`;这些属于用户数据,安装、升级与卸载脚本不得创建、迁移或删除。 @@ -234,7 +235,18 @@ - SSH 工具的权限预览必须包含连接请求或当前远端的 host id、target、port、remote cwd 与有界操作摘要;连接预览只解密本地 vault 以提取非秘密目标字段,绝不包含凭据,远端 shell 的完整命令继续由 Bash payload 单独提供。权限服务缺失或注册失败时必须 fail-safe,不得转为无提示自动允许。 - `pi-ssh` 只用于从本地项目显式操作服务器,不得自动探测、读取或向系统提示词注入远端 `AGENTS.md`、`CLAUDE.md` 或其他项目说明;远端内容只能由明确且已授权的 `ssh_*` 工具调用获取。 - 修改 SSH transport、vault、导入、权限集成或加载入口后至少运行 `pi-ssh` 包内 `npm test`、根脚本语法与 mock 配置测试、`pi-permission-system` 的 `npm run typecheck` / `npm run test` / `npm run build`、根扩展联合加载和实际 packed tarball 隔离安装验证。未经明确要求不得连接真实服务器或把真实密码写入测试。 -- 根加载顺序必须保持 `pi-permission-system` → `pi-ssh` → `pi-chrome` → `pi-tool-search`,确保内置 authorizer 与权限服务由同一扩展先注册/发布、SSH 桥接随后安装,Chrome 工具可在授权后进入目录,且 Tool Search 最后收集完整工具目录。 +- 根加载顺序必须保持 `pi-permission-system` → `pi-ssh` → `pi-chrome` → `system-prompt-cover` → `tool-routing` → `pi-tool-search`,确保内置 authorizer 与权限服务由同一扩展先注册/发布、SSH 桥接随后安装、Chrome 工具可在授权后进入目录、系统提示词封面在 `tool-routing` 压平提示词之前改写开头,且 Tool Search 最后收集完整工具目录。 + +## `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`。 +- 只能使用 Pi 的 `forceSystemPrompt` 投影路径(`before_agent_start` 返回 `{ systemPrompt }`)配合文本拼接来保持分区顺序;不得改用 `customPrompt` 选项。后者会抑制 `tools` / `rules` / `docs` 三个分区,而回填只能追加到末尾,无法恢复原顺序。 +- 不得在 handler 中写 `customPrompt` 或依赖其生效:`forceSystemPrompt` 优先级更高,二者同时存在时 `customPrompt` 会被静默忽略而不报错。 +- 分割边界必须是独占一行的裸标签(带 backreference 同名闭合),不能匹配带属性的标签:`` 嵌套在 `` 内,只有裸标签才是分区边界。 +- 封面文件必须叫 `SYSTEM_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`。 ## `pi-ask-user` 开发约定 diff --git a/README.md b/README.md index 87a0255..ee2adf9 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,18 @@ CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自 使用 `/dump-system-prompt` 可将当前扩展所见的有效提示词写入当前项目的 `.pi-debug/effective-system-prompt.md`。`.pi-debug/` 属于本地诊断输出,默认不提交。 +### 系统提示词封面 + +`extensions/system-prompt-cover.ts` 把 Pi 默认系统提示词的**开头**替换为组合包权威的 `config/system-prompt-cover.md`(部署到 `~/.pi/agent/SYSTEM_COVER.md`),同时**保留其余全部分区及其原始顺序**:``、``、``、``、``、`` 都不会被删除或重新排序。 + +实现上使用 Pi 的 `forceSystemPrompt` 投影路径(每轮把渲染结果作为强制定本发送)而不是 `customPrompt` 选项:后者会连带抑制 `tools`/`rules`/`docs` 三个分区,且回填只能追加到末尾,无法保持顺序。封面只在**请求**时应用,不修改 transcript 中已持久化的结构化分区。 + +封面每轮重新读取,因此修改 `~/.pi/agent/SYSTEM_COVER.md` 后在**下一轮**即生效,不需要重开会话;已经结束的历史对话不受影响。文件缺失或为空白时不干预,Pi 继续使用自身默认封面。 + +该文件由组合包直接维护,`install.sh` 与 `update.sh` 会在组合包安装/升级后从组合包副本同步过去,**不保留备份并且会覆盖**本地修改;要自定义请直接编辑仓库中的 `config/system-prompt-cover.md`。 + +`SYSTEM_COVER.md` 必须使用这个名字:不能改名为 `SYSTEM.md`,否则 Pi 会先把它当作 `customPrompt` 读入,封面扩展拿到的提示词就已经缺少可供回填的分区了。 + ### 接管当前 Chrome 组合包固定加载 `pi-chrome@0.15.46`,但不会自动取得浏览器控制权。首次安装后在 Pi 中运行 `/chrome onboard`,按提示在 `chrome://extensions` 以 Developer mode 加载组合包内的 unpacked Chrome 扩展,再执行 `/reload` 与 `/chrome doctor`。每个 Pi session 仍需由用户显式运行 `/chrome authorize`;可用 `/chrome revoke` 立即撤销。Bridge 只监听 `127.0.0.1:17318`,Chrome 扩展具有广泛的 tabs/scripting/debugger 权限,应只从可信组合包安装。 diff --git a/config/pi-permission-policy.jsonc b/config/pi-permission-policy.jsonc index 8c988db..e7d03dd 100644 --- a/config/pi-permission-policy.jsonc +++ b/config/pi-permission-policy.jsonc @@ -46,6 +46,10 @@ "memory_replace": "allow", "memory_remove": "allow", "skill_manage": "allow", + // pi-compaction's evidence recovery reads only the extension's own segment + // index and never touches the workspace. It is pinned in Tool Search's + // alwaysEnabled set, so it must not pay an AutoReview round trip per turn. + "context_tree_query": "allow", "ssh_connect": "ask", "ssh_cd": "ask", diff --git a/config/system-prompt-cover.md b/config/system-prompt-cover.md new file mode 100644 index 0000000..4c903bd --- /dev/null +++ b/config/system-prompt-cover.md @@ -0,0 +1 @@ +You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files. diff --git a/extensions/system-prompt-cover-config.ts b/extensions/system-prompt-cover-config.ts new file mode 100644 index 0000000..e23d1f0 --- /dev/null +++ b/extensions/system-prompt-cover-config.ts @@ -0,0 +1,57 @@ +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"; + +/** + * Matches the newline-delimited boundary before the first top-level prompt section. + * + * Pi renders the system prompt as `` followed by sections wrapped in tags of + * the same name (for example ``). Nested markup such as + * `` carries attributes and therefore never matches a + * bare tag line, so the first match is the end of the preamble. + */ +const FIRST_SECTION_BOUNDARY = /\n<[a-z][a-z0-9_-]*>\n/; + +/** Resolve the cover path inside a given agent directory. */ +export function resolveCoverPath(agentDir: string): string { + return join(agentDir, COVER_FILE_NAME); +} + +/** + * Read the bundle-managed cover file. + * + * Returns `undefined` when the file is absent, unreadable, or blank so the caller can + * leave Pi's own default prompt untouched instead of forcing an empty preamble. + */ +export function readCoverFile(coverPath: string): string | undefined { + let raw: string; + try { + raw = readFileSync(coverPath, "utf8"); + } catch { + return undefined; + } + const trimmed = raw.replace(/\s+$/, ""); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * Replace the rendered prompt's preamble with `cover`, keeping everything after the first + * top-level section byte-for-byte identical. + * + * Pi builds the preamble at the top of the rendered prompt, so swapping that prefix is + * what preserves section order: options-based replacement (`customPrompt`) would drop + * `tools`/`rules`/`docs` and rebuild them at the end, reordering the prompt. Splicing the + * rendered text keeps the existing order untouched. + * + * A rendered prompt without any top-level section degenerates to the cover alone. + */ +export function spliceCover(rendered: string, cover: string): string { + const trimmed = cover.replace(/\s+$/, ""); + const boundary = rendered.search(FIRST_SECTION_BOUNDARY); + if (boundary < 0) { + return trimmed; + } + return trimmed + rendered.slice(boundary); +} diff --git a/extensions/system-prompt-cover.ts b/extensions/system-prompt-cover.ts new file mode 100644 index 0000000..2815063 --- /dev/null +++ b/extensions/system-prompt-cover.ts @@ -0,0 +1,28 @@ +import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +import { readCoverFile, resolveCoverPath, spliceCover } from "./system-prompt-cover-config.ts"; + +export { COVER_FILE_NAME, readCoverFile, resolveCoverPath, spliceCover } from "./system-prompt-cover-config.ts"; + +/** + * Replaces Pi's default system prompt preamble with the bundle-managed cover while + * preserving every other prompt section in its original position. + * + * The cover is read on each `before_agent_start`, so edits to `SYSTEM_COVER.md` take + * effect on the next turn of an already-running session rather than requiring a restart. + * + * Returning `systemPrompt` sets Pi's `forceSystemPrompt`, which is projected onto the + * request at send time. That is deliberate: it is the only path that keeps the rendered + * section order intact. The options-based alternative (`customPrompt`) would suppress + * `tools`, `rules`, and `docs`, and any later re-injection cannot restore their original + * positions. Pi still diffs and persists the structured sections in the transcript. + */ +export default function systemPromptCoverExtension(pi: ExtensionAPI): void { + pi.on("before_agent_start", (event) => { + const cover = readCoverFile(resolveCoverPath(getAgentDir())); + if (cover === undefined) { + return; + } + return { systemPrompt: spliceCover(event.systemPrompt, cover) }; + }); +} diff --git a/install.sh b/install.sh index b94891a..c49aedf 100755 --- a/install.sh +++ b/install.sh @@ -64,6 +64,44 @@ record_install_failure() { install_failed=1 } +resolve_agent_dir() { + if [ -n "${PI_CODING_AGENT_DIR:-}" ]; then + case $PI_CODING_AGENT_DIR in + ~/*) printf '%s\n' "$HOME/${PI_CODING_AGENT_DIR#~/}" ;; + *) printf '%s\n' "$PI_CODING_AGENT_DIR" ;; + esac + return 0 + fi + 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 部署辅助脚本,已跳过封面部署。" + return 1 + fi + if ! command -v node >/dev/null 2>&1; then + warn "找不到 node,无法部署 SYSTEM_COVER.md;Pi 将使用默认系统提示词封面。" + 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" + return 1 + } + case $cover_status in + created) say "✓ 已部署系统提示词封面:$cover_agent_dir/SYSTEM_COVER.md" ;; + updated) say "✓ 已同步系统提示词封面到组合包当前版本。" ;; + current) say "✓ 系统提示词封面已是当前组合包版本。" ;; + *) + warn "SYSTEM_COVER.md 部署返回未知状态:$cover_status" + return 1 + ;; + esac +} + java_major_version() { java_version=$(java -version 2>&1 | awk -F '"' 'NR == 1 { print $2 }') case $java_version in @@ -333,7 +371,11 @@ if ! pi install "$PACKAGE_SOURCE"; then exit 1 fi -say "Pi 组合包安装完成,开始检查终端环境和机器级 CLI 依赖。" +say "Pi 组合包安装完成,开始部署受管配置。" + +deploy_system_prompt_cover || record_install_failure "SYSTEM_COVER.md" + +say "开始检查终端环境和机器级 CLI 依赖。" kitty_ok=0 if detect_kitty; then diff --git a/package.json b/package.json index 4b18cf1..d2b02c3 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "./pi-ssh/index.ts", "./pi-ask-user/index.ts", "./pi-chrome/extensions/chrome-profile-bridge/index.ts", + "./extensions/system-prompt-cover.ts", "./extensions/tool-routing.ts", "./pi-tool-search/extensions/index.ts" ], diff --git a/pi-compaction/CHANGELOG.md b/pi-compaction/CHANGELOG.md index ab4343c..a9163db 100644 --- a/pi-compaction/CHANGELOG.md +++ b/pi-compaction/CHANGELOG.md @@ -6,6 +6,9 @@ - Restore SubAPI native checkpoints on subsequent requests and preserve the existing Codex transport, portable model-switching summary and failure fallback. - Cover provider eligibility, URL/authentication handling and both providers' native checkpoint lifecycles with offline tests. - Preserve explicit SubAPI Authorization headers over API keys, support header-only Authorization/proxy authentication, and reject missing credentials without weakening Codex JWT requirements. +- Pass small tool results through unchanged: projection now replaces a tool result only when the summary plus its `Source:` recovery line is strictly shorter than the original text content. +- Preserve original content parts, including non-text parts, for every tool result that is not replaced. Indexed evidence remains recoverable through `context_tree_query` either way. +- Align the implementation with the documented "replace large tool results" behavior; add regression tests for pass-through and for recoverability without replacement. ## 1.0.0 diff --git a/pi-compaction/README.md b/pi-compaction/README.md index eb328de..8ed1409 100644 --- a/pi-compaction/README.md +++ b/pi-compaction/README.md @@ -6,6 +6,7 @@ High-performance unified context projection and compaction for Pi. - incrementally index completed tool calls; - replace large tool results with deterministic bounded evidence stubs; +- pass small tool results through unchanged whenever a stub would not be shorter; - refine large stubs with non-blocking semantic summaries; - expose one effective context projection to normal provider requests and Codex/SubAPI native compaction; - cancel Pi's raw threshold compaction until the effective lower-bound reaches the configured threshold; @@ -70,6 +71,8 @@ The gateway must have a compatible deployed version, model and available upstrea The only custom entry type is `pi-compaction`, version 1. Segment summary entries contain hashes, summaries, and source references; they never duplicate complete raw tool output. Native checkpoints use Pi compaction-entry details with the same kind/version. +Projection replaces a tool result only when the resulting summary plus its `Source:` recovery line is strictly shorter than the original text content. Small tool results are therefore passed through byte-for-byte and keep their original content parts, including any non-text parts. Because a stub is only larger than the text it would replace when the original is short, this comparison is the sole gate; no separate size option exists. Every indexed result remains recoverable through `context_tree_query` whether or not the message was replaced. + This implementation intentionally does not read legacy `context-prune-*` or `openai-codex-native-compaction` entries. Start a new session after switching to this extension. ## Interfaces diff --git a/pi-compaction/src/config.ts b/pi-compaction/src/config.ts index c54cc52..740e007 100644 --- a/pi-compaction/src/config.ts +++ b/pi-compaction/src/config.ts @@ -12,7 +12,7 @@ export const DEFAULT_CONFIG: PiCompactionConfig = { targetRatio: 0.55, deterministic: { enabled: true, - maxStubTokens: 256, + maxStubTokens: 1_024, }, semantic: { enabled: true, diff --git a/pi-compaction/src/segment-index.ts b/pi-compaction/src/segment-index.ts index 01547ee..9ceb6e4 100644 --- a/pi-compaction/src/segment-index.ts +++ b/pi-compaction/src/segment-index.ts @@ -346,10 +346,13 @@ export class SegmentIndex { if (message?.role !== "toolResult" || typeof message.toolCallId !== "string") return message; const record = this.records.get(sourceKey(message.toolCallId, message.timestamp)); if (!record) return message; + const original = contentText(message.content); const summary = record.semanticSummary ?? record.deterministicSummary; + const projected = `${summary}\nSource: ${record.sourceKey}; recover with context_tree_query.`; + if (projected.length >= original.length) return message; return { ...message, - content: [{ type: "text", text: `${summary}\nSource: ${record.sourceKey}; recover with context_tree_query.` }], + content: [{ type: "text", text: projected }], }; } diff --git a/pi-compaction/test/extension-runtime.test.ts b/pi-compaction/test/extension-runtime.test.ts index dcff63e..56589e5 100644 --- a/pi-compaction/test/extension-runtime.test.ts +++ b/pi-compaction/test/extension-runtime.test.ts @@ -54,7 +54,7 @@ test("one runtime projects tool evidence and recovers it through context_tree_qu role: "toolResult", toolCallId: "call-1", toolName: "bash", - content: [{ type: "text", text: "failure evidence" }], + content: [{ type: "text", text: `failure evidence\n${"x".repeat(12_000)}\nstack tail` }], isError: true, timestamp: 11, }; @@ -78,6 +78,39 @@ test("one runtime projects tool evidence and recovers it through context_tree_qu await h.handlers.get("session_shutdown")![0]!({}, ctx); }); +test("small tool results are passed through without a stub wrapper", async () => { + const h = harness(); + piCompaction(h.pi); + const ctx = context(); + await h.handlers.get("session_start")![0]!({}, ctx); + + const assistant = { + role: "assistant", + content: [{ type: "toolCall", id: "call-small", name: "bash", arguments: { command: "ls" } }], + timestamp: 20, + }; + const result = { + role: "toolResult", + toolCallId: "call-small", + toolName: "bash", + content: [{ type: "text", text: "ok\nfile-a.ts\nfile-b.ts" }], + timestamp: 21, + }; + + await h.handlers.get("turn_end")![0]!({ message: assistant, toolResults: [result] }, ctx); + const projected = await h.handlers.get("context")![0]!({ messages: [assistant, result] }, ctx); + + assert.strictEqual(projected.messages[1].content[0].text, "ok\nfile-a.ts\nfile-b.ts"); + assert.doesNotMatch(projected.messages[1].content[0].text, /pi-compaction tool=/); + + // Original evidence stays recoverable even when the message is passed through. + const query = h.tools.get("context_tree_query")!; + const recovered = await query.execute("q", { toolCallIds: ["call-small"] }, new AbortController().signal, undefined, ctx); + assert.match(recovered.content[0].text, /file-a\.ts/); + + await h.handlers.get("session_shutdown")![0]!({}, ctx); +}); + test("model switching after a native checkpoint projects the portable checkpoint", async () => { const h = harness(); piCompaction(h.pi); diff --git a/pi-tool-search/CHANGELOG.md b/pi-tool-search/CHANGELOG.md index 12baecc..2bf59bb 100644 --- a/pi-tool-search/CHANGELOG.md +++ b/pi-tool-search/CHANGELOG.md @@ -18,7 +18,8 @@ - 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. -- 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 while leaving customized configurations untouched. +- 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. - Weighted exact tool names, group metadata, and common SSH/Chrome/Context aliases during query routing; weak or ambiguous matches now return candidates instead of activating arbitrarily. - Added `/tool-search-status` for catalog source, capacity, active-group LRU order, and latest eviction diagnostics. diff --git a/pi-tool-search/README.md b/pi-tool-search/README.md index a063b26..bb16d7e 100644 --- a/pi-tool-search/README.md +++ b/pi-tool-search/README.md @@ -90,27 +90,29 @@ Pi reads this configuration from `/settings.json` (normally `~/.pi/ag "memory_add", "memory_replace", "memory_remove", - "skill_manage" + "skill_manage", + "context_tree_query" + ], ], "showToolSearchFooterStatus": false, "maxActiveGroups": 5, "maxToolsPerGroup": 8, "maxDynamicTools": 28, "groupOverrides": {}, - "bundleDefaultsVersion": 3 + "bundleDefaultsVersion": 4 } } ``` | Key | Bundle default | Description | |---|---|---| -| `alwaysEnabled` | CodeGraph explore, LSP diagnostics, Context execution trio, all six Hermes memory tools | Exact names pinned alongside Pi core tools | +| `alwaysEnabled` | CodeGraph explore, LSP diagnostics, Context execution trio, all six Hermes memory tools, `context_tree_query` | Exact names pinned alongside Pi core tools | | `showToolSearchFooterStatus` | `false` | Show active tool/group counts | | `maxActiveGroups` | `5` | Maximum simultaneously active dynamic groups | | `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` | `2` on a newly created default section | Bundle provenance for controlled default migrations; ignored by runtime routing | +| `bundleDefaultsVersion` | `4` on a newly created default section | Bundle provenance for controlled default migrations; ignored by runtime routing | Example override: diff --git a/pi-tool-search/extensions/config.ts b/pi-tool-search/extensions/config.ts index 179d981..6c8609b 100644 --- a/pi-tool-search/extensions/config.ts +++ b/pi-tool-search/extensions/config.ts @@ -26,6 +26,7 @@ export const BUNDLE_TOOL_SEARCH_DEFAULTS = { "memory_replace", "memory_remove", "skill_manage", + "context_tree_query", ], showToolSearchFooterStatus: false, maxActiveGroups: 5, @@ -34,7 +35,7 @@ export const BUNDLE_TOOL_SEARCH_DEFAULTS = { groupOverrides: {}, } as const; -export const BUNDLE_TOOL_SEARCH_DEFAULTS_VERSION = 3; +export const BUNDLE_TOOL_SEARCH_DEFAULTS_VERSION = 4; export interface ToolSearchConfig { alwaysEnabled: string[]; diff --git a/pi-tool-search/test/config.test.ts b/pi-tool-search/test/config.test.ts index ff6e5e4..96e79b5 100644 --- a/pi-tool-search/test/config.test.ts +++ b/pi-tool-search/test/config.test.ts @@ -30,6 +30,7 @@ test("writes bundle defaults when toolSearch is absent", async () => { "memory_replace", "memory_remove", "skill_manage", + "context_tree_query", ]); assert.deepEqual(JSON.parse(await readFile(join(agentDir, "settings.json"), "utf8")), { toolSearch: { @@ -39,7 +40,7 @@ test("writes bundle defaults when toolSearch is absent", async () => { maxToolsPerGroup: 8, maxDynamicTools: 28, groupOverrides: {}, - bundleDefaultsVersion: 3, + bundleDefaultsVersion: 4, }, }); assert.equal((await stat(join(agentDir, "settings.json"))).mode & 0o777, 0o600); diff --git a/scripts/deploy-system-prompt-cover.mjs b/scripts/deploy-system-prompt-cover.mjs new file mode 100644 index 0000000..5bd3d9b --- /dev/null +++ b/scripts/deploy-system-prompt-cover.mjs @@ -0,0 +1,135 @@ +#!/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 index 39a18a5..15b3b73 100755 --- a/scripts/migrate-tool-search-settings.mjs +++ b/scripts/migrate-tool-search-settings.mjs @@ -34,7 +34,7 @@ export const PREVIOUS_TOOL_SEARCH_DEFAULTS = { bundleDefaultsVersion: 2, }; -export const CURRENT_TOOL_SEARCH_RUNTIME_DEFAULTS = { +export const VERSION_3_TOOL_SEARCH_RUNTIME_DEFAULTS = { alwaysEnabled: [ "codegraph_explore", "lsp_diagnostics", @@ -55,9 +55,36 @@ export const CURRENT_TOOL_SEARCH_RUNTIME_DEFAULTS = { 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: 3, + bundleDefaultsVersion: 4, }; function isObject(value) { @@ -122,7 +149,9 @@ export async function migrateToolSearchSettings(settingsPath, options = {}) { || 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, 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" }; } diff --git a/tests/extension-load.test.ts b/tests/extension-load.test.ts index 62196a8..60c0810 100644 --- a/tests/extension-load.test.ts +++ b/tests/extension-load.test.ts @@ -138,6 +138,17 @@ test("all package extensions load together without global registration conflicts packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"), "the bundle must load the locally maintained Hermes memory extension", ); + // The cover must rewrite the prompt before tool-routing flattens it into a forced prompt; + // once flattened, later handlers cannot restore the section structure. + assert.ok( + packageJson.pi.extensions.includes("./extensions/system-prompt-cover.ts"), + "the bundle must load the system prompt cover extension", + ); + assert.ok( + packageJson.pi.extensions.indexOf("./extensions/system-prompt-cover.ts") < + packageJson.pi.extensions.indexOf("./extensions/tool-routing.ts"), + "the system prompt cover must run before tool-routing forces the prompt", + ); assert.equal(packageJson.dependencies["pi-hermes-memory"], "file:./pi-hermes-memory"); assert.ok( packageJson.files.includes("pi-hermes-memory"), diff --git a/tests/system-prompt-cover.test.ts b/tests/system-prompt-cover.test.ts new file mode 100644 index 0000000..b1b2041 --- /dev/null +++ b/tests/system-prompt-cover.test.ts @@ -0,0 +1,217 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { execFileSync } 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 { + COVER_FILE_NAME, + deploySystemPromptCover, + resolveAgentDir, +} from "../scripts/deploy-system-prompt-cover.mjs"; +import { + readCoverFile, + resolveCoverPath, + spliceCover, +} from "../extensions/system-prompt-cover-config.ts"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const bundledCoverPath = join(repositoryRoot, "config", "system-prompt-cover.md"); + +const DEFAULT_PREAMBLE = + "You are an expert coding assistant operating inside pi, a coding agent harness. " + + "You help users by reading files, executing commands, editing code, and writing new files."; + +/** A rendered prompt shaped like Pi's: preamble, then tag-wrapped top-level sections. */ +function renderedPrompt(preamble = DEFAULT_PREAMBLE) { + return [ + preamble, + "", + "- read: reads files", + "", + "", + "- Be concise", + "", + "", + "", + " ", + " s", + " ", + "", + "", + "", + '', + "Nested content with a -looking line is not a tag.", + "", + "", + "", + "/work", + "", + ].join("\n"); +} + +function tagsOf(text: string): string[] { + return [...text.matchAll(/^<([a-z][a-z0-9_-]*)>$/gm)].map((match) => match[1]); +} + +async function makeTempDir() { + return mkdtemp(join(tmpdir(), "my-pi-cover-")); +} + +test("bundled cover holds Pi's default preamble text", async () => { + const content = await readFile(bundledCoverPath, "utf8"); + assert.equal(content.replace(/\s+$/, ""), DEFAULT_PREAMBLE); +}); + +test("spliceCover replaces only the preamble and preserves the remaining bytes", () => { + const rendered = renderedPrompt(); + const spliced = spliceCover(rendered, "MY CUSTOM COVER"); + + assert.ok(spliced.startsWith("MY CUSTOM COVER\n")); + + // Everything after the preamble's terminating newline is untouched. + const originalTail = rendered.slice(rendered.indexOf("\n")); + assert.ok(spliced.endsWith(originalTail)); + + // Section order — including nested tags — is identical to the unmodified prompt. + assert.deepEqual(tagsOf(spliced), tagsOf(rendered)); + assert.deepEqual(tagsOf(spliced), [ + "tools", + "rules", + "skills", + "available_skills", + "project_context", + "cwd", + ]); +}); + +test("spliceCover keeps section order rather than appending recovered sections", () => { + const rendered = renderedPrompt("ORIGINAL COVER"); + const spliced = spliceCover(rendered, "NEW COVER"); + const order = tagsOf(spliced).join(","); + + assert.equal(order, "tools,rules,skills,available_skills,project_context,cwd"); + assert.ok( + order.indexOf("tools") < order.indexOf("cwd"), + "tools must still precede cwd; a re-appended section list would invert this", + ); + assert.ok( + order.indexOf("project_context") < order.indexOf("cwd"), + "project_context must keep its original position", + ); +}); + +test("spliceCover tolerates a trailing newline from the editor and a blank cover", () => { + const rendered = renderedPrompt(); + + assert.equal(spliceCover(rendered, "COVER\n\n"), spliceCover(rendered, "COVER")); + // A whitespace-only cover leaves an empty preamble but still keeps every section. + const blank = spliceCover(rendered, " \n"); + assert.equal(blank, rendered.slice(rendered.indexOf("\n"))); + assert.deepEqual(tagsOf(blank), tagsOf(rendered)); +}); + +test("spliceCover degenerates to the cover when the prompt has no sections", () => { + assert.equal(spliceCover("just a preamble", "NEW"), "NEW"); + assert.equal(spliceCover("", "NEW"), "NEW"); +}); + +test("spliceCover keeps an attribute-bearing nested tag inside its parent section", () => { + // Pi nests inside the bare tag, so the + // boundary must be the first *bare* tag line and the nested markup must survive verbatim. + const rendered = [ + "ORIGINAL COVER", + "", + "- read: reads files", + "", + "", + '', + "nested instructions", + "", + "", + "", + "/work", + "", + ].join("\n"); + + const spliced = spliceCover(rendered, "NEW"); + assert.equal(tagsOf(spliced).join(","), "tools,project_context,cwd"); + assert.ok(spliced.startsWith("NEW\n")); + assert.ok( + spliced.includes('\nnested instructions\n'), + "nested project instructions must be preserved byte-for-byte", + ); +}); + +test("readCoverFile returns undefined when the file is absent or blank", async () => { + const dir = await makeTempDir(); + try { + const coverPath = resolveCoverPath(dir); + assert.equal(readCoverFile(coverPath), undefined); + + await writeFile(coverPath, " \n\n", "utf8"); + assert.equal(readCoverFile(coverPath), undefined); + + await writeFile(coverPath, " REAL COVER \n", "utf8"); + assert.equal(readCoverFile(coverPath), " REAL COVER"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +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 index c060673..0673bcf 100644 --- a/tests/tool-search-settings-migration.test.ts +++ b/tests/tool-search-settings-migration.test.ts @@ -98,7 +98,7 @@ test("migrates the previous versioned default snapshot and pins the Hermes memor 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, 3); + 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`); } @@ -168,6 +168,16 @@ async function updateFixture(t: test.TestContext, piExitStatus = 0) { 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"), diff --git a/tests/update-git-clean-recovery.test.ts b/tests/update-git-clean-recovery.test.ts index 7bf8337..64e4604 100644 --- a/tests/update-git-clean-recovery.test.ts +++ b/tests/update-git-clean-recovery.test.ts @@ -32,6 +32,17 @@ async function fixture( 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( diff --git a/update.sh b/update.sh index 81e2786..861253d 100755 --- a/update.sh +++ b/update.sh @@ -142,6 +142,43 @@ migrate_tool_search_settings() { esac } +resolve_agent_dir() { + if [ -n "${PI_CODING_AGENT_DIR:-}" ]; then + case $PI_CODING_AGENT_DIR in + ~/*) printf '%s\n' "$HOME/${PI_CODING_AGENT_DIR#~/}" ;; + *) printf '%s\n' "$PI_CODING_AGENT_DIR" ;; + esac + return 0 + fi + 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 部署辅助脚本,已保留现有封面。" + return 1 + fi + if ! command -v node >/dev/null 2>&1; then + warn "找不到 node,无法同步 SYSTEM_COVER.md;Pi 将使用默认系统提示词封面。" + 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" + return 1 + } + case $cover_status in + created) say "✓ 已部署系统提示词封面:$cover_agent_dir/SYSTEM_COVER.md" ;; + updated) say "✓ 已同步系统提示词封面到组合包当前版本。" ;; + current) say "✓ 系统提示词封面已是当前组合包版本。" ;; + *) + warn "SYSTEM_COVER.md 部署返回未知状态:$cover_status" + return 1 + ;; + esac +} resolve_zsh_paths() { oh_my_zsh_dir=${ZSH:-$HOME/.oh-my-zsh} zsh_custom_dir=${ZSH_CUSTOM:-$oh_my_zsh_dir/custom} @@ -439,6 +476,7 @@ else 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" say "组合包升级完成,开始升级已安装的终端环境和机器级 CLI;未安装项将直接跳过。" update_kitty || record_update_failure "Kitty"