feat: 新增系统提示词封面,并修正工具结果投影与常驻工具

系统提示词封面(新增能力):

- 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。
- 分割边界要求独占一行的裸标签:<project_instructions path="..."> 嵌套在
  <project_context> 内,只有裸标签才是分区边界。
- 注册在 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 通过。
This commit is contained in:
云服务部-叶林立
2026-10-15 18:02:03 -07:00
parent cdd91b3f8b
commit dec48dab6d
24 changed files with 675 additions and 20 deletions
+17 -5
View File
@@ -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`。
- 上游来源:<https://github.com/tianrendong/pi-chrome>
- 初始导入快照:`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` 模式只注入 `<memory-policy>` 指令而不注入记忆正文,因此它不改变 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/<platform>.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/<platform>.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 同名闭合),不能匹配带属性的标签:`<project_instructions path="...">` 嵌套在 `<project_context>` 内,只有裸标签才是分区边界。
- 封面文件必须叫 `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` 开发约定
+12
View File
@@ -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`),同时**保留其余全部分区及其原始顺序**:`<tools>`、`<rules>`、`<docs>`、`<skills>`、`<project_context>`、`<cwd>` 都不会被删除或重新排序。
实现上使用 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 权限,应只从可信组合包安装。
+4
View File
@@ -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",
+1
View File
@@ -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.
+57
View File
@@ -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 `<preamble>` followed by sections wrapped in tags of
* the same name (for example `<tools>`). Nested markup such as
* `<project_instructions path="...">` carries attributes and therefore never matches a
* bare tag line, so the first match is the end of the preamble.
*/
const FIRST_SECTION_BOUNDARY = /\n<[a-z][a-z0-9_-]*>\n/;
/** Resolve the cover path inside 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);
}
+28
View File
@@ -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) };
});
}
+43 -1
View File
@@ -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
+1
View File
@@ -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"
],
+3
View File
@@ -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
+3
View File
@@ -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
+1 -1
View File
@@ -12,7 +12,7 @@ export const DEFAULT_CONFIG: PiCompactionConfig = {
targetRatio: 0.55,
deterministic: {
enabled: true,
maxStubTokens: 256,
maxStubTokens: 1_024,
},
semantic: {
enabled: true,
+4 -1
View File
@@ -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 }],
};
}
+34 -1
View File
@@ -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);
+2 -1
View File
@@ -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.
+6 -4
View File
@@ -90,27 +90,29 @@ Pi reads this configuration from `<agent-dir>/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:
+2 -1
View File
@@ -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[];
+2 -1
View File
@@ -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);
+135
View File
@@ -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
* `<agentDir>/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;
});
}
+32 -3
View File
@@ -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" };
}
+11
View File
@@ -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"),
+217
View File
@@ -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,
"<tools>",
"- read: reads files",
"</tools>",
"<rules>",
"- Be concise",
"</rules>",
"<skills>",
"<available_skills>",
" <skill>",
" <name>s</name>",
" </skill>",
"</available_skills>",
"</skills>",
"<project_context>",
'<project_instructions path="/work/AGENTS.md">',
"Nested content with a </project_context>-looking line is not a tag.",
"</project_instructions>",
"</project_context>",
"<cwd>",
"/work",
"</cwd>",
].join("\n");
}
function tagsOf(text: string): string[] {
return [...text.matchAll(/^<([a-z][a-z0-9_-]*)>$/gm)].map((match) => match[1]);
}
async function makeTempDir() {
return mkdtemp(join(tmpdir(), "my-pi-cover-"));
}
test("bundled cover holds Pi's default preamble text", async () => {
const content = await readFile(bundledCoverPath, "utf8");
assert.equal(content.replace(/\s+$/, ""), DEFAULT_PREAMBLE);
});
test("spliceCover replaces only the preamble and preserves the remaining bytes", () => {
const rendered = renderedPrompt();
const spliced = spliceCover(rendered, "MY CUSTOM COVER");
assert.ok(spliced.startsWith("MY CUSTOM COVER\n"));
// Everything after the preamble's terminating newline is untouched.
const originalTail = rendered.slice(rendered.indexOf("\n<tools>"));
assert.ok(spliced.endsWith(originalTail));
// Section order — including nested tags — is identical to the unmodified prompt.
assert.deepEqual(tagsOf(spliced), tagsOf(rendered));
assert.deepEqual(tagsOf(spliced), [
"tools",
"rules",
"skills",
"available_skills",
"project_context",
"cwd",
]);
});
test("spliceCover keeps section order rather than appending recovered sections", () => {
const rendered = renderedPrompt("ORIGINAL COVER");
const spliced = spliceCover(rendered, "NEW COVER");
const order = tagsOf(spliced).join(",");
assert.equal(order, "tools,rules,skills,available_skills,project_context,cwd");
assert.ok(
order.indexOf("tools") < order.indexOf("cwd"),
"tools must still precede cwd; a re-appended section list would invert this",
);
assert.ok(
order.indexOf("project_context") < order.indexOf("cwd"),
"project_context must keep its original position",
);
});
test("spliceCover tolerates a trailing newline from the editor and a blank cover", () => {
const rendered = renderedPrompt();
assert.equal(spliceCover(rendered, "COVER\n\n"), spliceCover(rendered, "COVER"));
// A whitespace-only cover leaves an empty preamble but still keeps every section.
const blank = spliceCover(rendered, " \n");
assert.equal(blank, rendered.slice(rendered.indexOf("\n<tools>")));
assert.deepEqual(tagsOf(blank), tagsOf(rendered));
});
test("spliceCover degenerates to the cover when the prompt has no sections", () => {
assert.equal(spliceCover("just a preamble", "NEW"), "NEW");
assert.equal(spliceCover("", "NEW"), "NEW");
});
test("spliceCover keeps an attribute-bearing nested tag inside its parent section", () => {
// Pi nests <project_instructions path="..."> inside the bare <project_context> tag, so the
// boundary must be the first *bare* tag line and the nested markup must survive verbatim.
const rendered = [
"ORIGINAL COVER",
"<tools>",
"- read: reads files",
"</tools>",
"<project_context>",
'<project_instructions path="/work/AGENTS.md">',
"nested instructions",
"</project_instructions>",
"</project_context>",
"<cwd>",
"/work",
"</cwd>",
].join("\n");
const spliced = spliceCover(rendered, "NEW");
assert.equal(tagsOf(spliced).join(","), "tools,project_context,cwd");
assert.ok(spliced.startsWith("NEW\n<tools>"));
assert.ok(
spliced.includes('<project_instructions path="/work/AGENTS.md">\nnested instructions\n</project_instructions>'),
"nested project instructions must be preserved byte-for-byte",
);
});
test("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 });
}
});
+11 -1
View File
@@ -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"),
+11
View File
@@ -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(
+38
View File
@@ -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"