mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
Compare commits
41
Commits
29ea39f9b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87940da51a | ||
|
|
2b21163a18 | ||
|
|
dae1a01f23 | ||
|
|
833b9514d4 | ||
|
|
f9a7f743dd | ||
|
|
56cbc83296 | ||
|
|
2a6bf8c929 | ||
|
|
a6d8416c76 | ||
|
|
a254d4fd68 | ||
|
|
e996f59469 | ||
|
|
0c45b1a438 | ||
|
|
360d8cffc8 | ||
|
|
b817d66a11 | ||
|
|
ba164896d2 | ||
|
|
dc4fe92e27 | ||
|
|
da9908fb6e | ||
|
|
2aa1be0b54 | ||
|
|
a0ee9389c7 | ||
|
|
0c9823ef5c | ||
|
|
ee1e6999d7 | ||
|
|
6fe6cb29ae | ||
|
|
97c2e75dfe | ||
|
|
f237025cf9 | ||
|
|
281249ad87 | ||
|
|
2682bd7fa7 | ||
|
|
7e6a8faae1 | ||
|
|
dec48dab6d | ||
|
|
cdd91b3f8b | ||
|
|
73f3117a7e | ||
|
|
312bb2ba5a | ||
|
|
879fc5d37f | ||
|
|
ff1e7400da | ||
|
|
6e7effa792 | ||
|
|
180da95ab7 | ||
|
|
a4807dde67 | ||
|
|
44f27f7585 | ||
|
|
e80959cc3f | ||
|
|
8002d7c213 | ||
|
|
b83413d670 | ||
|
|
5dd333882a | ||
|
|
d1b11f86f7 |
@@ -1,5 +0,0 @@
|
|||||||
# CodeGraph data files — local to each machine, not for committing.
|
|
||||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
|
||||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
|
||||||
*
|
|
||||||
!.gitignore
|
|
||||||
@@ -1,305 +1,65 @@
|
|||||||
# 仓库说明
|
# 仓库说明
|
||||||
|
|
||||||
本仓库用于维护可通过一次 `pi install` 部署的个人 Pi 扩展组合包。根 `package.json` 是唯一的组合包安装入口;根 `install.sh`、`update.sh` 和 `uninstall.sh` 只是对 `pi install` / `pi update` / `pi remove` 的便捷包装,不形成第二套部署机制。依赖、默认行为和必要配置必须由组合包自身声明或部署,不把安装后的手工配置作为默认流程。每个自维护扩展使用独立的顶层目录,扩展源码、测试和说明应保留在各自目录内。
|
本仓库维护可通过一次 `pi install` 部署的个人 Pi 扩展组合包。根 `package.json` 是唯一的组合包安装入口;`install.sh` / `update.sh` / `uninstall.sh` 只是 `pi install` / `pi update` / `pi remove` 的便捷包装,不形成第二套部署机制。仓库 `config/` 是组合包唯一配置来源,扩展运行时直接读取已安装组合包内的文件,不复制、不播种、不迁移;仅外部 npm 扩展固定读取 agent 目录路径时(当前 `hashline.json`、`lsp.json`)才允许加载时从 `config/` 覆盖写入。每个自维护扩展使用独立顶层目录;各扩展的维护约定见各自目录的 `AGENTS.md`(`extensions/` 下 wrapper 见 `extensions/AGENTS.md`),上游来源记录见各自 `UPSTREAM.md`(中文维护)。
|
||||||
|
|
||||||
## 当前扩展
|
## 扩展清单
|
||||||
|
|
||||||
- `pi-rtk-optimizer/`:默认只负责非搜索工具输出压缩;RTK 命令改写为可选能力。
|
| 目录 / 文件 | 职责 |
|
||||||
- 上游来源:<https://github.com/MasuRii/pi-rtk-optimizer>
|
| --- | --- |
|
||||||
- 初始导入快照:`d155d253cb2f1358e34e717d47a82ebccb08cb8e`(2026-07-03,`0.9.0`)。
|
| `pi-rtk-optimizer/` | 默认只压缩非搜索工具输出;RTK 命令改写为可选能力 |
|
||||||
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git`。
|
| `pi-ssh/` | 纯 `ssh2` 持久远程连接、远端工作区、SFTP 与有界自适应搜索 |
|
||||||
- `pi-ssh/`:从上游源码导入并由根组合包加载的纯 `ssh2` 远程操作扩展,通过受权限链复核的 `ssh_connect` 建立 Agent 控制的持久连接,以 `ssh_cd` 显式维护远端工作区,并以 SFTP 与有界自适应搜索提供独立的远端工具;运行时不调用 OpenSSH 或 `sshpass`。
|
| `pi-permission-system/` | 工具/路径/MCP/Skill/子代理转发权限基线,内置 AutoReview 与人工 `defer` 兜底 |
|
||||||
- 上游来源:<https://github.com/pansapiens/pi-ssh>
|
| `pi-subagents/` | Claude Code 风格隔离子 Agent:后台执行、steering、resume、嵌套与自定义 Agent |
|
||||||
- 初始导入快照:`e9a1059a0f37ab14b6a73ee608cb203edf803f31`(2026-06-23,`0.7.0`)。
|
| `pi-web-search/` | 统一 `web_search` / `web_fetch`,内部路由 Tavily/Exa/Keenable,对模型隐藏厂商 |
|
||||||
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git`、`node_modules` 或构建产物;纯 `ssh2` 设计参考 `99percentpeople/pi-extensions` 的明确 commit,来源记录保留在 `pi-ssh/UPSTREAM.md`。
|
| `pi-chrome/` | 显式授权接入已登录 Chrome Profile,授权后提供 21 个 `chrome_*` 工具 |
|
||||||
- `pi-permission-auto-review/`:作为 `pi-permission-system` 的 `auto-review` authorizer,使用 Codex Guardian 风格策略复核权限基线产生的 `ask`。
|
| `pi-tool-search/` | 校验过的工具目录 + 固定工具 + 有界动态组 LRU,按需加载原始 schema |
|
||||||
- 上游来源:<https://github.com/mzwing/pi-packages/tree/main/packages/pi-permission-auto-review>
|
| `pi-ask-user/` | `ask_user_question`:模型主动发起的单题/多题/选择/文本提问(TUI + RPC) |
|
||||||
- 初始导入快照:`8d196e4ef0884cac8326c366191dad3f585d470a`(`@mzwing/pi-permission-auto-review@0.2.0`)。
|
| `pi-worktree/` | `/wt`(`/worktree`)git worktree 斜杠命令,不注册模型工具 |
|
||||||
- 该目录从上游源码仓库导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
| `pi-hermes-memory/` | Hermes 风格持久记忆:policy-only 注入、六记忆工具、`skill_manage` |
|
||||||
- `pi-permission-system/`:提供工具、路径、MCP、Skill、子代理转发与 authorizer chain 的确定性权限基线。
|
| `pi-minimal-footer/` | 紧凑 footer:上下文仪表、订阅用量条、扩展状态 |
|
||||||
- 上游来源:<https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system>
|
| `pi-notify/` | Kitty OSC 99 完成与权限确认通知,精确聚焦来源窗口 |
|
||||||
- 初始导入快照:`ec4fdb11343dc94f7185b113e559a4cf9f8dc035`(`pi-permission-system-v26.2.1`)。
|
| `pi-compaction/` | 压力触发的观察遮蔽(Pi context_edit)、阈值整段摘要、effective token、native compaction |
|
||||||
- 该目录从明确 tag 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
| `pi-overflow/` | 新工具结果统一截断:超阈值原文落盘 + 有界视图 + `overflow_query` |
|
||||||
- `pi-subagents/`:提供 Claude Code 风格的隔离子 Agent、后台执行、steering、resume、嵌套代理与自定义 Agent。
|
| `extensions/fff-override.ts` | FFF `override` 模式接管 `find`/`grep` 与 `@` 补全,`multi_grep` 按上游保持关闭 |
|
||||||
- 上游来源:<https://github.com/tintinweb/pi-subagents>
|
| `extensions/bash-override.ts` | 用 Pi 的 `createBashToolDefinition` 重新注册 `bash`,仅替换模型可见描述(追加 `tools.jsonc` `descriptionAppend`);执行逻辑、输出结构、codemode 说明不变 |
|
||||||
- 初始导入快照:`3f9d35cd078d18a141eb5a6d8f4fc5010d756280`(`v0.18.0`)。
|
| `extensions/codegraph.ts` | 原生 `codegraph_explore`,自有 stdio JSON-RPC 直连 `codegraph serve`(`codegraph-client.ts`) |
|
||||||
- 该目录从明确 tag 导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留上游 `.pi` 状态;当前通过本地 `file:` 根依赖和 `extensions/subagents.ts` 默认加载,已实现权限 child lifecycle、根 authority `ask` 转发、根 transcript auto-review 身份桥接和 exact-path mandatory permission wrapper,3 个根编排工具由 Tool Search 的 `subagents` 组按需加载。
|
| `extensions/hashline.ts` | 部署 `config/pi-hashline-edit.json` 后加载 pi-hashline-edit 覆盖 `read`/`edit`(`hashline-config.ts`) |
|
||||||
- `extensions/fff-override.ts`:强制使用 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 FFF 的 `@` 补全;显式 CLI flag 仍遵循 FFF 官方优先级。
|
| `extensions/permission-system.ts` | 直接加载仓库内 `pi-permission-system/src/index.ts` |
|
||||||
- `extensions/tavily-override.ts`:加载 `@tavily/pi-extension`,把上游工具重命名为 `tavily_web_search` / `tavily_web_fetch`,并同步改写 label 与工具提示中的内部名称。
|
| `extensions/lsp.ts` | 物化包内 typescript-language-server、部署 LSP 配置并加载 pi-lsp(`lsp-config.ts`) |
|
||||||
- `@keenable/pi-search`:加载 `keenable_search` / `keenable_fetch` 及其 skill,默认 keyless,`KEENABLE_API_KEY` 仅用于提高速率限制。
|
| `extensions/prompt-guard.ts` | 第三方扩展代理 API:丢弃 `systemPrompt` 返回、应用 `tools.jsonc` description 覆盖、context 过滤器 |
|
||||||
- `search_config.sh` 与 `extensions/search-config.ts`:以交互式或参数方式把三家搜索 key 写入用户级 `search.env`(权限 `600`),并在搜索扩展初始化前加载;显式进程环境变量优先。
|
| `extensions/subagents.ts` | pi-subagents 加载 wrapper,注入权威 `permission-system.ts` 为 mandatory extension |
|
||||||
- `extensions/mcp.ts`:通过单个共享 `pi-mcp-adapter` 实例同时连接 Exa 托管 MCP 与机器现有的 `codegraph serve --mcp`,避免重复注册 Pi 的全局 MCP flag 与命令;Exa key 只通过 `x-api-key` 请求头发送,原始工具映射为统一的来源前缀形式,CodeGraph 只暴露 `codegraph_explore`。
|
| `extensions/context-view.ts` | 只读 `/context` observer,消费 pi-compaction projection metrics |
|
||||||
- `extensions/plugin-init.ts`:注册用户显式触发的 `/plugin_init`,在确认当前项目后只初始化缺失的 CodeGraph/Hippo 状态,并在全部成功后调用 Pi 官方热重载。
|
| `extensions/system-prompt.ts` | 唯一系统提示词出口(`system-prompt-config.ts` 为纯 helper),排在最后 |
|
||||||
- `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。
|
| `configure.sh` + `scripts/` | 交互式写入搜索 key(`auth.json`)与 SSH 主机导入 |
|
||||||
- `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。
|
| `install.sh` / `update.sh` / `uninstall.sh` | `pi install`/`update`/`remove` 包装 + 逐项询问的机器级依赖与终端配置 |
|
||||||
- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,并在权限扩展注册前将 `config/pi-permission-system.json` 同步为全局权威配置。
|
|
||||||
- `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`。
|
|
||||||
- `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)。
|
|
||||||
- 该目录从明确 commit 导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`、`node_modules` 或构建产物。
|
|
||||||
- `pi-tool-search/`:从上游 `v0.3.6` 源码导入并由本仓库直接维护;从完整工具定义生成并缓存经过校验的 Tool Cards/工作流分组,通过固定工具 + 有界动态组 LRU 加载当前 Pi 的原始 schema。
|
|
||||||
- 上游来源:<https://github.com/tuansondinh/pi-tool-search>
|
|
||||||
- 初始导入快照:`ddfb23646fd3957b791214de278e23aa393c9b13`(`v0.3.6`)。
|
|
||||||
- 该目录不是 submodule,不保留嵌套 `.git`、上游 `.pi` 状态、`node_modules` 或构建产物。
|
|
||||||
- `pi-ask-user/`:把 Pi 上游 `question.ts` 与 `questionnaire.ts` 示例整合为模型主动调用的 `ask_user_question` 工具,统一支持单题、多题、选择、自由文本、自定义回答与复核;不加载 `/qna`。
|
|
||||||
- 上游来源:<https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions>
|
|
||||||
- 初始参考快照:`dcd461925db2edf69a43c8135db1180d418afd54`(2026-08-24)。
|
|
||||||
- 该目录由本仓库直接维护,保留上游 MIT 许可证与来源说明;根组合包直接加载源码并通过本地 `file:` 依赖打包。
|
|
||||||
- `pi-lsp@0.1.7`:提供声明式 LSP 接入;组合包内置 `typescript-language-server@5.3.0` + `typescript@6.0.3`,并配置机器级 `kotlin-lsp --stdio` 与 `jdtls`。
|
|
||||||
- `hippo-memory-pi/`:从官方 `hippo-memory` 仓库的 `extensions/pi-extension/` 导入并由本仓库直接维护;提供 session start 项目记忆注入、工具错误过滤捕获、session shutdown sleep 和 5 个 `hippo_*` 工具。
|
|
||||||
- 上游来源:<https://github.com/kitfunso/hippo-memory>
|
|
||||||
- 初始导入快照:`e928179a3b35e8fe5837878aed071d6025ced45c`(`v1.33.0`)。
|
|
||||||
- 官方 npm tarball 不包含 Pi Extension;仓库保留官方 README、LICENSE 和来源说明,匹配 CLI 版本由 `config/hippo-memory-version` 声明。
|
|
||||||
- `@ogulcancelik/pi-codex-compaction@0.1.3`:为 `openai-codex` 提供原生远程 compaction,默认阈值为 90%。
|
|
||||||
- `pi-minimal-footer/`:基于上游紧凑 footer,保留上下文仪表、订阅用量条和扩展状态,并在 Codex Fast 模式开启时显示 `Fast on`。
|
|
||||||
- 上游来源:<https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-minimal-footer>
|
|
||||||
- 初始导入快照:`77bd1e175003cd08e6d05d9e7fed695f86ae87b7`(`@ogulcancelik/pi-minimal-footer@0.1.10`)。
|
|
||||||
- 该目录从明确 commit 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
|
||||||
- `pi-notify/`:基于 `@smoose/pi-notify` 的 Kitty 优先完成通知扩展,使用 OSC 99 精确聚焦来源 window/pane,并在 Pi 完全 settled 后通知。
|
|
||||||
- 上游来源:<https://github.com/smoosex/pi-notify>
|
|
||||||
- 初始导入快照:`3a3691ab690b4bc37a4412ab0dcbd35ef14adcbf`(`@smoose/pi-notify@0.1.1`)。
|
|
||||||
- 该目录从 npm 对应明确 commit 的源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
|
||||||
- `pi-extension-codex-fast-mode/`:为符合条件的 Codex Responses 请求设置 priority service tier,并把新会话默认值跨会话持久化。
|
|
||||||
- 上游来源:<https://github.com/Firstp1ck/pi-coding-agent-forge/tree/main/pi-extension-codex-fast-mode>
|
|
||||||
- 初始导入快照:`f1d0efd24a7f4ae99d19e10c5f4c3770a3bdd845`(`@firstpick/pi-extension-codex-fast-mode@0.1.1`)。
|
|
||||||
- 该目录从上游源码仓库导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
|
||||||
- `pi-condense@2.9.1`:总结已完成的工具调用批次,以短 stub 替换历史原始输出,并通过 `context_tree_query` 按需恢复;组合包在用户尚未配置 `contextPrune.enabled` 时默认开启。
|
|
||||||
- 根包还固定安装 `@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` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`@tintinweb/pi-subagents`、`pi-ask-user`、`pi-chrome`、`pi-minimal-footer`、`pi-ssh` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。
|
|
||||||
- `install.sh`:先安装根组合包,再交互检查 Hippo Memory CLI、Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Hippo 项目目录与 `hippo init` 始终由用户自行决定。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。
|
|
||||||
- `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` 工具默认快照时,才创建从生成起即为权限 `600` 的时间戳备份、以同目录临时文件原子迁移到 `5` / `28` 并写入 `bundleDefaultsVersion: 2`;任何自定义或无效配置必须保留,提交前检测到内容变化必须中止。无法从 `PI_PACKAGE_SOURCE` 定位更新后 helper 时必须保留配置并报告失败,不能回退到可能陈旧的脚本目录 helper。其他组件升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
|
|
||||||
- `uninstall.sh`:先移除根组合包;若检测到 Hippo Memory CLI,再明确询问是否卸载确认属于 npm 全局安装的 `hippo-memory`,默认保留且始终不删除 `.hippo/` 或用户记忆数据。其他共享终端环境和机器工具不卸载。
|
|
||||||
|
|
||||||
## 当前职责与默认行为
|
## 全局硬规则
|
||||||
|
|
||||||
- FFF 独占本地字面搜索;远端服务器由独立的 `ssh_find` / `ssh_grep` 结构化工具在已授权调用内自适应使用现有命令。RTK 不得处理本地或远端搜索工具的调用/结果,也不得通过默认命令改写接管 `rg`、`grep`、`find`、`fd`。
|
- 系统提示词单一所有权:`extensions/system-prompt.ts` 排在 `pi.extensions` 最后,是唯一允许在 `before_agent_start` 返回 `systemPrompt` 的扩展;其他扩展只能经 `systemPromptOptions.sections.<name>` 贡献分区,第三方扩展必须经 `extensions/` wrapper + `prompt-guard.ts` 加载,新增工具必须在 `config/prompt/tools.jsonc` 登记。宿主传入 `customPrompt`(子 Agent / SYSTEM.md)时不覆盖其开场白;子 Agent 有独立提示词画像 `config/prompt/subagent/`(preamble + 内置 Agent),判定规则与路由 scope/`subagentExclude` 细则见 `extensions/AGENTS.md`。
|
||||||
- RTK 默认只压缩非搜索输出,包括本地 `bash` 与远端 `ssh_bash` 的 ANSI 清理、测试聚合、构建过滤、Git 压缩、Lint 聚合和兜底截断;`ssh_bash` 只复用输出处理,远端命令不得进入 RTK rewrite。
|
- 加载顺序:`pi-overflow/capture.ts` 必须排在最前(先于 RTK 等结果改写扩展暂存原文),`permission-system` → `subagents` → `pi-ssh` → `pi-chrome` → `pi-tool-search` → `pi-overflow/index.ts` → `system-prompt`(最后);`pi-hermes-memory` 必须排在 `pi-compaction` 之前(`session_before_compact` 取消语义),`pi-compaction` 在 `pi-minimal-footer` 与 `context-view` 之前。
|
||||||
- `commandRewritingEnabled` 默认 `false`。默认安装不依赖系统 `rtk` CLI;只有用户主动开启命令改写时才需要 `rtk rewrite`。
|
- 依赖:外部依赖在根 `package.json` 用精确版本,自维护扩展一律 `file:` 本地依赖并更新锁文件;Pi 核心包只作 optional host peer,根 runtime deps 不含 Pi runtime,任何目录不得再装一套 Pi runtime;各子包用于测试的 `@earendil-works/*` devDependencies 一律写 `"latest"`,经 `scripts/update-pi-devdeps.sh` 统一刷新;原生构建依赖只按锁定版本进根 `allowScripts`(当前无放行项;pi-hermes-memory 的 `better-sqlite3@13` 用包内 N-API 预编译,不经根 `allowScripts`)。不要用仓库级 `.pi/settings.json` 代替组合包依赖。根 `files` 对子包按运行时子路径白名单列出(源码、package.json、README/LICENSE/CHANGELOG/UPSTREAM),不得整目录列入,以免打入嵌套 node_modules、dist 和测试;由 `tests/package-files.test.ts` 守护。
|
||||||
- `readCompaction.enabled`、`sourceCodeFilteringEnabled` 和 `smartTruncate.enabled` 当前均默认 `false`,源码读取保持原样。未经用户明确决定,不因节省上下文而改变这些默认值。
|
- 配置:`config/` 下全部文件是权威源、必须提交;不得读写 `settings.json` 自定义段、agent 目录独立配置文件或以环境变量作持久默认值;`config/` 只放机器无关值,路径类默认值在代码中运行时计算。`<agentDir>/my-pi/` 只放可丢弃运行时状态(日志、缓存),连同旧 `extensions/pi-permission-system/` 残留、构建产物与依赖目录都不提交;用户数据(记忆、会话、`auth.json` 等)不属于组合包配置,也不得放入该目录。
|
||||||
- 若以后开启 read 压缩,优先考虑 `readCompaction + smartTruncate`,源码过滤仍独立评估;必须保留精确 `offset/limit` 读取、短文件和行锚点的完整性。
|
- 组合包不加载任何 MCP adapter/SDK:Exa 与 CodeGraph 均用自有直连客户端。
|
||||||
- Context Mode 负责避免批量读取、命令研究和网页原始内容直接撑大上下文;FFF 仍负责精确字面搜索,RTK 仍处理未走 Context Mode 的普通输出。
|
- 导入/同步上游:只从官方 Git 仓库的明确 tag/commit 取源码快照,禁止 npm tarball/缓存/`node_modules`/`dist/` 等预编译产物作导入源;保留上游 LICENSE、作者、测试与来源说明,移除嵌套 `.git`、`.pi` 状态、`node_modules` 与构建产物;同步前先核对本地改动再按明确范围移植,不得整体覆盖。各目录的维护约定见 `AGENTS.md`,每个自维护目录必须有中文 `UPSTREAM.md` 记录来源,上游原文文件不翻译不改写。
|
||||||
- `chrome_snapshot`、`chrome_find` 以及带 `includeSnapshot=true` 的交互工具必须把完整结构化快照写入当前项目 `.pi/chrome-context/` 下的 Session 临时文件,工具结果只返回相对路径与有界摘要;模型必须立即用 `ctx_execute_file` 提取下一步所需信息,不得用 `read` / `cat` 把原始 JSON 重新塞入上下文。每个 Session/目标只保留最新快照,页面交互后旧快照视为失效,Session shutdown、撤销或授权过期时清理。
|
- 脚本:`install.sh`、`update.sh`、`uninstall.sh`、`configure.sh` 与 `scripts/lib.sh` 保持 POSIX `sh` 兼容并留在 `files` 中,行为变化同步 README;安装默认来源 `git:git@bitbucket.org:siakitem/my-pi.git`,可由 `PI_PACKAGE_SOURCE` 覆盖(update/uninstall 同规则);组合包安装失败立即停止,机器级依赖失败继续检查其余项并最终返回非零。
|
||||||
- Hashline 默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 和 `replaceText` 均关闭;它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
|
- 安装/升级纪律:机器级依赖与终端配置逐项询问、默认拒绝;`update.sh` 只升级已安装项、缺失项跳过,组件升级先比较本地与远端版本、一致不重装,成功升级后把 git 来源的受管 checkout `git reset --hard` 还原到已发布版本(本地路径来源不 reset、不 `git clean`);`update.sh` 先 `pi update --self` 同步 Pi 最新 release(版本比较由 Pi 自身完成,失败记录后继续),再升级组合包;组合包 `pi update` 失败 fail-fast——Pi 1.3.0 会据 `.pi-update-incomplete` 标记在下次 update 自愈,不做预清理或人工恢复。`pi update` 只在有新版本时整体覆盖已安装副本(含 `config/`),不逐字段迁移本机改动;需长期保留的自定义必须改仓库 `config/` 源文件。`.zshrc` 只改 `# >>> my-pi:<id> >>>` 受管块并先建时间戳备份,不覆盖用户 `~/.p10k.zsh`;卸载不得删除用户记忆数据(`.pi/agent/pi-hermes-memory/`、`projects-memory/`、`.hippo/`)或共享机器级工具。
|
||||||
- 工具路由规则不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索按场景只先选一个服务:广泛发现、新闻和候选来源用 Tavily,官方/技术/论文和高确定性来源用 Exa,中文、站点限定和日期筛选用 Keenable;只有必要时才跨服务复核。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。
|
- 未经明确要求不执行发布、提交、推送、运行安装/卸载脚本、安装到用户 Pi 目录等外部写操作;凭据不进入命令参数、日志、会话或仓库(`auth.json` 权限 `600`、不提交)。
|
||||||
- `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具仅支持交互式 TUI、顺序执行和最多 8 题;取消或 abort 必须返回 `cancelled: true`,不得伪造答案,且 `/qna` 不属于该扩展。其成功 `details.answers` 必须保留 `question` / `answer` 字段,以匹配 AutoReview 已有的可信结构化用户交互边界。
|
- 优先在目标扩展目录内完成改动;保留原项目 LICENSE 与版权;不让一个扩展依赖另一个扩展的未公开内部实现。
|
||||||
- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` 与 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。首次创建完整默认 `toolSearch` 段时必须通过唯一同目录临时文件以权限 `600` 原子写入并记录 `bundleDefaultsVersion: 2`,已有自定义段只补缺失运行时字段且不得伪造迁移来源标记。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。
|
|
||||||
- `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。授权撤销或过期时必须停用全部 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 兼容循环。
|
|
||||||
- `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
|
|
||||||
- CodeGraph MCP 扩展只配置 Pi 到外部 `codegraph` 命令的连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。安装、升级和普通启动流程不得执行 `codegraph init`;只有用户在目标项目显式调用 `/plugin_init` 并确认后,命令才可以 `.codegraph/codegraph.db` 与 `.hippo/hippo.db` 为权威标志初始化缺失状态。命令必须先预检两个 CLI,任一步失败不得热重载,也不得隐瞒此前已完成的部分初始化;不得改动 CodeGraph 更新或遥测设置。
|
|
||||||
- CodeGraph MCP 使用 `keep-alive` 并只直接暴露 `codegraph_explore`;命令缺失、项目未初始化或连接失败时不得阻止其他扩展加载。
|
|
||||||
- 所有 MCP 服务必须合并到 `extensions/mcp.ts` 创建的唯一 `pi-mcp-adapter` 实例;不得新增独立 adapter 扩展入口,否则会重复注册 `--mcp-config`、`/mcp` 等全局接口。根测试必须扫描全部本地扩展入口并强制这一不变量。
|
|
||||||
- Exa MCP 使用托管 Streamable HTTP 端点和 `eager` lifecycle;`EXA_API_KEY` 通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。所有在线搜索工具统一采用“来源名 + 原始语义工具名”的命名形式,例如 `tavily_web_search`、`exa_web_search`、`keenable_search`。Exa 高级搜索必须显式约束结果数量和文本长度;Tavily 输出默认控制 `max_results` 且非必要不请求 raw content;Keenable 优先利用中文、站点和日期筛选能力。
|
|
||||||
- 搜索 key 默认保存在 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/search.env`,文件必须为 `600` 且不得提交;`search_config.sh` 只输出配置状态,禁止回显 key。
|
|
||||||
- `pi-context-view` 只观察上下文占用,不参与压缩策略。
|
|
||||||
- `pi-condense` 负责压缩已经进入会话的历史工具结果,与 Context Mode 的输入隔离职责互补;`extensions/condense.ts` 只为 `settings.json` 的 `contextPrune` 补入缺失的组合包默认字段,必须保留全部用户显式值(包括 `enabled: false`),配置无效时不得覆盖原文件,配置完整时不得重复改写。组合包默认开启,使用 `agent-message` 触发、`turn` 批处理、73.5% 总预算与 4% 单轮增量触发,摘要模型和 thinking 均为 `default`,空闲/总超时为 90 秒/5 分钟,`quietOversizedSkips` 为 `false`;未显式部署的参数继续沿用上游默认,包括 skill 路径保护。
|
|
||||||
- Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier。`/fast-mode on|off` 同时更新当前分支记录与 Pi agent 目录中的 owner-only 全局默认值;新会话继承全局值,已有分支记录优先,配置缺失或无效时回退为关闭。
|
|
||||||
- Kitty 通知只在 TUI 模式的 `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。默认 `o=always`、`a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。
|
|
||||||
- `/notify on|off` 只覆盖当前 Session;持久默认来自 `PI_NOTIFY_*` 环境变量。`PI_NOTIFY_MESSAGE_SOURCE=none` 必须继续提供不泄露回复正文的隐私模式,非 TUI 模式不得写入 OSC 序列。
|
|
||||||
- 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜和敏感凭据路径;Git 非只读操作、包管理、外部目录、文件/系统/网络高风险操作与普通 MCP 调用先由 `pi-permission-system` 判为 `ask`。
|
|
||||||
- SSH 权限沿用同一条 `pi-permission-system` gate 与 `auto-review` authorizer chain:`ssh_connect`、`ssh_cd`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 默认 `ask`;连接前的证据解析已导入 host ID 为非秘密 target、port 与请求/default cwd,AutoReview 只可依据用户直接消息中的明确目标授权连接。`ssh_bash` 通过 `shellTools` 映射到完整 Bash 策略并设置 `decisionFloor: "ask"`,把普通 Bash `allow` 提升为复核请求,同时保留原有 `ask` 与硬 `deny`。全部远端路径不得送入基于本机 cwd 的 `path` / `external_directory` 归一化。
|
|
||||||
- `pi-permission-auto-review` 不是独立 `tool_call` gate,而是 `pi-permission-system` authorizer chain 中名为 `auto-review` 的链路;只复核权限基线产生的 `ask`,不会重复处理已 `allow` 或已 `deny` 的请求。
|
|
||||||
- reviewer 返回 `allow` 时自动批准、返回 `deny` 时直接拒绝,配置、模型、认证、超时或响应异常时必须 `defer` 到正常人工提示。`pi-permission-system` 的 delegation envelope 继续禁止 authorizer 自动批准全部 `path` 请求;`external_directory` 对内置只读路径工具(`read`、`find`、`grep`、`ls`)接受 reviewer 的 `allow`,写入、编辑、Bash、未知工具和其他外部访问仍转人工。
|
|
||||||
- 默认 reviewer 为 `openai-codex/codex-auto-review`、low reasoning、90 秒总重试预算和内置 Codex Guardian 风格策略;只把 active branch 中的直接用户消息与已识别结构化问答作为授权证据,assistant/tool/compaction 内容不能自行授权。
|
|
||||||
- `config/pi-permission-system.json` 必须显式配置 `authorizerChain: ["auto-review"]`,并把需要自动复核的 Git 非只读操作、包管理及其他类别声明为 `ask`;硬 `deny` 不得改成可由模型覆盖的 `ask`。
|
|
||||||
- `pi-lsp` 的 TypeScript/JavaScript 后端由根包固定依赖提供,`extensions/lsp.ts` 使用当前 Node 可执行文件直接启动包内 `typescript-language-server` CLI,不得依赖或调用 VS Code GUI;Kotlin/JDT LS 仍使用组合包部署的 `kotlin-lsp --stdio` 与 `jdtls` 配置,根包不安装这两个系统可执行文件,便捷脚本可在用户逐项明确确认后通过 Homebrew 安装 Kotlin LSP、Java 21 和 JDT LS。
|
|
||||||
- 终端环境配置只属于便捷脚本:Kitty 可选安装后可通过官方 kitten 启用 Solarized Dark;Oh My Zsh 使用不切换 shell 的 unattended 安装;Powerlevel10k 将仓库内置的 `config/p10k.zsh`(Rainbow/ASCII 单行紧凑主题)部署到 `~/.config/my-pi/p10k.zsh`(遵循 `XDG_CONFIG_HOME`),不得覆盖用户自己的 `~/.p10k.zsh`。`.zshrc` 中脚本拥有的内容必须使用 `# >>> my-pi:<id> >>>` / `# <<< my-pi:<id> <<<` 受管块,更新时只替换块内内容;标记不完整或重复时拒绝修改。实际修改前必须创建带时间戳的备份。`git`、`zsh-autosuggestions`、`zsh-syntax-highlighting` 在安装流程中逐项询问后才安装或启用。
|
|
||||||
- Codex 远程压缩只对 `openai-codex` 生效,默认在 turn boundary 达到 90% 时触发;官方 Hippo Extension 在已初始化项目的 session start 注入上下文、过滤捕获工具错误,并在 session shutdown 执行 `hippo sleep`。
|
|
||||||
- Hippo Extension 直接使用 PATH 中的官方全局 `hippo` CLI;`install.sh` 只在 CLI 缺失且用户明确确认后安装 `config/hippo-memory-version` 指定的 npm 版本,不得扫描项目、选择初始化目录或执行 `hippo init`。用户必须在自己选择的项目目录中手动初始化。
|
|
||||||
- `update.sh` 只升级已经安装且属于 npm 全局包的 Hippo CLI;必须从 `pi update` 后的组合包读取目标版本,先用 `npm view` 确认,再比较本地版本,版本相同不得重新安装,CLI 缺失或来源未知时保留现状。
|
|
||||||
- `uninstall.sh` 只在根包移除成功后询问是否卸载 npm 全局 Hippo CLI,默认拒绝;不得删除项目 `.hippo/`、全局记忆或旧 Hermes 数据。
|
|
||||||
- `install.sh` 默认从 `git:git@bitbucket.org:siakitem/my-pi.git` 安装,并允许用 `PI_PACKAGE_SOURCE` 覆盖来源;组合包安装失败时立即停止,机器级依赖安装失败时继续检查其余依赖并最终返回非零状态。
|
|
||||||
- CodeGraph 的可选安装使用官方远程安装脚本;macOS Kitty 使用 Homebrew cask,其他系统使用 Kitty 官方安装器;Kotlin LSP、Java 21 和 JDT LS 使用 Homebrew;Oh My Zsh 使用官方安装器,Powerlevel10k 与两个第三方 Zsh 插件从各自官方 Git 仓库克隆。升级时 Git checkout、Homebrew 包、Kitty 官方安装和 CodeGraph 都必须先比较本地与远端版本,版本一致时不得重新下载安装;无法可靠比较时保留现状并报告。缺少 Homebrew、curl 或 git 时保留已经完成的组合包安装并报告失败。
|
|
||||||
- `install.sh` 不检查默认不需要的 `rtk` CLI;`update.sh` 和 `uninstall.sh` 使用同一个 `PI_PACKAGE_SOURCE` 规则分别调用 `pi update` 和 `pi remove`。运行 `update.sh` 视为明确授权升级已安装组件及同步已有受管配置;其中 Tool Search 只能自动迁移完整匹配仓库旧默认快照的配置,不能逐字段猜测或覆盖自定义值。不得安装缺失组件或改写受管块之外的用户配置;卸载仅可在用户当次明确确认后移除 npm 全局 Hippo CLI,其他共享机器级工具保持不变。
|
|
||||||
|
|
||||||
## 修改边界
|
|
||||||
|
|
||||||
- 优先在目标扩展目录内完成改动;不要让一个扩展依赖另一个扩展的未公开内部实现。
|
|
||||||
- 保留原项目的 `LICENSE`、版权信息和必要的来源说明。
|
|
||||||
- 扩展运行目录中的 `config.json`、日志、构建产物、覆盖率目录和依赖目录属于本地状态,不应提交;`config/pi-permission-system.json`、`config/pi-hashline-edit.json` 与 `config/lsp.json` 分别是权限链、Hashline 默认行为和 LSP 后端的组合包权威源配置,必须提交并维护。
|
|
||||||
- 外部 Pi 扩展依赖必须在根 `package.json` 中使用精确版本,自维护 Fast mode 等扩展必须使用本地 `file:` 依赖,并更新根 `package-lock.json`;不要用仓库级 `.pi/settings.json` 代替组合包依赖。
|
|
||||||
- 需要原生构建的依赖只按锁定版本加入根 `allowScripts`;当前 `better-sqlite3@12.11.1` 仍由 Context Mode 使用并保持精确放行。官方 Hippo CLI 是独立机器级 npm 包,不得因此向根包批量批准其他 install scripts。
|
|
||||||
- Pi 核心包只作为宿主 peer dependencies,不得在组合包内再安装或打包一套 Pi runtime;保留根 `.npmrc` 的 peer 安装策略。
|
|
||||||
- `pi-tool-search/` 的根依赖必须保持 `file:./pi-tool-search`,组合包直接加载 `pi-tool-search/extensions/index.ts`;同步上游时先核对本地增量动态工具改造,禁止用旧 npm 入口或上游旧宿主实现直接覆盖。
|
|
||||||
- 新引入或同步自维护扩展时,必须先联网确认其官方源码仓库地址,再从官方 Git 仓库的明确 tag/commit 拉取源码快照;禁止以 npm tarball、npm 缓存、`node_modules`、`dist/` 或其他预编译发布产物作为导入源。npm registry 只能用于核对包名、版本和发布元数据,不能替代源码仓库。
|
|
||||||
- 源码导入必须保留上游源码、测试、必要文档、许可证和来源记录,移除嵌套 `.git`、上游本地 `.pi` 状态、`node_modules`、覆盖率与构建产物,并在本文件和扩展来源说明中记录官方仓库、tag、commit 与版本。
|
|
||||||
- `pi-notify/` 的根依赖必须保持 `file:./pi-notify`,组合包直接加载 `pi-notify/src/index.ts`;不得同时安装或加载 npm 预编译入口,否则会重复通知。
|
|
||||||
- 上游仅作为参考来源。同步上游改动时先核对本仓库已有修改,再按明确范围移植;不要直接覆盖本地实现。
|
|
||||||
- `install.sh`、`update.sh`、`uninstall.sh` 和 `search_config.sh` 必须保持 POSIX `sh` 兼容和可执行权限,并包含在根 `package.json` 的 `files` 中;修改脚本行为时同步更新 README 和本文件。
|
|
||||||
- 安装流程中的机器级依赖和用户终端配置必须保持逐项询问且默认拒绝,不得在没有用户明确确认的情况下自动安装或改写。用户主动运行 `update.sh` 只授权升级已安装项和同步已有受管配置;Tool Search 完整旧默认快照迁移属于该受管同步,但必须先备份并保持 fail-safe。缺失项仍必须跳过。`.zshrc` 修改必须局限于受管块并保留备份,卸载不得顺带删除或还原共享工具和用户终端配置。
|
|
||||||
- 未经明确要求,不执行发布、提交、推送、运行安装/卸载脚本或安装到用户 Pi 运行目录等外部写操作。
|
|
||||||
|
|
||||||
## 根目录 TypeScript 与 LSP 验证约定
|
|
||||||
|
|
||||||
- 根目录当前有意不安装 Pi runtime,也没有根级 `tsconfig.json`、`devDependencies` 或 `@types/node`;`@earendil-works/pi-*` 只作为 optional host peer dependencies。TypeScript LSP 因而会把根目录 `.ts` 文件放入 inferred project,不能把宿主 Pi 的运行时解析能力等同于仓库内静态类型环境。
|
|
||||||
- 每个任务首次对相关根目录 TypeScript 文件调用 `lsp_diagnostics` 时先建立并记录基线;后续只关注相对基线新增、消失或位置变化的诊断。文件和类型环境都未变化时不得重复调用 LSP 并重复报告同一组结果。
|
|
||||||
- 当前可预期的基线包括:宿主 peer 缺失导致的 `TS2307`(例如找不到 `@earendil-works/pi-coding-agent`),Node 类型缺失导致的 `node:*` / `node:test` / `node:assert` `TS2591`,以及由这些上游类型缺失级联产生的 `TS7006`、`TS18048`、`TS2722` 等。遇到级联错误时先确认其是否随缺失类型而产生,不要把它们直接归因于本次实现。
|
|
||||||
- 已知基线不等于忽略所有诊断:凡是无法由上述缺失依赖解释的新语法错误、结构类型错误、错误属性访问或本次修改所在代码的新诊断,必须修复并重新验证。交付时应把“已知类型环境基线”和“本次新增诊断”分开说明。
|
|
||||||
- 不得为了消除 LSP 基线而在根包安装第二套 Pi runtime、加入机器相关的宿主绝对路径或提交只在单机有效的 `paths` 映射。若确需新增根级 `tsconfig.json`、`@types/node` 或其他开发类型环境,必须作为明确的仓库设计变更评估,并同步更新依赖、锁文件和本文档。
|
|
||||||
- 不要从泛型或重载的 `ExtensionAPI` 方法直接用 `Parameters<...>` 猜取具体工具定义;解析失败时它可能退化为 `unknown`。包装第三方工具时优先使用上游导出的具体类型;没有稳定导出时定义覆盖实际访问字段的最小结构类型,并用运行时集成测试验证。
|
|
||||||
- `node --test` 可直接测试仓库内的纯 TypeScript helper,但测试入口不得静态导入 `node_modules` 中的 `.ts`;Node 原生 type stripping 会对这种路径报 `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`。第三方 TypeScript 扩展 wrapper 应拆出本地纯 helper 做单元测试,并通过隔离的临时 Pi agent/jiti 加载验证真实扩展入口。
|
|
||||||
- 对根目录扩展的最终验证优先组合使用:纯 helper 的 `node --test`、`git diff --check`、所需 npm 锁文件检查,以及隔离 Pi 环境的扩展加载。LSP 启动成功、单元测试通过和真实 Pi 加载通过是不同证据,不得互相替代或把类型环境基线误报为 LSP 后端启动失败。
|
|
||||||
|
|
||||||
## `pi-rtk-optimizer` 开发约定
|
|
||||||
|
|
||||||
- 组合包运行环境为 Node.js 22.19 或更高版本;RTK 子目录开发验证还需要 npm、Bun 和项目声明的开发依赖。
|
|
||||||
- 扩展入口是 `pi-rtk-optimizer/index.ts`,主要实现位于 `pi-rtk-optimizer/src/`。
|
|
||||||
- RTK 命令改写默认关闭;若用户主动开启,其命令支持策略由已安装的 `rtk rewrite` 决定,不在扩展内重复维护规则。
|
|
||||||
- FFF override 独占搜索工具与搜索输出;RTK 不得处理 `grep` 工具结果。
|
|
||||||
- read 压缩属于有损能力。任何默认值调整都必须同时说明哪些内容可能被省略、精确读取如何恢复原文,以及对 `edit` 文本匹配和排障证据的影响。
|
|
||||||
- 修改配置结构时同步检查默认值、归一化逻辑、设置界面、类型定义、README 示例和相关测试。
|
|
||||||
- 工具输出压缩可能损失证据。排障和审计相关改动应优先保证原始输出可恢复,并覆盖锚点完整性和截断边界。
|
|
||||||
|
|
||||||
## `pi-permission-system` 开发约定
|
|
||||||
|
|
||||||
- 扩展入口是 `pi-permission-system/src/index.ts`,公共跨扩展服务入口是 `pi-permission-system/src/service.ts`,测试保留在该目录的 `test/`。
|
|
||||||
- 同步上游必须从明确 tag/commit 移植并记录快照;保留上游 `LICENSE`、作者和来源,不导入 `dist/`、嵌套 `.git` 或上游包目录的 `.pi` 本地状态。
|
|
||||||
- 本地 delegation envelope 允许内置只读路径工具(`read`、`find`、`grep`、`ls`)对 `external_directory` 接受 authorizer 的 `allow`;`write`、`edit`、Bash、未知工具、未确定 surface 以及全部 `path` ask 必须继续 `defer` 到终端人工 authority。
|
|
||||||
- 修改 gate、authorizer chain、delegation envelope、子代理转发或公共 service 类型时,必须同步更新包内 README/架构文档和对应测试。
|
|
||||||
- 根包必须通过 `file:./pi-permission-system` 提供运行时依赖,包装入口必须直接加载仓库源码;不得同时加载 npm 预编译入口或第二个 permission-system 实例。
|
|
||||||
|
|
||||||
## `pi-permission-auto-review` 开发约定
|
|
||||||
|
|
||||||
- 扩展入口是 `pi-permission-auto-review/index.ts`,主要实现位于 `pi-permission-auto-review/src/`,测试保留在该目录的 `test/`。
|
|
||||||
- 同步上游时必须从源码仓库的明确 tag/commit 移植并记录快照;不得以 npm tarball、`dist/` 或 `node_modules` 中的编译产物覆盖本地源码。
|
|
||||||
- 保留上游 `LICENSE`、作者和来源信息;本地兼容改动优先保持在目标目录内,不依赖其他扩展的未公开内部实现。
|
|
||||||
- reviewer 只能处理 `pi-permission-system` 已判定为 `ask` 的请求;不得绕过硬 `deny`,不得另加并行 `tool_call` 审批层,也不得扩大 `pi-permission-system` 明确授予的 delegation envelope。
|
|
||||||
- 修改 reviewer 策略、可信证据边界、配置结构、模型解析或 authorizer 注册时,必须同步更新 README、schema 和相应测试。
|
|
||||||
|
|
||||||
## `hippo-memory-pi` 开发约定
|
|
||||||
|
|
||||||
- 扩展入口是 `hippo-memory-pi/index.ts`,来源记录是 `hippo-memory-pi/UPSTREAM.md`;只从官方 `kitfunso/hippo-memory` 的明确 tag/commit 移植 `extensions/pi-extension/`,保留官方 README、LICENSE 和版权。
|
|
||||||
- 官方 npm tarball 当前不包含 Pi Extension,因此根包直接打包并加载本地源码;不得同时加载第三方 `@the-forge-flow/hippo-memory-pi` 或第二个 Hippo Pi Extension。
|
|
||||||
- CLI 版本权威源是 `config/hippo-memory-version`;修改它时必须同步上游源码快照、README、AGENTS、安装/升级 mock 测试和扩展加载验证。
|
|
||||||
- 扩展依赖 PATH 中的全局 `hippo`。CLI 缺失时必须 fail-safe,不得阻止其他扩展加载;项目未执行 `hippo init` 时不得擅自创建 `.hippo/`。
|
|
||||||
|
|
||||||
## `pi-extension-codex-fast-mode` 开发约定
|
|
||||||
|
|
||||||
- 扩展入口是 `pi-extension-codex-fast-mode/index.ts`,测试保留在该目录的 `tests/`,来源记录是 `UPSTREAM.md`。
|
|
||||||
- 同步上游必须从明确 commit 移植并保留 `LICENSE`、作者和来源;不得导入嵌套 `.git`、`node_modules` 或构建产物。
|
|
||||||
- 全局默认配置必须位于 Pi agent 目录的 `extensions/pi-extension-codex-fast-mode/config.json`,使用原子写入和 owner-only 权限;无效或不可读配置必须安全回退为关闭且不得静默覆盖。
|
|
||||||
- 新会话使用全局默认值,已有分支的最新有效 custom entry 必须继续优先;修改持久化、分支恢复、provider/API 资格或请求 payload 时必须同步 README、技术文档和测试。
|
|
||||||
- 根包必须通过 `file:./pi-extension-codex-fast-mode` 提供依赖并直接加载仓库源码,不得同时安装或加载 npm 预编译入口。
|
|
||||||
- 修改该扩展后至少运行包内 `npm test`、`npm run check`、`npm run smoke` 与 `npm pack --dry-run --json`;加载或依赖入口变化还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
|
|
||||||
|
|
||||||
|
|
||||||
## `pi-subagents` 开发约定
|
|
||||||
|
|
||||||
- 扩展入口是 `pi-subagents/src/index.ts`,测试保留在该目录的 `test/`,来源记录是 `pi-subagents/UPSTREAM.md`。
|
|
||||||
- 同步上游必须从明确 tag/commit 移植并保留 README、CHANGELOG、SECURITY、作者和 LICENSE;不得导入嵌套 `.git`、上游 `.pi` 状态、`node_modules`、覆盖率或构建产物。
|
|
||||||
- 根包必须通过 `file:./pi-subagents` 提供运行时依赖,并只通过 `extensions/subagents.ts` wrapper 直接加载仓库源码;不得绕过 wrapper 直接列出上游入口,不得同时安装或加载 npm 预编译入口,也不得把 Pi host runtime 作为组合包开发依赖带入根安装。
|
|
||||||
- 子会话必须在 `bindExtensions()` 前注册到 `pi-permission-system` 的 process-global child registry,并在真实 dispose 时清理;resume、并发和嵌套 Agent 必须维持正确 parent/child session identity。
|
|
||||||
- `extensions/subagents.ts` 是组合包配置入口:它只能把组合包内权威 `extensions/permission-system.ts` 的 canonical absolute path 作为 mandatory extension 注入。Agent frontmatter 的 `extensions: false`、`isolated` 和 `exclude_extensions` 均不得移除该 lifecycle;路径缺失、被同名其他路径冒充或 reload 后未精确命中必须在创建 AgentSession 前 fail-closed。
|
|
||||||
- mandatory extension 只强制绑定 handlers,不自动开放它注册的工具;工具可见性仍由 Agent 的正常 extensions/tools 策略决定。mandatory 列表不得从 Agent Markdown 或项目配置读取。
|
|
||||||
- `ask` 只能由 auto-review 在 delegation envelope 内明确 `allow` 时自动批准;reviewer `defer`、异常或不在 envelope 内必须转发父会话人工 authority,不得因子会话无 UI 静默放宽。父模型生成的子 Agent prompt 不得伪装成人类直接授权证据。
|
|
||||||
- 子 Agent 转发的 access facts 必须先由根 authority 的 `SessionRules` 匹配;父会话 whole-session grant 可覆盖语义一致的后代操作,普通单次批准不得复制为可重复消费的子 Agent grant。auto-review 必须运行在根会话并只使用根 transcript 的可信用户证据。
|
|
||||||
- 修改 Agent lifecycle、权限桥接、tool/extension scope、resume、nested agents 或 session persistence 时,必须同步包内 README、CHANGELOG、测试,并运行 `npm run lint`、`npm run typecheck`、`npm run test` 和 `npm run build`。
|
|
||||||
## `pi-notify` 开发约定
|
|
||||||
|
|
||||||
- 扩展入口是 `pi-notify/src/index.ts`,Kitty/macOS 后端位于 `pi-notify/src/terminal.ts`,测试保留在 `pi-notify/tests/`,来源记录是 `pi-notify/UPSTREAM.md`。
|
|
||||||
- 同步上游必须从明确 tag/commit 移植,保留作者、MIT 许可声明与来源;不得导入嵌套 `.git`、`node_modules` 或构建产物。
|
|
||||||
- Kitty OSC 99 必须保留 source-window focus、Session 间唯一 ID、Base64 payload 和 tmux passthrough;修改 visibility/action、事件生命周期、fallback、模板、隐私或命令时必须同步 README 和测试。
|
|
||||||
- 默认只在 TUI `agent_settled` 后通知,非 TUI 输出完整性、自动 retry/compaction/follow-up 去重、总耗时、短任务阈值和多窗口点击聚焦都属于回归边界。
|
|
||||||
- 根包必须通过 `file:./pi-notify` 提供依赖并直接加载源码,不得同时加载 npm `@smoose/pi-notify` 实现。
|
|
||||||
|
|
||||||
## `pi-chrome` 开发约定
|
|
||||||
|
|
||||||
- 扩展入口是 `pi-chrome/extensions/chrome-profile-bridge/index.ts`,Companion MV3 扩展位于其 `browser-extension/`,Session 快照存储位于 `pi-chrome/src/session-snapshot-store.ts`,测试保留在 `pi-chrome/test/` 与 `pi-chrome/test-suite/`,来源记录是 `pi-chrome/UPSTREAM.md`。
|
|
||||||
- 同步上游必须从官方 `tianrendong/pi-chrome` 的明确 tag/commit 移植并保留 README、CHANGELOG、SECURITY、文档、测试、作者和 LICENSE;不得导入嵌套 `.git`、`node_modules` 或构建产物。
|
|
||||||
- 根包必须通过 `file:./pi-chrome` 提供依赖并直接加载仓库源码,不得同时安装或加载 npm 预编译入口。
|
|
||||||
- 完整 Chrome snapshot 不得进入普通工具文本或 `details`;只能原子写入当前项目 `.pi/chrome-context/<session>/<target>/snapshot.json`,使用哈希目录、POSIX `700` 目录 / `600` 文件,并只返回工作区相对路径和有界元数据。快照不得跨 Session 持久化,Session 启动/关闭、撤销和授权过期必须清理。
|
|
||||||
- `chrome_snapshot` 和带 `includeSnapshot=true` 的动作必须引导模型立即使用 `ctx_execute_file`,不得使用 `read` / `cat`;任何可能改变页面或目标的成功操作必须使旧快照失效,`chrome_find` 只可额外返回有界匹配摘要。
|
|
||||||
- 修改 bridge、Companion 扩展、授权 lifecycle、快照存储或加载入口后至少运行包内 `npm test`、根 Tool Routing/Tool Search/扩展联合加载测试、`npm pack --dry-run --json` 和实际 packed tarball 隔离安装验证;未经明确要求不得连接或控制真实 Chrome。
|
|
||||||
|
|
||||||
|
|
||||||
## `pi-ssh` 开发约定
|
|
||||||
|
|
||||||
- 扩展入口是 `pi-ssh/index.ts`,`ssh2` transport 与 SFTP 实现在 `pi-ssh/src/ssh2-transport.ts`,有界自适应远程搜索位于 `pi-ssh/src/remote-search.ts`,AES-GCM vault 位于 `pi-ssh/src/vault.ts`,选择性导入入口是根 `ssh_config.sh` 与 `pi-ssh/scripts/ssh-config.mjs`,权限桥接位于 `pi-ssh/permission-integration.ts`,测试保留在 `pi-ssh/test/`,来源记录是 `pi-ssh/UPSTREAM.md`。
|
|
||||||
- 同步上游必须从明确 tag/commit 移植并保留 `LICENSE`、作者和来源;参考其他实现时记录明确 commit,不得导入嵌套 `.git`、`node_modules` 或构建产物。
|
|
||||||
- 运行时必须保持纯 `ssh2`,不得回退到系统 OpenSSH、`sshpass`、`SSH_ASKPASS` 或 ControlMaster;认证可使用密码、私钥文件或 `ssh2` 直连的 SSH Agent socket(包括 1Password),Agent 模式只保存 socket 路径且不得导出私钥。OpenSSH 只允许由显式配置脚本通过 `ssh -G` 解析用户选中的 alias,已导入配置变化必须由用户显式更新。第一版遇到 `ProxyJump` / `ProxyCommand` 必须拒绝,不能静默忽略。
|
|
||||||
- 根包必须直接固定安装 `ssh2@1.17.0` 与配置 CLI 所需的 `jiti@2.7.0`,因为 packed bundle 直接加载其内置 `pi-ssh/` 源码;`ssh2` 与 `cpu-features` 的 install scripts 只构建可选加速绑定,当前不得加入根 `allowScripts`,纯 JavaScript fallback 必须可运行。
|
|
||||||
- SSH vault 使用同目录独立随机 key 和 AES-256-GCM 整体加密,目录/文件在 POSIX 上必须保持 `700` / `600`;该设计只防止误看或单独泄漏密文,不防同一用户读取 key。密码、私钥 passphrase、私钥内容、Agent 提供的签名材料、vault key 与解密明文不得进入命令参数、日志、Pi session、权限证据或明文临时文件。
|
|
||||||
- 已导入主机必须固定 SHA256 Host Key;不匹配时 fail closed。私钥认证只保存文件路径,Agent 认证只保存 socket 路径,均不得复制或导出私钥内容;文件工具使用 SFTP,写入优先临时文件与原子 rename,远端断线不得自动重放命令。
|
|
||||||
- `ssh_connect` 是唯一运行时连接入口,只接受 vault 中已导入的 host ID 与可选绝对/`~/` remote cwd,并必须默认 `ask` 后进入 AutoReview;它必须声明 `sequential` execution mode 且支持取消,Agent 必须作为独立步骤调用并等待成功后才能发出依赖连接的其他 `ssh_*` 调用,避免连接建立或替换与远端操作重叠。未知 host ID 只可有界列出已导入 ID,不得泄露 endpoint 或凭据。不得注册 `/ssh`、`--ssh`、session resume 自动重连或把用户 `!` 命令切换到远端。用户未在直接请求中明确服务器和具体远端任务时不得推断、替换或连接主机;新连接替换旧连接,会话结束自动断开。
|
|
||||||
- `ssh_find` / `ssh_grep` 必须在已授权工具调用内部按 `fd/fdfind → git ls-files → find` 与 `rg → git grep → find+grep` 顺序检测服务器现有能力,不安装或上传远端二进制;搜索目标必须作为绝对参数与执行 cwd 分离,搜索进程始终从当前已确认 `remoteCwd` 启动,单文件 `ssh_grep` 目标不得被当作目录执行 `cd`。输入必须 shell-safe,结果数量/单行/总捕获必须有界并显式报告 backend 与 truncated;30 秒超时必须报告解析后的 root 并提示先缩小范围。RTK 不得处理这两个搜索工具的结果。
|
|
||||||
- `ssh_cd`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 的远端路径不能按本机路径执行 `path` / `external_directory` gate;必须通过公共 `PermissionsService` 注册显式 extractor 关闭默认 `input.path` 推断,并保持六个工具的默认策略为 `ask`。权限预览必须依据远端 `remoteCwd` / `remoteHome` 解析相对路径与 `~/`,同时显示请求值和解析后的绝对远端路径。
|
|
||||||
- SSH transport 在会话中持久,但不得维护隐藏的交互式 Shell/PTY 状态;`ssh_bash` 每次调用必须从连接当前 `remoteCwd` 启动独立的非交互 Bash,必须忽略 Pi 本地 Bash factory 的 cwd 与 `PI_*` 会话环境。跨调用的工作区切换只能通过受复核且声明 `sequential` execution mode 的 `ssh_cd` 显式验证并更新连接状态;Agent 必须把它作为独立步骤调用并等待成功后,才可发出依赖新目录的远端工具调用。普通命令中的 `cd` 只可作为当次命令的临时目录变化,`cd`、`export`、alias 或函数不得被伪装为持久状态。
|
|
||||||
- 远端 HOME/cwd 探测与 `ssh_cd` 验证必须使用可取消、输出有界、随机标记 framing 的固定命令,只接受唯一绝对 POSIX 路径;banner、畸形/多行输出、超限、超时或取消时不得更新状态。独立 exec channel 最多并发 4 路,排队调用必须可立即取消;共享 SFTP 操作继续串行。
|
|
||||||
- `ssh_bash` 必须在权威权限配置的 `shellTools` 中映射 `commandArgument: "command"` 并设置 `decisionFloor: "ask"`,复用 Bash 命令拆分、硬拒绝、风险 `ask` 和 authorizer chain;全局 floor 必须在项目配置字段级合并时保留,不得新增第二个并行 `tool_call` 审批层。
|
|
||||||
- RTK 只把 `ssh_bash` 当作 Bash 输出别名执行 ANSI 清理、测试/构建/Git/Lint 聚合与兜底截断;不得对远端命令启用 RTK rewrite,也不得处理 `ssh_read`、`ssh_find` 或 `ssh_grep` 返回。
|
|
||||||
- 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-auto-review` → `pi-permission-system` → `pi-ssh` → `pi-chrome` → `pi-tool-search`,确保 authorizer 先注册、权限服务先发布、SSH 桥接随后安装,Chrome 工具可在授权后进入目录,且 Tool Search 最后收集完整工具目录。
|
|
||||||
|
|
||||||
|
|
||||||
## `pi-ask-user` 开发约定
|
|
||||||
|
|
||||||
- 扩展入口是 `pi-ask-user/index.ts`,schema、归一化、TUI 与工具注册位于 `pi-ask-user/src/`,纯 helper 测试保留在 `pi-ask-user/test/`。
|
|
||||||
- 工具名必须保持 `ask_user_question`,并由 Tool Search 的独立 `user-interaction` 组按需加载;根加载顺序必须位于 `pi-tool-search` 之前。
|
|
||||||
- 参数必须有界并在运行时复核 question id、题型、选项和值的有效性;TUI 必须顺序执行、监听 abort、按 width 失效缓存,并把外层 `Focusable` 状态传播给内嵌 Editor。
|
|
||||||
- 成功结果的 `details` 必须保持 `version`、`cancelled: false` 与非空 `answers`,每个 answer 必须提供原始 `question` 和用户 `answer`;取消结果不得成为权限授权证据。修改结果结构时同步验证 `pi-permission-auto-review` transcript 解析。
|
|
||||||
- 同步上游示例时从明确 commit 移植并保留 `LICENSE`、`UPSTREAM.md` 与本地整合差异;不得恢复 `/qna`、重复的单题工具或多个扩展入口。
|
|
||||||
- 修改后至少运行包内 `npm test` / `npm run check`、Tool Search 的 typecheck/test/build、AutoReview transcript 测试、根扩展联合加载和 packed tarball 隔离安装验证。
|
|
||||||
|
|
||||||
|
|
||||||
## `pi-tool-search` 开发约定
|
|
||||||
|
|
||||||
- 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `pi-tool-search/extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `pi-tool-search/extensions/catalog.ts`,配置部署位于 `pi-tool-search/extensions/config.ts`,测试保留在 `pi-tool-search/test/`。
|
|
||||||
- 同步上游必须记录明确 tag/commit,保留 README、CHANGELOG、文档、作者和许可证说明;不得导入上游 `.pi`、嵌套 `.git`、`node_modules` 或构建产物。
|
|
||||||
- 修改 Tool Card 提示词、目录校验、分组、缓存、active-tool/LRU 生命周期、配置或工具结果时,必须同步更新包内 README、当前动态加载文档、CHANGELOG 和测试。
|
|
||||||
- 新增、删除或重命名组合包暴露工具时必须同步维护 `BUNDLE_GROUP_DEFINITIONS`;日常只读工具、provider 工具和高风险管理工具不得因名称相似而混入同一大组,根验证应确认标准组合包没有落入 unknown-tools 路径。
|
|
||||||
- 生成目录只能让模型提供摘要、使用边界、关键词与分组;工具名、参数和原始 schema 必须来自宿主。校验或模型调用失败必须 fail-safe 到确定性目录,缓存不得写入仓库或放宽权限。
|
|
||||||
- 根包必须通过 `file:./pi-tool-search` 提供本地依赖并直接加载仓库源码,不得同时安装或加载 npm `pi-tool-search` 实现。
|
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
组合包依赖或加载入口变化时,至少验证根 `npm install` 幂等、锁文件有效、MCP adapter 唯一所有者不变量测试,以及全部扩展可在隔离的临时 Pi agent 目录加载。权限配置变化时使用仓库内固定快照的 `pi-permission-system` schema 校验,并验证包装入口部署后的文件与仓库源配置一致。
|
- 组合包依赖或加载入口变化:验证根 `npm install` 幂等、锁文件有效、全部扩展可在隔离临时 Pi agent 目录加载(见 `tests/extension-load.test.ts`);权限配置变化用包内固定 schema 校验,并确认运行时 `config/` 路径确实位于组合包内。
|
||||||
|
- 脚本变化:`sh -n` 每个脚本 + ShellCheck,并确认可执行权限、`files` 包含与 README 一致;搜索/SSH 配置测试只用虚拟凭据、隔离 HOME 与 mock transport,不连真实服务器。
|
||||||
|
- 真实 `pi install`/`update`/`remove`、Homebrew、远程安装器、Git 克隆、真实 SSH、真实终端配置的端到端验证属外部写操作,未经要求不执行;用隔离 HOME + mock 验证分支行为。
|
||||||
|
- 根目录测试:`node --test 'tests/*.test.ts'`、`git diff --check`;各扩展目录的验证命令见其 `AGENTS.md`。未能运行的验证交付时明确说明,不以静态检查代替运行结果。
|
||||||
|
|
||||||
安装、升级、卸载、搜索或 SSH 配置脚本变化时,至少运行 `sh -n install.sh`、`sh -n update.sh`、`sh -n uninstall.sh`、`sh -n search_config.sh`、`sh -n ssh_config.sh` 和 ShellCheck,并核对脚本仍具有可执行权限、仍包含在根 `package.json` 的 `files` 中、README 描述与实际流程一致。Git 升级中断恢复必须用隔离 agent dir 和 mock `pi` / `git` 覆盖合法标记连续清理后成功、无标记保持失败、checkout 身份不匹配拒绝清理及单次 Pi 重试边界。Tool Search settings 迁移必须用隔离 agent dir 覆盖完整旧快照迁移、当前值幂等、自定义配置保留、无效 JSON fail-safe、备份/权限、`pi update` 失败不迁移和 `PI_CODING_AGENT_DIR`。搜索配置测试只能使用虚拟 key 和隔离 HOME;SSH 配置测试必须使用隔离 HOME、虚拟凭据与 mock transport,不得连接真实服务器或把真实密码写入测试输出。涉及真实 `pi install`、`pi update`、`pi remove`、Homebrew、远程安装器、Git 克隆、真实 SSH 连接或真实用户终端配置的端到端验证属于外部写操作,未经明确要求不得执行;可以使用隔离的临时 HOME 和 mock 命令验证分支行为。
|
<!-- pi-hermes-memory:start -->
|
||||||
|
## 项目记忆(pi-hermes-memory)
|
||||||
|
|
||||||
Hippo 脚本变化必须额外用隔离 HOME/PATH 和 mock `pi`、`npm`、`hippo` 验证:安装缺失 CLI 但不调用 `hippo init`、相同版本不升级、不同版本精确升级、未知来源 CLI 不替换、卸载默认保留、明确确认后只卸载 npm 全局包且保留数据。不得在测试中执行真实全局 npm 写入或修改真实 `.hippo/`。
|
持久记忆由 `pi-hermes-memory` 提供,默认 `policy-only` 模式:system prompt 只注入 `<memory-policy>` 指令,不注入记忆正文。
|
||||||
|
|
||||||
在 `pi-permission-system/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`。在 `pi-permission-auto-review/` 内至少运行同样三项;涉及权限集成时还要使用仓库内 `pi-permission-system` 验证 authorizer 注册、`allow` / `deny` / `defer` 与 delegation envelope。
|
- 任务开始时若可能依赖历史上下文,先调用 `memory_search`;需要历史对话原话时用 `session_search`。
|
||||||
|
- 发现值得持久化的事实、偏好、约定、纠正或失败教训时,主动用 `memory_add` 保存,不必等用户开口;更新或删除用 `memory_replace` / `memory_remove`。
|
||||||
在 `pi-notify/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm pack --dry-run --json`;涉及加载入口或 lifecycle 时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
|
- 完成需要反复试错、或用户教了具体流程的任务后,用 `skill_manage` 把可复用流程沉淀为 skill(`scope: "global"` 跨项目,`scope: "project"` 本项目)。
|
||||||
|
- 记忆与 skill 数据位于 `~/.pi/agent/pi-hermes-memory/` 与 `~/.pi/agent/projects-memory/`,属于用户数据,不得提交、自动迁移或删除。
|
||||||
在 `pi-tool-search/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`;涉及加载入口或 active-tool 行为时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
|
<!-- pi-hermes-memory:end -->
|
||||||
|
|
||||||
|
|
||||||
在 `pi-rtk-optimizer/` 内按改动范围选择最小充分验证:
|
|
||||||
|
|
||||||
- `npm run build`:TypeScript 转译检查。
|
|
||||||
- `npm run typecheck`:完整类型检查。
|
|
||||||
- `npm run test`:运行 Bun 测试。
|
|
||||||
- `npm run check`:类型、测试和打包检查的完整验证。
|
|
||||||
|
|
||||||
若环境缺少依赖或未执行某项验证,交付时明确说明,不以静态检查代替运行结果。
|
|
||||||
|
|
||||||
<!-- hippo:start -->
|
|
||||||
## Project Memory (Hippo)
|
|
||||||
|
|
||||||
At the start of every task, run:
|
|
||||||
```bash
|
|
||||||
hippo context --auto --budget 1500
|
|
||||||
```
|
|
||||||
Read the output before writing any code.
|
|
||||||
|
|
||||||
On errors or unexpected behaviour:
|
|
||||||
```bash
|
|
||||||
hippo remember "<description of what went wrong>" --error
|
|
||||||
```
|
|
||||||
|
|
||||||
On task completion:
|
|
||||||
```bash
|
|
||||||
hippo outcome --good
|
|
||||||
```
|
|
||||||
|
|
||||||
When Hippo's Codex wrapper is installed, session-end capture runs automatically.
|
|
||||||
If the wrapper is not installed, capture a brief summary manually:
|
|
||||||
```bash
|
|
||||||
hippo capture --stdin <<< '<decisions, errors, lessons — 2-5 bullets>'
|
|
||||||
```
|
|
||||||
<!-- hippo:end -->
|
|
||||||
|
|||||||
@@ -2,28 +2,25 @@
|
|||||||
|
|
||||||
个人维护的 Pi 扩展组合包。安装一次即可加载并配置:
|
个人维护的 Pi 扩展组合包。安装一次即可加载并配置:
|
||||||
|
|
||||||
- `@ff-labs/pi-fff@0.10.5`:使用官方 `override` 模式接管 `find`、`grep` 和 `multi_grep`。
|
- `@ff-labs/pi-fff@0.10.5`:使用官方 `override` 模式接管 `find` 和 `grep`;上游默认关闭的 `multi_grep` 保持关闭。
|
||||||
- 本仓库维护的 `pi-rtk-optimizer`:只处理 Bash/read 等输出压缩,默认不改写命令,也不处理搜索工具结果。
|
- 本仓库维护的 `pi-rtk-optimizer`:只处理 Bash/read 等输出压缩,默认不改写命令,也不处理搜索工具结果。
|
||||||
- `context-mode@1.0.169`:通过 Pi adapter 提供 `ctx_*` 工具、上下文隔离和会话连续性。
|
- 本仓库自维护的 `pi-web-search`:对模型只暴露 `web_search` / `web_fetch` 两个工具;`web_search` 由当前主模型在 Tavily / Exa / Keenable 间路由,`web_fetch` 固定走 Exa,三家均以纯 `fetch` 直连(Exa 为 `mcp.exa.ai` 的 JSON-RPC,不经 MCP adapter)。
|
||||||
- `@tavily/pi-extension@0.1.2`:提供 Tavily 广泛搜索与页面提取;通过本地 override 暴露为 `tavily_web_search` / `tavily_web_fetch`。
|
- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性启动常驻 `codegraph serve --mcp` 子进程,由组合包自有 stdio JSON-RPC 客户端直连(无 MCP adapter/SDK);serve 的文件监听自动同步索引,无结果缓存;未初始化项目经确认后才 `codegraph init`,拒绝记入会话不再重复询问。
|
||||||
- `@keenable/pi-search@0.1.2`:提供紧凑的 `keenable_search` / `keenable_fetch`,并加载其搜索 skill;API key 可选。
|
- `extensions/context-view.ts`:通过 `/context` 只读显示 `pi-compaction` 的 effective context,不建立第二套上下文口径。
|
||||||
- `pi-mcp-adapter@2.26.0`:通过单个共享 adapter 实例接入机器现有的 CodeGraph 和 Exa 托管 MCP,避免重复注册 Pi 的全局 MCP flag 与命令。
|
|
||||||
- `pi-context-view@0.4.2`:查看上下文占用。
|
|
||||||
- 本仓库维护的 `pi-extension-codex-fast-mode`:基于 `@firstpick/pi-extension-codex-fast-mode@0.1.1`,为 Codex provider 提供跨会话持久化的 `/fast-mode`。
|
|
||||||
- `pi-lsp@0.1.7`:为 TypeScript/JavaScript、Kotlin 和 Java 提供 LSP 诊断、跳转与符号工具;TypeScript 后端由组合包内置。
|
- `pi-lsp@0.1.7`:为 TypeScript/JavaScript、Kotlin 和 Java 提供 LSP 诊断、跳转与符号工具;TypeScript 后端由组合包内置。
|
||||||
- `hippo-memory-pi/`:从官方 `hippo-memory v1.33.0` 源码导入的 Pi Extension,提供项目记忆注入、错误捕获和 sleep consolidation。
|
- `pi-hermes-memory/`:从官方 `pi-hermes-memory v0.9.10` 源码导入的持久记忆扩展,提供全局/项目/用户/失败四类记忆、SQLite FTS5 会话与记忆检索、`SKILL.md` 过程记忆、correction 检测与自动 consolidation。
|
||||||
- `@ogulcancelik/pi-codex-compaction@0.1.3`:为 `openai-codex` 提供原生远程压缩。
|
- 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer。
|
||||||
- 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer,并在 Codex Fast 模式开启时显示 `Fast on`。
|
- 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 或权限系统即将请求人工确认时通知,并支持点击精确聚焦来源 Kitty 窗口。
|
||||||
- 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 后通知,并支持点击精确聚焦来源 Kitty 窗口。
|
- 本仓库维护的 `pi-compaction`:压力触发时以 Pi `context_edit` 批量遮蔽旧工具结果、空闲时按阈值触发整段摘要、effective token 计量、`context_tree_query`、portable checkpoint 与 Codex native compaction。
|
||||||
- `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。
|
- 本仓库自维护的 `pi-overflow`:统一的新工具结果展示截断——超阈值原文落盘、向模型展示「头 + 信号行 + 尾」的有界视图,并提供 `overflow_query` 按 id 搜索、行区间或 JSON 路径取回原文。
|
||||||
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`,用一个顺序 TUI 同时支持单题、多题、选择题、文本题、自定义回答与提交前复核。
|
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`;TUI 中保留单题、多题、选择、文本、自定义回答与提交前复核,Paseo 等兼容 RPC 客户端通过标准 Pi extension UI 逐题交互。
|
||||||
|
- 本仓库自维护的 `pi-worktree`:纯斜杠命令(`/wt` / `/worktree`)git worktree 管理,`/wt new` 一键拆分、`/wt merge` 一键合并回来源分支(默认 ff-only,可选 `--squash`),通过 `SessionManager.forkFrom` + `switchSession` 切换会话 cwd;登记状态在 `<agentDir>/pi-worktree/state.json`,不注册模型工具、不 push。
|
||||||
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。
|
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。
|
||||||
- 本仓库维护的 `pi-chrome`:从上游 `0.15.46` 源码导入,通过显式会话授权接入用户当前已登录的 Chrome Profile;大体积页面快照写入 Session 临时文件并交给 Context Mode 隔离分析,21 个 `chrome_*` 工具仍由 Tool Search 按工作流加载。
|
- 本仓库维护的 `pi-chrome`:从上游 `0.15.46` 源码导入,通过显式会话授权接入用户当前已登录的 Chrome Profile;页面快照 JSON 直接作为工具结果返回,超长时由 pi-overflow 截断并用 `overflow_query` 查询,21 个 `chrome_*` 工具仍由 Tool Search 按工作流加载。
|
||||||
- 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。
|
- 本仓库维护的 `pi-permission-system`:只读取一份全局普通策略与一份独立 Hard Guards,按 pattern 具体度裁决 allow/ask/deny,并整合 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底;自动评审模型完全由配置文件指定,未配置时提示并转人工审批;工具显隐仍只由 Tool Search 管理。
|
||||||
- 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入,负责工具、路径、MCP、硬拒绝和兜底权限基线。
|
- 本仓库维护的 `pi-subagents`:从 `@tintinweb/pi-subagents@0.18.0` 源码导入并默认加载,提供隔离子 Agent、后台执行、steering、resume、嵌套代理与自定义 Agent;通过 mandatory permission wrapper 接入权限 child lifecycle、根 authority `ask` 转发和根 transcript auto-review 身份桥接,其 3 个编排工具由 Tool Search 的 `subagents` 组按需加载。子 Agent 使用独立提示词画像 `config/prompt/subagent/`(自有 preamble 与内置 Agent 定义),不嵌入父会话提示词。
|
||||||
- 本仓库维护的 `pi-subagents`:从 `@tintinweb/pi-subagents@0.18.0` 源码导入并默认加载,提供隔离子 Agent、后台执行、steering、resume、嵌套代理与自定义 Agent;通过 mandatory permission wrapper 接入权限 child lifecycle、根 authority `ask` 转发和根 transcript auto-review 身份桥接,其 3 个编排工具由 Tool Search 的 `subagents` 组按需加载。
|
|
||||||
- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
|
- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
|
||||||
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
|
- `extensions/system-prompt.ts`:组合包唯一的系统提示词出口(排在最后加载),按 `config/prompt/` 组合开场白、工具简介与跨工具路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
|
||||||
|
|
||||||
## 安装、升级与卸载
|
## 安装、升级与卸载
|
||||||
|
|
||||||
@@ -54,7 +51,7 @@ pi install git:git@bitbucket.org:siakitem/my-pi.git
|
|||||||
`.zshrc` 的 `plugins` 数组中启用。后两个插件缺少源码时会先克隆到 Oh My Zsh 的
|
`.zshrc` 的 `plugins` 数组中启用。后两个插件缺少源码时会先克隆到 Oh My Zsh 的
|
||||||
custom plugins 目录;语法高亮按最后一个插件处理。
|
custom plugins 目录;语法高亮按最后一个插件处理。
|
||||||
- 最后继续检查 `codegraph`、`kotlin-lsp`、Java 21+ 和 `jdtls`。Kotlin、Java 和 JDT LS
|
- 最后继续检查 `codegraph`、`kotlin-lsp`、Java 21+ 和 `jdtls`。Kotlin、Java 和 JDT LS
|
||||||
的自动安装使用 Homebrew。
|
的自动安装使用 Homebrew,CodeGraph 使用官方安装脚本。
|
||||||
|
|
||||||
脚本首次实际改动 `.zshrc` 前会在同目录创建带时间戳的备份。Oh My Zsh loader、
|
脚本首次实际改动 `.zshrc` 前会在同目录创建带时间戳的备份。Oh My Zsh loader、
|
||||||
Powerlevel10k 主题/配置和各 Zsh 插件使用 `# >>> my-pi:<id> >>>` 到
|
Powerlevel10k 主题/配置和各 Zsh 插件使用 `# >>> my-pi:<id> >>>` 到
|
||||||
@@ -71,13 +68,14 @@ Powerlevel10k 主题/配置和各 Zsh 插件使用 `# >>> my-pi:<id> >>>` 到
|
|||||||
./update.sh
|
./update.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
升级脚本先执行:
|
升级脚本先把 Pi 本体同步到最新发布版本,再升级组合包:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
pi update --self
|
||||||
pi update git:git@bitbucket.org:siakitem/my-pi.git
|
pi update git:git@bitbucket.org:siakitem/my-pi.git
|
||||||
```
|
```
|
||||||
|
|
||||||
默认 Git 组合包若在 Pi 内部清理大型 `node_modules` 时因目录仍非空而留下 `.pi-update-incomplete` 标记,脚本不会在升级前破坏性预清理。首次 `pi update` 失败后,它只对默认来源推导出的缓存 checkout 进行恢复,并同时校验中断标记、canonical Git 根目录和精确 `origin`;校验通过后最多连续执行 4 次依赖清理,再只重试一次 `pi update`,由 Pi 自己重新安装依赖并清除标记。自定义来源、缺少标记或身份不匹配仍立即失败,不会清理猜测出的路径。
|
`pi update --self` 由 Pi 自己比较本地与最新 release,已是最新时直接跳过;它失败只记录为升级失败,不阻断组合包升级。脚本不会在升级前破坏性预清理安装缓存:组合包的 `pi update` 失败时立即停止后续升级——Pi 1.3.0 会在下一次 `pi update` 时依据自己的 `.pi-update-incomplete` 标记自动重新清理并恢复未完成的升级,重新运行 `update.sh` 即可,脚本不做人工恢复。
|
||||||
|
|
||||||
随后只处理已经安装的项目,未安装项直接跳过且不会询问安装:Kitty 及已启用的
|
随后只处理已经安装的项目,未安装项直接跳过且不会询问安装:Kitty 及已启用的
|
||||||
Solarized Dark、Oh My Zsh、Powerlevel10k、两个第三方 Zsh 插件、CodeGraph,以及由
|
Solarized Dark、Oh My Zsh、Powerlevel10k、两个第三方 Zsh 插件、CodeGraph,以及由
|
||||||
@@ -89,8 +87,7 @@ Git checkout 在 `fetch` 后比较本地与上游提交,Homebrew 包在刷新
|
|||||||
日志中的“Zsh 插件未安装,升级流程跳过”不是升级失败:升级脚本不会新增用户此前未选择的
|
日志中的“Zsh 插件未安装,升级流程跳过”不是升级失败:升级脚本不会新增用户此前未选择的
|
||||||
插件。需要补装时重新运行 `./install.sh`,并在对应的逐项询问中明确选择 `Y`。
|
插件。需要补装时重新运行 `./install.sh`,并在对应的逐项询问中明确选择 `Y`。
|
||||||
|
|
||||||
升级时会优先从 `pi update` 后的已安装组合包目录读取最新配置。对于 Pi agent 目录中的 `settings.json`,只有 `toolSearch` 完整匹配旧组合包生成的 `3` 组 / `20` 工具默认快照时,才会备份并原子迁移到当前 `5` / `28` 默认值;任何字段不同、结构无效或存在自定义工具时都不会被覆盖。Powerlevel10k 受管副本只在内容不同时备份并原子同步;`.zshrc` 也只更新已有的 my-pi 受管块,不触碰块外配置。因此安装流程负责补齐缺失项,升级流程只升级已安装项并同步可明确识别的受管配置。
|
`pi update` 在有新版本时用更新后的组合包整体覆盖已安装副本;`update.sh` 在每次成功升级后还会把默认 git 来源的已安装 checkout `git reset --hard` 还原到已发布版本(本地路径来源不 reset,不做 `git clean` 以保留 `node_modules`),因此对已安装副本(含 `config/`)的本地修改不会保留。扩展运行时直接读取已安装组合包内的 `config/` 文件,脚本不再做任何配置部署。Powerlevel10k 受管副本只在内容不同时备份并原子同步;`.zshrc` 也只更新已有的 my-pi 受管块,不触碰块外配置。因此安装流程负责补齐缺失项,升级流程只升级已安装项。
|
||||||
首次获得这项迁移能力时需要执行已经包含该逻辑的新版 `update.sh`;旧脚本正在运行时不会因为 `pi update` 替换包内容而自动获得后半段迁移步骤。如果刚通过旧脚本升级到含迁移逻辑的版本,可再运行一次更新后的脚本。
|
|
||||||
|
|
||||||
卸载组合包:
|
卸载组合包:
|
||||||
|
|
||||||
@@ -108,46 +105,62 @@ PI_PACKAGE_SOURCE="$PWD" ./install.sh
|
|||||||
PI_PACKAGE_SOURCE="git:git@bitbucket.org:siakitem/my-pi.git" ./update.sh
|
PI_PACKAGE_SOURCE="git:git@bitbucket.org:siakitem/my-pi.git" ./update.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
根 `package.json` 对外部依赖使用精确版本,对本仓库维护的包(包括 Codex Fast mode)使用 `file:` 路径;`package-lock.json` 固定完整依赖树。不要再用项目级 `.pi/settings.json` 重复安装这些扩展,否则同一扩展可能被加载两次。
|
根 `package.json` 对外部依赖使用精确版本,对本仓库维护的包使用 `file:` 路径;`package-lock.json` 固定完整依赖树。不要再用项目级 `.pi/settings.json` 重复安装这些扩展,否则同一扩展可能被加载两次。
|
||||||
|
|
||||||
Hippo 官方 Pi Extension 存在于上游 Git 仓库的 `extensions/pi-extension/`,但没有包含在
|
持久记忆由本仓库维护的 `pi-hermes-memory` 提供。它从官方 `chandra447/pi-hermes-memory`
|
||||||
`hippo-memory@1.33.0` npm tarball 中。本仓库从官方 `v1.33.0` /
|
的 `v0.9.10` / `df624056a2e8c68bc89ba0a81e1c120856ec9665` 导入源码到 `pi-hermes-memory/` 并直接加载,
|
||||||
`e928179a3b35e8fe5837878aed071d6025ced45c` 导入源码到 `hippo-memory-pi/` 并直接加载;
|
通过本地 `file:` 依赖安装,不需要额外的机器级 CLI。
|
||||||
匹配的 CLI 版本由 `config/hippo-memory-version` 声明。`install.sh` 会在 CLI 缺失时询问是否执行
|
|
||||||
`npm install -g hippo-memory@<固定版本>`,但安装脚本不会选择或初始化项目。进入目标项目后可在 Pi 中执行
|
默认 `policy-only` 模式只把 `<memory-policy>` 指令注入 system prompt,不注入记忆正文,因此不会改变
|
||||||
`/plugin_init`:命令会显示当前目录并要求确认,以 `.codegraph/codegraph.db` 和 `.hippo/hippo.db` 为
|
prompt 前缀、对前缀缓存友好。模型需要历史上下文时主动调用 `memory_search` / `session_search`,
|
||||||
初始化标志,只为缺失状态运行 `codegraph init` 与 `hippo init`;全部成功后通过 Pi 官方热重载重新连接 CodeGraph MCP 并重新触发 Hippo
|
写入用 `memory_add` / `memory_replace` / `memory_remove`,过程记忆用 `skill_manage`。这六个工具由
|
||||||
session start。也可以继续在终端手动执行两个 CLI 的 `init` 命令。
|
Tool Search 常驻,保证 policy 指示与模型可见工具一致。
|
||||||
|
|
||||||
|
记忆与 skill 数据位于 `~/.pi/agent/pi-hermes-memory/`(全局)与 `~/.pi/agent/projects-memory/<项目>/`
|
||||||
|
(项目级)。这些是用户数据,安装、升级与卸载脚本不会创建、迁移或删除;扩展配置来自已安装组合包的
|
||||||
|
`config/pi-hermes-memory.json`。
|
||||||
|
|
||||||
|
CodeGraph 采用惰性初始化:`codegraph_explore` 在未建立索引的项目上被调用时会先向用户确认,
|
||||||
|
确认后才运行 `codegraph init` 并自动重试本次探索,拒绝一次后本会话不再询问;不需要手动执行任何初始化命令。
|
||||||
|
|
||||||
默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启 RTK command rewriting 时,才需要另外安装 `rtk` 可执行文件。
|
默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启 RTK command rewriting 时,才需要另外安装 `rtk` 可执行文件。
|
||||||
|
|
||||||
CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自行确保 `codegraph` 在
|
CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自行确保 `codegraph` 在
|
||||||
启动 Pi 的 `PATH` 中。组合包只有在用户显式调用 `/plugin_init` 并确认当前项目后才会创建
|
启动 Pi 的 `PATH` 中(`install.sh` 会交互式提供官方安装)。扩展只有在用户于确认对话框中
|
||||||
`.codegraph/`;安装、升级和普通启动流程不会创建或维护索引,也不会修改 CodeGraph 的更新或遥测设置。
|
明确同意后才会创建 `.codegraph/`;安装、升级和普通启动流程不会创建或维护索引,也不会修改
|
||||||
|
CodeGraph 的更新或遥测设置。
|
||||||
|
|
||||||
## 组合行为
|
## 组合行为
|
||||||
|
|
||||||
### 模型主动提问
|
### 模型主动提问
|
||||||
|
|
||||||
`pi-ask-user/` 把 Pi 上游的单题与多题示例合并为唯一的 `ask_user_question` 工具;不提供 `/qna` 或其他用户命令。模型只有在继续任务确实缺少用户决定、偏好、确认或澄清时才应加载 `user-interaction` 组并调用它,不得重复询问直接消息中已有的信息。工具仅在交互式 TUI 中运行,最多一次提交 8 个问题,支持选择、自由文本、显式跳过、返回修改和最终复核;取消结果不会作为 AutoReview 的授权证据。
|
`pi-ask-user/` 把 Pi 上游的单题与多题示例合并为唯一的 `ask_user_question` 工具;不提供 `/qna` 或其他用户命令。模型只有在继续任务确实缺少用户决定、偏好、确认或澄清时才应加载 `user-interaction` 组并调用它,不得重复询问直接消息中已有的信息。工具最多一次提交 8 个问题:交互式 TUI 支持选择、自由文本、显式跳过、返回修改和最终复核;Paseo 等具备 Pi extension UI bridge 的 RPC 客户端使用逐题 `select` / `input` 对话框。取消结果不会作为 AutoReview 的授权证据,JSON/print 等无交互 UI 模式保持拒绝执行。
|
||||||
|
|
||||||
### 工具与搜索路由
|
### 工具与搜索路由
|
||||||
|
|
||||||
`extensions/tool-routing.ts` 不替换 Pi 默认系统提示词,而是在每轮开始前根据当前激活工具追加简短规则。代码结构、调用关系和待修改 symbol 优先使用 CodeGraph,定义、引用、类型和修改后诊断优先使用 LSP;仓库字面搜索先用 FFF `find` 缩小文件或目录范围,再在已收敛的路径中用 `grep`/`multi_grep` 获取行号。宽泛搜索命中大量结果、发生截断或达到上限时,应继续缩小路径、glob 或 pattern,而不是提高 limit 或输出全部结果。
|
`config/prompt/routing.jsonc` 定义跨工具路由规则,由 `extensions/system-prompt.ts` 在每轮开始前按当前激活工具渲染为 `<tool_routing>` 分区。代码结构、调用关系和待修改 symbol 优先使用 CodeGraph,定义、引用、类型和修改后诊断优先使用 LSP;本地字面搜索按已知信息直接选工具:确切标识符或文本用 `grep` 字面模式并限定 path/exclude,多个命名变体合并为一次 `grep` 正则 alternation,文件名、路径片段或功能概念用 FFF `find` 模糊全路径匹配,然后只 `read` 命中区域。对搜索结果做提取、去重、计数或聚合时,在 codemode 脚本中调用 `tools.grep` / `tools.find` 并用 JavaScript 后处理;bash 中不得运行 `grep`/`rg`/`find`/`fd`/`git grep`(策略 deny 的 reason 指明替代工具),程序需要文件清单时传 `$(git ls-files '<glob>')`。宽泛搜索命中大量结果、发生截断或达到上限时,应继续缩小路径、glob 或 pattern,而不是提高 limit 或输出全部结果。
|
||||||
|
|
||||||
在线搜索默认按任务只选一个服务:关键词不确定、新闻/近期话题或需要较多候选来源时优先 Tavily;官方/API 文档、版本信息、论文和高确定性事实确认优先 Exa;中文政策、政府公告、中文网页、站点限定和日期筛选优先 Keenable。只有来源质量或不确定性确实需要时才跨服务复核,避免默认把三套结果全部塞进上下文。
|
在线搜索默认按任务只选一个服务:关键词不确定、新闻/近期话题或需要较多候选来源时优先 Tavily;官方/API 文档、版本信息、论文和高确定性事实确认优先 Exa;中文政策、政府公告、中文网页、站点限定和日期筛选优先 Keenable。只有来源质量或不确定性确实需要时才跨服务复核,避免默认把三套结果全部塞进上下文。
|
||||||
|
|
||||||
日志、测试/构建输出、工作区内大文件的探索/分析/总结以及不可预测的大输出优先交给 Context Mode。只有确实需要精确源码或准备修改时,才用带 `offset/limit` 的 Hashline `read` 读取最小区域并取得 `LINE#HASH` 锚点,随后用锚定 `edit` 修改;成功编辑返回的新锚点可继续复用,只有锚点过期或下一目标区域尚未展示时才重新读取。已有工具结果足够时停止检索,避免对同一问题依次重复调用 CodeGraph、FFF 和 `read`。
|
日志、测试/构建输出以及不可预测的大输出由 pi-overflow 截断展示,原文可用 `overflow_query` 按 id 检索。只有确实需要精确源码或准备修改时,才用带 `offset/limit` 的 Hashline `read` 读取最小区域并取得 `LINE#HASH` 锚点,随后用锚定 `edit` 修改;成功编辑返回的新锚点可继续复用,只有锚点过期或下一目标区域尚未展示时才重新读取。已有工具结果足够时停止检索,避免对同一问题依次重复调用 CodeGraph、FFF 和 `read`。
|
||||||
|
|
||||||
使用 `/dump-system-prompt` 可将当前扩展所见的有效提示词写入当前项目的 `.pi-debug/effective-system-prompt.md`。`.pi-debug/` 属于本地诊断输出,默认不提交。
|
使用 `/dump-system-prompt` 可将当前扩展所见的有效提示词写入当前项目的 `.pi-debug/effective-system-prompt.md`。`.pi-debug/` 属于本地诊断输出,默认不提交。
|
||||||
|
|
||||||
|
### 系统提示词单一入口
|
||||||
|
|
||||||
|
`extensions/system-prompt.ts` 是组合包唯一的系统提示词出口,排在 `pi.extensions` 最后加载。提示词内容全部由 `config/prompt/` 管理:`preamble.md` 替换 Pi 默认开场白(其余 `<tools>`、`<rules>`、`<skills>`、`<project_context>`、`<cwd>` 等分区及顺序原样保留),`tools.jsonc` 覆盖各工具的简介与规则,`routing.jsonc` 渲染 `<tool_routing>` 分区,`sections.jsonc` 决定各扩展贡献分区的顺序。
|
||||||
|
|
||||||
|
其他扩展不直接改写提示词:pi-chrome、pi-ssh、pi-hermes-memory 等通过 `systemPromptOptions.sections.<name>` 贡献结构化分区,pi-rtk-optimizer 通过 `promptGuidelines` 补充规则;第三方扩展(FFF、Hashline、LSP)经 `extensions/` 下的包装文件与 `prompt-guard.ts` 代理加载,拦截直接的提示词改写并按 `tools.jsonc` 应用工具说明覆盖。
|
||||||
|
|
||||||
|
`config/prompt/` 每轮重新读取(按 mtime 缓存),修改已安装组合包内的文件后下一轮即生效;配置解析失败时警告一次并保持 Pi 默认提示词,不阻止会话。该目录由组合包直接维护并随组合包发布;对已安装副本的本地修改不做保护,`pi update` 会直接覆盖;要长期自定义请直接编辑仓库中的 `config/prompt/`。
|
||||||
|
|
||||||
### 接管当前 Chrome
|
### 接管当前 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 权限,应只从可信组合包安装。
|
组合包固定加载 `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 权限,应只从可信组合包安装。
|
||||||
|
|
||||||
授权后注册的 21 个 `chrome_*` 工具不会常驻提示词;Tool Search 将它们分为 `chrome-navigation`、`chrome-interaction` 和 `chrome-debugging`。普通网页资料发现仍使用 Tavily/Exa/Keenable,只有需要读取登录态页面或实际操作当前 Chrome 时才加载 Chrome 组。
|
授权后注册的 21 个 `chrome_*` 工具不会常驻提示词;Tool Search 将它们分为 `chrome-navigation`、`chrome-interaction` 和 `chrome-debugging`。普通网页资料发现仍使用 Tavily/Exa/Keenable,只有需要读取登录态页面或实际操作当前 Chrome 时才加载 Chrome 组。
|
||||||
|
|
||||||
`chrome_snapshot`、`chrome_find` 和带 `includeSnapshot=true` 的交互不会再把完整页面结构直接塞进模型上下文:完整 JSON 只写入当前项目 `.pi/chrome-context/` 下按 Session/目标隔离的临时文件,工具结果仅返回相对路径和小型摘要。模型应立即用 `ctx_execute_file` 从该文件提取下一步所需的 uid、文本或状态,不能用 `read` / `cat` 重新读入全文。每个目标只保留最新快照;页面操作后旧快照失效,Session 结束、`/chrome revoke` 或授权过期时清理。
|
`chrome_snapshot` 和带 `includeSnapshot=true` 的交互把页面快照 JSON 直接作为工具结果返回,`details` 只保留元素数、匹配数等小型元数据;`chrome_find` 只返回紧凑的排序匹配。快照超长时结果会带 `[pi-overflow id=…]` 标记,模型用 `overflow_query` 的 `jsonPath`(如 `elements[0].uid`)或 `search` 提取所需的 uid、文本或状态,而不是重复抓取快照。页面交互后旧快照即失效,需要重新抓取;早期版本写入 Session 临时文件的快照机制已移除。
|
||||||
|
|
||||||
### 按需加载工具 schema
|
### 按需加载工具 schema
|
||||||
|
|
||||||
@@ -155,156 +168,153 @@ CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自
|
|||||||
|
|
||||||
本地源码直接使用 `@earendil-works/pi-coding-agent`、当前 `typebox`、`ModelRegistry.complete()` 与 `setActiveTools()`,不安装、别名映射或加载旧 `@mariozechner` runtime,也不使用 provider payload 改写、代理执行或隐藏 `sendMessage` 循环。
|
本地源码直接使用 `@earendil-works/pi-coding-agent`、当前 `typebox`、`ModelRegistry.complete()` 与 `setActiveTools()`,不安装、别名映射或加载旧 `@mariozechner` runtime,也不使用 provider payload 改写、代理执行或隐藏 `sendMessage` 循环。
|
||||||
|
|
||||||
新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics`、`ctx_execute`、`ctx_execute_file`、`ctx_batch_execute` 和 `tool_search`;Windows 上额外把内置 `powershell` 与 `bash` 作为同类本地 Shell 核心工具固定启用,非 Windows 平台则从目录中过滤它。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、Chrome 导航/交互/调试、用户交互、CodeGraph/LSP、Tavily、Exa、Keenable、Context Mode 执行/知识库/观测/管理、Memory 查询/维护、Skill 和 MCP 管理。不可用、已固定或尚未授权而未注册的可选工具会自动从动态组中滤除。
|
新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics`、六个记忆工具、`context_tree_query`、`overflow_query`、Pi 内置 `codemode`(exposure `model-only`)和 `tool_search`;Windows 上额外把内置 `powershell` 与 `bash` 作为同类本地 Shell 核心工具固定启用,非 Windows 平台则从目录中过滤它。exposure 为 `hidden` 或 `codemode`(script-only)的工具一律不进入目录。`codemode` 在权限策略中为 `allow`:脚本本身无副作用,脚本发起的每个嵌套工具调用仍携带 `parentToolCallId` 经过同一权限门裁决;RTK 跳过所有嵌套调用,保证 bash 的 `structuredContent` 原样传给脚本。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、Chrome 导航/交互/调试、用户交互、CodeGraph/LSP、Tavily、Exa、Keenable、Memory 查询/维护与 Skill 管理。不可用、已固定或尚未授权而未注册的可选工具会自动从动态组中滤除。
|
||||||
|
|
||||||
标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具,并且当前调用不是精确加载已有分组时,才可使用当前已认证模型补充目录;精确已有分组始终立即按当前确定性目录激活,不等待无关未知工具的模型增强。模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会恢复预置目录,`/tool-search-status` 显示目录来源、容量、LRU 顺序和最近淘汰原因。
|
标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具,并且当前调用不是精确加载已有分组时,才可使用当前已认证模型补充目录;精确已有分组始终立即按当前确定性目录激活,不等待无关未知工具的模型增强。模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `my-pi/tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会恢复预置目录,`/tool-search-status` 显示目录来源、容量、LRU 顺序和最近淘汰原因。
|
||||||
|
|
||||||
动态组默认最多同时加载 5 个、每个模型生成组最多 8 个工具、全部动态组最多 28 个工具。`tool_search.groups` 可在一次调用中原子加载最多 5 个精确组;完整请求本身超限时保持原状态。加载或调用组内工具会更新 LRU,超限时先卸载最久未使用且不在本次请求内的组。自然语言 query 对精确工具名和已提交 workflow alias 加权,弱匹配或近似同分只返回候选。首次纯增加载继续使用 Pi 原生增量传播;发生卸载和加载的替换由 Pi 自动走安全 fallback。
|
动态组默认最多同时加载 5 个、每个模型生成组最多 8 个工具、全部动态组最多 28 个工具。`tool_search.groups` 可在一次调用中原子加载最多 5 个精确组;完整请求本身超限时保持原状态。加载或调用组内工具会更新 LRU,超限时先卸载最久未使用且不在本次请求内的组。自然语言 query 对精确工具名和已提交 workflow alias 加权,弱匹配或近似同分只返回候选。首次纯增加载继续使用 Pi 原生增量传播;发生卸载和加载的替换由 Pi 自动走安全 fallback。
|
||||||
|
|
||||||
Pi 的用户级配置文件通常是 `~/.pi/agent/settings.json`(准确位置为 Pi agent dir 下的 `settings.json`)。组合包首次加载仅在其中缺少对应字段时写入:
|
Tool Search 的配置文件是已安装组合包内的 `config/tool-search.json`(即本仓库的 `config/tool-search.json`,随 `pi install` / `pi update` 就位,扩展运行时直接读取):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"toolSearch": {
|
|
||||||
"alwaysEnabled": [
|
"alwaysEnabled": [
|
||||||
"codegraph_explore",
|
"codegraph_explore",
|
||||||
"lsp_diagnostics",
|
"lsp_diagnostics",
|
||||||
"ctx_execute",
|
"memory_search",
|
||||||
"ctx_execute_file",
|
"session_search",
|
||||||
"ctx_batch_execute"
|
"memory_add",
|
||||||
|
"memory_replace",
|
||||||
|
"memory_remove",
|
||||||
|
"skill_manage",
|
||||||
|
"context_tree_query",
|
||||||
|
"overflow_query",
|
||||||
|
"codemode"
|
||||||
],
|
],
|
||||||
"showToolSearchFooterStatus": false,
|
"showToolSearchFooterStatus": false,
|
||||||
"maxActiveGroups": 5,
|
"maxActiveGroups": 5,
|
||||||
"maxToolsPerGroup": 8,
|
"maxToolsPerGroup": 8,
|
||||||
"maxDynamicTools": 28,
|
"maxDynamicTools": 28,
|
||||||
"groupOverrides": {},
|
"groupOverrides": {}
|
||||||
"bundleDefaultsVersion": 2
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
扩展正常加载只补充缺失字段,不覆盖用户已有配置;首次创建完整 Tool Search 默认段时也写入 `bundleDefaultsVersion: 2`。用户显式运行新版 `update.sh` 时,脚本会在 `pi update` 成功后使用更新后组合包中的 Node helper 检查完整旧默认快照:只有精确匹配旧版 `3`/`20` 默认值才会创建权限 `600` 的时间戳备份并原子替换为当前配置;任何自定义或无效配置都会保留,提交前检测到文件内容变化也会中止。`alwaysEnabled` 中的精确工具名属于固定工具,不占动态组额度;`groupOverrides` 可把指定工具强制归入命名组。默认关闭状态行,避免与 minimal footer 叠加。
|
扩展只读取该文件,从不写入:文件缺失、不可读或结构无效时回退内置的 `BUNDLE_TOOL_SEARCH_DEFAULTS`;对已安装副本的手改不做保护,`pi update` 会覆盖,长期自定义应改仓库 `config/tool-search.json`。`alwaysEnabled` 中的精确工具名属于固定工具,不占动态组额度;`groupOverrides` 可把指定工具强制归入命名组。默认关闭状态行,避免与 minimal footer 叠加。
|
||||||
|
|
||||||
### 精确读写归 Hashline
|
### 精确读写归 Hashline
|
||||||
|
|
||||||
组合包固定加载 `pi-hashline-edit@0.8.3`,以带 `LINE#HASH` 行锚点的实现覆盖 Pi 内置 `read` 和 `edit`。`extensions/hashline.ts` 在加载扩展前把 `config/pi-hashline-edit.json` 同步到 `~/.pi/agent/hashline.json`:`hashLength` 保持最小的 `2`,`grep` 强制关闭以避免与 FFF 冲突,`replaceText` 关闭以要求修改使用可验证锚点。
|
组合包固定加载 `pi-hashline-edit@0.8.3`,以带 `LINE#HASH` 行锚点的实现覆盖 Pi 内置 `read` 和 `edit`。`extensions/hashline.ts` 在加载扩展前把 `config/pi-hashline-edit.json` 同步到 `~/.pi/agent/hashline.json`:`hashLength` 保持最小的 `2`,`grep` 强制关闭以避免与 FFF 冲突,`replaceText` 关闭以要求修改使用可验证锚点。
|
||||||
|
|
||||||
Hashline 不负责压缩大文件;每行锚点本身还会增加少量 token。上下文节省来自先用 CodeGraph/LSP 定位、再用 Context Mode 隔离分析,最后只对真正准备修改的区域执行小范围 Hashline `read`。因此不应为了探索、计数、比较或总结而直接读取完整大文件。
|
Hashline 不负责压缩大文件;每行锚点本身还会增加少量 token。上下文节省来自先用 CodeGraph/LSP 定位,最后只对真正准备修改的区域执行小范围 Hashline `read`。因此不应为了探索、计数、比较或总结而直接读取完整大文件。
|
||||||
|
|
||||||
### 搜索归 FFF
|
### 搜索归 FFF
|
||||||
|
|
||||||
`extensions/fff-override.ts` 强制设置 FFF 官方的 `PI_FFF_MODE=override`(显式传入
|
`extensions/fff-override.ts` 强制设置 FFF 官方的 `PI_FFF_MODE=override`(显式传入
|
||||||
`--fff-mode` 仍按 FFF 官方优先级覆盖它)。FFF 替换
|
`--fff-mode` 仍按 FFF 官方优先级覆盖它)。FFF 替换
|
||||||
Pi 的 `find` 和 `grep`,并提供 `multi_grep`;RTK 对所有 `grep` 工具结果直接跳过,
|
Pi 的 `find` 和 `grep`;`multi_grep` 上游标注为有害、默认关闭,wrapper 在加载前
|
||||||
|
删除 `PI_FFF_MULTIGREP` 环境变量,使其永远无法注册。RTK 对所有 `grep` 工具结果直接跳过,
|
||||||
也默认关闭 Bash 命令改写,因此不会把 `rg`、`grep`、`find` 或 `fd` 改写到 RTK。
|
也默认关闭 Bash 命令改写,因此不会把 `rg`、`grep`、`find` 或 `fd` 改写到 RTK。
|
||||||
|
|
||||||
|
`extensions/bash-override.ts` 用 Pi 的 `createBashToolDefinition` 重新注册内置 `bash`,
|
||||||
|
把「不要用 bash 搜索」的警告经 `tools.jsonc` 的 `descriptionAppend` 写进工具 schema,
|
||||||
|
让模型在调用时就看到替代工具;执行逻辑、输出结构和 codemode 的 `tools.bash` 说明完全沿用上游定义。
|
||||||
|
|
||||||
### 输出压缩归 RTK
|
### 输出压缩归 RTK
|
||||||
|
|
||||||
RTK 保留 Bash、read、build、test、lint 和 Git 输出压缩。默认 `read` 的有损压缩
|
RTK 保留 Bash、read、build、test、lint 和 Git 输出压缩。默认 `read` 的有损压缩
|
||||||
仍关闭;可用 `/rtk` 查看或调整。旧机器已有的 RTK 配置若没有
|
仍关闭;可用 `/rtk` 查看或调整。旧机器已有的 RTK 配置若没有
|
||||||
`commandRewritingEnabled` 字段,也会按 `false` 归一化,不需要迁移配置。
|
`commandRewritingEnabled` 字段,也会按 `false` 归一化,不需要迁移配置。
|
||||||
|
|
||||||
### 大输出归 Context Mode
|
### 大输出归 pi-overflow
|
||||||
|
|
||||||
Context Mode 直接加载 npm 包内置的 Pi adapter 和 skills,不需要额外写入
|
新工具结果的展示截断统一由 `pi-overflow` 负责:超阈值的原文落盘、模型只看到
|
||||||
`~/.pi/agent/mcp.json`,也不需要全局安装 `context-mode` 命令。它提供 `ctx_*` 工具,
|
「头 + 信号行 + 尾」的有界视图,需要原文时用 `overflow_query` 检索(详见下文
|
||||||
将批量读取、命令研究和网页内容先在隔离进程中处理,再把收敛后的结果送入上下文。
|
「Pi Overflow」小节)。FFF 仍负责精确字面搜索;RTK 继续对 Bash、build、test、lint
|
||||||
|
和 Git 输出做语义压缩,其兜底硬截断已关闭。
|
||||||
FFF 仍负责精确字面搜索;RTK 继续压缩未走 Context Mode 的 Bash、build、test、lint
|
|
||||||
和 Git 输出。Context Mode 不替换这两个扩展的现有配置。
|
|
||||||
|
|
||||||
### SSH 服务器配置
|
### SSH 服务器配置
|
||||||
|
|
||||||
`pi-ssh` 运行时使用纯 `ssh2`,不依赖系统 `ssh` 或 `sshpass`。首次使用前显式导入需要的 OpenSSH alias:
|
`pi-ssh` 运行时使用纯 `ssh2`,不依赖系统 `ssh` 或 `sshpass`。首次使用前显式导入需要的 OpenSSH alias:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./ssh_config.sh import packaging-server
|
./configure.sh ssh import packaging-server
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本只在导入阶段调用 `ssh -G` 解析用户选中的 alias,随后交互选择 SSH Agent(包括 1Password)、私钥文件或密码认证、显示并确认服务器 Host Key、测试连接,再把完整配置写入 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/hosts.enc`。Agent 模式只保存 socket 路径,私钥仍留在 Agent 中;邻接的随机 `vault.key` 用于 AES-256-GCM 解密,目录与两个文件在 POSIX 上分别是 `700` / `600`。该加密只避免误看或单独泄漏密文,能读取两文件的同一用户仍可解密。
|
脚本只在导入阶段调用 `ssh -G` 解析用户选中的 alias,随后交互选择 SSH Agent(包括 1Password)、私钥文件或密码认证、显示并确认服务器 Host Key、测试连接,再把完整配置写入 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/hosts.enc`。Agent 模式只保存 socket 路径,私钥仍留在 Agent 中;邻接的随机 `vault.key` 用于 AES-256-GCM 解密,目录与两个文件在 POSIX 上分别是 `700` / `600`。该加密只避免误看或单独泄漏密文,能读取两文件的同一用户仍可解密。
|
||||||
|
|
||||||
可用 `./ssh_config.sh list|update|remove|rotate-key` 管理已导入主机。第一版拒绝 `ProxyJump` / `ProxyCommand`,且不会自动读取远端 `AGENTS.md` 或 `CLAUDE.md`。连接不再通过 `/ssh`、`--ssh` 或 session resume 建立:用户在具体远端任务中明确指定已导入 host ID 后,Agent 把受复核、串行且可取消的 `ssh_connect` 作为独立步骤调用并等待成功,再使用其他 `ssh_*` 工具;连接动作及后续操作均进入 `ask → auto-review`,相对远端路径会在预览中显示请求值和解析后的绝对路径,会话结束自动断开。SSH transport 在会话内持久,独立命令最多使用 4 路有界并发 exec channel,但每次 `ssh_bash` 都启动独立的非交互 Shell;需要持续改变后续远端操作的工作目录时,Agent 同样必须把受复核且串行执行的 `ssh_cd` 作为独立步骤调用并等待成功,而不是依赖某次 Shell 中临时执行的 `cd`。
|
可用 `./configure.sh ssh list|update|remove|rotate-key` 管理已导入主机。第一版拒绝 `ProxyJump` / `ProxyCommand`,且不会自动读取远端 `AGENTS.md` 或 `CLAUDE.md`。连接不再通过 `/ssh`、`--ssh` 或 session resume 建立:用户在具体远端任务中明确指定已导入 host ID 后,Agent 把受复核、串行且可取消的 `ssh_connect` 作为独立步骤调用并等待成功,再使用其他 `ssh_*` 工具;连接动作及后续操作均进入 `ask → auto-review`,相对远端路径会在预览中显示请求值和解析后的绝对路径,会话结束自动断开。SSH transport 在会话内持久,独立命令最多使用 4 路有界并发 exec channel,但每次 `ssh_bash` 都启动独立的非交互 Shell;需要持续改变后续远端操作的工作目录时,Agent 同样必须把受复核且串行执行的 `ssh_cd` 作为独立步骤调用并等待成功,而不是依赖某次 Shell 中临时执行的 `cd`。
|
||||||
|
|
||||||
远端文件搜索使用独立的 `ssh_find` / `ssh_grep`:在每次获批调用内按服务器现有能力选择 `fd/fdfind → git ls-files → find` 或 `rg → git grep → find+grep`,不安装远端软件,并以最大 200 条结果、单行截断和 `truncated` 标记约束返回。搜索目标作为绝对参数与当前远端执行 cwd 分离,因此 `ssh_grep` 可直接搜索单个文件而不会尝试把文件当目录进入;30 秒超时会报告解析后的 root 并提示先用 `ssh_find` 缩小范围。远端 HOME/cwd 通过可取消、有界随机标记探测获得,畸形结果不会更新连接状态。RTK 只压缩 `ssh_bash` 的非搜索输出,不改写远端命令,也不处理 `ssh_find`、`ssh_grep` 或 `ssh_read`。详细说明见 [`pi-ssh/README.md`](pi-ssh/README.md)。
|
远端文件搜索使用独立的 `ssh_find` / `ssh_grep`:在每次获批调用内按服务器现有能力选择 `fd/fdfind → git ls-files → find` 或 `rg → git grep → find+grep`,不安装远端软件,并以最大 200 条结果、单行截断和 `truncated` 标记约束返回。搜索目标作为绝对参数与当前远端执行 cwd 分离,因此 `ssh_grep` 可直接搜索单个文件而不会尝试把文件当目录进入;30 秒超时会报告解析后的 root 并提示先用 `ssh_find` 缩小范围。远端 HOME/cwd 通过可取消、有界随机标记探测获得,畸形结果不会更新连接状态。RTK 只压缩 `ssh_bash` 的非搜索输出,不改写远端命令,也不处理 `ssh_find`、`ssh_grep` 或 `ssh_read`。详细说明见 [`pi-ssh/README.md`](pi-ssh/README.md)。
|
||||||
|
|
||||||
### 在线搜索服务
|
### 在线搜索服务
|
||||||
|
|
||||||
运行 `./search_config.sh` 可用隐藏输入依次配置 Tavily、Exa 和 Keenable;也可一次无交互配置:
|
运行 `./configure.sh search` 可用隐藏输入依次配置 Tavily、Exa 和 Keenable;key 不得出现在命令行参数中,非交互方式从标准输入读取(每次调用最多一个 provider):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./search_config.sh --tavily '...' --exa '...' --keenable '...'
|
printf '%s' "$TAVILY_API_KEY" | ./configure.sh search --tavily -
|
||||||
```
|
```
|
||||||
|
|
||||||
只传部分参数时保留其他服务的已有值。脚本把 key 原子写入
|
只传部分参数时保留其他服务的已有值。凭据写入 Pi 自己的
|
||||||
`${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/search.env` 并设置权限为 `600`;
|
`auth.json`(`$PI_CODING_AGENT_DIR/auth.json`,默认 `~/.pi/agent/auth.json`,权限 `600`):
|
||||||
`MY_PI_SEARCH_CONFIG` 或 `--config` 可覆盖路径。组合包在搜索扩展初始化前加载该文件,
|
每个 provider 一条 `{ "type": "api_key", "key": "..." }` 条目,原子加锁写入,
|
||||||
但调用 Pi 时显式传入的同名环境变量优先。配置文件不属于仓库,不会随安装包提交。
|
其他条目(如 OAuth 凭据)原样保留。`--list` 只显示配置状态、不回显 key;
|
||||||
|
`--migrate` 可从旧的 `search.env` 导入缺失条目;`--auth FILE` 可覆盖文件路径。
|
||||||
|
|
||||||
Tavily 通过 `extensions/tavily-override.ts` 加载官方扩展。override 只改工具身份,不改官方执行逻辑:上游 `web_search` / `web_fetch` 被注册为 `tavily_web_search` / `tavily_web_fetch`,label 和提示词中的内部工具引用也同步带上 Tavily 标识。使用前需设置 `TAVILY_API_KEY`。Tavily 适合广泛发现,但来源可能混杂且输出较长,应从较小的 `max_results` 开始,非必要不返回 raw content。
|
key 值支持 auth.json 原生解析语义:`$VAR` / `${VAR}` 环境变量插值、`!command`
|
||||||
|
执行命令取 stdout(如 `"!op read op://Vault/Tavily/key"`)、`$$` / `$!` 转义。
|
||||||
|
显式进程环境变量因此不再被直接读取——需要 env 注入时把 key 写成
|
||||||
|
`"$TAVILY_API_KEY"` 即可。`KEENABLE_API_URL`(非 secret 的端点覆盖)仍走
|
||||||
|
进程环境变量,也可放在 keenable 凭据的 `env` 字段中。
|
||||||
|
|
||||||
Keenable 直接加载 npm 包的扩展和 skill,默认无需 key;设置 `KEENABLE_API_KEY` 可提高速率限制。它适合中文内容、`site` 限定和发布日期筛选。搜索结果保持紧凑,只对选中的 URL 调用 `keenable_fetch`。
|
三个服务由本仓库自维护的 `pi-web-search/` 统一封装:对模型只暴露 `web_search` / `web_fetch` 两个工具,schema 与提示词中不出现任何厂商名;每次 `web_search` 先由当前主模型(`ctx.model` + `ctx.modelRegistry.complete`,零额外配置)按内置路由规则在候选厂商中选一个,再按统一参数翻译后调用,`details` 记录实际 provider 与路由方式;选择结果按会话内 LRU 缓存,路由失败或被选厂商报错时按可用性顺序自动降级。`web_fetch` 固定调用 Exa 的 `web_fetch_exa` 提取正文(默认每页 3000 字符上限),失败时静默降级到 Keenable、再到 Tavily。
|
||||||
|
|
||||||
`extensions/mcp.ts` 通过单个 `pi-mcp-adapter` 实例同时连接 Exa 与 CodeGraph,避免两个 adapter 重复注册 `--mcp-config`、`/mcp` 和 `/mcp-auth`。其中 Exa 连接 `https://mcp.exa.ai/mcp`,并把 MCP 原始工具映射为统一的来源前缀形式:`exa_web_search`、`exa_web_fetch` 和 `exa_web_search_advanced`。配置 `EXA_API_KEY` 后通过 `x-api-key` 请求头认证,不把 key 放进 URL。Exa 适合官方文档、技术资料、版本信息和论文;高级搜索必须显式限制结果数量与返回文本长度,避免响应过大。
|
`pi-web-search/clients/` 全部是纯 `fetch` 实现,三个 key 统一由 `pi-web-search/auth.ts` 从 auth.json 解析(Tavily 直连 `api.tavily.com/search|extract`,必须有 `tavily` 凭据,无 key 时不参与路由;Keenable 复刻上游 client,默认走 keyless `/public` 端点,`keenable` 凭据只提高速率限制并保留 SSRF host 防护;Exa 直接对 `https://mcp.exa.ai/mcp` 发 JSON-RPC——initialize 一次并缓存 `mcp-session-id`、随后 `tools/call`,会话失效(HTTP 404)自动重建,`exa` 凭据只通过 `x-api-key` 请求头发送,不再依赖 `pi-mcp-adapter`)。Exa 搜索统一走 `web_search_advanced_exa` 并显式限制结果数量与 `textMaxCharacters`,避免响应过大。
|
||||||
|
|
||||||
根测试会扫描所有本地扩展入口,强制只有 `extensions/mcp.ts` 可以创建或导入 `pi-mcp-adapter`。新增 MCP 服务必须合并进这个共享实例,不能再增加独立 adapter 入口;隔离 Pi 加载仍作为发布前的运行时验证。
|
### CodeGraph 是原生工具
|
||||||
|
|
||||||
### CodeGraph 只负责连接
|
`extensions/codegraph.ts` 注册原生 `codegraph_explore` 工具,不走 MCP adapter/SDK:每个索引根
|
||||||
|
惰性启动一个常驻 `codegraph serve --mcp -p <root>` 子进程,由组合包自有的
|
||||||
|
`extensions/codegraph-client.ts` 以逐行 JSON-RPC over stdio 直连(与 `pi-web-search` 直连 Exa
|
||||||
|
同一思路),`initialize` → `tools/list` → 每次调用一次 `tools/call`。`projectPath` 缺省为会话 cwd,
|
||||||
|
沿目录向上寻找最近的 `.codegraph/codegraph.db` 决定索引根;调用参数只透传上游
|
||||||
|
`inputSchema` 声明的属性。
|
||||||
|
|
||||||
同一个 `extensions/mcp.ts` adapter 配置启动
|
与一次性 `codegraph explore` CLI 不同,serve 进程内置文件监听会自动同步索引,新符号数秒内
|
||||||
`codegraph serve --mcp`,不会读取或覆盖用户已有的 MCP 配置。连接采用
|
可搜到,因此扩展不再做结果缓存。子进程闲置 10 分钟或会话结束时关闭;调用因子进程死亡失败时
|
||||||
`keep-alive`,并只把官方当前主工具 `codegraph_explore` 作为同名 Pi 工具暴露;
|
换新客户端透明重试一次。serve 可能拉起 CodeGraph 自己的 daemon(`.codegraph/daemon.sock`),
|
||||||
CodeGraph 的文件 watcher 由该 MCP 进程自身维护。
|
子进程退出后最多再驻留 5 分钟——这是上游行为,组合包不管理它。
|
||||||
|
|
||||||
若机器没有 `codegraph`,或当前项目尚未建立索引,CodeGraph 会报告连接/初始化错误,
|
若目标目录没有索引,工具先弹出确认;用户同意后运行 `codegraph init` 并自动重试本次调用,
|
||||||
其他扩展仍可正常工作。安装与索引状态可在机器上自行检查:
|
拒绝一次后本会话不再询问;CLI 不可用或服务调用失败时把结果打回并提示改用
|
||||||
|
`find`/`grep`/`read`/LSP,不影响其他扩展。索引状态可在项目内自行检查:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
codegraph --version
|
|
||||||
codegraph status
|
codegraph status
|
||||||
```
|
```
|
||||||
|
|
||||||
在当前项目需要同时初始化 CodeGraph 和 Hippo 时,可直接执行:
|
### 统一权限系统、硬防御与 AutoReview
|
||||||
|
|
||||||
```text
|
`pi-permission-system/` 从上游 `pi-permission-system-v26.2.1` tag 导入,初始源码快照为 `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`。原 `pi-permission-auto-review` 的 reviewer、可信证据边界和 Codex Guardian 风格策略已整合进同一个扩展;其 MIT 许可和来源记录保留在 `pi-permission-system/LICENSES/` 与 `docs/auto-review/`。根组合包不再加载第二个 AutoReview 扩展。
|
||||||
/plugin_init
|
|
||||||
```
|
|
||||||
|
|
||||||
该命令拒绝文件系统根目录和用户主目录,先确认两个 CLI 均可用,再依次初始化缺失数据库状态。任一步失败都不会热重载,也不会回滚此前已成功完成的初始化;两者已初始化时仍可用该命令快速热重载扩展。
|
`extensions/permission-system.ts` 只加载扩展本身,不做任何配置播种或迁移;扩展运行时直接读取已安装组合包内的两份 JSONC:
|
||||||
|
|
||||||
### 权限基线自动部署
|
- `config/pi-permission-policy.jsonc`:唯一的全局普通 allow/ask/deny 策略,同时保存运行参数与 AutoReview 配置。项目级 `.pi/extensions/pi-permission-system/policy.jsonc` 不再读取。
|
||||||
|
- `config/pi-permission-hard-guards.jsonc`:仅全局生效的硬防御。硬 `deny` 先于 Session grant、per-agent 规则和 AutoReview,不能被后续层放宽。
|
||||||
|
- 同一文件内的 `protectedWrites`:Pi 凭据/全局设置/模型配置、已安装组合包与扩展目录、pi-ssh 凭据库等受保护文件的写入永远走人工确认——不经 AutoReview、yolo 或 Session grant,弹窗没有「本会话允许」选项,没有可用确认界面时直接拒绝。读取不受影响,`ssh_bash` 等远端 shell 工具不参与本机路径保护。
|
||||||
|
|
||||||
`pi-permission-system/` 从上游 `pi-permission-system-v26.2.1` tag 导入,初始源码快照为 `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`。根包通过本地 `file:` 依赖提供其运行时依赖,并由包装入口直接加载仓库源码,不再加载 npm 包中的实现。
|
普通 pattern 不再按“最后一条命中”覆盖,而按具体度选择:精确匹配优先,其次字面字符更多、通配符更少、pattern 更长,只有完全同级时才以后声明者为准。因此具体 deny 会稳定压过宽泛的 `"*": "allow"`。
|
||||||
|
|
||||||
`extensions/permission-system.ts` 在权限扩展注册前,把仓库中的
|
系统不再产生 `external_directory` 判断:项目外路径与项目内路径一样,只走用户配置的工具、`path` 和 Bash 规则。Tool Search 是唯一的工具显隐/激活控制者;权限系统只在工具被调用时做裁决。
|
||||||
`config/pi-permission-system.json` 同步到 Pi agent 目录。仓库文件是权威配置;直接在
|
|
||||||
运行目录通过 UI 修改的策略会在下次加载组合包时被覆盖,长期调整应提交到本仓库。
|
|
||||||
|
|
||||||
当前策略按类别划分:
|
`<agentDir>/my-pi/` 只保留可随时丢弃的运行时状态(如权限日志、Tool Search 目录缓存);组合包配置一律位于仓库 `config/` 并随组合包发布,对已安装副本的本地改动由 `pi update` 覆盖,要长期保留的自定义必须改仓库 `config/` 下的权威源。旧位置 `~/.pi/agent/extensions/pi-permission-system/` 的残留文件被忽略,不迁移也不清理。
|
||||||
|
|
||||||
- 默认工具:默认允许,避免常规编码操作反复确认;FFF 的 `grep`、`find`、
|
默认普通策略对未匹配请求返回 `ask`,并通过 `authorizerChain: ["auto-review"]` 先交给内置 reviewer。高危 Hard Guard 直接终止;AutoReview 的 `allow` / `deny` 对任一普通策略 `ask`(包括 `path`)直接结束当前审批;模型、配置、认证、超时或输出异常统一 `defer` 到人工界面。
|
||||||
`multi_grep` 明确允许。
|
|
||||||
- 搜索边界:直接通过 Bash 调用 `rg`、`grep`、`find`、`fd`、`git grep` 拒绝,统一走 FFF。
|
|
||||||
- 路径与凭据:外部目录询问;环境文件、SSH/GPG、云凭据、Keychain 和 Pi 认证文件拒绝;
|
|
||||||
`.env.example` 作为无密钥模板允许。
|
|
||||||
- Git:状态、diff、日志、对象查看等只读命令允许;其他 Git 命令进入 `ask`,由自动复核 authorizer 裁决。
|
|
||||||
- Bash 变更操作:Git 非只读操作、包管理、文件变更、系统操作、网络访问和环境导出均由权限基线先判定;命中 `ask` 后再进入自动复核。
|
|
||||||
- MCP 与 Skill:列举 MCP 状态允许,调用其他 MCP 工具进入 `ask`;本地 Skill 加载允许。
|
|
||||||
|
|
||||||
`permissionReviewLog` 已开启,后续可依据真实命中记录继续收敛规则。
|
自动评审模型完全由配置文件决定:`autoReview.provider` 与 `autoReview.model` 都不设内置默认值,扩展也不会合成替代模型。两者任一缺失时,若 `auto-review` 已在 `authorizerChain` 中,`session_start` 会提示一次,之后每个 `ask` 都 `defer` 到人工审批界面;provider 未注册或模型未发布同样 `defer`。`reasoning` 也由配置决定,不设默认常量。
|
||||||
|
|
||||||
### Permission Auto Review 与权限基线的边界
|
每个 AutoReview `allow` / `deny` 记入权限 review 日志(`defer` 记为 `errorCategory`),日志只保存有界、脱敏的请求标识与风险/授权等级,不保存 rationale、原始工具参数或 transcript。扩展不汇总历史裁决,也不启动任何后台分析 Agent,更不会自动修改策略文件。
|
||||||
|
|
||||||
`pi-permission-auto-review/` 从上游源码仓库的 `@mzwing/pi-permission-auto-review@0.2.0` tag 导入,初始源码快照为 `8d196e4ef0884cac8326c366191dad3f585d470a`。该目录与 `pi-rtk-optimizer/` 一样由本仓库直接维护,不依赖 npm 中的预编译扩展包。
|
SSH 仍复用同一权限入口:`ssh_connect`、远端路径工具默认为 `ask`,`ssh_bash` 映射完整 Bash 策略并设置 `decisionFloor: "ask"`。硬拒绝不能被 reviewer 覆盖;普通 `defer` 才进入人工兜底。
|
||||||
|
|
||||||
扩展以 `auto-review` 名称注册到 `pi-permission-system` 的 authorizer chain。只有权限基线先得到 `ask` 时才调用 reviewer:模型返回 `allow` 时自动批准,返回 `deny` 时直接拒绝,配置、模型、认证、超时或响应异常时返回 `defer` 并继续走正常人工提示。这样只有一条权限链,不再存在两个独立 `tool_call` gate 造成的重复审批。
|
|
||||||
|
|
||||||
边界保持如下:
|
|
||||||
|
|
||||||
- `allow`:权限基线已明确允许的常规操作不会调用 reviewer。
|
|
||||||
- `deny`:Bash 直搜、敏感凭据路径等硬拒绝不会交给 reviewer,也不能被其绕过。
|
|
||||||
- `ask`:Git 非只读操作、包管理、文件/系统/网络/环境操作和普通 MCP 调用交给 reviewer;`path` 仍全部受 delegation envelope 保护。`external_directory` 允许内置只读路径工具(`read`、`find`、`grep`、`ls`)接受 reviewer 的 `allow`,`write`、`edit`、Bash、未知工具及其他外部目录访问仍降级为人工确认。
|
|
||||||
|
|
||||||
reviewer 默认使用 `openai-codex/codex-auto-review`、low reasoning 和内置 Codex Guardian 风格策略,并读取当前 session active branch 中的可信用户证据。可通过 `/permission-auto-review` 查看或调整全局/项目配置;无配置时使用源码内置默认值。
|
|
||||||
|
|
||||||
### TypeScript、Kotlin 与 Java LSP
|
### TypeScript、Kotlin 与 Java LSP
|
||||||
|
|
||||||
@@ -326,54 +336,64 @@ brew install JetBrains/utils/kotlin-lsp
|
|||||||
|
|
||||||
JDT LS 需要 Java 21 或更高版本;可使用带 `jdtls` wrapper 的官方发行包或系统包。
|
JDT LS 需要 Java 21 或更高版本;可使用带 `jdtls` wrapper 的官方发行包或系统包。
|
||||||
|
|
||||||
### Hippo Memory
|
### 持久记忆(pi-hermes-memory)
|
||||||
|
|
||||||
官方扩展要求全局 `hippo` CLI,并要求目标项目存在 `.hippo/`。组合包加载仓库内官方扩展源码,
|
组合包加载仓库内 `pi-hermes-memory` 源码并注册六个工具:
|
||||||
注册 `hippo_recall`、`hippo_remember`、`hippo_outcome`、`hippo_status` 和 `hippo_context` 五个工具。
|
|
||||||
Session 启动时它注入项目记忆上下文,失败工具结果经过噪声过滤、每 Session 上限和去重后自动记录,
|
|
||||||
Session 关闭时运行 `hippo sleep`,从 Git 学习、整理记忆并执行官方共享逻辑。项目 `.hippo/` 是
|
|
||||||
本机记忆状态,本仓库默认忽略提交。
|
|
||||||
|
|
||||||
Hermes 的 `memory_add` / `session_search` / `skill_manage` 与 Markdown/SQLite 数据不会被 Hippo 自动迁移;
|
| 工具 | 作用 |
|
||||||
切换扩展不会删除 `~/.pi/agent/pi-hermes-memory/` 或 `~/.pi/agent/projects-memory/` 中的旧数据。需要保留的
|
|---|---|
|
||||||
旧记忆应另行审核后导入,不在安装、升级或卸载流程中自动转换或删除。
|
| `memory_search` | 检索持久记忆:全局、用户、项目、失败四类 |
|
||||||
|
| `session_search` | 在 SQLite FTS5 trigram 索引中检索历史会话原话 |
|
||||||
|
| `memory_add` | 新增一条持久记忆 |
|
||||||
|
| `memory_replace` | 按子串匹配修改已有记忆 |
|
||||||
|
| `memory_remove` | 按子串匹配删除已有记忆 |
|
||||||
|
| `skill_manage` | 创建、查看、修补、重写或删除 `SKILL.md` 过程记忆 |
|
||||||
|
|
||||||
`install.sh` 只对缺失的 Hippo CLI 询问一次,默认拒绝;缺少 npm 时保留已完成的组合包安装并报告失败。
|
默认 `policy-only` 模式不在 system prompt 中注入记忆正文,只注入 `<memory-policy>` 指令告诉模型何时
|
||||||
脚本不会扫描仓库、选择目录或执行 `hippo init`。`update.sh` 只处理已经存在且确认属于 npm 全局安装的
|
检索、何时写入。因此首轮 token 成本很低,也保持 prompt 前缀稳定。写工具只写扩展自己的数据目录,
|
||||||
Hippo CLI:从升级后的组合包读取固定版本,先通过 npm 确认版本存在,再比较本地版本,只有不同时才升级。
|
不触碰工作区。
|
||||||
`uninstall.sh` 移除组合包后询问是否一并执行 `npm uninstall -g hippo-memory`,默认保留;无论选择什么,
|
|
||||||
`.hippo/` 和用户记忆数据都不会被脚本删除。
|
|
||||||
|
|
||||||
### Kitty 完成通知
|
`skill_manage` 是扩展提供的能力:Pi 宿主只负责发现并加载 skill 目录,本身没有创建 skill 的工具。
|
||||||
|
该扩展同时注册 `~/.pi/agent/projects-memory/<项目>/skills/` 供 Pi 发现,因此不加载它时,这些既有
|
||||||
|
skill 也不会出现在 Pi 的 skill 索引中。
|
||||||
|
|
||||||
`pi-notify/` 直接加载本地维护源码。它只在交互式 TUI 中监听 `agent_settled`,因此自动重试、自动
|
记忆与 skill 位于 `~/.pi/agent/pi-hermes-memory/`(全局)和 `~/.pi/agent/projects-memory/<项目>/`
|
||||||
compaction 和排队 follow-up 完成前不会提前通知。Kitty 后端使用 Base64 OSC 99、每个 Pi Session
|
(项目级)。这些是用户数据:安装、升级和卸载脚本都
|
||||||
独立的稳定通知 ID、显式 `a=focus`,并默认设置 `o=always`:即使来源 Kitty window/pane 正在接收
|
不会创建、迁移或删除它们,也不会自动转换旧 Hippo `.hippo/` 数据;扩展配置来自已安装组合包的
|
||||||
键盘输入也会显示通知,点击通知由 Kitty 返回原始来源;仍可通过环境变量改用 `unfocused` 或 `invisible`。
|
`config/pi-hermes-memory.json`。
|
||||||
|
|
||||||
默认忽略不足 3 秒的短任务。`/notify on|off|test|status` 可控制当前 Session;持久默认通过
|
### Kitty 完成与权限确认通知
|
||||||
`PI_NOTIFY_ENABLED`、`PI_NOTIFY_MIN_SECONDS`、`PI_NOTIFY_VISIBILITY`、`PI_NOTIFY_ACTION`、
|
|
||||||
`PI_NOTIFY_MESSAGE_SOURCE` 等环境变量配置。`PI_NOTIFY_MESSAGE_SOURCE=none` 可避免把回复摘要发送到
|
|
||||||
macOS 通知中心。Kitty 位于 tmux 内时需要 `set -g allow-passthrough all` 并重启 tmux server。
|
|
||||||
完整配置和上游差异见 [`pi-notify/README.md`](pi-notify/README.md)。
|
|
||||||
|
|
||||||
### Codex 原生远程压缩
|
`pi-notify/` 直接加载本地维护源码。完成通知只在交互式 TUI 中监听 `agent_settled`,因此自动重试、自动
|
||||||
|
compaction 和排队 follow-up 完成前不会提前通知;同时监听 `pi-permission-system` 的
|
||||||
|
`permissions:ui_prompt`,仅在权限系统即将显示人工确认界面时立即通知,策略或 AutoReview 已直接完成的
|
||||||
|
决定不会通知。Kitty 后端使用 Base64 OSC 99、每个 Pi Session 独立的稳定通知 ID、显式 `a=focus`,
|
||||||
|
并默认设置 `o=always`:即使来源 Kitty window/pane 正在接收键盘输入也会显示通知,点击通知由 Kitty
|
||||||
|
返回原始来源;仍可通过 `visibility` 改用 `unfocused` 或 `invisible`。
|
||||||
|
|
||||||
远程压缩只对 `openai-codex/openai-codex-responses` 生效,使用上游默认配置:在 turn boundary
|
默认忽略不足 3 秒的短任务,但权限确认通知不受该时长门槛影响。`/notify on|off|test|status` 可控制
|
||||||
达到 90% 上下文占用时触发,并写入 Pi 原生 compaction boundary。失败时保持原历史且不静默
|
当前 Session 的两类通知;持久默认通过组合包 `config/pi-notify.json` 的 `enabled`、`minSeconds`、
|
||||||
回退到文本摘要;checkpoint 与创建它的 Codex model 绑定。
|
`visibility`、`action`、`messageSource` 等字段配置。`messageSource: "none"`
|
||||||
|
会同时隐藏回复摘要以及权限值和请求者细节。Kitty 位于 tmux 内时需要
|
||||||
|
`set -g allow-passthrough all` 并重启 tmux server。完整配置和上游差异见
|
||||||
|
[`pi-notify/README.md`](pi-notify/README.md)。
|
||||||
|
|
||||||
### Pi Condense
|
### Pi Compaction
|
||||||
|
|
||||||
组合包加载时会只为 `contextPrune` 中缺失的字段补入默认策略,任何用户显式值(包括 `/pruner off` 写入的 `enabled: false`)均优先且不会被覆盖。默认使用 `agent-message` 触发模式和 `turn` 批处理;正常上下文达到窗口的 73.5% 时中途整理,单轮增长达到窗口的 4% 时提前整理。对于 272K 上下文模型,两者约为 200K 和 11K tokens;其他模型按各自窗口比例计算。摘要使用当前默认模型和默认 thinking,连续 90 秒无输出才判定空闲超时,每次最长运行 5 分钟,并保留未压缩或摘要过大等跳过提示。
|
组合包只加载仓库内维护的 `pi-compaction/`。它管理已经进入 Session 的历史投影:上下文压力达到阈值时,在 `turn_end` 批量提交 Pi 原生 `context_edit`,把最近 `keepRecentTurns` 轮之外的大型工具结果遮蔽为指向 `context_tree_query` 的单行占位符;遮蔽成批发生、受 trigger/target 回差与 `minClearTokens` 约束。原始工具结果保留在 Session 中(UI、导出不变),可通过 `context_tree_query` 按 tool call ID 有界恢复。
|
||||||
|
|
||||||
同一用户任务中的多轮工具调用通常在最终文本回复后触发整理,预算触发器则可在长任务中提前处理;后续上下文只保留摘要和 `t1`、`t2` 等引用。需要原文时,模型可调用 `context_tree_query({ toolCallIds: ["t1"] })` 恢复。
|
普通 Provider、阈值判断、Codex native compaction、`/context`、`/compaction-status` 和 minimal footer 都读取同一个 effective projection snapshot。整段摘要只在空闲 `agent_settled` 且达到绝对 token 阈值(默认 400K,受窗口比例约束,272K 窗口时约为 90%)时经 `ctx.compact` 触发一次;Pi 自身的阈值压缩作为最终安全网始终有效,`session_before_compact` 不再按扩展自身阈值取消。Codex native API 直接消费 effective ResponseItems,同时保存有界 portable summary 供模型或 Provider 切换。
|
||||||
|
|
||||||
常用命令:`/pruner status` 查看状态,`/pruner settings` 调整配置,`/pruner now` 立即处理待总结批次,`/pruner tree` 浏览已归档调用,`/pruner stats` 查看累计成本和节省,`/pruner off` 关闭。配置保存在 Pi agent 目录 `settings.json` 的 `contextPrune` 字段。建议保留默认的 skill 路径保护,并把必须逐字复用输出的工具加入 `protectedTools`。
|
唯一配置位于已安装组合包的 `config/pi-compaction.json`:`masking` 段为遮蔽触发/目标阈值、`keepRecentTurns`、`minEntryTokens` 与 `excludeTools`,`summary` 段为整段摘要阈值与 thinking 档位,扩展只在内存中归一化且不写回配置。`/compaction-status` 显示 raw/effective/reclaimed token、置信度、segment 和已遮蔽结果数。完整配置、Session version 1 格式和双上游来源见 [`pi-compaction/README.md`](pi-compaction/README.md) 与 [`pi-compaction/UPSTREAM.md`](pi-compaction/UPSTREAM.md)。
|
||||||
|
|
||||||
它与 Context Mode 的职责不同:Context Mode 避免大型原始输出进入当前模型上下文,Pi Condense 则压缩已经进入会话、且已完成使用的历史工具结果。它也不替代 Codex 原生远程 compaction;不希望自动整理历史时可运行 `/pruner off`。
|
原生远程压缩同时支持 `openai-codex` + `openai-codex-responses` 和 `sub-api` + `openai-responses`,使用触发时当前会话模型。SubAPI 保留现有模型配置,从 `baseUrl` 调用公共 `/responses` 并使用 `compaction_trigger` 流式协议;优先保留非空显式 `Authorization`,否则使用 API key,也支持仅凭非空 `cf-aig-authorization` 的代理认证。缺失或空白凭据在请求前拒绝。SubAPI 不要求客户端解析 ChatGPT account ID,也不调用独立 `/responses/compact`;Codex 的 JWT 要求保持不变。网关部署须支持 Sub2API 原生压缩 v2;请求失败沿用 Pi 回退,切换模型或 Provider 时使用 portable summary。
|
||||||
|
|
||||||
### Codex fast mode
|
本实现不读取或迁移旧 `contextPrune`、`context-prune-*` 或旧 Codex native checkpoint;切换后应创建新 Session。它与 pi-overflow 职责互补:pi-overflow 在新工具结果进入会话时即截断展示,Pi Compaction 管理已经进入会话的长期历史。
|
||||||
|
|
||||||
该扩展只在 `openai-codex` provider 的 Responses 请求上加入
|
### Pi Overflow
|
||||||
`service_tier: "priority"`。使用 `/fast-mode on|off` 修改当前分支状态时,也会把全局默认值原子写入 Pi agent 目录下的 `extensions/pi-extension-codex-fast-mode/config.json`;新会话继承该默认值,恢复已有会话时仍由分支内最新记录优先。`/fast-mode status` 同时显示当前分支状态和新会话默认值。配置目录与文件分别使用 `0700` / `0600` 权限;配置缺失或无效时安全回退为关闭。其他 provider 不受影响。
|
|
||||||
|
组合包自维护的 `pi-overflow/` 统一负责「每个新工具结果给模型看多少」。它有两个入口:`capture.ts` 排在 `pi.extensions` 最前,只把原始结果文本暂存到进程内 Map(容量 64),绝不修改结果;`index.ts` 排在 `system-prompt.ts` 之前、所有其他扩展之后,此时 RTK 等 handler 已经完成变换——它用最终可见文本判断是否超阈值,落盘的原文取 bash 的 `details.fullOutputPath` 文件、暂存原文或当前文本三者中的最完整版本,视图与其中的行号则一律基于落盘原文生成,保证与 `overflow_query` 读取的内容一致。
|
||||||
|
|
||||||
|
当最终文本仍超过 `thresholdChars`(默认 24000)时,模型看到的是确定性的「头部 40 行 + 中段匹配 error/warn 等信号行(至多 40 条)+ 尾部 120 行」视图,并写入 `details.overflow.id`;当上游已把结果压短但原文超阈值时,只追加一行 `[pi-overflow id=…]` 恢复标记。原文以 `sha1(sessionId+toolCallId)` 前 12 位 hex 为 id 存入 `<agentDir>/my-pi/pi-overflow/`(目录 `700`、文件 `600`、原子写入),session 启动时按 `retentionDays`/`maxStoreBytes` 清理。模型可用 `overflow_query` 按 id 正则搜索(带上下文合并)、按行区间或 `jsonPath` 读取;`read`/`edit`/`write`/`overflow_query`/`ask_user_question` 与嵌套调用永远豁免。配置为组合包内 `config/pi-overflow.json`,详见 [`pi-overflow/README.md`](pi-overflow/README.md)。
|
||||||
|
|
||||||
|
RTK 的兜底截断与 pi-chrome 的 30K 字符截断已收编进本扩展:`config/rtk-optimizer.json` 中 `outputCompaction.truncate.enabled` 默认关闭,chrome_* 工具返回完整文本;pi-web-search 的 `web_fetch` 默认 `max_characters` 提高到 20000(上限 100000),`web_search` 的 `max_results` 约束在 1–20。
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
1.33.0
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"masking": {
|
||||||
|
"enabled": true,
|
||||||
|
"triggerTokens": 200000,
|
||||||
|
"triggerRatio": 0.6,
|
||||||
|
"targetTokens": 100000,
|
||||||
|
"targetRatio": 0.35,
|
||||||
|
"minClearTokens": 50000,
|
||||||
|
"keepRecentTurns": 10,
|
||||||
|
"minEntryTokens": 256,
|
||||||
|
"excludeTools": ["ask_user_question"]
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"triggerTokens": 400000,
|
||||||
|
"triggerRatio": 0.9,
|
||||||
|
"thinking": "low"
|
||||||
|
},
|
||||||
|
"native": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"lazyInitialization": false,
|
||||||
|
"memoryMode": "policy-only",
|
||||||
|
"memoryPolicyStyle": "full",
|
||||||
|
"memoryCharLimit": 5000,
|
||||||
|
"userCharLimit": 5000,
|
||||||
|
"projectCharLimit": 5000,
|
||||||
|
"nudgeInterval": 10,
|
||||||
|
"reviewRecentMessages": 0,
|
||||||
|
"reviewEnabled": true,
|
||||||
|
"reviewTransport": "direct",
|
||||||
|
"flushOnCompact": true,
|
||||||
|
"flushOnShutdown": true,
|
||||||
|
"flushMinTurns": 6,
|
||||||
|
"flushRecentMessages": 0,
|
||||||
|
"flushCompactTimeoutMs": 60000,
|
||||||
|
"memoryOverflowStrategy": "auto-consolidate",
|
||||||
|
"overflowGraceMs": 180000,
|
||||||
|
"autoConsolidate": true,
|
||||||
|
"correctionDetection": true,
|
||||||
|
"failureInjectionEnabled": true,
|
||||||
|
"failureInjectionMaxAgeDays": 7,
|
||||||
|
"failureInjectionMaxEntries": 5,
|
||||||
|
"consolidationChunking": false,
|
||||||
|
"consolidationChunkChars": 4000,
|
||||||
|
"consolidationTimeoutMs": 180000,
|
||||||
|
"usageHitTrackingEnabled": true,
|
||||||
|
"consolidationUsageSignals": true,
|
||||||
|
"autoConsolidationWarnOnFailure": true,
|
||||||
|
"nudgeToolCalls": 15,
|
||||||
|
"standingInstructionsEnabled": true,
|
||||||
|
"sessionSearch": { "variant": "legacy" },
|
||||||
|
"quickCheckOnOpen": true,
|
||||||
|
"sessionRetentionDays": 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"showCwd": true,
|
||||||
|
"showBranch": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"title": "π - {project}",
|
||||||
|
"body": "{message}",
|
||||||
|
"messageSource": "assistant",
|
||||||
|
"messageMax": 80,
|
||||||
|
"minSeconds": 3,
|
||||||
|
"visibility": "always",
|
||||||
|
"action": "focus",
|
||||||
|
"soundCommand": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"thresholdChars": 24000,
|
||||||
|
"headLines": 40,
|
||||||
|
"tailLines": 120,
|
||||||
|
"maxSignalLines": 40,
|
||||||
|
"signalPattern": "\\b(error|errors|fail|failed|failure|warn|warning|exception|traceback|panic|fatal)\\b",
|
||||||
|
"maxLineChars": 500,
|
||||||
|
"exemptTools": ["read", "edit", "write", "overflow_query", "ask_user_question"],
|
||||||
|
"retentionDays": 7,
|
||||||
|
"maxStoreBytes": 1073741824,
|
||||||
|
"maxEntryBytes": 67108864
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
// Bundle-owned hard guards, read directly from the installed bundle's
|
||||||
|
// config/ directory. pi update overwrites this file; project configuration
|
||||||
|
// cannot override it.
|
||||||
|
"version": 1,
|
||||||
|
"permission": {
|
||||||
|
"bash": {
|
||||||
|
"rm -rf /": "deny",
|
||||||
|
"rm -rf /*": "deny",
|
||||||
|
"rm -rf ~": "deny",
|
||||||
|
"rm -rf ~/*": "deny",
|
||||||
|
"sudo rm -rf /": "deny",
|
||||||
|
"sudo rm -rf /*": "deny",
|
||||||
|
"mkfs*": "deny",
|
||||||
|
"dd * of=/dev/*": "deny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Writes to the files below always reach a human prompt — never AutoReview,
|
||||||
|
// never yolo, never an existing session grant — and are denied outright when
|
||||||
|
// no confirmation UI is available. Reads are unaffected. `*` crosses `/`, so
|
||||||
|
// `/**` covers a whole subtree. `{agentDir}` is the Pi agent directory,
|
||||||
|
// `{configHome}` is $XDG_CONFIG_HOME or ~/.config; a leading `~/` also works.
|
||||||
|
"protectedWrites": [
|
||||||
|
{ "pattern": "{agentDir}/auth.json", "label": "Pi 凭据(模型与搜索 API key)" },
|
||||||
|
{ "pattern": "{agentDir}/settings.json", "label": "Pi 全局设置" },
|
||||||
|
{ "pattern": "{agentDir}/models.json", "label": "Pi 模型配置" },
|
||||||
|
{ "pattern": "{agentDir}/trust.json", "label": "项目信任记录" },
|
||||||
|
{ "pattern": "{agentDir}/hashline.json", "label": "Hashline 配置" },
|
||||||
|
{ "pattern": "{agentDir}/lsp.json", "label": "LSP 配置" },
|
||||||
|
{ "pattern": "{agentDir}/git/**", "label": "已安装的组合包(含 config/ 与权限系统源码)" },
|
||||||
|
{ "pattern": "{agentDir}/npm/**", "label": "已安装的 npm 扩展" },
|
||||||
|
{ "pattern": "{agentDir}/extensions/**", "label": "Pi 自动加载的扩展目录" },
|
||||||
|
{ "pattern": "{configHome}/my-pi/**", "label": "pi-ssh 凭据库与 p10k 配置" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
{
|
||||||
|
"debugLog": true,
|
||||||
|
"permissionReviewLog": true,
|
||||||
|
"yoloMode": false,
|
||||||
|
"doublePressToConfirm": true,
|
||||||
|
|
||||||
|
// AutoReview is the default authority for deterministic review decisions.
|
||||||
|
//
|
||||||
|
// The reviewer is a conversational model asked to judge one planned action
|
||||||
|
// under a bundled security policy. With no provider/model pair it follows
|
||||||
|
// the session's current model, so review quality tracks whatever you run.
|
||||||
|
// It answers by calling `approve` or `reject`; a reject — and every failure
|
||||||
|
// (timeout, provider error, malformed verdict) — defers to the human
|
||||||
|
// permission prompt with the model's reason shown. Approvals are cached
|
||||||
|
// within one user message, so the same ask does not pay for a second review.
|
||||||
|
//
|
||||||
|
// To pin a dedicated review model instead, set BOTH "provider" and "model";
|
||||||
|
// exactly one set is a misconfiguration and also defers to the human.
|
||||||
|
// "reasoning" defaults to "low" and only applies to reasoning-capable models.
|
||||||
|
"authorizerChain": ["auto-review"],
|
||||||
|
"autoReview": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"shellTools": {
|
||||||
|
"ssh_bash": {
|
||||||
|
"commandArgument": "command",
|
||||||
|
"decisionFloor": "ask"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"permission": {
|
||||||
|
// Unknown tools are reviewed instead of silently allowed.
|
||||||
|
"*": "ask",
|
||||||
|
"read": "allow",
|
||||||
|
"write": "allow",
|
||||||
|
"edit": "allow",
|
||||||
|
"grep": "allow",
|
||||||
|
"find": "allow",
|
||||||
|
"ls": "allow",
|
||||||
|
"codegraph_explore": "allow",
|
||||||
|
"lsp_diagnostics": "allow",
|
||||||
|
"lsp_definition": "allow",
|
||||||
|
"lsp_references": "allow",
|
||||||
|
"lsp_hover": "allow",
|
||||||
|
"lsp_symbols": "allow",
|
||||||
|
"tool_search": "allow",
|
||||||
|
"ask_user_question": "allow",
|
||||||
|
// Launching, steering or reading a subagent has no side effect itself;
|
||||||
|
// every child action is still gated by the permission system and any
|
||||||
|
// unresolved "ask" is forwarded to the root session.
|
||||||
|
"Agent": "allow",
|
||||||
|
"get_subagent_result": "allow",
|
||||||
|
"steer_subagent": "allow",
|
||||||
|
"web_search": "allow",
|
||||||
|
"chrome_snapshot": "allow",
|
||||||
|
"chrome_find": "allow",
|
||||||
|
"chrome_inspect": "allow",
|
||||||
|
"chrome_wait_for": "allow",
|
||||||
|
"chrome_screenshot": "allow",
|
||||||
|
"chrome_list_console_messages": "allow",
|
||||||
|
"chrome_list_network_requests": "allow",
|
||||||
|
"chrome_get_network_request": "allow",
|
||||||
|
|
||||||
|
// Hermes memory tools write only to the extension's own agent-directory
|
||||||
|
// stores and never touch the workspace, so they stay allowed. memory_search
|
||||||
|
// is invoked by the injected memory policy on most turns and must not
|
||||||
|
// trigger an AutoReview round trip each time.
|
||||||
|
"memory_search": "allow",
|
||||||
|
"session_search": "allow",
|
||||||
|
"memory_add": "allow",
|
||||||
|
"memory_replace": "allow",
|
||||||
|
"memory_remove": "allow",
|
||||||
|
// Skills skill_manage writes are loaded into every future session, so
|
||||||
|
// their creation stays human-reviewed.
|
||||||
|
"skill_manage": "ask",
|
||||||
|
// 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",
|
||||||
|
// pi-overflow's inspector reads only the extension's own stored output
|
||||||
|
// directory (id-validated); pinned in Tool Search's alwaysEnabled set.
|
||||||
|
"overflow_query": "allow",
|
||||||
|
// A codemode script does nothing by itself: every nested tool call it makes
|
||||||
|
// runs through this same gate (Pi emits tool_call with parentToolCallId).
|
||||||
|
"codemode": "allow",
|
||||||
|
|
||||||
|
// Model-chosen URLs can exfiltrate data — same reason curl/wget ask.
|
||||||
|
"web_fetch": "ask",
|
||||||
|
|
||||||
|
"ssh_connect": "ask",
|
||||||
|
"ssh_cd": "ask",
|
||||||
|
"ssh_read": "ask",
|
||||||
|
"ssh_write": "ask",
|
||||||
|
"ssh_edit": "ask",
|
||||||
|
"ssh_find": "ask",
|
||||||
|
"ssh_grep": "ask",
|
||||||
|
|
||||||
|
"path": {
|
||||||
|
"*": "allow",
|
||||||
|
".git/*": "ask"
|
||||||
|
},
|
||||||
|
"bash": {
|
||||||
|
"*": "allow",
|
||||||
|
"rg*": { "action": "deny", "reason": "Bash content search is disabled. Locally use the grep tool (pattern + path/exclude; regex alternation for several names); to extract/dedupe/count matches, call tools.grep in codemode and process in JS; to filter command output, use overflow_query on the stored result. Over SSH use ssh_grep." },
|
||||||
|
"grep*": { "action": "deny", "reason": "Bash content search is disabled. Locally use the grep tool (pattern + path/exclude; regex alternation for several names); to extract/dedupe/count matches, call tools.grep in codemode and process in JS; to filter command output, use overflow_query on the stored result. Over SSH use ssh_grep." },
|
||||||
|
"find*": { "action": "deny", "reason": "Bash file search is disabled. Locally use the find tool (fuzzy path, or a glob in path such as 'src/**/*.ts'); to pass a file list to a program use $(git ls-files '<glob>'). Over SSH use ssh_find." },
|
||||||
|
"fd*": { "action": "deny", "reason": "Bash file search is disabled. Locally use the find tool (fuzzy path, or a glob in path such as 'src/**/*.ts'); to pass a file list to a program use $(git ls-files '<glob>'). Over SSH use ssh_find." },
|
||||||
|
"git *": "ask",
|
||||||
|
"git status*": "allow",
|
||||||
|
"git diff*": "allow",
|
||||||
|
"git log*": "allow",
|
||||||
|
"git show*": "allow",
|
||||||
|
"git rev-parse*": "allow",
|
||||||
|
"git ls-files*": "allow",
|
||||||
|
"git grep*": { "action": "deny", "reason": "Bash content search is disabled. Locally use the grep tool (pattern + path/exclude; regex alternation for several names); to extract/dedupe/count matches, call tools.grep in codemode and process in JS; to filter command output, use overflow_query on the stored result. Over SSH use ssh_grep." },
|
||||||
|
"git branch": "allow",
|
||||||
|
"git branch --show-current*": "allow",
|
||||||
|
"git remote -v": "allow",
|
||||||
|
"git remote get-url*": "allow",
|
||||||
|
"git blame*": "allow",
|
||||||
|
"git describe*": "allow",
|
||||||
|
"git rev-list*": "allow",
|
||||||
|
"git merge-base*": "allow",
|
||||||
|
"git ls-tree*": "allow",
|
||||||
|
"git cat-file*": "allow",
|
||||||
|
"git name-rev*": "allow",
|
||||||
|
"git shortlog*": "allow",
|
||||||
|
"git stash list*": "allow",
|
||||||
|
"git worktree list*": "allow",
|
||||||
|
"git tag -l*": "allow",
|
||||||
|
"git tag --list*": "allow",
|
||||||
|
"git config --get*": "allow",
|
||||||
|
"git config --list*": "allow",
|
||||||
|
"npm install *": "ask",
|
||||||
|
"npm add *": "ask",
|
||||||
|
"npm uninstall *": "ask",
|
||||||
|
"npm update *": "ask",
|
||||||
|
"pnpm install *": "ask",
|
||||||
|
"pnpm add *": "ask",
|
||||||
|
"pnpm uninstall *": "ask",
|
||||||
|
"pnpm update *": "ask",
|
||||||
|
"bun install *": "ask",
|
||||||
|
"bun add *": "ask",
|
||||||
|
"bun uninstall *": "ask",
|
||||||
|
"bun update *": "ask",
|
||||||
|
"pip install *": "ask",
|
||||||
|
"pip3 install *": "ask",
|
||||||
|
"brew install *": "ask",
|
||||||
|
"brew uninstall *": "ask",
|
||||||
|
"rm *": "ask",
|
||||||
|
"/bin/rm *": "ask",
|
||||||
|
"rmdir *": "ask",
|
||||||
|
"/bin/rmdir *": "ask",
|
||||||
|
"unlink *": "ask",
|
||||||
|
"/usr/bin/unlink *": "ask",
|
||||||
|
"mv *": "ask",
|
||||||
|
"/bin/mv *": "ask",
|
||||||
|
"truncate *": "ask",
|
||||||
|
"dd *": "ask",
|
||||||
|
"chmod *": "ask",
|
||||||
|
"chown *": "ask",
|
||||||
|
"sudo *": "ask",
|
||||||
|
"su *": "ask",
|
||||||
|
"kill *": "ask",
|
||||||
|
"pkill *": "ask",
|
||||||
|
"diskutil *": "ask",
|
||||||
|
"security *": "ask",
|
||||||
|
"launchctl *": "ask",
|
||||||
|
"osascript *": "ask",
|
||||||
|
"open *": "ask",
|
||||||
|
"ssh *": "ask",
|
||||||
|
"scp *": "ask",
|
||||||
|
"sftp *": "ask",
|
||||||
|
"rsync *": "ask",
|
||||||
|
"curl *": "ask",
|
||||||
|
"wget *": "ask",
|
||||||
|
"printenv*": "ask"
|
||||||
|
},
|
||||||
|
"skill": {
|
||||||
|
"*": "allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-packages/main/packages/pi-permission-system/schemas/permissions.schema.json",
|
|
||||||
"debugLog": false,
|
|
||||||
"permissionReviewLog": true,
|
|
||||||
"yoloMode": false,
|
|
||||||
"doublePressToConfirm": true,
|
|
||||||
"authorizerChain": [
|
|
||||||
"auto-review"
|
|
||||||
],
|
|
||||||
"shellTools": {
|
|
||||||
"ssh_bash": {
|
|
||||||
"commandArgument": "command",
|
|
||||||
"decisionFloor": "ask"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"permission": {
|
|
||||||
"*": "allow",
|
|
||||||
"grep": "allow",
|
|
||||||
"find": "allow",
|
|
||||||
"multi_grep": "allow",
|
|
||||||
"ssh_connect": "ask",
|
|
||||||
"ssh_cd": "ask",
|
|
||||||
"ssh_read": "ask",
|
|
||||||
"ssh_write": "ask",
|
|
||||||
"ssh_edit": "ask",
|
|
||||||
"ssh_find": "ask",
|
|
||||||
"ssh_grep": "ask",
|
|
||||||
"external_directory": {
|
|
||||||
"*": "ask"
|
|
||||||
},
|
|
||||||
"path": {
|
|
||||||
"*": "allow",
|
|
||||||
".git/*": "ask",
|
|
||||||
"*.env": "deny",
|
|
||||||
"*.env.*": "deny",
|
|
||||||
"*.env.example": "allow",
|
|
||||||
"~/.ssh": "deny",
|
|
||||||
"~/.ssh/*": "deny",
|
|
||||||
"~/.gnupg": "deny",
|
|
||||||
"~/.gnupg/*": "deny",
|
|
||||||
"~/.aws": "deny",
|
|
||||||
"~/.aws/*": "deny",
|
|
||||||
"~/.kube": "deny",
|
|
||||||
"~/.kube/*": "deny",
|
|
||||||
"~/.docker/config.json": "deny",
|
|
||||||
"~/.config/gh/hosts.yml": "deny",
|
|
||||||
"~/.netrc": "deny",
|
|
||||||
"~/.npmrc": "deny",
|
|
||||||
"~/.pypirc": "deny",
|
|
||||||
"~/Library/Keychains": "deny",
|
|
||||||
"~/Library/Keychains/*": "deny",
|
|
||||||
"~/.pi/agent/auth.json": "deny",
|
|
||||||
"~/.pi/agent/models.json": "deny",
|
|
||||||
"~/.pi/agent/models-store.json": "deny"
|
|
||||||
},
|
|
||||||
"bash": {
|
|
||||||
"*": "allow",
|
|
||||||
"rg*": "deny",
|
|
||||||
"grep*": "deny",
|
|
||||||
"find*": "deny",
|
|
||||||
"fd*": "deny",
|
|
||||||
"git *": "ask",
|
|
||||||
"git status*": "allow",
|
|
||||||
"git diff*": "allow",
|
|
||||||
"git log*": "allow",
|
|
||||||
"git show*": "allow",
|
|
||||||
"git rev-parse*": "allow",
|
|
||||||
"git ls-files*": "allow",
|
|
||||||
"git grep*": "deny",
|
|
||||||
"git branch": "allow",
|
|
||||||
"git branch --show-current*": "allow",
|
|
||||||
"git remote -v": "allow",
|
|
||||||
"git remote get-url*": "allow",
|
|
||||||
"npm install *": "ask",
|
|
||||||
"npm add *": "ask",
|
|
||||||
"npm uninstall *": "ask",
|
|
||||||
"npm update *": "ask",
|
|
||||||
"pnpm install *": "ask",
|
|
||||||
"pnpm add *": "ask",
|
|
||||||
"pnpm uninstall *": "ask",
|
|
||||||
"pnpm update *": "ask",
|
|
||||||
"bun install *": "ask",
|
|
||||||
"bun add *": "ask",
|
|
||||||
"bun uninstall *": "ask",
|
|
||||||
"bun update *": "ask",
|
|
||||||
"pip install *": "ask",
|
|
||||||
"pip3 install *": "ask",
|
|
||||||
"brew install *": "ask",
|
|
||||||
"brew uninstall *": "ask",
|
|
||||||
"rm *": "ask",
|
|
||||||
"/bin/rm *": "ask",
|
|
||||||
"rmdir *": "ask",
|
|
||||||
"/bin/rmdir *": "ask",
|
|
||||||
"unlink *": "ask",
|
|
||||||
"/usr/bin/unlink *": "ask",
|
|
||||||
"mv *": "ask",
|
|
||||||
"/bin/mv *": "ask",
|
|
||||||
"truncate *": "ask",
|
|
||||||
"dd *": "ask",
|
|
||||||
"chmod *": "ask",
|
|
||||||
"chown *": "ask",
|
|
||||||
"sudo *": "ask",
|
|
||||||
"su *": "ask",
|
|
||||||
"kill *": "ask",
|
|
||||||
"pkill *": "ask",
|
|
||||||
"diskutil *": "ask",
|
|
||||||
"security *": "ask",
|
|
||||||
"launchctl *": "ask",
|
|
||||||
"osascript *": "ask",
|
|
||||||
"open *": "ask",
|
|
||||||
"ssh *": "ask",
|
|
||||||
"scp *": "ask",
|
|
||||||
"sftp *": "ask",
|
|
||||||
"rsync *": "ask",
|
|
||||||
"curl *": "ask",
|
|
||||||
"wget *": "ask",
|
|
||||||
"printenv*": "ask"
|
|
||||||
},
|
|
||||||
"mcp": {
|
|
||||||
"*": "ask",
|
|
||||||
"mcp_status": "allow",
|
|
||||||
"mcp_list": "allow"
|
|
||||||
},
|
|
||||||
"skill": {
|
|
||||||
"*": "allow"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"root": "~/.pi/worktrees",
|
||||||
|
"branchPrefix": "wt/"
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
// Cross-tool workflow rules rendered by extensions/system-prompt.ts into the
|
||||||
|
// <tool_routing> section. A rule is included when every tool in "whenAll"
|
||||||
|
// and at least one tool in "whenAny" is active; a rule with neither is
|
||||||
|
// always included. Rules render in file order as "- <text>".
|
||||||
|
// Optional "scope": "main" | "subagent" restricts a rule to that session
|
||||||
|
// audience (main session vs pi-subagents child); absent means both.
|
||||||
|
"version": 1,
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"whenAny": ["codegraph_explore"],
|
||||||
|
"text": "Use codegraph_explore first for architecture, symbol relationships, code flows, and symbols you are about to change. Treat the source it returns as already read for understanding; do not repeat the same exploration with read or search unless information is missing. Before editing, still read the exact target range to get fresh LINE#HASH anchors."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["lsp_definition", "lsp_references", "lsp_hover", "lsp_symbols", "lsp_diagnostics"],
|
||||||
|
"text": "Use LSP for symbol definitions, references, hover/type information, and post-edit diagnostics when the language server supports the file."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["find", "grep"],
|
||||||
|
"text": "Pick the local search tool by what you already know:\n - Exact identifier or text: grep with a literal pattern, constrained by path/exclude. Do not run find first just to locate it.\n - Several identifiers or naming variants: one grep with a regex alternation (e.g. user_id|userId|UserId) instead of several greps.\n - A filename, path fragment, or feature concept: find (fuzzy whole-path match; use a glob in path for exact names).\n - Then read only the matching region with offset/limit."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["find", "grep"],
|
||||||
|
"text": "Do not run a broad search expected to return hundreds or thousands of files or matches. If results truncate or hit a limit, narrow the path, glob, or pattern instead of increasing the limit or dumping all results."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAll": ["codemode"],
|
||||||
|
"whenAny": ["find", "grep"],
|
||||||
|
"text": "To extract, deduplicate, count, or aggregate across search matches (what grep -o | sort -u | wc would do), call tools.grep / tools.find inside one codemode script and post-process the results in JavaScript."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["bash"],
|
||||||
|
"text": "Use bash for builds, tests, git, package scripts, and other programs. Never run grep, rg, find, fd, or git grep in bash — standalone, piped, inside a loop, or in $(…). Search files with the grep/find tools; when a program needs a file list (wc, node, tsc), pass $(git ls-files '<glob>') as arguments rather than piping into xargs."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAll": ["bash", "overflow_query"],
|
||||||
|
"text": "To filter a command's output, run it without | grep; if the result carries a [pi-overflow id=…] marker, search it with overflow_query instead of re-running."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["ssh_connect"],
|
||||||
|
"scope": "main",
|
||||||
|
"text": "Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["ssh_connect"],
|
||||||
|
"scope": "subagent",
|
||||||
|
"text": "Use ssh_connect only when the delegated task explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["ssh_cd"],
|
||||||
|
"text": "Treat ssh_cd as a reviewed persistent workspace transition: call it as a separate step and wait for success before issuing ssh_bash or relative ssh_read/ssh_write/ssh_edit/ssh_find/ssh_grep calls that depend on the new remote cwd. Use cd inside ssh_bash only for an intentionally temporary, command-local directory change."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["ssh_find", "ssh_grep"],
|
||||||
|
"text": "For remote SSH searches, use ssh_find to narrow remote file paths before ssh_grep searches file contents. Keep path and limit bounded; when a result says truncated, narrow the path or pattern instead of increasing the limit. Do not run find, fd, grep, or rg through ssh_bash."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["web_search", "web_fetch"],
|
||||||
|
"text": "Use web_search for current, real-world, or citable web information; provider selection is handled internally. Keep queries focused and use site/published_* filters only when they materially narrow results. Read selected pages with web_fetch instead of repeating similar searches."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAll": ["chrome_snapshot", "overflow_query"],
|
||||||
|
"text": "chrome_snapshot (and Chrome actions with includeSnapshot=true) return the page snapshot JSON directly. When it is large the result carries a [pi-overflow id=…] marker: extract only the uids, text, or state you need with overflow_query (jsonPath or search) instead of taking another snapshot, and take a fresh snapshot after any page interaction because the previous one is stale."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["overflow_query"],
|
||||||
|
"text": "When a tool result starts with or ends with a [pi-overflow id=…] marker, the full original output is stored; inspect it with overflow_query (search, line ranges, or jsonPath) instead of re-running the command or dumping it again."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAny": ["read"],
|
||||||
|
"text": "Use read only when exact source is needed or the file is small. Before editing, request the smallest useful offset/limit range; do not read a complete large source file merely to copy, compare, hash, count, or explore it. Reading project overview docs such as a README in full is fine."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whenAll": ["read", "edit"],
|
||||||
|
"text": "For existing-file changes, obtain fresh LINE#HASH anchors with a small read (find/grep output has no anchors), then use anchored edit operations. Reuse fresh anchors returned by successful edits for chained changes; re-read only when anchors are stale or the next target region was not shown."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "When a tool call is denied by policy, use the alternative named in the denial and continue the subtask; do not drop it or present unverified facts as checked."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Stop searching once the current evidence is sufficient. Avoid repeating the same lookup through CodeGraph, find, grep, and read without a concrete information gap."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
// Order of named system-prompt sections contributed through
|
||||||
|
// systemPromptOptions.sections. extensions/system-prompt.ts reorders the
|
||||||
|
// contributed sections to this list; contributed sections not listed here
|
||||||
|
// keep their contribution order after the listed ones.
|
||||||
|
// Optional "subagentExclude": section names removed entirely in subagent
|
||||||
|
// (customPrompt) sessions — e.g. sections that describe main-session-only
|
||||||
|
// affordances. Defaults to [].
|
||||||
|
"version": 1,
|
||||||
|
"order": ["memory_policy", "remote_ssh", "chrome_profile_bridge", "tool_routing"],
|
||||||
|
"subagentExclude": ["memory_policy", "chrome_profile_bridge"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: Explore
|
||||||
|
display_name: Explore
|
||||||
|
description: 'Fast read-only search agent for locating code. Use it to find files by pattern (eg. "src/components/**/*.tsx"), grep for symbols or keywords (eg. "API endpoints"), or answer "where is X defined / which files reference Y." Do NOT use it for code review, design-doc auditing, cross-file consistency checks, or open-ended analysis — it reads excerpts rather than whole files and will miss content past its read window. When calling, specify search breadth: "quick" for a single targeted lookup, "medium" for moderate exploration, or "very thorough" to search across multiple locations and naming conventions.'
|
||||||
|
tools: read, bash, grep, find, ls
|
||||||
|
extensions: true
|
||||||
|
skills: true
|
||||||
|
prompt_mode: replace
|
||||||
|
---
|
||||||
|
# Role: read-only code search
|
||||||
|
|
||||||
|
You locate code and answer "where is X / what references Y" questions for the delegating agent. You never modify anything.
|
||||||
|
|
||||||
|
## Hard limits
|
||||||
|
- Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp).
|
||||||
|
- Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state.
|
||||||
|
- Never search through bash (grep, rg, find, fd); use the find and grep tools. For several names use one grep with a regex alternation (a|b); when a program needs a file list, pass $(git ls-files '<glob>').
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
- Match the requested breadth: "quick" is one targeted lookup, "medium" covers a few related locations, "very thorough" searches multiple locations and naming conventions.
|
||||||
|
- When codegraph_explore is available, start there for symbols and relationships. Otherwise use find to narrow paths, grep inside the narrowed paths, and read small ranges only to confirm a match.
|
||||||
|
- Make independent lookups in parallel.
|
||||||
|
|
||||||
|
## Report
|
||||||
|
- Lead with the direct answer.
|
||||||
|
- Cite every finding with an absolute path and line number.
|
||||||
|
- State what you searched for and did not find, so the delegating agent knows the coverage.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: Plan
|
||||||
|
display_name: Plan
|
||||||
|
description: Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.
|
||||||
|
tools: read, bash, grep, find, ls
|
||||||
|
extensions: true
|
||||||
|
skills: true
|
||||||
|
prompt_mode: replace
|
||||||
|
---
|
||||||
|
# Role: read-only implementation planner
|
||||||
|
|
||||||
|
You explore the codebase and return an implementation plan to the delegating agent. You never modify anything.
|
||||||
|
|
||||||
|
## Hard limits
|
||||||
|
- Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp).
|
||||||
|
- Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state.
|
||||||
|
- Never search through bash (grep, rg, find, fd); use the find and grep tools. For several names use one grep with a regex alternation (a|b); when a program needs a file list, pass $(git ls-files '<glob>').
|
||||||
|
|
||||||
|
## Process
|
||||||
|
1. Restate the goal and the constraints you were given. Record anything ambiguous as an open question instead of guessing.
|
||||||
|
2. Explore enough to ground every step in real files, existing patterns, and reusable helpers.
|
||||||
|
3. Design the change: sequencing, interfaces and data shapes, edge cases, and the tests that prove it.
|
||||||
|
4. Call out trade-offs and risks, and name the rejected alternative when the choice is not obvious.
|
||||||
|
|
||||||
|
## Report
|
||||||
|
- A step-by-step plan where each step names the files it touches.
|
||||||
|
- Open questions, if any.
|
||||||
|
- End with:
|
||||||
|
|
||||||
|
### Critical files
|
||||||
|
3–5 absolute paths, each with a one-line reason.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
name: general-purpose
|
||||||
|
display_name: Agent
|
||||||
|
description: General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.
|
||||||
|
extensions: true
|
||||||
|
skills: true
|
||||||
|
prompt_mode: append
|
||||||
|
---
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
You are a subagent operating inside pi, a coding agent harness. Another agent — the delegating agent — assigned you one bounded task, and it is your only audience. The human user does not see your messages and cannot answer questions.
|
||||||
|
|
||||||
|
- Do exactly the assigned task. Do not expand its scope, start unrelated work, or delegate further unless the task requires it.
|
||||||
|
- If the task is ambiguous, make the most reasonable assumption, state it, and continue. If you are blocked, stop and report precisely what is missing instead of guessing.
|
||||||
|
- Your final message is the deliverable returned to the delegating agent. Make it self-contained: the answer or result first, then the supporting evidence (absolute file paths with line numbers, commands you ran and their outcomes), then assumptions, risks, and anything left unfinished.
|
||||||
|
- Keep verified facts separate from inferences. Never claim something was changed, run, or tested unless you actually did it in this session.
|
||||||
|
- Durable memory and skill writes are unavailable here. If you learn something worth remembering, include it in your final report for the delegating agent to decide.
|
||||||
|
- Be concise but complete. Do not use emojis.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
// Bundle-authoritative tool guidance, owned by extensions/system-prompt.ts.
|
||||||
|
//
|
||||||
|
// Every tool the bundle can expose must have an entry. Per field:
|
||||||
|
// "snippet" — replaces the tool's one-line <tools> entry (null hides it)
|
||||||
|
// "guidelines" — replaces the tool's <rules> lines
|
||||||
|
// "description" — replaces the tool description at registration
|
||||||
|
// "descriptionAppend" — appended to the description at registration (on top
|
||||||
|
// of "description" when both are set)
|
||||||
|
// "description"/"descriptionAppend" only apply to tools registered through a
|
||||||
|
// bundle adapter (prompt-guard), which includes the bash override and the
|
||||||
|
// third-party wrappers; other in-repo tools edit their own source instead.
|
||||||
|
// An absent field keeps the upstream text. "inherit": true means the
|
||||||
|
// upstream text was reviewed against this bundle and is kept as-is.
|
||||||
|
"version": 1,
|
||||||
|
"tools": {
|
||||||
|
"bash": {
|
||||||
|
"snippet": "Run shell commands for builds, tests, git, package scripts, and other programs; search files with the find/grep tools, not bash",
|
||||||
|
"descriptionAppend": "Not for searching files: use the grep tool for file contents and the find tool for paths (e.g. `grep -n X src/*.ts` → grep {pattern: \"X\", path: \"src/*.ts\"}); grep, rg, find, fd, and git grep are denied here. When a program needs many files, pass $(git ls-files '<glob>') as arguments instead of piping into xargs, which triggers a permission review.",
|
||||||
|
"guidelines": [
|
||||||
|
"You can inspect PI_* environment variables for current model and session details.",
|
||||||
|
"Search commands are denied in bash; call the tools instead: `grep -n X src/*.ts` → grep {pattern: \"X\", path: \"src/*.ts\"}; `grep -rl X src` → grep {pattern: \"X\", path: \"src/\"} and take the file names; `find . -name '*.ts'` → find {pattern: \"\", path: \"**/*.ts\"}.",
|
||||||
|
"When a program needs many files (wc, node, tsc), pass $(git ls-files '<glob>') as arguments; piping into xargs triggers a permission review."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"read": { "inherit": true },
|
||||||
|
"edit": {
|
||||||
|
"guidelines": [
|
||||||
|
"Use edit with LINE#HASH anchors from the latest read or edit result for all file changes; batch every change to one file into a single edit call.",
|
||||||
|
"find/grep results carry plain line numbers, not LINE#HASH anchors; read the exact target range before editing.",
|
||||||
|
"Never reuse anchors from an earlier read or edit response once a newer one exists for the same file; treat only the most recent response as carrying valid anchors.",
|
||||||
|
"After a successful edit, the returned --- Anchors --- block replaces a re-read for nearby follow-up edits.",
|
||||||
|
"On [E_STALE_ANCHOR], the error lists the stale refs and any content-matched candidate anchors; re-read the file to get current anchors before retrying."
|
||||||
|
],
|
||||||
|
"description": "Patch a text file at `LINE#HASH` anchors copied verbatim from the latest read result or the anchors block of a previous edit. find/grep results have no anchors — read the target range first.\n\nBatch every change to a file into one `edit` call: all operations go in the `edits` array, every edit sets `op`, and all anchors must come from the same pre-edit read. Edits validate against one snapshot and apply together, so line numbers never shift between entries of the same call.\n\nOps:\n- `replace` — replace the single line at `pos`, or the inclusive span `pos`..`end`. `lines` is the complete new content for the whole span; `lines: []` deletes it. Without `end`, exactly one line is replaced no matter how many entries `lines` has.\n- `append` — insert `lines` after `pos`; omit `pos` to append at end of file.\n- `prepend` — insert `lines` before `pos`; omit `pos` to insert at start of file.\n\nExample — single-line and span replace in one call:\n```json\n{ \"path\": \"src/main.ts\", \"edits\": [\n { \"op\": \"replace\", \"pos\": \"12#MQ\", \"lines\": [\"const x = 1;\"] },\n { \"op\": \"replace\", \"pos\": \"5#VR\", \"end\": \"8#QV\", \"lines\": [\n \"function greet(name) {\",\n \" return `Hello, ${name}`;\",\n \"}\"\n ] }\n] }\n```\n\nRules:\n- `lines` is literal file content with exact indentation. Never include `LINE#HASH:` or bare `HH:` prefixes, diff `+`/`-` markers, or a copy of a neighboring line — the `:content` part of an anchor is context for you, not payload, and repeating a boundary line duplicates it in the file.\n- Anchors are opaque: copy them exactly, never compute, shift, or guess one.\n- An anchor may keep the `:content` suffix from read output (`\"12#MQ: const x = 1;\"`). The runtime cross-checks that content against the file — catching hash collisions and recovering whitespace-only drift — so keep it for high-risk anchors such as range endpoints. Copy the content as rendered; if you shorten it, keep the start of the line and mark the cut with `...`.\n- Edits in one call must not overlap or touch adjacent lines — merge such changes into a single edit."
|
||||||
|
},
|
||||||
|
"write": { "inherit": true },
|
||||||
|
"ls": { "inherit": true },
|
||||||
|
"powershell": { "inherit": true },
|
||||||
|
"grep": { "snippet": "Search file contents via the FFF index (git-aware, smart-case, frecency-ranked, regex or literal); replaces bash grep/rg" },
|
||||||
|
"find": { "snippet": "Find files by fuzzy path or glob via the FFF index; replaces bash find/fd" },
|
||||||
|
"tool_search": { "inherit": true },
|
||||||
|
"codemode": {
|
||||||
|
"guidelines": [
|
||||||
|
"Use codemode to batch two or more independent tool calls (Promise.allSettled), chain dependent lookups, or filter large output down to what you need; call a single tool directly instead of wrapping it in a script.",
|
||||||
|
"Call edit, write, ssh_connect, ssh_cd, ask_user_question, and Agent directly, never from a codemode script.",
|
||||||
|
"Inside scripts, search with tools.grep or tools.find and do extraction, dedup, and counting in JavaScript; never run grep, find, rg, or fd through tools.bash."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"codegraph_explore": { "snippet": "Explore code structure from the CodeGraph index: symbols, relationships, call flows, and their source" },
|
||||||
|
"lsp_diagnostics": { "inherit": true },
|
||||||
|
"lsp_definition": { "inherit": true },
|
||||||
|
"lsp_references": { "inherit": true },
|
||||||
|
"lsp_hover": { "inherit": true },
|
||||||
|
"lsp_symbols": { "inherit": true },
|
||||||
|
|
||||||
|
"web_search": { "inherit": true },
|
||||||
|
"web_fetch": { "inherit": true },
|
||||||
|
|
||||||
|
"memory_search": { "inherit": true },
|
||||||
|
"session_search": { "inherit": true },
|
||||||
|
"memory_add": { "inherit": true },
|
||||||
|
"memory_replace": { "inherit": true },
|
||||||
|
"memory_remove": { "inherit": true },
|
||||||
|
"skill_manage": { "inherit": true },
|
||||||
|
"context_tree_query": { "inherit": true },
|
||||||
|
"overflow_query": { "inherit": true },
|
||||||
|
|
||||||
|
"ask_user_question": { "inherit": true },
|
||||||
|
|
||||||
|
"ssh_connect": { "inherit": true },
|
||||||
|
"ssh_cd": { "inherit": true },
|
||||||
|
"ssh_read": { "inherit": true },
|
||||||
|
"ssh_write": { "inherit": true },
|
||||||
|
"ssh_edit": { "inherit": true },
|
||||||
|
"ssh_find": { "inherit": true },
|
||||||
|
"ssh_grep": { "inherit": true },
|
||||||
|
"ssh_bash": { "inherit": true },
|
||||||
|
|
||||||
|
"Agent": { "inherit": true },
|
||||||
|
"get_subagent_result": { "inherit": true },
|
||||||
|
"steer_subagent": { "inherit": true },
|
||||||
|
|
||||||
|
"chrome_launch": { "inherit": true },
|
||||||
|
"chrome_tab": { "inherit": true },
|
||||||
|
"chrome_navigate": { "inherit": true },
|
||||||
|
"chrome_snapshot": { "inherit": true },
|
||||||
|
"chrome_find": { "inherit": true },
|
||||||
|
"chrome_inspect": { "inherit": true },
|
||||||
|
"chrome_wait_for": { "inherit": true },
|
||||||
|
"chrome_screenshot": { "inherit": true },
|
||||||
|
"chrome_click": { "inherit": true },
|
||||||
|
"chrome_type": { "inherit": true },
|
||||||
|
"chrome_fill": { "inherit": true },
|
||||||
|
"chrome_key": { "inherit": true },
|
||||||
|
"chrome_hover": { "inherit": true },
|
||||||
|
"chrome_drag": { "inherit": true },
|
||||||
|
"chrome_tap": { "inherit": true },
|
||||||
|
"chrome_scroll": { "inherit": true },
|
||||||
|
"chrome_evaluate": { "inherit": true },
|
||||||
|
"chrome_list_console_messages": { "inherit": true },
|
||||||
|
"chrome_list_network_requests": { "inherit": true },
|
||||||
|
"chrome_get_network_request": { "inherit": true },
|
||||||
|
"chrome_upload_file": { "inherit": true }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"commandRewritingEnabled": false,
|
||||||
|
"mode": "rewrite",
|
||||||
|
"guardWhenRtkMissing": true,
|
||||||
|
"showRewriteNotifications": true,
|
||||||
|
"outputCompaction": {
|
||||||
|
"enabled": true,
|
||||||
|
"stripAnsi": true,
|
||||||
|
"readCompaction": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"truncate": {
|
||||||
|
"enabled": false,
|
||||||
|
"maxChars": 12000
|
||||||
|
},
|
||||||
|
"sourceCodeFilteringEnabled": false,
|
||||||
|
"preserveExactSkillReads": false,
|
||||||
|
"sourceCodeFiltering": "none",
|
||||||
|
"smartTruncate": {
|
||||||
|
"enabled": false,
|
||||||
|
"maxLines": 220
|
||||||
|
},
|
||||||
|
"aggregateTestOutput": true,
|
||||||
|
"filterBuildOutput": true,
|
||||||
|
"compactGitOutput": true,
|
||||||
|
"aggregateLinterOutput": true,
|
||||||
|
"trackSavings": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"alwaysEnabled": [
|
||||||
|
"codegraph_explore",
|
||||||
|
"lsp_diagnostics",
|
||||||
|
"memory_search",
|
||||||
|
"session_search",
|
||||||
|
"memory_add",
|
||||||
|
"memory_replace",
|
||||||
|
"memory_remove",
|
||||||
|
"skill_manage",
|
||||||
|
"context_tree_query",
|
||||||
|
"overflow_query",
|
||||||
|
"codemode"
|
||||||
|
],
|
||||||
|
"showToolSearchFooterStatus": false,
|
||||||
|
"maxActiveGroups": 5,
|
||||||
|
"maxToolsPerGroup": 8,
|
||||||
|
"maxDynamicTools": 28,
|
||||||
|
"groupOverrides": {}
|
||||||
|
}
|
||||||
Executable
+153
@@ -0,0 +1,153 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(CDPATH=; cd "$(dirname "$0")" && pwd) || exit 1
|
||||||
|
|
||||||
|
say() {
|
||||||
|
printf '%s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
warn() {
|
||||||
|
printf '错误: %s\n' "$*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
用法:
|
||||||
|
./configure.sh search [选项] 配置搜索 API key(tavily / exa / keenable)
|
||||||
|
./configure.sh ssh [选项] 配置 SSH 主机凭据
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
search_usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
用法:
|
||||||
|
./configure.sh search
|
||||||
|
./configure.sh search --tavily - 从标准输入读取 key
|
||||||
|
./configure.sh search --migrate [search.env 路径]
|
||||||
|
./configure.sh search --list
|
||||||
|
|
||||||
|
选项:
|
||||||
|
--tavily - 从标准输入读取 tavily api_key 凭据
|
||||||
|
--exa - 从标准输入读取 exa api_key 凭据
|
||||||
|
--keenable - 从标准输入读取 keenable api_key 凭据
|
||||||
|
--migrate 从旧 search.env 导入缺失的 key
|
||||||
|
--list 显示各 provider 配置状态(不输出 key)
|
||||||
|
--auth FILE 覆盖 auth.json 路径
|
||||||
|
-h, --help 显示帮助
|
||||||
|
|
||||||
|
key 不得出现在命令行参数中:非交互方式用 `--<provider> -` 从标准输入
|
||||||
|
读取,每次调用最多一个 provider 可经标准输入配置;不传参数时进入
|
||||||
|
隐藏输入的交互模式,未指定的 provider 保留已有值。凭据写入 Pi 的
|
||||||
|
auth.json($PI_CODING_AGENT_DIR/auth.json 或 ~/.pi/agent/auth.json,
|
||||||
|
权限 600)。key 支持 auth.json 原生语义:字面量、$ENV_VAR / ${ENV_VAR}
|
||||||
|
插值、以及 !command(执行命令取 stdout,如 "!op read op://Vault/Tavily/key")。
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_search() {
|
||||||
|
SEARCH_AUTH_MJS="$SCRIPT_DIR/scripts/search-auth.mjs"
|
||||||
|
[ -f "$SEARCH_AUTH_MJS" ] || { warn "找不到 ${SEARCH_AUTH_MJS}"; exit 1; }
|
||||||
|
|
||||||
|
SEARCH_AUTH_FILE=
|
||||||
|
search_mode=interactive
|
||||||
|
search_did_work=0
|
||||||
|
search_stdin_provider=
|
||||||
|
|
||||||
|
run_search_auth() {
|
||||||
|
if [ -n "$SEARCH_AUTH_FILE" ]; then
|
||||||
|
node "$SEARCH_AUTH_MJS" --auth "$SEARCH_AUTH_FILE" "$@"
|
||||||
|
else
|
||||||
|
node "$SEARCH_AUTH_MJS" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
search_has_key() {
|
||||||
|
run_search_auth --has "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
search_prompt_key() {
|
||||||
|
prompt_label=$1
|
||||||
|
prompt_configured=$2
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
|
warn "交互模式需要终端;请改用 '--<provider> -' 从标准输入提供 key。"
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
if [ "$prompt_configured" -eq 1 ]; then
|
||||||
|
printf '%s API key(回车保留现有值): ' "$prompt_label" >&2
|
||||||
|
else
|
||||||
|
printf '%s API key(回车跳过): ' "$prompt_label" >&2
|
||||||
|
fi
|
||||||
|
stty -echo
|
||||||
|
IFS= read -r prompt_value || prompt_status=$?
|
||||||
|
stty echo
|
||||||
|
printf '\n' >&2
|
||||||
|
[ "${prompt_status:-0}" -eq 0 ] || return "$prompt_status"
|
||||||
|
printf '%s\n' "$prompt_value"
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case $1 in
|
||||||
|
--tavily|--exa|--keenable)
|
||||||
|
[ "$#" -ge 2 ] || { warn "$1 缺少 KEY 或 -"; exit 2; }
|
||||||
|
if [ "$2" != "-" ]; then
|
||||||
|
warn "$1 不接受明文 key:key 不得出现在命令行参数中;请用交互模式或 '$1 -' 从标准输入提供。"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
[ -z "$search_stdin_provider" ] || { warn "每次调用最多一个 provider 可经标准输入读取 key。"; exit 2; }
|
||||||
|
search_stdin_provider=${1#--}
|
||||||
|
search_mode=noninteractive; search_did_work=1
|
||||||
|
run_search_auth --set-stdin "$search_stdin_provider" || exit $?
|
||||||
|
shift 2 ;;
|
||||||
|
--migrate)
|
||||||
|
search_mode=noninteractive; search_did_work=1
|
||||||
|
if [ "$#" -ge 2 ] && [ "${2#--}" = "$2" ]; then
|
||||||
|
run_search_auth --migrate "$2" || exit $?
|
||||||
|
shift 2
|
||||||
|
else
|
||||||
|
run_search_auth --migrate || exit $?
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
say "如确认 auth.json 可用,可手动删除旧 search.env 文件。" ;;
|
||||||
|
--list) search_mode=noninteractive; search_did_work=1; run_search_auth --list || exit $?; shift ;;
|
||||||
|
--auth)
|
||||||
|
[ "$#" -ge 2 ] || { warn "--auth 缺少 FILE"; exit 2; }
|
||||||
|
SEARCH_AUTH_FILE=$2; shift 2 ;;
|
||||||
|
-h|--help) search_usage; exit 0 ;;
|
||||||
|
*) warn "未知参数:$1"; search_usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$search_mode" = interactive ]; then
|
||||||
|
for provider in tavily exa keenable; do
|
||||||
|
case $provider in
|
||||||
|
tavily) label=Tavily ;;
|
||||||
|
exa) label=Exa ;;
|
||||||
|
keenable) label=Keenable ;;
|
||||||
|
esac
|
||||||
|
if search_has_key "$provider"; then configured=1; else configured=0; fi
|
||||||
|
value=$(search_prompt_key "$label" "$configured") || exit $?
|
||||||
|
if [ -n "$value" ]; then
|
||||||
|
printf '%s' "$value" | run_search_auth --set-stdin "$provider" || exit $?
|
||||||
|
search_did_work=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$search_did_work" -eq 1 ] || say "未提供任何 key,auth.json 保持不变。"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case ${1:-} in
|
||||||
|
search)
|
||||||
|
shift
|
||||||
|
configure_search "$@"
|
||||||
|
;;
|
||||||
|
ssh)
|
||||||
|
shift
|
||||||
|
exec node "$SCRIPT_DIR/pi-ssh/scripts/ssh-config-launcher.mjs" "$@"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# my-pi 维护约定(extensions/)
|
||||||
|
|
||||||
|
本目录是组合包的薄 wrapper 与纯 helper 层:第三方扩展一律经这里的包装文件(多数再套 `prompt-guard.ts` 代理)加载,自维护实现位于各自顶层目录。
|
||||||
|
|
||||||
|
## 系统提示词单一所有权
|
||||||
|
|
||||||
|
- `extensions/system-prompt.ts` 是组合包唯一的系统提示词出口,必须排在 `pi.extensions` 最后,也是唯一允许在 `before_agent_start` 返回 `systemPrompt` 的扩展。提示词内容统一由 `config/prompt/` 管理:`preamble.md`(开场白)、`tools.jsonc`(每个工具的简介、规则及第三方工具说明覆盖)、`routing.jsonc`(跨工具工作流规则,渲染为 `<tool_routing>`)、`sections.jsonc`(分区顺序)。
|
||||||
|
- 其他组合包扩展不得返回 `systemPrompt`,也不得在 `context` 钩子中注入提示性消息;需要动态提示时只能写入 `event.systemPromptOptions.sections.<name>` 并在 `sections.jsonc` 登记顺序,静态规则写入 `promptGuidelines` 或该工具在 `tools.jsonc` 中的条目。
|
||||||
|
- 第三方扩展必须经 `extensions/` 下的组合包包装文件加载,由 `extensions/prompt-guard.ts` 的代理 API 拦截其对系统提示词的直接改写,并在注册工具时应用 `tools.jsonc` 的 `description` 覆盖;上游硬编码且无配置开关的注入只能在对应包装文件内处理,并以测试锁定所依赖的上游原文(依赖保持精确版本)。
|
||||||
|
- 新增工具必须在 `tools.jsonc` 登记(覆盖说明,或以 `"inherit": true` 表示已按本组合包审阅上游原文),否则 `tests/system-prompt.test.ts` 失败;`config/prompt/` 中的文字不得引用被策略禁止或被替换的用法。运行时若检测到其他扩展已定死提示词或存在未登记工具,`system-prompt.ts` 会各提示一次。
|
||||||
|
|
||||||
|
## `system-prompt.ts` 开发约定
|
||||||
|
|
||||||
|
- 扩展入口是 `extensions/system-prompt.ts`,必须排在 `pi.extensions` 最后;纯 helper(JSONC 加载与校验、`applyToolCatalog` / `renderRouting` / `orderSections` / `spliceCover` / `readPreamble` 等)位于 `extensions/system-prompt-config.ts` 以便 `node --test` 单测;组合包权威源是 `config/prompt/`(`preamble.md` / `tools.jsonc` / `routing.jsonc` / `sections.jsonc`),扩展运行时直接读取已安装组合包内文件,不经任何脚本部署。
|
||||||
|
- 现代 Pi 下必须通过修改规范化 `systemPromptOptions`(`toolSnippets` / `toolGuidelines` / `sections`)保留分区结构,再读取重渲染的 `event.systemPrompt` 拼入封面并返回 `systemPrompt`;宿主不提供结构化 options 或 options 不参与重渲染时自动降级为文本拼接。不得改用 `customPrompt` 选项——它会抑制 `tools` / `rules` / `docs` 分区。
|
||||||
|
- 宿主传入 `customPrompt`(pi-subagents 子 Agent prompt、SYSTEM.md)时,该开场白自含身份标记(如 `<active_agent/>`),不得用 `preamble.md` 覆盖;其余分区处理(tool catalog、`<tool_routing>`、分区排序、降级追加)照常。子会话判定只用 `systemPromptOptions.customPrompt` 上的 `<active_agent name="..."/>` 契约(`isSubagentCustomPrompt`),绝不检测渲染后提示词——主会话的项目文档也可能含该字面量。
|
||||||
|
- 子 Agent 提示词画像在 `config/prompt/subagent/`(`preamble.md` + `agents/*.md`):由 `extensions/subagents.ts` 注入 pi-subagents 的 `basePrompt`/`bundledAgentsDir` 选项,替代上游的父提示词嵌入与内置默认 Agent;子会话经 `excludedExtensionPaths` 排除 `pi-ask-user`(子会话无人可问)。
|
||||||
|
- `routing.jsonc` 规则可选 `"scope": "main" | "subagent"`(缺省两者皆适用,其他值校验失败),`renderRouting` 按 audience 过滤;`sections.jsonc` 可选 `"subagentExclude"` 分区名列表,子会话在排序前删除这些分区(当前排除 `memory_policy` 与 `chrome_profile_bridge`)。
|
||||||
|
- 封面拼接边界必须是独占一行的裸标签,不能匹配带属性的标签:`<project_instructions path="...">` 嵌套在 `<project_context>` 内,只有裸标签才是分区边界。
|
||||||
|
- `preamble.md` 缺失、不可读或空白时保持 Pi 默认封面,不得写入空 preamble;`config/prompt/` 解析或校验失败时 `before_agent_start` 警告一次并整体 no-op,绝不抛出。
|
||||||
|
- 检测到其他扩展已设置 `forceSystemPrompt`、或激活工具未在 `tools.jsonc` 登记时各提示一次(warning);本扩展返回的 `systemPrompt` 把提示词冻结为最终文本是刻意设计——这也是它必须最后加载的原因。
|
||||||
|
- `config/prompt/` 随组合包发布,`pi install` / `pi update` 后即就位,无需任何部署 helper;对已安装副本的临时修改由 `pi update` 覆盖。路由规则本体见 `config/prompt/routing.jsonc` 与 README,`system-prompt.ts` 只负责渲染,不得替换 Pi 默认系统提示词。
|
||||||
|
- 修改 loader、`spliceCover` 语义、注册顺序或 `config/prompt/` 路径后,必须运行 `node --test tests/system-prompt.test.ts`、`node --test tests/bundle-config.test.ts`、`node --test tests/extension-load.test.ts`,并确认 `npm pack --dry-run` 包含 `config/prompt/`。
|
||||||
|
|
||||||
|
## 各 wrapper 约定
|
||||||
|
|
||||||
|
- `fff-override.ts`:强制 FFF 官方 `override` 模式,统一接管 `find`、`grep` 和 `@` 补全;`multi_grep` 按上游保持关闭(加载前删除 `PI_FFF_MULTIGREP` 环境变量)。FFF 独占本地字面搜索,远端服务器搜索由 `ssh_find` / `ssh_grep` 负责,RTK 不得接管搜索工具(见 `pi-rtk-optimizer/AGENTS.md`)。显式 CLI flag 仍遵循 FFF 官方优先级。
|
||||||
|
- `bash-override.ts`:用 `createBashToolDefinition` 重新注册内置 `bash`,目的只是让 `tools.jsonc` 的 `descriptionAppend` 落到模型可见描述里(prompt-guard 在注册时拼接)。ExtensionContext 不暴露 session settings,因此 `shellPath`/`commandPrefix` 在首次 execute 时按 `ctx.cwd` 经 `SettingsManager.create(ctx.cwd, getAgentDir())` 惰性解析并按 cwd 缓存,metadata 来自 `process.cwd()` 的定义;执行、输出结构与 codemode 说明完全委托上游定义,不得改动上游执行逻辑,也不得用 prepareLoadout 或 provider-payload 改写实现同样效果。
|
||||||
|
- `codegraph.ts` + `codegraph-client.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性常驻 `codegraph serve --mcp -p <root>` 子进程,经组合包自有 stdio 逐行 JSON-RPC 客户端直连(不经 shell、不带 pi-mcp-adapter 或 MCP SDK);serve 进程内置文件监听自动同步索引,无结果缓存。子进程 10 分钟无调用自动关闭、会话结束统一关闭;调用中死亡换新客户端透明重试一次;其启动的上游 daemon(`.codegraph/daemon.sock`)退出后可多驻留 ≤5 分钟,属上游行为,组合包不管理。
|
||||||
|
- CodeGraph 是机器级依赖,`install.sh`/`update.sh` 经官方安装器管理 `codegraph` CLI,组合包不内置其运行时。安装、升级和普通启动流程不得执行 `codegraph init`;只有 `codegraph_explore` 在未初始化项目上被调用、且用户在 `ctx.ui.confirm` 中明确确认后才可 `codegraph init` 当前目录,拒绝记入会话级 declined 不再重复询问;init 失败必须如实报告并让模型回退内置工具;不得改动 CodeGraph 更新或遥测设置。CLI 缺失、init 被拒或服务调用失败都不得抛出,必须以文本结果打回并引导模型改用 `find`/`grep`/`read`/LSP。已移除的 `plugin-init.ts`(原 `/plugin_init`)职责已并入本文件的惰性确认,不得恢复。
|
||||||
|
- `hashline.ts` + `hashline-config.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`;默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 与 `replaceText` 均关闭。它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
|
||||||
|
- `permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,自身不部署、不播种、不迁移任何配置;运行时直接读取已安装组合包内 `config/pi-permission-policy.jsonc` / `config/pi-permission-hard-guards.jsonc`;日志保留在 `<agentDir>/my-pi/permission-system/logs/`。旧 `extensions/pi-permission-system/` 位置的残留文件被忽略,不迁移也不清理;项目级 policy 不读取。
|
||||||
|
- `lsp.ts` + `lsp-config.ts`:TypeScript/JavaScript 后端由根包固定依赖提供,用当前 Node 可执行文件直接启动包内 `typescript-language-server` CLI,不得依赖或调用 VS Code GUI;Kotlin/JDT LS 使用组合包部署的 `kotlin-lsp --stdio` 与 `jdtls` 配置,根包不安装这两个系统可执行文件。
|
||||||
|
- `subagents.ts`:pi-subagents 的组合包 wrapper,注入权威 `permission-system.ts` 的 canonical absolute path 为子会话 mandatory extension,并提供 `config/prompt/subagent/` 提示词画像(`basePrompt` + `bundledAgentsDir`)与 `excludedExtensionPaths`(排除 `pi-ask-user`);完整规则见 `pi-subagents/AGENTS.md`。
|
||||||
|
- `context-view.ts`:只读的本地 effective-context observer(`/context`),只读取 `pi-compaction/src/service.ts` 发布的 projection metrics,不注册生产 `context` writer;`pi-minimal-footer` 优先显示同一 effective usage。
|
||||||
|
- `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
|
||||||
|
|
||||||
|
## 根目录 TypeScript 与 LSP 验证约定
|
||||||
|
|
||||||
|
- 根目录当前有意不安装 Pi runtime,也没有根级 `tsconfig.json`、`devDependencies` 或 `@types/node`;`@earendil-works/pi-*` 只作为 optional host peer dependencies。TypeScript LSP 因而会把根目录 `.ts` 文件放入 inferred project,不能把宿主 Pi 的运行时解析能力等同于仓库内静态类型环境。
|
||||||
|
- 每个任务首次对相关根目录 TypeScript 文件调用 `lsp_diagnostics` 时先建立并记录基线;后续只关注相对基线新增、消失或位置变化的诊断。文件和类型环境都未变化时不得重复调用 LSP 并重复报告同一组结果。
|
||||||
|
- 当前可预期的基线包括:宿主 peer 缺失导致的 `TS2307`(例如找不到 `@earendil-works/pi-coding-agent`),Node 类型缺失导致的 `node:*` / `node:test` / `node:assert` `TS2591`,以及由这些上游类型缺失级联产生的 `TS7006`、`TS18048`、`TS2722` 等。遇到级联错误时先确认其是否随缺失类型而产生,不要把它们直接归因于本次实现。
|
||||||
|
- 已知基线不等于忽略所有诊断:凡是无法由上述缺失依赖解释的新语法错误、结构类型错误、错误属性访问或本次修改所在代码的新诊断,必须修复并重新验证。交付时应把“已知类型环境基线”和“本次新增诊断”分开说明。
|
||||||
|
- 不得为了消除 LSP 基线而在根包安装第二套 Pi runtime、加入机器相关的宿主绝对路径或提交只在单机有效的 `paths` 映射。若确需新增根级 `tsconfig.json`、`@types/node` 或其他开发类型环境,必须作为明确的仓库设计变更评估,并同步更新依赖、锁文件和本文档。
|
||||||
|
- 不要从泛型或重载的 `ExtensionAPI` 方法直接用 `Parameters<...>` 猜取具体工具定义;解析失败时它可能退化为 `unknown`。包装第三方工具时优先使用上游导出的具体类型;没有稳定导出时定义覆盖实际访问字段的最小结构类型,并用运行时集成测试验证。
|
||||||
|
- `node --test` 可直接测试仓库内的纯 TypeScript helper,但测试入口不得静态导入 `node_modules` 中的 `.ts`;Node 原生 type stripping 会对这种路径报 `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`。第三方 TypeScript 扩展 wrapper 应拆出本地纯 helper 做单元测试,并通过隔离的临时 Pi agent/jiti 加载验证真实扩展入口。
|
||||||
|
- 对根目录扩展的最终验证优先组合使用:纯 helper 的 `node --test`、`git diff --check`、所需 npm 锁文件检查,以及隔离 Pi 环境的扩展加载。LSP 启动成功、单元测试通过和真实 Pi 加载通过是不同证据,不得互相替代或把类型环境基线误报为 LSP 后端启动失败。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
createBashToolDefinition,
|
||||||
|
type ExtensionAPI,
|
||||||
|
getAgentDir,
|
||||||
|
SettingsManager,
|
||||||
|
} from "@earendil-works/pi-coding-agent";
|
||||||
|
import { guardExtensionApi } from "./prompt-guard.ts";
|
||||||
|
|
||||||
|
type BashDefinition = ReturnType<typeof createBashToolDefinition>;
|
||||||
|
|
||||||
|
/** Re-register Pi's bash so tools.jsonc can append to its model-facing description; execution stays upstream. */
|
||||||
|
export default function bashOverrideExtension(pi: ExtensionAPI): void {
|
||||||
|
const sessionDefinitions = new Map<string, BashDefinition>();
|
||||||
|
const sessionDefinition = (cwd: string): BashDefinition => {
|
||||||
|
let definition = sessionDefinitions.get(cwd);
|
||||||
|
if (definition === undefined) {
|
||||||
|
const settings = SettingsManager.create(cwd, getAgentDir());
|
||||||
|
definition = createBashToolDefinition(cwd, {
|
||||||
|
shellPath: settings.getShellPath(),
|
||||||
|
commandPrefix: settings.getShellCommandPrefix(),
|
||||||
|
});
|
||||||
|
sessionDefinitions.set(cwd, definition);
|
||||||
|
}
|
||||||
|
return definition;
|
||||||
|
};
|
||||||
|
|
||||||
|
guardExtensionApi(pi).registerTool({
|
||||||
|
...sessionDefinition(process.cwd()),
|
||||||
|
execute: (toolCallId, params, signal, onUpdate, ctx) =>
|
||||||
|
sessionDefinition(ctx?.cwd ?? process.cwd()).execute(
|
||||||
|
toolCallId,
|
||||||
|
params,
|
||||||
|
signal,
|
||||||
|
onUpdate,
|
||||||
|
ctx,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
/**
|
||||||
|
* Minimal stdio JSON-RPC 2.0 client for `codegraph serve --mcp -p <root>`.
|
||||||
|
*
|
||||||
|
* Same approach as pi-web-search/clients/exa.ts (plain JSON-RPC, no MCP
|
||||||
|
* adapter or SDK): `codegraph serve --mcp` speaks one JSON object per line on
|
||||||
|
* stdio. Unlike the one-shot `codegraph explore` CLI, the serve process runs a
|
||||||
|
* file watcher that keeps the index synced, so newly written symbols become
|
||||||
|
* searchable without a manual `codegraph sync`.
|
||||||
|
*
|
||||||
|
* One client exists per index root; `extensions/codegraph.ts` owns the map,
|
||||||
|
* idle closing, and the one-shot respawn retry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
spawn as nodeSpawn,
|
||||||
|
type ChildProcess,
|
||||||
|
type SpawnOptions,
|
||||||
|
} from "node:child_process";
|
||||||
|
|
||||||
|
const PROTOCOL_VERSION = "2025-06-18";
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
||||||
|
const STDERR_TAIL_LINES = 20;
|
||||||
|
const CLOSE_GRACE_MS = 1_000;
|
||||||
|
|
||||||
|
export type CodegraphSpawn = (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: SpawnOptions,
|
||||||
|
) => ChildProcess;
|
||||||
|
|
||||||
|
export interface CodegraphRpcClientOptions {
|
||||||
|
/** Index root — passed to `codegraph serve --mcp -p <root>` and used as cwd. */
|
||||||
|
root: string;
|
||||||
|
/** Test seam: replace the child_process spawn. */
|
||||||
|
spawn?: CodegraphSpawn;
|
||||||
|
/** Default "codegraph". */
|
||||||
|
command?: string;
|
||||||
|
/** Default ["serve", "--mcp", "-p", root]. */
|
||||||
|
args?: string[];
|
||||||
|
/** Per-request timeout; default 120 s. */
|
||||||
|
requestTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingRequest {
|
||||||
|
resolve: (result: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentText(result: Record<string, unknown>): string {
|
||||||
|
const content = Array.isArray(result.content)
|
||||||
|
? (result.content as Record<string, unknown>[])
|
||||||
|
: [];
|
||||||
|
return content
|
||||||
|
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
||||||
|
.map((part) => part.text as string)
|
||||||
|
.join("\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CodegraphRpcClient {
|
||||||
|
private readonly spawnFn: CodegraphSpawn;
|
||||||
|
private readonly command: string;
|
||||||
|
private readonly args: string[];
|
||||||
|
private readonly timeoutMs: number;
|
||||||
|
|
||||||
|
private child: ChildProcess | undefined;
|
||||||
|
private stdoutBuffer = "";
|
||||||
|
private stderrLines: string[] = [];
|
||||||
|
private pending = new Map<number, PendingRequest>();
|
||||||
|
private nextId = 1;
|
||||||
|
private startPromise: Promise<void> | undefined;
|
||||||
|
/** null = upstream tool declares no inputSchema.properties → forward all args. */
|
||||||
|
private schemaProps: ReadonlySet<string> | null = null;
|
||||||
|
private deadFlag = false;
|
||||||
|
private closedFlag = false;
|
||||||
|
private deathFinalized = false;
|
||||||
|
private exitDetail = "";
|
||||||
|
private deathReason = "codegraph serve 子进程已退出";
|
||||||
|
|
||||||
|
private readonly options: CodegraphRpcClientOptions;
|
||||||
|
|
||||||
|
constructor(options: CodegraphRpcClientOptions) {
|
||||||
|
this.options = options;
|
||||||
|
this.spawnFn = options.spawn ?? nodeSpawn;
|
||||||
|
this.command = options.command ?? "codegraph";
|
||||||
|
this.args =
|
||||||
|
options.args ?? ["serve", "--mcp", "-p", options.root];
|
||||||
|
this.timeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once the child has exited or errored — the handler respawns instead. */
|
||||||
|
get dead(): boolean {
|
||||||
|
return this.deadFlag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Property names of the upstream codegraph_explore inputSchema. */
|
||||||
|
schemaPropertyNames(): readonly string[] {
|
||||||
|
return this.schemaProps === null ? [] : [...this.schemaProps];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spawn + initialize + tools/list. Memoized: concurrent callers share it. */
|
||||||
|
start(): Promise<void> {
|
||||||
|
if (!this.startPromise) {
|
||||||
|
this.startPromise = this.doStart();
|
||||||
|
// A dead child during startup rejects this promise; attach a no-op
|
||||||
|
// branch so it never surfaces as an unhandled rejection when a
|
||||||
|
// racing close() kills the child with nobody awaiting start().
|
||||||
|
this.startPromise.catch(() => {});
|
||||||
|
}
|
||||||
|
return this.startPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doStart(): Promise<void> {
|
||||||
|
let child: ChildProcess;
|
||||||
|
try {
|
||||||
|
child = this.spawnFn(this.command, this.args, {
|
||||||
|
cwd: this.options.root,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.markDead(
|
||||||
|
`无法启动 codegraph serve:${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
throw this.deathError();
|
||||||
|
}
|
||||||
|
this.child = child;
|
||||||
|
this.attach(child);
|
||||||
|
await this.request("initialize", {
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "my-pi-codegraph", version: "1.0.0" },
|
||||||
|
});
|
||||||
|
this.notify("notifications/initialized");
|
||||||
|
const list = (await this.request("tools/list", {})) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const tools = Array.isArray(list.tools) ? list.tools : [];
|
||||||
|
const tool = tools.find(
|
||||||
|
(entry) => isRecord(entry) && entry.name === "codegraph_explore",
|
||||||
|
);
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error("codegraph serve 未提供 codegraph_explore 工具");
|
||||||
|
}
|
||||||
|
const properties =
|
||||||
|
isRecord(tool) && isRecord(tool.inputSchema)
|
||||||
|
? tool.inputSchema.properties
|
||||||
|
: undefined;
|
||||||
|
this.schemaProps = isRecord(properties)
|
||||||
|
? new Set(Object.keys(properties))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `tools/call codegraph_explore`. Forwards only the argument properties the
|
||||||
|
* upstream inputSchema declares (e.g. `projectPath` is forwarded only when
|
||||||
|
* upstream lists it). Returns the raw result object; the handler passes
|
||||||
|
* `result.content` through verbatim.
|
||||||
|
*/
|
||||||
|
async callExplore(
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
await this.start();
|
||||||
|
const forwarded: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(args)) {
|
||||||
|
if (this.schemaProps === null || this.schemaProps.has(key)) {
|
||||||
|
forwarded[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await this.request(
|
||||||
|
"tools/call",
|
||||||
|
{ name: "codegraph_explore", arguments: forwarded },
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (isRecord(result) && result.isError) {
|
||||||
|
throw new Error(
|
||||||
|
`codegraph_explore 返回错误:${contentText(result) || "unknown"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return isRecord(result) ? result : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End stdin, then SIGTERM after a 1 s grace. Idempotent. */
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.closedFlag) return;
|
||||||
|
this.closedFlag = true;
|
||||||
|
const child = this.child;
|
||||||
|
this.failAll(new Error("codegraph serve 客户端已关闭"));
|
||||||
|
if (!child) return;
|
||||||
|
try {
|
||||||
|
child.stdin?.end();
|
||||||
|
} catch {
|
||||||
|
// stdin already closed.
|
||||||
|
}
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
} catch {
|
||||||
|
// Already gone.
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}, CLOSE_GRACE_MS);
|
||||||
|
timer.unref();
|
||||||
|
child.once("exit", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
child.once("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internals ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private attach(child: ChildProcess): void {
|
||||||
|
// EPIPE on a write after the child died is reported via exit/close.
|
||||||
|
child.stdin?.on("error", () => {});
|
||||||
|
child.stdout?.setEncoding("utf8");
|
||||||
|
child.stdout?.on("data", (chunk: string) => {
|
||||||
|
this.stdoutBuffer += chunk;
|
||||||
|
for (;;) {
|
||||||
|
const newline = this.stdoutBuffer.indexOf("\n");
|
||||||
|
if (newline < 0) break;
|
||||||
|
const line = this.stdoutBuffer.slice(0, newline).trim();
|
||||||
|
this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1);
|
||||||
|
if (line) this.handleLine(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.stderr?.setEncoding("utf8");
|
||||||
|
child.stderr?.on("data", (chunk: string) => {
|
||||||
|
for (const line of chunk.split(/\r?\n/u)) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
this.stderrLines.push(line);
|
||||||
|
if (this.stderrLines.length > STDERR_TAIL_LINES) {
|
||||||
|
this.stderrLines.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.once("error", (error) => {
|
||||||
|
// Spawn failure (e.g. ENOENT) — the message carries the detail.
|
||||||
|
this.markDead(`无法启动 codegraph serve:${error.message}`);
|
||||||
|
});
|
||||||
|
child.once("exit", (code, signal) => {
|
||||||
|
this.deadFlag = true;
|
||||||
|
this.exitDetail =
|
||||||
|
signal !== null ? `signal ${signal}` : `code ${code}`;
|
||||||
|
});
|
||||||
|
// Reject pending on "close" (stdio drained), not "exit" — otherwise the
|
||||||
|
// final stderr lines may not have been delivered yet.
|
||||||
|
child.once("close", () => {
|
||||||
|
this.markDead(`codegraph serve 子进程退出(${this.exitDetail})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleLine(line: string): void {
|
||||||
|
let message: unknown;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
return; // Not JSON — server chatter; ignore.
|
||||||
|
}
|
||||||
|
if (!isRecord(message)) return;
|
||||||
|
|
||||||
|
// Server → client request: we implement no methods; reply -32601.
|
||||||
|
if (typeof message.method === "string" && message.id !== undefined) {
|
||||||
|
this.sendRaw({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: message.id,
|
||||||
|
error: { code: -32601, message: "Method not found" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Response to one of our requests.
|
||||||
|
if (message.id !== undefined) {
|
||||||
|
const entry = this.pending.get(Number(message.id));
|
||||||
|
if (!entry) return;
|
||||||
|
if (message.error !== undefined) {
|
||||||
|
const detail = isRecord(message.error)
|
||||||
|
? (message.error.message ?? JSON.stringify(message.error))
|
||||||
|
: String(message.error);
|
||||||
|
entry.reject(new Error(`codegraph serve ${String(detail)}`));
|
||||||
|
} else {
|
||||||
|
entry.resolve(message.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Anything else (server notifications) is ignored.
|
||||||
|
}
|
||||||
|
|
||||||
|
private request(
|
||||||
|
method: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (this.deadFlag || this.closedFlag) {
|
||||||
|
return Promise.reject(this.deathError());
|
||||||
|
}
|
||||||
|
const stdin = this.child?.stdin;
|
||||||
|
if (!this.child || !stdin || stdin.destroyed) {
|
||||||
|
return Promise.reject(new Error("codegraph serve 子进程不可用"));
|
||||||
|
}
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return Promise.reject(new Error("codegraph serve 请求已中止"));
|
||||||
|
}
|
||||||
|
const id = this.nextId++;
|
||||||
|
return new Promise<unknown>((resolve, reject) => {
|
||||||
|
let onAbort: (() => void) | undefined;
|
||||||
|
const finish = (action: () => void): void => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
||||||
|
this.pending.delete(id);
|
||||||
|
action();
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
finish(() =>
|
||||||
|
reject(new Error(`codegraph serve 请求 ${method} 超时`)),
|
||||||
|
);
|
||||||
|
}, this.timeoutMs);
|
||||||
|
timer.unref();
|
||||||
|
onAbort = () => {
|
||||||
|
this.notify("notifications/cancelled", {
|
||||||
|
requestId: id,
|
||||||
|
reason: "aborted",
|
||||||
|
});
|
||||||
|
finish(() => reject(new Error("codegraph serve 请求已中止")));
|
||||||
|
};
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
}
|
||||||
|
this.pending.set(id, {
|
||||||
|
resolve: (result) => finish(() => resolve(result)),
|
||||||
|
reject: (error) => finish(() => reject(error)),
|
||||||
|
});
|
||||||
|
this.sendRaw({ jsonrpc: "2.0", id, method, params });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(method: string, params?: Record<string, unknown>): void {
|
||||||
|
this.sendRaw({ jsonrpc: "2.0", method, params });
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendRaw(message: Record<string, unknown>): void {
|
||||||
|
try {
|
||||||
|
this.child?.stdin?.write(`${JSON.stringify(message)}\n`);
|
||||||
|
} catch {
|
||||||
|
// stdin already closed — the exit handler will fail pending calls.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private markDead(reason: string): void {
|
||||||
|
if (this.deathFinalized) return;
|
||||||
|
this.deathFinalized = true;
|
||||||
|
this.deadFlag = true;
|
||||||
|
const tail = this.stderrLines.join("\n").trim();
|
||||||
|
this.deathReason = tail ? `${reason};stderr: ${tail}` : reason;
|
||||||
|
this.failAll(this.deathError());
|
||||||
|
}
|
||||||
|
|
||||||
|
private deathError(): Error {
|
||||||
|
return new Error(
|
||||||
|
this.closedFlag && !this.deadFlag
|
||||||
|
? "codegraph serve 客户端已关闭"
|
||||||
|
: this.deathReason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private failAll(error: Error): void {
|
||||||
|
const pending = [...this.pending.values()];
|
||||||
|
this.pending.clear();
|
||||||
|
for (const entry of pending) entry.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import type {
|
||||||
|
AgentToolResult,
|
||||||
|
ExecOptions,
|
||||||
|
ExecResult,
|
||||||
|
ExtensionAPI,
|
||||||
|
ExtensionContext,
|
||||||
|
} from "@earendil-works/pi-coding-agent";
|
||||||
|
import { stat } from "node:fs/promises";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { CodegraphRpcClient } from "./codegraph-client.ts";
|
||||||
|
|
||||||
|
const INIT_TIMEOUT_MS = 600_000;
|
||||||
|
const IDLE_CLOSE_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
export interface ExploreParams {
|
||||||
|
query: string;
|
||||||
|
maxFiles?: number;
|
||||||
|
projectPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodegraphExec = (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options?: ExecOptions,
|
||||||
|
) => Promise<ExecResult>;
|
||||||
|
|
||||||
|
/** The slice of CodegraphRpcClient the handler depends on (test seam). */
|
||||||
|
export interface CodegraphExploreClient {
|
||||||
|
readonly dead: boolean;
|
||||||
|
start(): Promise<void>;
|
||||||
|
callExplore(
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
close(): Promise<void> | void;
|
||||||
|
schemaPropertyNames(): readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodegraphExploreDeps {
|
||||||
|
/** One-shot exec — used only for `codegraph init` after user approval. */
|
||||||
|
exec: CodegraphExec;
|
||||||
|
/** One client per index root; production wires `new CodegraphRpcClient({ root })`. */
|
||||||
|
createClient: (root: string) => CodegraphExploreClient;
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk up from startDir looking for a .codegraph/codegraph.db index file. */
|
||||||
|
export async function findIndexedRoot(
|
||||||
|
startDir: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
let dir = resolve(startDir);
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
if ((await stat(join(dir, ".codegraph", "codegraph.db"))).isFile())
|
||||||
|
return dir;
|
||||||
|
} catch {
|
||||||
|
// Missing or unreadable index — keep walking up.
|
||||||
|
}
|
||||||
|
const parent = dirname(dir);
|
||||||
|
if (parent === dir) return undefined;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResult(text: string): AgentToolResult {
|
||||||
|
return { content: [{ type: "text", text }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function failureText(result: ExecResult): string {
|
||||||
|
const detail = result.stdout.trim() || result.stderr.trim();
|
||||||
|
return detail
|
||||||
|
? `(退出码 ${result.code})${detail.slice(0, 400)}`
|
||||||
|
: `(退出码 ${result.code})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notIndexedText(startDir: string): string {
|
||||||
|
return `CodeGraph 未在 ${startDir} 建立索引(用户未授权或未安装)。本会话对该目录请改用 find/grep/read/LSP;如需启用 CodeGraph,请用户安装 codegraph CLI 并运行 codegraph init。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceFailureText(error: unknown): string {
|
||||||
|
const detail = errorMessage(error);
|
||||||
|
if (/ENOENT/u.test(detail)) {
|
||||||
|
return `未找到 codegraph CLI(${detail})。请用户运行 install.sh 安装;本次请改用 find/grep/read/LSP。`;
|
||||||
|
}
|
||||||
|
return `CodeGraph 服务调用失败(${detail})。请改用 find/grep/read/LSP 完成本次探索。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IndexDecision = { root: string } | { bounce: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native codegraph_explore tool backed by `codegraph serve --mcp`.
|
||||||
|
*
|
||||||
|
* Each index root gets one long-lived child (lazily started on first use,
|
||||||
|
* closed after 10 idle minutes or on session shutdown) spoken to through the
|
||||||
|
* bundle's own stdio JSON-RPC client — no MCP adapter. Unlike the one-shot
|
||||||
|
* `codegraph explore` CLI, the serve process runs a file watcher that keeps
|
||||||
|
* the index synced, so there is no result cache and no manual sync step.
|
||||||
|
*
|
||||||
|
* When the target has no index, the tool asks the user once whether to run
|
||||||
|
* `codegraph init`; a decline is remembered for the rest of the session. All
|
||||||
|
* failures bounce the model to find/grep/read/LSP — the tool never throws.
|
||||||
|
*/
|
||||||
|
export function createCodegraphExploreHandler(deps: CodegraphExploreDeps) {
|
||||||
|
const clients = new Map<string, CodegraphExploreClient>();
|
||||||
|
const declined = new Set<string>();
|
||||||
|
const pendingDecisions = new Map<string, Promise<IndexDecision>>();
|
||||||
|
const idleTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
const registeredProps = ["maxFiles", "projectPath", "query"];
|
||||||
|
let driftWarned = false;
|
||||||
|
|
||||||
|
async function decideIndex(
|
||||||
|
startDir: string,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<IndexDecision> {
|
||||||
|
const indexed = await findIndexedRoot(startDir);
|
||||||
|
if (indexed !== undefined) return { root: indexed };
|
||||||
|
if (!ctx.hasUI) return { bounce: notIndexedText(startDir) };
|
||||||
|
|
||||||
|
const confirmed = await ctx.ui.confirm(
|
||||||
|
"启用 CodeGraph",
|
||||||
|
`项目 ${startDir} 尚未建立 CodeGraph 索引。\n初始化会写入 .codegraph/ 并全量解析源码(大项目需要一些时间)。是否现在初始化?`,
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
declined.add(startDir);
|
||||||
|
return { bounce: notIndexedText(startDir) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const init = await deps.exec("codegraph", ["init", startDir], {
|
||||||
|
cwd: startDir,
|
||||||
|
timeout: INIT_TIMEOUT_MS,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (init.code !== 0) {
|
||||||
|
return {
|
||||||
|
bounce: `codegraph init 失败${failureText(init)}。请改用 find/grep/read/LSP 完成本次探索。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { root: (await findIndexedRoot(startDir)) ?? startDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decideIndexOnce(
|
||||||
|
startDir: string,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
let pending = pendingDecisions.get(startDir);
|
||||||
|
if (!pending) {
|
||||||
|
pending = decideIndex(startDir, ctx, signal).finally(() =>
|
||||||
|
pendingDecisions.delete(startDir),
|
||||||
|
);
|
||||||
|
pendingDecisions.set(startDir, pending);
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function armIdleTimer(root: string, client: CodegraphExploreClient): void {
|
||||||
|
const existing = idleTimers.get(root);
|
||||||
|
if (existing) clearTimeout(existing);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
idleTimers.delete(root);
|
||||||
|
if (clients.get(root) === client) clients.delete(root);
|
||||||
|
void Promise.resolve(client.close()).catch(() => {});
|
||||||
|
}, IDLE_CLOSE_MS);
|
||||||
|
timer.unref();
|
||||||
|
idleTimers.set(root, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropClient(root: string, client: CodegraphExploreClient): void {
|
||||||
|
if (clients.get(root) === client) clients.delete(root);
|
||||||
|
const timer = idleTimers.get(root);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
idleTimers.delete(root);
|
||||||
|
}
|
||||||
|
void Promise.resolve(client.close()).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSchemaDrift(
|
||||||
|
client: CodegraphExploreClient,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
): void {
|
||||||
|
if (driftWarned || !ctx.hasUI) return;
|
||||||
|
const upstream = [...client.schemaPropertyNames()].sort();
|
||||||
|
if (JSON.stringify(upstream) === JSON.stringify(registeredProps)) return;
|
||||||
|
driftWarned = true;
|
||||||
|
ctx.ui.notify(
|
||||||
|
`CodeGraph 上游 codegraph_explore 参数已变化:上游 ${upstream.join(", ") || "(无)"},扩展注册 ${registeredProps.join(", ")}。`,
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExplore(
|
||||||
|
params: ExploreParams,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<AgentToolResult> {
|
||||||
|
const startDir = resolve(params.projectPath ?? ctx.cwd);
|
||||||
|
if (declined.has(startDir)) return toResult(notIndexedText(startDir));
|
||||||
|
|
||||||
|
let root: string;
|
||||||
|
try {
|
||||||
|
const decision = await decideIndexOnce(startDir, ctx, signal);
|
||||||
|
if ("bounce" in decision) return toResult(decision.bounce);
|
||||||
|
root = decision.root;
|
||||||
|
} catch (error) {
|
||||||
|
return toResult(serviceFailureText(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
let client: CodegraphExploreClient | undefined;
|
||||||
|
const attempt = async (): Promise<AgentToolResult> => {
|
||||||
|
const existing = clients.get(root);
|
||||||
|
const active =
|
||||||
|
existing && !existing.dead ? existing : deps.createClient(root);
|
||||||
|
client = active;
|
||||||
|
clients.set(root, active);
|
||||||
|
armIdleTimer(root, active);
|
||||||
|
try {
|
||||||
|
await active.start();
|
||||||
|
checkSchemaDrift(active, ctx);
|
||||||
|
const result = await active.callExplore(
|
||||||
|
params as unknown as Record<string, unknown>,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
const content = result.content;
|
||||||
|
return {
|
||||||
|
content: Array.isArray(content)
|
||||||
|
? (content as AgentToolResult["content"])
|
||||||
|
: [{ type: "text", text: "codegraph_explore 返回空输出。" }],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// A user abort says nothing about the child's health — keep it warm.
|
||||||
|
if (!signal?.aborted) dropClient(root, active);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await attempt();
|
||||||
|
} catch (first) {
|
||||||
|
// Retry once only when the child died — a live client failing a call
|
||||||
|
// (RPC error, isError) is not fixed by respawning.
|
||||||
|
if (!client?.dead) return toResult(serviceFailureText(first));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await attempt();
|
||||||
|
} catch (second) {
|
||||||
|
return toResult(serviceFailureText(second));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function close(): Promise<void> {
|
||||||
|
for (const timer of idleTimers.values()) clearTimeout(timer);
|
||||||
|
idleTimers.clear();
|
||||||
|
const all = [...clients.values()];
|
||||||
|
clients.clear();
|
||||||
|
await Promise.all(
|
||||||
|
all.map((client) =>
|
||||||
|
Promise.resolve(client.close()).catch(() => {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handle: handleExplore, close };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function codegraphExtension(pi: ExtensionAPI): void {
|
||||||
|
const handler = createCodegraphExploreHandler({
|
||||||
|
exec: (command, args, options) => pi.exec(command, args, options),
|
||||||
|
createClient: (root) => new CodegraphRpcClient({ root }),
|
||||||
|
});
|
||||||
|
pi.on("session_shutdown", () => handler.close());
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "codegraph_explore",
|
||||||
|
label: "codegraph_explore",
|
||||||
|
description:
|
||||||
|
"PRIMARY TOOL — call FIRST for almost any question OR before an edit: how does X work, architecture, a bug, where/what is X, surveying an area, or the symbols you are about to change. Returns the verbatim source of the relevant symbols grouped by file in ONE capped call (Read-equivalent — treat the shown source as already Read; do NOT re-open those files), plus the call path among them. Query can be a natural-language question OR a bag of symbol/file names. Usually the ONLY call you need — more accurate context, in far fewer tokens and round-trips than a search/Read/Grep loop.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). For a flow question, name the symbols spanning the flow (e.g. "mutateElement renderScene"). A natural-language question works too.',
|
||||||
|
},
|
||||||
|
maxFiles: {
|
||||||
|
type: "number",
|
||||||
|
description:
|
||||||
|
"Maximum number of files to include source code from (default: 12)",
|
||||||
|
},
|
||||||
|
projectPath: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Absolute path to the project to query (or any directory inside it) — codegraph uses the nearest .codegraph/ index at or above that path. Omit to use this session's default project. Pass it to query a second codebase, or when the session root has no index of its own (e.g. a monorepo where only sub-projects are indexed).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
||||||
|
return handler.handle(params as ExploreParams, ctx, signal);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
|
|
||||||
export type CondenseDefaultResult = "updated" | "unchanged" | "skipped-invalid";
|
|
||||||
|
|
||||||
const CONDENSE_BUNDLE_DEFAULTS = {
|
|
||||||
enabled: true,
|
|
||||||
pruneOn: "agent-message",
|
|
||||||
batchingMode: "turn",
|
|
||||||
autoBudgetThreshold: 0.735,
|
|
||||||
budgetTurnDelta: 0.04,
|
|
||||||
summarizerModel: "default",
|
|
||||||
summarizerThinking: "default",
|
|
||||||
summarizerIdleTimeoutMs: 90_000,
|
|
||||||
summarizerMaxTimeoutMs: 300_000,
|
|
||||||
quietOversizedSkips: false,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function isObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fill missing pi-condense settings with bundle defaults while preserving every explicit user choice. */
|
|
||||||
export function ensureCondenseDefaults(agentDir: string): CondenseDefaultResult {
|
|
||||||
const targetPath = join(agentDir, "settings.json");
|
|
||||||
let settings: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(readFileSync(targetPath, "utf8"));
|
|
||||||
if (!isObject(parsed)) return "skipped-invalid";
|
|
||||||
settings = parsed;
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return "skipped-invalid";
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = settings.contextPrune;
|
|
||||||
if (existing !== undefined && !isObject(existing)) return "skipped-invalid";
|
|
||||||
|
|
||||||
const contextPrune = existing ?? {};
|
|
||||||
const alreadyComplete = Object.keys(CONDENSE_BUNDLE_DEFAULTS).every((key) => Object.hasOwn(contextPrune, key));
|
|
||||||
if (alreadyComplete) return "unchanged";
|
|
||||||
|
|
||||||
settings.contextPrune = { ...CONDENSE_BUNDLE_DEFAULTS, ...contextPrune };
|
|
||||||
mkdirSync(dirname(targetPath), { recursive: true });
|
|
||||||
const temporaryPath = `${targetPath}.my-pi.tmp`;
|
|
||||||
writeFileSync(temporaryPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
||||||
renameSync(temporaryPath, targetPath);
|
|
||||||
return "updated";
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
||||||
import { ensureCondenseDefaults } from "./condense-config.ts";
|
|
||||||
|
|
||||||
/** Apply the bundle default before pi-condense reads settings on session_start. */
|
|
||||||
export default async function bundledCondenseExtension(pi: ExtensionAPI): Promise<void> {
|
|
||||||
const result = ensureCondenseDefaults(getAgentDir());
|
|
||||||
if (result === "skipped-invalid") {
|
|
||||||
console.warn("my-pi: skipped the pi-condense default because settings.json or contextPrune is invalid");
|
|
||||||
}
|
|
||||||
const { default: condenseExtension } = await import("../node_modules/pi-condense/index.ts");
|
|
||||||
condenseExtension(pi);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { getCompactionService } from "../pi-compaction/src/service.js";
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
return new Intl.NumberFormat("en-US").format(Math.round(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function contextView(pi: ExtensionAPI): void {
|
||||||
|
pi.registerCommand("context", {
|
||||||
|
description: "Show the effective pi-compaction context projection",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
const sessionId = ctx.sessionManager.getSessionId();
|
||||||
|
const metrics = getCompactionService()?.getMetrics(sessionId);
|
||||||
|
const text = metrics
|
||||||
|
? [
|
||||||
|
`Effective: ${formatNumber(metrics.effectiveTokens)} / ${formatNumber(metrics.contextWindow)} (${metrics.percent.toFixed(1)}%, ${metrics.confidence})`,
|
||||||
|
`Raw: ${formatNumber(metrics.rawTokens)} / ${formatNumber(metrics.contextWindow)}`,
|
||||||
|
`Reclaimed: ${formatNumber(metrics.reclaimedTokens)}`,
|
||||||
|
`Segments: ${formatNumber(metrics.segmentCount)}`,
|
||||||
|
`Masked tool results: ${formatNumber(metrics.maskedResults)}`,
|
||||||
|
`Generation: ${formatNumber(metrics.generation)}`,
|
||||||
|
].join("\n")
|
||||||
|
: "No pi-compaction projection is available yet. Send one message to build the first snapshot.";
|
||||||
|
if (ctx.hasUI) ctx.ui.notify(text, "info");
|
||||||
|
else process.stderr.write(`${text}\n`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
const EXA_TOOL_ALIASES: Record<string, string> = {
|
|
||||||
web_search_exa: "exa_web_search",
|
|
||||||
web_fetch_exa: "exa_web_fetch",
|
|
||||||
web_search_advanced_exa: "exa_web_search_advanced",
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ExaToolDefinition = {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function mapExaToolName(name: string): string {
|
|
||||||
return EXA_TOOL_ALIASES[name] ?? name;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function prefixExaTool<T extends ExaToolDefinition>(definition: T): T {
|
|
||||||
const name = mapExaToolName(definition.name);
|
|
||||||
if (name === definition.name) return definition;
|
|
||||||
|
|
||||||
const displayName = name
|
|
||||||
.slice("exa_".length)
|
|
||||||
.split("_")
|
|
||||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
||||||
.join(" ");
|
|
||||||
return {
|
|
||||||
...definition,
|
|
||||||
name,
|
|
||||||
label: `Exa ${displayName}`,
|
|
||||||
} as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToolApi = {
|
|
||||||
registerTool: (tool: unknown) => unknown;
|
|
||||||
setActiveTools: (names: string[]) => unknown;
|
|
||||||
unregisterTool?: (name: string) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createExaToolApiOverride<T extends object>(pi: T): T {
|
|
||||||
const api = pi as T & ToolApi;
|
|
||||||
return new Proxy(pi, {
|
|
||||||
get(target, property) {
|
|
||||||
if (property === "registerTool") {
|
|
||||||
return (definition: ExaToolDefinition) => api.registerTool(prefixExaTool(definition));
|
|
||||||
}
|
|
||||||
if (property === "setActiveTools") {
|
|
||||||
return (names: string[]) => api.setActiveTools([...new Set(names.map(mapExaToolName))]);
|
|
||||||
}
|
|
||||||
if (property === "unregisterTool") {
|
|
||||||
return api.unregisterTool ? (name: string) => api.unregisterTool?.(mapExaToolName(name)) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = Reflect.get(target, property, target);
|
|
||||||
return typeof value === "function" ? value.bind(target) : value;
|
|
||||||
},
|
|
||||||
}) as T;
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
import fffExtension from "../node_modules/@ff-labs/pi-fff/src/index.ts";
|
import fffExtension from "../node_modules/@ff-labs/pi-fff/src/index.ts";
|
||||||
|
import { guardExtensionApi } from "./prompt-guard.ts";
|
||||||
|
|
||||||
/** Load FFF as the bundle's canonical `find` and `grep` implementation. */
|
/** Load FFF as the bundle's canonical `find` and `grep` implementation. */
|
||||||
export default function fffOverrideExtension(pi: ExtensionAPI): void {
|
export default function fffOverrideExtension(pi: ExtensionAPI): void {
|
||||||
process.env.PI_FFF_MODE = "override";
|
process.env.PI_FFF_MODE = "override";
|
||||||
fffExtension(pi);
|
delete process.env.PI_FFF_MULTIGREP;
|
||||||
|
fffExtension(guardExtensionApi(pi));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
import { deployBundleHashlineConfig } from "./hashline-config.ts";
|
import { deployBundleHashlineConfig } from "./hashline-config.ts";
|
||||||
|
import { guardExtensionApi } from "./prompt-guard.ts";
|
||||||
|
|
||||||
/** Deploy bundle defaults before pi-hashline-edit reads its module-level config. */
|
/**
|
||||||
|
* Deploy bundle defaults before pi-hashline-edit reads its module-level config.
|
||||||
|
* The guard applies the catalog's `edit` description override and keeps the
|
||||||
|
* third-party extension from writing the system prompt directly.
|
||||||
|
*/
|
||||||
export default async function bundledHashlineExtension(pi: ExtensionAPI): Promise<void> {
|
export default async function bundledHashlineExtension(pi: ExtensionAPI): Promise<void> {
|
||||||
deployBundleHashlineConfig(getAgentDir());
|
deployBundleHashlineConfig(getAgentDir());
|
||||||
const { default: hashlineExtension } = await import("../node_modules/pi-hashline-edit/index.ts");
|
const { default: hashlineExtension } = await import("../node_modules/pi-hashline-edit/index.ts");
|
||||||
hashlineExtension(pi);
|
hashlineExtension(guardExtensionApi(pi));
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -2,6 +2,7 @@ import lspExtension from "../node_modules/pi-lsp/extensions/pi-lsp/index.ts";
|
|||||||
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { deployBundleLspConfig } from "./lsp-config.ts";
|
import { deployBundleLspConfig } from "./lsp-config.ts";
|
||||||
|
import { guardExtensionApi } from "./prompt-guard.ts";
|
||||||
|
|
||||||
const TYPESCRIPT_LANGUAGE_SERVER_CLI = fileURLToPath(
|
const TYPESCRIPT_LANGUAGE_SERVER_CLI = fileURLToPath(
|
||||||
new URL("../node_modules/typescript-language-server/lib/cli.mjs", import.meta.url),
|
new URL("../node_modules/typescript-language-server/lib/cli.mjs", import.meta.url),
|
||||||
@@ -13,5 +14,5 @@ export default function bundledLspExtension(pi: ExtensionAPI): void {
|
|||||||
nodeBinary: process.execPath,
|
nodeBinary: process.execPath,
|
||||||
serverCliPath: TYPESCRIPT_LANGUAGE_SERVER_CLI,
|
serverCliPath: TYPESCRIPT_LANGUAGE_SERVER_CLI,
|
||||||
});
|
});
|
||||||
lspExtension(pi);
|
lspExtension(guardExtensionApi(pi));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
|
|
||||||
export const EXA_TOOLS = ["web_search_exa", "web_fetch_exa", "web_search_advanced_exa"];
|
|
||||||
const EXA_MCP_URL = `https://mcp.exa.ai/mcp?tools=${EXA_TOOLS.join(",")}`;
|
|
||||||
|
|
||||||
export function createMcpConfig(env: Record<string, string | undefined>) {
|
|
||||||
const hasExaApiKey = Boolean(env.EXA_API_KEY?.trim());
|
|
||||||
return {
|
|
||||||
settings: {
|
|
||||||
disableProxyTool: true,
|
|
||||||
scriptMode: false,
|
|
||||||
},
|
|
||||||
mcpServers: {
|
|
||||||
exa: {
|
|
||||||
url: EXA_MCP_URL,
|
|
||||||
...(hasExaApiKey ? { headers: { "x-api-key": "$env:EXA_API_KEY" } } : {}),
|
|
||||||
protocolVersion: "auto" as const,
|
|
||||||
lifecycle: "eager" as const,
|
|
||||||
directTools: EXA_TOOLS,
|
|
||||||
includeTools: EXA_TOOLS,
|
|
||||||
toolPrefix: "none" as const,
|
|
||||||
approveTools: false,
|
|
||||||
exposeResources: false,
|
|
||||||
},
|
|
||||||
codegraph: {
|
|
||||||
command: "codegraph",
|
|
||||||
args: ["serve", "--mcp"],
|
|
||||||
lifecycle: "keep-alive" as const,
|
|
||||||
directTools: ["codegraph_explore"],
|
|
||||||
includeTools: ["codegraph_explore"],
|
|
||||||
toolPrefix: "none" as const,
|
|
||||||
approveTools: false,
|
|
||||||
exposeResources: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
||||||
import { createMcpAdapter } from "pi-mcp-adapter";
|
|
||||||
import { createExaToolApiOverride } from "./exa-tool-prefix.ts";
|
|
||||||
import { createMcpConfig } from "./mcp-config.ts";
|
|
||||||
import { loadSearchConfigEnv } from "./search-config.ts";
|
|
||||||
|
|
||||||
/** Connect Pi to Exa and CodeGraph through one shared MCP adapter instance. */
|
|
||||||
export default function mcpExtension(pi: ExtensionAPI): void {
|
|
||||||
const searchEnvironment = loadSearchConfigEnv();
|
|
||||||
const mcpAdapter = createMcpAdapter({ config: createMcpConfig(searchEnvironment) });
|
|
||||||
mcpAdapter(createExaToolApiOverride(pi));
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,13 @@
|
|||||||
import permissionSystemExtension from "../pi-permission-system/src/index.ts";
|
import permissionSystemExtension from "../pi-permission-system/src/index.ts";
|
||||||
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const BUNDLE_CONFIG_PATH = fileURLToPath(new URL("../config/pi-permission-system.json", import.meta.url));
|
/**
|
||||||
|
* Load the bundled permission system directly.
|
||||||
function deployBundlePermissionConfig(): void {
|
*
|
||||||
const targetPath = join(getAgentDir(), "extensions", "pi-permission-system", "config.json");
|
* Its bundle-owned config (pi-permission-policy.jsonc, pi-permission-hard-guards.jsonc)
|
||||||
const bundledConfig = readFileSync(BUNDLE_CONFIG_PATH, "utf8");
|
* is read directly from the installed bundle's `config/` directory; the
|
||||||
|
* extension itself never seeds, deploys, or migrates configuration.
|
||||||
try {
|
*/
|
||||||
if (readFileSync(targetPath, "utf8") === bundledConfig) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Missing or unreadable target: replace it with the bundle-owned baseline.
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(dirname(targetPath), { recursive: true });
|
|
||||||
const temporaryPath = `${targetPath}.my-pi.tmp`;
|
|
||||||
writeFileSync(temporaryPath, bundledConfig, "utf8");
|
|
||||||
renameSync(temporaryPath, targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deploy the bundle-owned policy before registering permission hooks. */
|
|
||||||
export default function bundledPermissionSystemExtension(pi: ExtensionAPI): void {
|
export default function bundledPermissionSystemExtension(pi: ExtensionAPI): void {
|
||||||
deployBundlePermissionConfig();
|
|
||||||
permissionSystemExtension(pi);
|
permissionSystemExtension(pi);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
||||||
import { stat } from "node:fs/promises";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { join, parse, resolve } from "node:path";
|
|
||||||
|
|
||||||
interface ExecResult {
|
|
||||||
code: number;
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PluginInitExecutor = (
|
|
||||||
command: string,
|
|
||||||
args: string[],
|
|
||||||
options: { cwd: string },
|
|
||||||
) => Promise<ExecResult>;
|
|
||||||
|
|
||||||
interface InitTarget {
|
|
||||||
name: "CodeGraph" | "Hippo";
|
|
||||||
command: "codegraph" | "hippo";
|
|
||||||
database: string;
|
|
||||||
initArgs(cwd: string): string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const INIT_TARGETS: InitTarget[] = [
|
|
||||||
{
|
|
||||||
name: "CodeGraph",
|
|
||||||
command: "codegraph",
|
|
||||||
database: join(".codegraph", "codegraph.db"),
|
|
||||||
initArgs: (cwd) => ["init", cwd],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Hippo",
|
|
||||||
command: "hippo",
|
|
||||||
database: join(".hippo", "hippo.db"),
|
|
||||||
initArgs: () => ["init"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function isFile(path: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return (await stat(path)).isFile();
|
|
||||||
} catch (error) {
|
|
||||||
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return false;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validatePluginInitCwd(cwd: string): string {
|
|
||||||
const projectRoot = resolve(cwd);
|
|
||||||
if (projectRoot === parse(projectRoot).root) {
|
|
||||||
throw new Error("拒绝在文件系统根目录初始化项目插件。");
|
|
||||||
}
|
|
||||||
if (projectRoot === resolve(homedir())) {
|
|
||||||
throw new Error("拒绝直接在用户主目录初始化项目插件;请先进入具体项目目录。");
|
|
||||||
}
|
|
||||||
return projectRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function findMissingPluginInitializers(cwd: string): Promise<InitTarget[]> {
|
|
||||||
const projectRoot = validatePluginInitCwd(cwd);
|
|
||||||
const states = await Promise.all(
|
|
||||||
INIT_TARGETS.map(async (target) => ({
|
|
||||||
target,
|
|
||||||
initialized: await isFile(join(projectRoot, target.database)),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
return states.filter(({ initialized }) => !initialized).map(({ target }) => target);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactFailure(result: ExecResult): string {
|
|
||||||
const output = (result.stderr.trim() || result.stdout.trim()).replace(/\s+/gu, " ");
|
|
||||||
return output ? output.slice(0, 400) : `退出码 ${result.code}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeChecked(
|
|
||||||
exec: PluginInitExecutor,
|
|
||||||
target: InitTarget,
|
|
||||||
args: string[],
|
|
||||||
cwd: string,
|
|
||||||
phase: "检测" | "初始化",
|
|
||||||
): Promise<void> {
|
|
||||||
let result: ExecResult;
|
|
||||||
try {
|
|
||||||
result = await exec(target.command, args, { cwd });
|
|
||||||
} catch (error) {
|
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(`${target.name} ${phase}失败:${detail}`, { cause: error });
|
|
||||||
}
|
|
||||||
if (result.code !== 0) {
|
|
||||||
throw new Error(`${target.name} ${phase}失败:${compactFailure(result)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initializeMissingPlugins(
|
|
||||||
cwd: string,
|
|
||||||
targets: InitTarget[],
|
|
||||||
exec: PluginInitExecutor,
|
|
||||||
onStart?: (target: InitTarget) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const projectRoot = validatePluginInitCwd(cwd);
|
|
||||||
|
|
||||||
// Preflight both CLIs before creating either project's state.
|
|
||||||
for (const target of INIT_TARGETS) {
|
|
||||||
await executeChecked(exec, target, ["--version"], projectRoot, "检测");
|
|
||||||
}
|
|
||||||
const completed: InitTarget[] = [];
|
|
||||||
for (const target of targets) {
|
|
||||||
onStart?.(target);
|
|
||||||
try {
|
|
||||||
await executeChecked(exec, target, target.initArgs(projectRoot), projectRoot, "初始化");
|
|
||||||
completed.push(target);
|
|
||||||
} catch (error) {
|
|
||||||
if (completed.length === 0) throw error;
|
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(
|
|
||||||
`${detail};已完成的 ${completed.map(({ name }) => name).join("、")} 初始化会保留,不会自动回滚。`,
|
|
||||||
{ cause: error },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function pluginInitExtension(pi: ExtensionAPI): void {
|
|
||||||
pi.registerCommand("plugin_init", {
|
|
||||||
description: "Initialize CodeGraph and Hippo for the current project, then hot-reload Pi",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
if (!ctx.hasUI) return;
|
|
||||||
|
|
||||||
let projectRoot: string;
|
|
||||||
let missing: InitTarget[];
|
|
||||||
try {
|
|
||||||
projectRoot = validatePluginInitCwd(ctx.cwd);
|
|
||||||
missing = await findMissingPluginInitializers(projectRoot);
|
|
||||||
} catch (error) {
|
|
||||||
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = missing.length > 0 ? missing.map(({ name }) => name).join("、") : "无(均已初始化)";
|
|
||||||
const confirmed = await ctx.ui.confirm(
|
|
||||||
"初始化项目插件",
|
|
||||||
`项目:${projectRoot}\n待初始化:${pending}\n\n完成后将热重载 Pi 扩展。是否继续?`,
|
|
||||||
);
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await initializeMissingPlugins(
|
|
||||||
projectRoot,
|
|
||||||
missing,
|
|
||||||
(command, args, options) => pi.exec(command, args, options),
|
|
||||||
(target) => ctx.ui.notify(`正在初始化 ${target.name}…`, "info"),
|
|
||||||
);
|
|
||||||
ctx.ui.notify(
|
|
||||||
missing.length > 0 ? "项目插件初始化完成,正在热重载 Pi…" : "项目插件均已初始化,正在热重载 Pi…",
|
|
||||||
"info",
|
|
||||||
);
|
|
||||||
await ctx.reload();
|
|
||||||
} catch (error) {
|
|
||||||
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
import {
|
||||||
|
loadToolCatalog,
|
||||||
|
type ToolCatalog,
|
||||||
|
type ToolCatalogEntry,
|
||||||
|
} from "./system-prompt-config.ts";
|
||||||
|
|
||||||
|
type AnyMessage = { role?: string; content?: unknown } & Record<string, unknown>;
|
||||||
|
|
||||||
|
type AnyHandler = (event: never, ctx: never) => unknown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The description a tool registers with under its catalog entry: a `description`
|
||||||
|
* override replaces the upstream text, a `descriptionAppend` is joined onto
|
||||||
|
* whichever base wins (override first, upstream otherwise) with a blank line.
|
||||||
|
*/
|
||||||
|
export function catalogDescription(description: string, entry: ToolCatalogEntry): string {
|
||||||
|
if ("inherit" in entry) return description;
|
||||||
|
const base = typeof entry.description === "string" ? entry.description : description;
|
||||||
|
return typeof entry.descriptionAppend === "string"
|
||||||
|
? `${base.trimEnd()}\n\n${entry.descriptionAppend}`
|
||||||
|
: base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptGuardOptions {
|
||||||
|
/**
|
||||||
|
* Post-process the messages a wrapped `context` handler leaves behind.
|
||||||
|
* Receives the message list snapshot from before the handler ran and the
|
||||||
|
* list after; returns the list the handler's result should report.
|
||||||
|
*/
|
||||||
|
contextFilter?: (before: AnyMessage[], after: AnyMessage[]) => AnyMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy an `ExtensionAPI` for third-party extensions so they cannot bypass the
|
||||||
|
* bundle's single system-prompt owner (`extensions/system-prompt.ts`):
|
||||||
|
*
|
||||||
|
* - `registerTool` applies the tool's `description` override and
|
||||||
|
* `descriptionAppend` suffix from `config/prompt/tools.jsonc` when the
|
||||||
|
* catalog carries them (lazily loaded; a catalog error keeps the upstream
|
||||||
|
* description). An append composes onto the override when both are set, or
|
||||||
|
* onto the upstream description when only the append is set.
|
||||||
|
* - `before_agent_start` handlers keep every returned field except
|
||||||
|
* `systemPrompt`, which is dropped.
|
||||||
|
* - `context` handlers are post-processed through `opts.contextFilter`.
|
||||||
|
* - Everything else passes through unchanged.
|
||||||
|
*/
|
||||||
|
export function guardExtensionApi(pi: ExtensionAPI, opts: PromptGuardOptions = {}): ExtensionAPI {
|
||||||
|
let catalogCache: ToolCatalog | null | undefined;
|
||||||
|
const catalog = (): ToolCatalog | null => {
|
||||||
|
if (catalogCache === undefined) {
|
||||||
|
try {
|
||||||
|
catalogCache = loadToolCatalog();
|
||||||
|
} catch {
|
||||||
|
catalogCache = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return catalogCache;
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerTool: ExtensionAPI["registerTool"] = (tool) => {
|
||||||
|
const entry = catalog()?.tools[tool.name];
|
||||||
|
const description =
|
||||||
|
entry === undefined ? tool.description : catalogDescription(tool.description, entry);
|
||||||
|
if (description === tool.description) {
|
||||||
|
pi.registerTool(tool);
|
||||||
|
} else {
|
||||||
|
pi.registerTool({ ...tool, description });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const on: ExtensionAPI["on"] = ((event: string, handler: AnyHandler): unknown => {
|
||||||
|
let wrapped = handler;
|
||||||
|
if (event === "before_agent_start") {
|
||||||
|
wrapped = (async (eventPayload: unknown, ctx: unknown) => {
|
||||||
|
const result = await handler(eventPayload as never, ctx as never);
|
||||||
|
if (result !== null && typeof result === "object" && "systemPrompt" in result) {
|
||||||
|
const rest = { ...(result as Record<string, unknown>) };
|
||||||
|
delete rest.systemPrompt;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}) as AnyHandler;
|
||||||
|
} else if (event === "context" && opts.contextFilter) {
|
||||||
|
const filter = opts.contextFilter;
|
||||||
|
wrapped = (async (eventPayload: { messages?: AnyMessage[] }, ctx: unknown) => {
|
||||||
|
const messages = Array.isArray(eventPayload.messages) ? eventPayload.messages : [];
|
||||||
|
const before = messages.slice();
|
||||||
|
const result = await handler(eventPayload as never, ctx as never);
|
||||||
|
const resultMessages =
|
||||||
|
result !== null &&
|
||||||
|
typeof result === "object" &&
|
||||||
|
Array.isArray((result as { messages?: unknown }).messages)
|
||||||
|
? ((result as { messages: AnyMessage[] }).messages)
|
||||||
|
: messages;
|
||||||
|
const filtered = filter(before, resultMessages);
|
||||||
|
if (filtered === resultMessages) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(result !== null && typeof result === "object" ? (result as Record<string, unknown>) : {}),
|
||||||
|
messages: filtered,
|
||||||
|
};
|
||||||
|
}) as AnyHandler;
|
||||||
|
}
|
||||||
|
// `on` is overloaded per event name; drop to a loosely typed call and
|
||||||
|
// forward whatever the real registration returns (e.g. a disposer).
|
||||||
|
return (pi.on as (name: string, h: AnyHandler) => unknown)(event, wrapped);
|
||||||
|
}) as ExtensionAPI["on"];
|
||||||
|
|
||||||
|
return new Proxy(pi, {
|
||||||
|
get(target, prop, receiver) {
|
||||||
|
if (prop === "registerTool") return registerTool;
|
||||||
|
if (prop === "on") return on;
|
||||||
|
const value = Reflect.get(target, prop, receiver);
|
||||||
|
return typeof value === "function" ? value.bind(target) : value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
export const SEARCH_API_KEY_NAMES = ["TAVILY_API_KEY", "EXA_API_KEY", "KEENABLE_API_KEY"] as const;
|
|
||||||
|
|
||||||
type SearchApiKeyName = (typeof SEARCH_API_KEY_NAMES)[number];
|
|
||||||
type SearchEnvironment = Record<string, string | undefined>;
|
|
||||||
|
|
||||||
const SAFE_VALUE = /^[A-Za-z0-9._-]+$/;
|
|
||||||
|
|
||||||
export function getSearchConfigPath(env: SearchEnvironment = process.env): string {
|
|
||||||
if (env.MY_PI_SEARCH_CONFIG?.trim()) {
|
|
||||||
return env.MY_PI_SEARCH_CONFIG.trim();
|
|
||||||
}
|
|
||||||
const configHome = env.XDG_CONFIG_HOME?.trim() || join(env.HOME?.trim() || homedir(), ".config");
|
|
||||||
return join(configHome, "my-pi", "search.env");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSearchConfig(text: string): Partial<Record<SearchApiKeyName, string>> {
|
|
||||||
const parsed: Partial<Record<SearchApiKeyName, string>> = {};
|
|
||||||
for (const rawLine of text.split(/\r?\n/u)) {
|
|
||||||
const line = rawLine.trim().replace(/^export[ \t]+/u, "");
|
|
||||||
if (!line || line.startsWith("#")) continue;
|
|
||||||
const separator = line.indexOf("=");
|
|
||||||
if (separator < 1) continue;
|
|
||||||
const name = line.slice(0, separator).trim() as SearchApiKeyName;
|
|
||||||
const value = line.slice(separator + 1).trim();
|
|
||||||
if (!SEARCH_API_KEY_NAMES.includes(name) || !SAFE_VALUE.test(value)) continue;
|
|
||||||
parsed[name] = value;
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load package-managed search credentials without overriding explicit process environment values. */
|
|
||||||
export function loadSearchConfigEnv(
|
|
||||||
env: SearchEnvironment = process.env,
|
|
||||||
configPath = getSearchConfigPath(env),
|
|
||||||
): Partial<Record<SearchApiKeyName, string>> {
|
|
||||||
let parsed: Partial<Record<SearchApiKeyName, string>> = {};
|
|
||||||
try {
|
|
||||||
parsed = parseSearchConfig(readFileSync(configPath, "utf8"));
|
|
||||||
} catch {
|
|
||||||
// Missing or unreadable config is equivalent to an empty package-managed config.
|
|
||||||
}
|
|
||||||
const effective: Partial<Record<SearchApiKeyName, string>> = {};
|
|
||||||
for (const name of SEARCH_API_KEY_NAMES) {
|
|
||||||
if (!env[name]?.trim() && parsed[name]) env[name] = parsed[name];
|
|
||||||
if (env[name]?.trim()) effective[name] = env[name]?.trim();
|
|
||||||
}
|
|
||||||
return effective;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load search API keys before provider extensions initialize. */
|
|
||||||
export default function searchConfigExtension(): void {
|
|
||||||
loadSearchConfigEnv();
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { createPiSubagentsExtension } from "../pi-subagents/src/index.ts";
|
import { createPiSubagentsExtension } from "../pi-subagents/src/index.ts";
|
||||||
|
import {
|
||||||
|
readSubagentPreamble,
|
||||||
|
resolveSubagentAgentsDir,
|
||||||
|
} from "./system-prompt-config.ts";
|
||||||
|
|
||||||
// Bundle authority: Agent frontmatter may disable optional extensions, but it
|
// Bundle authority: Agent frontmatter may disable optional extensions, but it
|
||||||
// cannot remove the exact wrapper that deploys my-pi's permission baseline and
|
// cannot remove the exact wrapper that deploys my-pi's permission baseline and
|
||||||
@@ -8,6 +12,17 @@ const BUNDLED_PERMISSION_EXTENSION = fileURLToPath(
|
|||||||
new URL("./permission-system.ts", import.meta.url),
|
new URL("./permission-system.ts", import.meta.url),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Subagents get their own prompt profile: config/prompt/subagent/preamble.md
|
||||||
|
// replaces the parent-prompt embedding upstream performs, and
|
||||||
|
// config/prompt/subagent/agents/*.md are loaded as built-in agent types.
|
||||||
|
// ask_user_question is main-session-only — a child has no user to ask.
|
||||||
|
const ASK_USER_EXTENSION = fileURLToPath(
|
||||||
|
new URL("../pi-ask-user/index.ts", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
export default createPiSubagentsExtension({
|
export default createPiSubagentsExtension({
|
||||||
mandatoryExtensionPaths: [BUNDLED_PERMISSION_EXTENSION],
|
mandatoryExtensionPaths: [BUNDLED_PERMISSION_EXTENSION],
|
||||||
|
basePrompt: readSubagentPreamble,
|
||||||
|
bundledAgentsDir: resolveSubagentAgentsDir(),
|
||||||
|
excludedExtensionPaths: [ASK_USER_EXTENSION],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import { readFileSync, statSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helpers for the bundle's single system-prompt owner
|
||||||
|
* (`extensions/system-prompt.ts`). No Pi runtime imports so `node --test` can
|
||||||
|
* exercise everything here directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Entry in `config/prompt/tools.jsonc`. */
|
||||||
|
export type ToolCatalogEntry =
|
||||||
|
| { inherit: true }
|
||||||
|
| {
|
||||||
|
snippet?: string | null;
|
||||||
|
guidelines?: string[];
|
||||||
|
description?: string;
|
||||||
|
descriptionAppend?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ToolCatalog {
|
||||||
|
version: 1;
|
||||||
|
tools: Record<string, ToolCatalogEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingRule {
|
||||||
|
whenAll?: string[];
|
||||||
|
whenAny?: string[];
|
||||||
|
/** Which sessions the rule renders for; absent means both. */
|
||||||
|
scope?: "main" | "subagent";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingConfig {
|
||||||
|
version: 1;
|
||||||
|
rules: RoutingRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionsConfig {
|
||||||
|
version: 1;
|
||||||
|
order: string[];
|
||||||
|
/** Contributed sections removed from subagent (customPrompt) sessions. */
|
||||||
|
subagentExclude: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of Pi's (normalized) systemPromptOptions this module reads and
|
||||||
|
* mutates. Declared structurally so the installed host version does not leak
|
||||||
|
* into the config helper's types.
|
||||||
|
*/
|
||||||
|
export interface MutablePromptOptions {
|
||||||
|
selectedTools?: string[];
|
||||||
|
toolSnippets?: Record<string, string>;
|
||||||
|
toolGuidelines?: Record<string, string[]>;
|
||||||
|
promptGuidelines?: string[];
|
||||||
|
sections?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROMPT_CONFIG_DIR = new URL("../config/prompt/", import.meta.url);
|
||||||
|
|
||||||
|
export function resolvePreamblePath(): string {
|
||||||
|
return fileURLToPath(new URL("preamble.md", PROMPT_CONFIG_DIR));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveToolsPath(): string {
|
||||||
|
return fileURLToPath(new URL("tools.jsonc", PROMPT_CONFIG_DIR));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRoutingPath(): string {
|
||||||
|
return fileURLToPath(new URL("routing.jsonc", PROMPT_CONFIG_DIR));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSectionsPath(): string {
|
||||||
|
return fileURLToPath(new URL("sections.jsonc", PROMPT_CONFIG_DIR));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSubagentPreamblePath(): string {
|
||||||
|
return fileURLToPath(new URL("subagent/preamble.md", PROMPT_CONFIG_DIR));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSubagentAgentsDir(): string {
|
||||||
|
return fileURLToPath(new URL("subagent/agents/", PROMPT_CONFIG_DIR));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip `//` and `/* ... *\/` comments while respecting string literals, so a
|
||||||
|
* `//` inside a quoted value (for example `"https://…"`) is never removed.
|
||||||
|
*/
|
||||||
|
export function stripJsoncComments(source: string): string {
|
||||||
|
let out = "";
|
||||||
|
let inString = false;
|
||||||
|
for (let i = 0; i < source.length; i++) {
|
||||||
|
const ch = source[i];
|
||||||
|
if (inString) {
|
||||||
|
out += ch;
|
||||||
|
if (ch === "\\") {
|
||||||
|
if (i + 1 < source.length) out += source[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') inString = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
inString = true;
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "/" && source[i + 1] === "/") {
|
||||||
|
while (i < source.length && source[i] !== "\n") i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "/" && source[i + 1] === "*") {
|
||||||
|
i += 2;
|
||||||
|
while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) i++;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileCacheEntry {
|
||||||
|
mtimeMs: number;
|
||||||
|
size: number;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCache = new Map<string, FileCacheEntry>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read, strip, parse and validate a JSONC config file, cached by mtime+size so
|
||||||
|
* a `config/prompt/` edit takes effect on the next turn without re-parsing
|
||||||
|
* unchanged files every turn.
|
||||||
|
*/
|
||||||
|
function loadJsonc<T>(path: string, validate: (raw: unknown) => T): T {
|
||||||
|
const stat = statSync(path);
|
||||||
|
const cached = fileCache.get(path);
|
||||||
|
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
||||||
|
return cached.value as T;
|
||||||
|
}
|
||||||
|
const raw = JSON.parse(stripJsoncComments(readFileSync(path, "utf8")));
|
||||||
|
const value = validate(raw);
|
||||||
|
fileCache.set(path, { mtimeMs: stat.mtimeMs, size: stat.size, value });
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(path: string, message: string): never {
|
||||||
|
throw new Error(`config/prompt/${path}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringArray(value: unknown): value is string[] {
|
||||||
|
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOL_ENTRY_KEYS = new Set(["inherit", "snippet", "guidelines", "description", "descriptionAppend"]);
|
||||||
|
|
||||||
|
function validateToolEntry(name: string, raw: unknown): ToolCatalogEntry {
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
fail("tools.jsonc", `tool "${name}" must be an object`);
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(raw)) {
|
||||||
|
if (!TOOL_ENTRY_KEYS.has(key)) {
|
||||||
|
fail("tools.jsonc", `tool "${name}" has unknown key "${key}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ("inherit" in raw) {
|
||||||
|
if (raw.inherit !== true || Object.keys(raw).length !== 1) {
|
||||||
|
fail("tools.jsonc", `tool "${name}": "inherit" must be the only key and equal true`);
|
||||||
|
}
|
||||||
|
return { inherit: true };
|
||||||
|
}
|
||||||
|
const hasField =
|
||||||
|
"snippet" in raw || "guidelines" in raw || "description" in raw || "descriptionAppend" in raw;
|
||||||
|
if (!hasField) {
|
||||||
|
fail("tools.jsonc", `tool "${name}" needs "inherit": true or at least one of snippet/guidelines/description/descriptionAppend`);
|
||||||
|
}
|
||||||
|
if ("snippet" in raw && raw.snippet !== null && typeof raw.snippet !== "string") {
|
||||||
|
fail("tools.jsonc", `tool "${name}".snippet must be a string or null`);
|
||||||
|
}
|
||||||
|
if ("guidelines" in raw && !isStringArray(raw.guidelines)) {
|
||||||
|
fail("tools.jsonc", `tool "${name}".guidelines must be an array of strings`);
|
||||||
|
}
|
||||||
|
if ("description" in raw && typeof raw.description !== "string") {
|
||||||
|
fail("tools.jsonc", `tool "${name}".description must be a string`);
|
||||||
|
}
|
||||||
|
if ("descriptionAppend" in raw && typeof raw.descriptionAppend !== "string") {
|
||||||
|
fail("tools.jsonc", `tool "${name}".descriptionAppend must be a string`);
|
||||||
|
}
|
||||||
|
return raw as ToolCatalogEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateToolCatalog(raw: unknown): ToolCatalog {
|
||||||
|
if (!isPlainObject(raw) || raw.version !== 1 || !isPlainObject(raw.tools)) {
|
||||||
|
fail("tools.jsonc", 'expected { "version": 1, "tools": { ... } }');
|
||||||
|
}
|
||||||
|
const tools: Record<string, ToolCatalogEntry> = {};
|
||||||
|
for (const [name, entry] of Object.entries(raw.tools)) {
|
||||||
|
tools[name] = validateToolEntry(name, entry);
|
||||||
|
}
|
||||||
|
return { version: 1, tools };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTING_RULE_KEYS = new Set(["whenAll", "whenAny", "scope", "text"]);
|
||||||
|
|
||||||
|
function validateRoutingRule(index: number, raw: unknown): RoutingRule {
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
fail("routing.jsonc", `rule #${index} must be an object`);
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(raw)) {
|
||||||
|
if (!ROUTING_RULE_KEYS.has(key)) {
|
||||||
|
fail("routing.jsonc", `rule #${index} has unknown key "${key}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof raw.text !== "string") {
|
||||||
|
fail("routing.jsonc", `rule #${index}.text must be a string`);
|
||||||
|
}
|
||||||
|
if ("whenAll" in raw && !isStringArray(raw.whenAll)) {
|
||||||
|
fail("routing.jsonc", `rule #${index}.whenAll must be an array of strings`);
|
||||||
|
}
|
||||||
|
if ("whenAny" in raw && !isStringArray(raw.whenAny)) {
|
||||||
|
fail("routing.jsonc", `rule #${index}.whenAny must be an array of strings`);
|
||||||
|
}
|
||||||
|
if ("scope" in raw && raw.scope !== "main" && raw.scope !== "subagent") {
|
||||||
|
fail("routing.jsonc", `rule #${index}.scope must be "main" or "subagent"`);
|
||||||
|
}
|
||||||
|
return raw as RoutingRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRoutingConfig(raw: unknown): RoutingConfig {
|
||||||
|
if (!isPlainObject(raw) || raw.version !== 1 || !Array.isArray(raw.rules)) {
|
||||||
|
fail("routing.jsonc", 'expected { "version": 1, "rules": [ ... ] }');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
rules: raw.rules.map((rule, index) => validateRoutingRule(index, rule)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTION_NAME_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
||||||
|
|
||||||
|
export function validateSectionsConfig(raw: unknown): SectionsConfig {
|
||||||
|
if (!isPlainObject(raw) || raw.version !== 1 || !isStringArray(raw.order)) {
|
||||||
|
fail("sections.jsonc", 'expected { "version": 1, "order": [ ... ] }');
|
||||||
|
}
|
||||||
|
for (const name of raw.order) {
|
||||||
|
if (!SECTION_NAME_PATTERN.test(name) || name === "preamble") {
|
||||||
|
fail("sections.jsonc", `"${name}" is not a valid section name`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ("subagentExclude" in raw && !isStringArray(raw.subagentExclude)) {
|
||||||
|
fail("sections.jsonc", `"subagentExclude" must be an array of section names`);
|
||||||
|
}
|
||||||
|
for (const name of raw.subagentExclude ?? []) {
|
||||||
|
if (!SECTION_NAME_PATTERN.test(name) || name === "preamble") {
|
||||||
|
fail("sections.jsonc", `"subagentExclude" entry "${name}" is not a valid section name`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
order: raw.order,
|
||||||
|
subagentExclude: isStringArray(raw.subagentExclude) ? raw.subagentExclude : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadToolCatalog(): ToolCatalog {
|
||||||
|
return loadJsonc(resolveToolsPath(), validateToolCatalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadRoutingRules(): RoutingConfig {
|
||||||
|
return loadJsonc(resolveRoutingPath(), validateRoutingConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSectionsConfig(): SectionsConfig {
|
||||||
|
return loadJsonc(resolveSectionsPath(), validateSectionsConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the bundle-managed prompt preamble.
|
||||||
|
*
|
||||||
|
* Returns `undefined` when the file is absent, unreadable, or blank so the
|
||||||
|
* caller leaves Pi's own default preamble untouched instead of forcing an
|
||||||
|
* empty one.
|
||||||
|
*/
|
||||||
|
export function readPreamble(): string | undefined {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = readFileSync(resolvePreamblePath(), "utf8");
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = raw.replace(/\s+$/, "");
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the bundle's subagent base preamble (`config/prompt/subagent/preamble.md`),
|
||||||
|
* re-read on each call so the file is never cached between spawns.
|
||||||
|
* Blank or missing → `undefined`, which keeps pi-subagents' upstream composition.
|
||||||
|
*/
|
||||||
|
export function readSubagentPreamble(): string | undefined {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = readFileSync(resolveSubagentPreamblePath(), "utf8");
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = raw.replace(/\s+$/, "");
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `<active_agent name="..."/>` tag pi-subagents stamps into every child
|
||||||
|
* prompt — tested ONLY against `systemPromptOptions.customPrompt`, never the
|
||||||
|
* rendered prompt: a main session may contain the literal tag via project
|
||||||
|
* docs and must still be treated as main.
|
||||||
|
*/
|
||||||
|
const ACTIVE_AGENT_TAG = /<active_agent\s+name=["'][^"']+["'][^>]*>/i;
|
||||||
|
|
||||||
|
export function isSubagentCustomPrompt(customPrompt: unknown): boolean {
|
||||||
|
return typeof customPrompt === "string" && ACTIVE_AGENT_TAG.test(customPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the tool catalog to the mutable prompt options: a `snippet` string
|
||||||
|
* replaces the tool's `<tools>` line (`null` hides it), `guidelines` replaces
|
||||||
|
* the tool's `<rules>` lines; `{inherit:true}` entries are left untouched.
|
||||||
|
* `description` and `descriptionAppend` are not applied here — they are
|
||||||
|
* registration-time concerns handled by `extensions/prompt-guard.ts`.
|
||||||
|
*
|
||||||
|
* Returns the selected tool names that have no catalog entry at all.
|
||||||
|
*/
|
||||||
|
export function applyToolCatalog(options: MutablePromptOptions, catalog: ToolCatalog): string[] {
|
||||||
|
const snippets = (options.toolSnippets ??= {});
|
||||||
|
const guidelines = (options.toolGuidelines ??= {});
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const name of options.selectedTools ?? []) {
|
||||||
|
const entry = catalog.tools[name];
|
||||||
|
if (entry === undefined) {
|
||||||
|
missing.push(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ("inherit" in entry) continue;
|
||||||
|
if (entry.snippet !== undefined) {
|
||||||
|
if (entry.snippet === null) {
|
||||||
|
delete snippets[name];
|
||||||
|
} else {
|
||||||
|
snippets[name] = entry.snippet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.guidelines !== undefined) {
|
||||||
|
guidelines[name] = [...entry.guidelines];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the matching routing rules as `- <text>` lines joined by newlines.
|
||||||
|
* A rule applies when every `whenAll` tool is active and at least one
|
||||||
|
* `whenAny` tool is active (a rule with neither always applies). An absent
|
||||||
|
* `selectedTools` means every tool counts as active, matching the previous
|
||||||
|
* tool-routing behavior.
|
||||||
|
*
|
||||||
|
* `audience` filters `scope`: rules without a scope render for both "main"
|
||||||
|
* and "subagent" sessions, scoped rules only for their own audience.
|
||||||
|
*/
|
||||||
|
export function renderRouting(
|
||||||
|
rules: RoutingRule[],
|
||||||
|
selectedTools: string[] | undefined,
|
||||||
|
audience: "main" | "subagent" = "main",
|
||||||
|
): string {
|
||||||
|
const isActive = (name: string) => selectedTools === undefined || selectedTools.includes(name);
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule.scope !== undefined && rule.scope !== audience) continue;
|
||||||
|
if (rule.whenAll !== undefined && !rule.whenAll.every(isActive)) continue;
|
||||||
|
if (rule.whenAny !== undefined && !rule.whenAny.some(isActive)) continue;
|
||||||
|
lines.push(`- ${rule.text}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the sections map in configured order; contributed sections not
|
||||||
|
* listed keep their original insertion order after the listed ones.
|
||||||
|
*/
|
||||||
|
export function orderSections(
|
||||||
|
sections: Record<string, string>,
|
||||||
|
order: readonly string[],
|
||||||
|
): Record<string, string> {
|
||||||
|
const ordered: Record<string, string> = {};
|
||||||
|
for (const name of order) {
|
||||||
|
if (Object.hasOwn(sections, name)) ordered[name] = sections[name];
|
||||||
|
}
|
||||||
|
for (const [name, value] of Object.entries(sections)) {
|
||||||
|
if (!Object.hasOwn(ordered, name)) ordered[name] = value;
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches the newline-delimited boundary before the first top-level prompt
|
||||||
|
* section. 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/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the rendered prompt's preamble with `preamble`, 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 preserves section order: options-based replacement (`customPrompt`)
|
||||||
|
* would drop `tools`/`rules`/`docs` and rebuild them at the end. A rendered
|
||||||
|
* prompt without any top-level section degenerates to the preamble alone.
|
||||||
|
*/
|
||||||
|
export function spliceCover(rendered: string, preamble: string): string {
|
||||||
|
const trimmed = preamble.replace(/\s+$/, "");
|
||||||
|
const boundary = rendered.search(FIRST_SECTION_BOUNDARY);
|
||||||
|
if (boundary < 0) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return trimmed + rendered.slice(boundary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import type { BeforeAgentStartEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyToolCatalog,
|
||||||
|
isSubagentCustomPrompt,
|
||||||
|
loadRoutingRules,
|
||||||
|
loadSectionsConfig,
|
||||||
|
loadToolCatalog,
|
||||||
|
orderSections,
|
||||||
|
readPreamble,
|
||||||
|
renderRouting,
|
||||||
|
spliceCover,
|
||||||
|
type MutablePromptOptions,
|
||||||
|
type RoutingConfig,
|
||||||
|
type SectionsConfig,
|
||||||
|
type ToolCatalog,
|
||||||
|
} from "./system-prompt-config.ts";
|
||||||
|
|
||||||
|
const DUMP_RELATIVE_PATH = ".pi-debug/effective-system-prompt.md";
|
||||||
|
|
||||||
|
export function getSystemPromptDumpPath(cwd: string): string {
|
||||||
|
return resolve(cwd, DUMP_RELATIVE_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The options object as Pi 1.3 normalizes it; `MutablePromptOptions` omits `forceSystemPrompt`/`customPrompt`. */
|
||||||
|
type RuntimePromptOptions = MutablePromptOptions & {
|
||||||
|
forceSystemPrompt?: string;
|
||||||
|
customPrompt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injectable seams so the composer can be exercised under `node --test`
|
||||||
|
* without touching the real `config/prompt/` files.
|
||||||
|
*/
|
||||||
|
export interface SystemPromptComposerDeps {
|
||||||
|
loadToolCatalog?: () => ToolCatalog;
|
||||||
|
loadRoutingRules?: () => RoutingConfig;
|
||||||
|
loadSectionsConfig?: () => SectionsConfig;
|
||||||
|
readPreamble?: () => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemPromptComposer {
|
||||||
|
handleBeforeAgentStart: (
|
||||||
|
event: BeforeAgentStartEvent,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
) => { systemPrompt: string } | undefined;
|
||||||
|
getLastComposed: () => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bundle's single system-prompt owner. It is the only extension allowed to
|
||||||
|
* return `systemPrompt`; every other bundle or third-party extension must
|
||||||
|
* contribute structurally through `event.systemPromptOptions`.
|
||||||
|
*
|
||||||
|
* Per turn it applies `config/prompt/tools.jsonc` (tool snippets/guidelines),
|
||||||
|
* renders `config/prompt/routing.jsonc` into a `<tool_routing>` section, orders
|
||||||
|
* contributed sections per `config/prompt/sections.jsonc`, then reads the
|
||||||
|
* re-rendered prompt and splices in `config/prompt/preamble.md` before
|
||||||
|
* returning the final text.
|
||||||
|
*
|
||||||
|
* If an earlier extension violated the contract by setting
|
||||||
|
* `forceSystemPrompt`, the prompt is already frozen: the composer degrades to
|
||||||
|
* text splicing (preamble + appended `<tool_routing>` when absent) and warns
|
||||||
|
* once. Config load/validation failures warn once and leave Pi's default
|
||||||
|
* prompt untouched — the handler never throws.
|
||||||
|
*/
|
||||||
|
export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}): SystemPromptComposer {
|
||||||
|
const loadCatalog = deps.loadToolCatalog ?? loadToolCatalog;
|
||||||
|
const loadRouting = deps.loadRoutingRules ?? loadRoutingRules;
|
||||||
|
const loadSections = deps.loadSectionsConfig ?? loadSectionsConfig;
|
||||||
|
const loadPreamble = deps.readPreamble ?? readPreamble;
|
||||||
|
|
||||||
|
let lastComposed: string | undefined;
|
||||||
|
let forcedNotified = false;
|
||||||
|
let configErrorNotified = false;
|
||||||
|
const notifiedMissingSets = new Set<string>();
|
||||||
|
|
||||||
|
function handleBeforeAgentStart(
|
||||||
|
event: BeforeAgentStartEvent,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
): { systemPrompt: string } | undefined {
|
||||||
|
const options = event.systemPromptOptions as unknown as RuntimePromptOptions | undefined;
|
||||||
|
const forced = options !== undefined && options.forceSystemPrompt !== undefined;
|
||||||
|
if (forced && ctx.hasUI && !forcedNotified) {
|
||||||
|
forcedNotified = true;
|
||||||
|
ctx.ui.notify(
|
||||||
|
"检测到其他扩展直接改写了系统提示词(违反单一入口规则),本轮降级为文本拼接。",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let catalog: ToolCatalog;
|
||||||
|
let routing: RoutingConfig;
|
||||||
|
let sections: SectionsConfig;
|
||||||
|
let preamble: string | undefined;
|
||||||
|
try {
|
||||||
|
catalog = loadCatalog();
|
||||||
|
routing = loadRouting();
|
||||||
|
sections = loadSections();
|
||||||
|
preamble = loadPreamble();
|
||||||
|
} catch (error) {
|
||||||
|
if (ctx.hasUI && !configErrorNotified) {
|
||||||
|
configErrorNotified = true;
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
ctx.ui.notify(`系统提示词配置加载失败:${detail}`, "warning");
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A subagent session is identified by its customPrompt carrying the
|
||||||
|
// pi-subagents <active_agent/> tag — never by the rendered prompt, which
|
||||||
|
// a main session may legitimately contain the literal tag inside.
|
||||||
|
const isSubagent = isSubagentCustomPrompt(options?.customPrompt);
|
||||||
|
const audience = isSubagent ? "subagent" : "main";
|
||||||
|
|
||||||
|
let routingText = "";
|
||||||
|
if (options) {
|
||||||
|
const missing = applyToolCatalog(options, catalog);
|
||||||
|
if (missing.length > 0 && ctx.hasUI) {
|
||||||
|
const key = [...missing].sort().join("\n");
|
||||||
|
if (!notifiedMissingSets.has(key)) {
|
||||||
|
notifiedMissingSets.add(key);
|
||||||
|
ctx.ui.notify(`以下工具未在 config/prompt/tools.jsonc 登记:${missing.join(", ")}`, "warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routingText = renderRouting(routing.rules, options.selectedTools, audience);
|
||||||
|
options.sections ??= {};
|
||||||
|
if (isSubagent) {
|
||||||
|
for (const name of sections.subagentExclude) {
|
||||||
|
delete options.sections[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (routingText.length > 0) {
|
||||||
|
options.sections.tool_routing = routingText;
|
||||||
|
}
|
||||||
|
options.sections = orderSections(options.sections, sections.order);
|
||||||
|
} else {
|
||||||
|
routingText = renderRouting(routing.rules, undefined, audience);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendered = event.systemPrompt;
|
||||||
|
if (routingText.length > 0 && !rendered.includes("<tool_routing>")) {
|
||||||
|
// Degrade path: the prompt was frozen by forceSystemPrompt, so the
|
||||||
|
// contributed section cannot render — append it as plain text.
|
||||||
|
rendered += `\n\n<tool_routing>\n${routingText}\n</tool_routing>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-empty customPrompt (pi-subagents agent prompt, SYSTEM.md) owns
|
||||||
|
// the whole preamble — including identities like <active_agent/> that
|
||||||
|
// the permission system reads — so the bundle cover must not replace it.
|
||||||
|
const ownsPreamble = typeof options?.customPrompt === "string" && options.customPrompt.length > 0;
|
||||||
|
const final = preamble && !ownsPreamble ? spliceCover(rendered, preamble) : rendered;
|
||||||
|
lastComposed = final;
|
||||||
|
return { systemPrompt: final };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleBeforeAgentStart,
|
||||||
|
getLastComposed: () => lastComposed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function systemPromptExtension(pi: ExtensionAPI): void {
|
||||||
|
const composer = createSystemPromptComposer();
|
||||||
|
|
||||||
|
pi.on("before_agent_start", (event, ctx) => composer.handleBeforeAgentStart(event, ctx));
|
||||||
|
|
||||||
|
pi.registerCommand("dump-system-prompt", {
|
||||||
|
description: "Write the effective Pi system prompt to .pi-debug/effective-system-prompt.md",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
const outputPath = getSystemPromptDumpPath(ctx.cwd);
|
||||||
|
const effectivePrompt = composer.getLastComposed() ?? ctx.getSystemPrompt();
|
||||||
|
|
||||||
|
await mkdir(dirname(outputPath), { recursive: true });
|
||||||
|
await writeFile(outputPath, effectivePrompt, "utf8");
|
||||||
|
ctx.ui.notify(`System prompt written to ${outputPath} (${effectivePrompt.length} chars)`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
||||||
import tavilyExtension from "../node_modules/@tavily/pi-extension/index.ts";
|
|
||||||
import { prefixTavilyTool, type TavilyToolDefinition } from "./tavily-tool-prefix.ts";
|
|
||||||
|
|
||||||
|
|
||||||
/** Load Tavily while making the provider explicit in every registered tool name. */
|
|
||||||
export default function tavilyOverrideExtension(pi: ExtensionAPI): void {
|
|
||||||
const prefixedPi = new Proxy(pi, {
|
|
||||||
get(target, property) {
|
|
||||||
if (property === "registerTool") {
|
|
||||||
return (definition: TavilyToolDefinition) =>
|
|
||||||
(target.registerTool as (tool: unknown) => void)(prefixTavilyTool(definition));
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = Reflect.get(target, property, target);
|
|
||||||
return typeof value === "function" ? value.bind(target) : value;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
tavilyExtension(prefixedPi);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
const TAVILY_TOOL_PREFIX = "tavily_";
|
|
||||||
|
|
||||||
export type TavilyToolDefinition = {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
promptSnippet?: string;
|
|
||||||
promptGuidelines?: string[];
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
function rewriteToolReferences(text: string): string {
|
|
||||||
return text.replace(/\bweb_search\b/g, "tavily_web_search").replace(/\bweb_fetch\b/g, "tavily_web_fetch");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function prefixTavilyTool<T extends TavilyToolDefinition>(definition: T): T {
|
|
||||||
return {
|
|
||||||
...definition,
|
|
||||||
name: `${TAVILY_TOOL_PREFIX}${definition.name}`,
|
|
||||||
label: definition.label.startsWith("Tavily ") ? definition.label : `Tavily ${definition.label}`,
|
|
||||||
description: rewriteToolReferences(definition.description),
|
|
||||||
promptSnippet: definition.promptSnippet ? rewriteToolReferences(definition.promptSnippet) : undefined,
|
|
||||||
promptGuidelines: definition.promptGuidelines?.map(rewriteToolReferences),
|
|
||||||
} as T;
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import type { BuildSystemPromptOptions, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
||||||
import { mkdir, writeFile } from "node:fs/promises";
|
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
|
|
||||||
const ROUTING_MARKER = "<!-- my-pi-tool-routing -->";
|
|
||||||
const DUMP_RELATIVE_PATH = ".pi-debug/effective-system-prompt.md";
|
|
||||||
|
|
||||||
type SelectedTools = BuildSystemPromptOptions["selectedTools"];
|
|
||||||
|
|
||||||
function hasTool(selectedTools: SelectedTools, name: string): boolean {
|
|
||||||
return selectedTools === undefined || selectedTools.includes(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasAnyTool(selectedTools: SelectedTools, names: string[]): boolean {
|
|
||||||
return names.some((name) => hasTool(selectedTools, name));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildToolRoutingSection(selectedTools: SelectedTools): string {
|
|
||||||
const rules: string[] = [];
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "codegraph_explore")) {
|
|
||||||
rules.push(
|
|
||||||
"- Use codegraph_explore first for architecture, symbol relationships, code flows, and symbols you are about to change. Treat source returned by it as already read; do not repeat the same exploration with read or search unless information is missing.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
hasTool(selectedTools, "lsp_definition") ||
|
|
||||||
hasTool(selectedTools, "lsp_references") ||
|
|
||||||
hasTool(selectedTools, "lsp_diagnostics")
|
|
||||||
) {
|
|
||||||
rules.push(
|
|
||||||
"- Use LSP for symbol definitions, references, hover/type information, and post-edit diagnostics when the language server supports the file.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "find") || hasTool(selectedTools, "grep") || hasTool(selectedTools, "multi_grep")) {
|
|
||||||
rules.push(
|
|
||||||
"- For literal search, narrow in stages instead of requesting a repository-wide dump:",
|
|
||||||
" 1. Use find to identify likely files or directories. Constrain path/glob and keep the result limit small.",
|
|
||||||
" 2. Use grep or multi_grep only inside the narrowed path or file to obtain matching line numbers. Start with a specific or literal pattern, a small context window, and a bounded limit.",
|
|
||||||
" 3. Use read with offset/limit only for the exact matching region when full source is still needed.",
|
|
||||||
"- Do not run a broad search expected to return hundreds or thousands of files or matches. If results truncate or hit a limit, narrow the path, glob, or pattern instead of increasing the limit or dumping all results.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "ssh_connect")) {
|
|
||||||
rules.push(
|
|
||||||
"- Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "ssh_cd")) {
|
|
||||||
rules.push(
|
|
||||||
"- Treat ssh_cd as a reviewed persistent workspace transition: call it as a separate step and wait for success before issuing ssh_bash or relative ssh_read/ssh_write/ssh_edit/ssh_find/ssh_grep calls that depend on the new remote cwd. Use cd inside ssh_bash only for an intentionally temporary, command-local directory change.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "ssh_find") || hasTool(selectedTools, "ssh_grep")) {
|
|
||||||
rules.push(
|
|
||||||
"- For remote SSH searches, use ssh_find to narrow remote file paths before ssh_grep searches file contents. Keep path and limit bounded; when a result says truncated, narrow the path or pattern instead of increasing the limit. Do not run find, fd, grep, or rg through ssh_bash.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tavilyTools = ["tavily_web_search", "tavily_web_fetch"];
|
|
||||||
const exaTools = ["exa_web_search", "exa_web_fetch", "exa_web_search_advanced"];
|
|
||||||
const keenableTools = ["keenable_search", "keenable_fetch"];
|
|
||||||
if (hasAnyTool(selectedTools, [...tavilyTools, ...exaTools, ...keenableTools])) {
|
|
||||||
rules.push(
|
|
||||||
"- Choose one online search provider based on the task instead of querying Tavily, Exa, and Keenable by default; cross-check with another provider only when source quality or uncertainty warrants it.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (hasAnyTool(selectedTools, tavilyTools)) {
|
|
||||||
rules.push(
|
|
||||||
"- Use Tavily tools for broad discovery when keywords are uncertain, for news/recent/trending topics, or when many candidate sources are useful. Tavily results can mix official pages, blogs, forums, and secondary sources, so keep max_results modest, avoid raw content unless needed, and fetch only selected pages when depth matters.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (hasAnyTool(selectedTools, exaTools)) {
|
|
||||||
rules.push(
|
|
||||||
"- Use Exa tools when source precision matters: official or API documentation, release/version information, academic papers, research material, and high-quality fact confirmation. Use exa_web_fetch to continue reading selected pages; for exa_web_search_advanced, explicitly bound the result count and returned text length to prevent oversized responses.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (hasAnyTool(selectedTools, keenableTools)) {
|
|
||||||
rules.push(
|
|
||||||
"- Use Keenable tools for Chinese-language pages, policies and government notices, site-restricted search, and publication-date filtering. Prefer its compact search results and fetch only selected pages to keep returned content controlled; KEENABLE_API_KEY is optional but raises rate limits.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "ctx_execute") || hasTool(selectedTools, "ctx_execute_file")) {
|
|
||||||
rules.push(
|
|
||||||
"- Use Context Mode for logs, test/build output, generated data, large-file exploration/analysis/summarization, and any command whose output may be large or unpredictable. Prefer ctx_execute_file over read when deriving an answer from a large file inside the workspace, and print only the derived answer needed for the task.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "ctx_execute_file")) {
|
|
||||||
rules.push(
|
|
||||||
"- ctx_execute_file is confined to the current project root. Do not call it for absolute paths outside the workspace, ../ traversal, or symlinks that resolve outside the workspace; host permission approval does not bypass this Context Mode boundary. Use another explicitly authorized tool for those files.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "chrome_snapshot") && hasTool(selectedTools, "ctx_execute_file")) {
|
|
||||||
rules.push(
|
|
||||||
"- After chrome_snapshot or a Chrome action with includeSnapshot=true, immediately analyze the returned workspace-relative capture path with ctx_execute_file; never use read or cat for the raw JSON. Print only the uids, text, or state needed for the next action, and take a fresh snapshot after any interaction because the previous capture may be stale.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "read")) {
|
|
||||||
rules.push(
|
|
||||||
"- Use read only when exact source is needed or the file is small. Before editing, request the smallest useful offset/limit range; do not read a complete large file merely to copy, compare, hash, count, explore, or summarize it.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTool(selectedTools, "read") && hasTool(selectedTools, "edit")) {
|
|
||||||
rules.push(
|
|
||||||
"- For existing-file changes, obtain fresh LINE#HASH anchors with a small read, then use anchored edit operations. Reuse fresh anchors returned by successful edits for chained changes; re-read only when anchors are stale or the next target region was not shown.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
rules.push(
|
|
||||||
"- Stop searching once the current evidence is sufficient. Avoid repeating the same lookup through CodeGraph, find, grep, and read without a concrete information gap.",
|
|
||||||
);
|
|
||||||
|
|
||||||
return `${ROUTING_MARKER}\n## Tool Routing\n\n${rules.join("\n")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendToolRouting(systemPrompt: string, selectedTools: SelectedTools): string {
|
|
||||||
if (systemPrompt.includes(ROUTING_MARKER)) {
|
|
||||||
return systemPrompt;
|
|
||||||
}
|
|
||||||
return `${systemPrompt}\n\n${buildToolRoutingSection(selectedTools)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSystemPromptDumpPath(cwd: string): string {
|
|
||||||
return resolve(cwd, DUMP_RELATIVE_PATH);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function toolRoutingExtension(pi: ExtensionAPI): void {
|
|
||||||
let lastEffectivePrompt: string | undefined;
|
|
||||||
|
|
||||||
pi.on("before_agent_start", (event) => {
|
|
||||||
lastEffectivePrompt = appendToolRouting(event.systemPrompt, event.systemPromptOptions.selectedTools);
|
|
||||||
return { systemPrompt: lastEffectivePrompt };
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerCommand("dump-system-prompt", {
|
|
||||||
description: "Write the effective Pi system prompt to .pi-debug/effective-system-prompt.md",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
const outputPath = getSystemPromptDumpPath(ctx.cwd);
|
|
||||||
const selectedTools = ctx.getSystemPromptOptions().selectedTools;
|
|
||||||
const effectivePrompt = lastEffectivePrompt ?? appendToolRouting(ctx.getSystemPrompt(), selectedTools);
|
|
||||||
|
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
|
||||||
await writeFile(outputPath, effectivePrompt, "utf8");
|
|
||||||
ctx.ui.notify(`System prompt written to ${outputPath} (${effectivePrompt.length} chars)`, "info");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# Hippo Memory - Pi Extension
|
|
||||||
|
|
||||||
> **my-pi packaging note:** this official source is bundled and loaded directly by the root package; do not copy it to another Pi extension directory. Use the root `install.sh`, `update.sh`, and `uninstall.sh` prompts to manage the matching global CLI. Upstream's original manual instructions are retained below for reference.
|
|
||||||
|
|
||||||
Biologically-inspired memory for [Pi coding agent](https://github.com/badlogic/pi-mono). Memories decay by default, retrieval strengthens them, errors stick longer, and sleep consolidation compresses episodes into patterns.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install hippo CLI
|
|
||||||
npm install -g hippo-memory
|
|
||||||
|
|
||||||
# Init hippo in your project (or scan all repos)
|
|
||||||
hippo init
|
|
||||||
# or: hippo init --scan ~
|
|
||||||
|
|
||||||
# Copy extension to Pi's extensions directory
|
|
||||||
cp -r extensions/pi-extension ~/.pi/agent/extensions/hippo-memory
|
|
||||||
```
|
|
||||||
|
|
||||||
Or for project-level:
|
|
||||||
```bash
|
|
||||||
cp -r extensions/pi-extension .pi/extensions/hippo-memory
|
|
||||||
```
|
|
||||||
|
|
||||||
## What it does
|
|
||||||
|
|
||||||
| Automatic | When |
|
|
||||||
|---|---|
|
|
||||||
| Injects memory context | Session start |
|
|
||||||
| Captures tool errors | After any failed tool call |
|
|
||||||
| Learns from git | On `hippo sleep` (session end) |
|
|
||||||
| Consolidates memories | On `hippo sleep` (session end) |
|
|
||||||
| Shares to global store | On `hippo sleep` (session end) |
|
|
||||||
|
|
||||||
Error capture has three filters to prevent memory pollution:
|
|
||||||
1. Noise pattern filter (timeouts, ECONNREFUSED, etc.)
|
|
||||||
2. Per-session rate limit (max 5 error memories)
|
|
||||||
3. Per-session deduplication
|
|
||||||
|
|
||||||
## Tools
|
|
||||||
|
|
||||||
The extension registers 5 tools for the LLM:
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|---|---|
|
|
||||||
| `hippo_recall` | Search memories by topic |
|
|
||||||
| `hippo_remember` | Store a new memory |
|
|
||||||
| `hippo_outcome` | Report if memories were helpful |
|
|
||||||
| `hippo_status` | Check memory health |
|
|
||||||
| `hippo_context` | Smart context from git state |
|
|
||||||
|
|
||||||
## Config
|
|
||||||
|
|
||||||
Edit the `DEFAULT_CONFIG` object at the top of `index.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const DEFAULT_CONFIG = {
|
|
||||||
budget: 4000, // Token budget for recall
|
|
||||||
contextBudget: 1500, // Token budget for auto-context
|
|
||||||
framing: 'observe', // observe | suggest | assert
|
|
||||||
autoContext: true, // Inject context at session start
|
|
||||||
autoLearn: true, // Capture tool errors
|
|
||||||
autoSleep: true, // Run consolidation on exit
|
|
||||||
maxErrorsPerSession: 5, // Error capture rate limit
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- `hippo-memory` CLI installed globally: `npm install -g hippo-memory`
|
|
||||||
- `.hippo/` initialized in your project: `hippo init`
|
|
||||||
- Pi coding agent
|
|
||||||
|
|
||||||
## How it differs from the OpenClaw plugin
|
|
||||||
|
|
||||||
Same core behavior. The Pi extension uses Pi's event system (`session_start`, `tool_result`, `session_shutdown`) instead of OpenClaw's hook system. Both use `execFileSync` with args arrays (no shell injection).
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Upstream provenance
|
|
||||||
|
|
||||||
- Upstream repository: <https://github.com/kitfunso/hippo-memory>
|
|
||||||
- Imported release: `v1.33.0`
|
|
||||||
- Imported commit: `e928179a3b35e8fe5837878aed071d6025ced45c`
|
|
||||||
- Imported source: `extensions/pi-extension/`
|
|
||||||
- Initial import date: 2026-08-19
|
|
||||||
|
|
||||||
This directory contains the official Hippo Memory Pi extension source, which is
|
|
||||||
present in the upstream Git repository but excluded from the
|
|
||||||
`hippo-memory@1.33.0` npm tarball. The extension is maintained directly in
|
|
||||||
`my-pi`, is not a submodule, and does not retain a nested `.git` directory.
|
|
||||||
|
|
||||||
The matching CLI version is declared in `config/hippo-memory-version` and is
|
|
||||||
managed as an optional machine-level dependency by the bundle helper scripts.
|
|
||||||
Future upstream updates must be ported from an exact tag/commit and reviewed
|
|
||||||
against local changes rather than overwriting this directory wholesale.
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
/**
|
|
||||||
* Hippo Memory - Pi Coding Agent Extension
|
|
||||||
*
|
|
||||||
* Auto-injects memory context at session start, captures tool errors,
|
|
||||||
* and runs consolidation on shutdown. Registers hippo tools for the LLM.
|
|
||||||
*
|
|
||||||
* Install: copy to ~/.pi/agent/extensions/hippo-memory/
|
|
||||||
* Or add to .pi/extensions/ in your project.
|
|
||||||
*
|
|
||||||
* Requires: hippo-memory CLI installed globally (npm install -g hippo-memory)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Config
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface HippoConfig {
|
|
||||||
budget: number;
|
|
||||||
contextBudget: number;
|
|
||||||
framing: 'observe' | 'suggest' | 'assert';
|
|
||||||
autoContext: boolean;
|
|
||||||
autoLearn: boolean;
|
|
||||||
autoSleep: boolean;
|
|
||||||
maxErrorsPerSession: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG: HippoConfig = {
|
|
||||||
budget: 4000,
|
|
||||||
contextBudget: 1500,
|
|
||||||
framing: 'observe',
|
|
||||||
autoContext: true,
|
|
||||||
autoLearn: true,
|
|
||||||
autoSleep: true,
|
|
||||||
maxErrorsPerSession: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Noise filtering (matches openclaw-plugin patterns)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const NOISE_ERROR_PATTERNS: RegExp[] = [
|
|
||||||
/Local media path is not under an allowed directory/i,
|
|
||||||
/timed out\.?\s*Restart/i,
|
|
||||||
/EISDIR:\s*illegal operation on a directory/i,
|
|
||||||
/Missing required parameter:\s*path/i,
|
|
||||||
/ENOENT:\s*no such file or directory/i,
|
|
||||||
/EACCES:\s*permission denied/i,
|
|
||||||
/EPERM:\s*operation not permitted/i,
|
|
||||||
/socket hang up/i,
|
|
||||||
/ECONNREFUSED/i,
|
|
||||||
/ECONNRESET/i,
|
|
||||||
/ERR_SOCKET_CONNECTION_TIMEOUT/i,
|
|
||||||
/net::ERR_/i,
|
|
||||||
/Navigation timeout/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
function isNoiseError(error: string): boolean {
|
|
||||||
return NOISE_ERROR_PATTERNS.some((p) => p.test(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True for a string value, without assuming the input's shape — used on
|
|
||||||
* fields read off the untyped Pi extension event/plugin surface. */
|
|
||||||
function isString<T>(value: T): value is T & string {
|
|
||||||
return typeof value === 'string';
|
|
||||||
}
|
|
||||||
|
|
||||||
function hashError(toolName: string, error: string): string {
|
|
||||||
return `${toolName}::${error.replace(/\s+/g, ' ').trim().slice(0, 80).toLowerCase()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Session state
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
let sessionErrorCount = 0;
|
|
||||||
const sessionErrorHashes = new Set<string>();
|
|
||||||
let sessionMemoryCount = 0;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Hippo CLI wrapper (no shell, args as array)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function runHippo(args: readonly string[], cwd?: string): string {
|
|
||||||
try {
|
|
||||||
const result = execFileSync('hippo', args, {
|
|
||||||
cwd: cwd || process.cwd(),
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: 30_000,
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
// `encoding: 'utf8'` above selects the ExecFileSyncOptionsWithStringEncoding
|
|
||||||
// overload, so `result` is always a `string` here — no runtime check needed.
|
|
||||||
return result.trim();
|
|
||||||
} catch (err: any) {
|
|
||||||
return err.stdout?.trim() || err.message || 'hippo command failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hippoAvailable(): boolean {
|
|
||||||
try {
|
|
||||||
execFileSync('hippo', ['status'], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: 5000,
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hippoInitialized(cwd: string): boolean {
|
|
||||||
return existsSync(join(cwd, '.hippo'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Extension entry point
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export default function (pi: any) {
|
|
||||||
// Check hippo is available
|
|
||||||
if (!hippoAvailable()) {
|
|
||||||
console.error('[hippo] hippo-memory CLI not found. Install: npm install -g hippo-memory');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = { ...DEFAULT_CONFIG };
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Session start: inject memory context
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pi.on('session_start', async (_event: any, ctx: any) => {
|
|
||||||
sessionErrorCount = 0;
|
|
||||||
sessionErrorHashes.clear();
|
|
||||||
sessionMemoryCount = 0;
|
|
||||||
|
|
||||||
if (!config.autoContext) return;
|
|
||||||
|
|
||||||
const cwd = ctx.cwd || process.cwd();
|
|
||||||
if (!hippoInitialized(cwd)) return;
|
|
||||||
|
|
||||||
const context = runHippo(
|
|
||||||
['context', '--auto', '--budget', String(config.contextBudget), '--framing', config.framing],
|
|
||||||
cwd,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (context && context.length > 10 && !context.includes('No hippo store')) {
|
|
||||||
return {
|
|
||||||
systemPromptAppend: `\n\n## Project Memory (Hippo)\n\n${context}\n\nWhen you learn something important, tell the user to run: hippo remember "<lesson>"\nWhen you encounter errors, suggest: hippo remember "<error description>" --error`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Tool errors: auto-capture (with noise filtering + rate limiting + dedup)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if (config.autoLearn) {
|
|
||||||
pi.on('tool_result', async (event: any, ctx: any) => {
|
|
||||||
if (!event.isError) return;
|
|
||||||
|
|
||||||
const error = isString(event.content)
|
|
||||||
? event.content
|
|
||||||
: Array.isArray(event.content)
|
|
||||||
? event.content.map((c: any) => c.text || '').join(' ')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
if (!error || error.length < 10) return;
|
|
||||||
|
|
||||||
// Filter 1: noise
|
|
||||||
if (isNoiseError(error)) return;
|
|
||||||
|
|
||||||
// Filter 2: rate limit
|
|
||||||
if (sessionErrorCount >= config.maxErrorsPerSession) return;
|
|
||||||
|
|
||||||
// Filter 3: dedup
|
|
||||||
const hash = hashError(event.toolName || 'unknown', error);
|
|
||||||
if (sessionErrorHashes.has(hash)) return;
|
|
||||||
|
|
||||||
const cwd = ctx.cwd || process.cwd();
|
|
||||||
if (!hippoInitialized(cwd)) return;
|
|
||||||
|
|
||||||
const toolTag = (event.toolName || 'unknown')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9-]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.slice(0, 30);
|
|
||||||
|
|
||||||
const truncated = error.replace(/\s+/g, ' ').trim().slice(0, 500);
|
|
||||||
const text = `Tool '${event.toolName || 'unknown'}' failed: ${truncated}`;
|
|
||||||
|
|
||||||
const args: string[] = ['remember', text, '--error', '--observed', '--tag', 'pi-agent'];
|
|
||||||
if (toolTag) args.push('--tag', toolTag);
|
|
||||||
|
|
||||||
const result = runHippo(args, cwd);
|
|
||||||
if (result.includes('Remembered [')) {
|
|
||||||
sessionErrorHashes.add(hash);
|
|
||||||
sessionErrorCount++;
|
|
||||||
sessionMemoryCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Session shutdown: run consolidation
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pi.on('session_shutdown', async (_event: any, ctx: any) => {
|
|
||||||
if (!config.autoSleep) return;
|
|
||||||
|
|
||||||
const cwd = ctx.cwd || process.cwd();
|
|
||||||
if (!hippoInitialized(cwd)) return;
|
|
||||||
|
|
||||||
// Only sleep if we captured some memories this session
|
|
||||||
if (sessionMemoryCount < 1) {
|
|
||||||
// Still run sleep for the auto-learn-from-git + auto-share features
|
|
||||||
try { runHippo(['sleep'], cwd); } catch { /* best effort */ }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
runHippo(['sleep'], cwd);
|
|
||||||
} catch {
|
|
||||||
// Best effort — don't block shutdown
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Tools: hippo_recall
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pi.registerTool({
|
|
||||||
name: 'hippo_recall',
|
|
||||||
description: 'Search project memory for relevant context. Returns memories ranked by relevance and strength.',
|
|
||||||
promptSnippet: 'hippo_recall: Search project memory by topic',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
query: { type: 'string', description: 'What to search for (natural language)' },
|
|
||||||
budget: { type: 'number', description: 'Max tokens to return (default: 4000)' },
|
|
||||||
},
|
|
||||||
required: ['query'],
|
|
||||||
},
|
|
||||||
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, ctx: any) {
|
|
||||||
const cwd = ctx.cwd || process.cwd();
|
|
||||||
const budget = params.budget || config.budget;
|
|
||||||
const result = runHippo(
|
|
||||||
['recall', params.query, '--budget', String(budget), '--framing', config.framing],
|
|
||||||
cwd,
|
|
||||||
);
|
|
||||||
return { content: [{ type: 'text', text: result || 'No relevant memories found.' }] };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Tools: hippo_remember
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pi.registerTool({
|
|
||||||
name: 'hippo_remember',
|
|
||||||
description: 'Store a lesson, insight, or error in project memory. Memories decay over time unless retrieved.',
|
|
||||||
promptSnippet: 'hippo_remember: Store a memory (lessons, errors, decisions)',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
text: { type: 'string', description: 'The memory content' },
|
|
||||||
error: { type: 'boolean', description: 'Tag as error (doubles retention)' },
|
|
||||||
tag: { type: 'string', description: 'Optional tag for categorization' },
|
|
||||||
pin: { type: 'boolean', description: 'Pin memory (never decays)' },
|
|
||||||
},
|
|
||||||
required: ['text'],
|
|
||||||
},
|
|
||||||
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, ctx: any) {
|
|
||||||
const cwd = ctx.cwd || process.cwd();
|
|
||||||
const args: string[] = ['remember', params.text];
|
|
||||||
if (params.error) args.push('--error');
|
|
||||||
if (params.pin) args.push('--pin');
|
|
||||||
if (params.tag) {
|
|
||||||
const safe = params.tag.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 30);
|
|
||||||
if (safe) args.push('--tag', safe);
|
|
||||||
}
|
|
||||||
const result = runHippo(args, cwd);
|
|
||||||
if (result.includes('Remembered [')) sessionMemoryCount++;
|
|
||||||
return { content: [{ type: 'text', text: result || 'Memory stored.' }] };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Tools: hippo_outcome
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pi.registerTool({
|
|
||||||
name: 'hippo_outcome',
|
|
||||||
description: 'Report whether recalled memories were helpful. Strengthens or weakens them.',
|
|
||||||
promptSnippet: 'hippo_outcome: Rate recalled memories as good or bad',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
good: { type: 'boolean', description: 'true = helpful, false = not helpful' },
|
|
||||||
},
|
|
||||||
required: ['good'],
|
|
||||||
},
|
|
||||||
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, ctx: any) {
|
|
||||||
const cwd = ctx.cwd || process.cwd();
|
|
||||||
const flag = params.good ? '--good' : '--bad';
|
|
||||||
const result = runHippo(['outcome', flag], cwd);
|
|
||||||
return { content: [{ type: 'text', text: result || 'Outcome recorded.' }] };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Tools: hippo_status
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pi.registerTool({
|
|
||||||
name: 'hippo_status',
|
|
||||||
description: 'Show memory health: counts, strength distribution, conflicts.',
|
|
||||||
promptSnippet: 'hippo_status: Check memory store health',
|
|
||||||
parameters: { type: 'object', properties: {} },
|
|
||||||
async execute(_toolCallId: string, _params: any, _signal: any, _onUpdate: any, ctx: any) {
|
|
||||||
const cwd = ctx.cwd || process.cwd();
|
|
||||||
const result = runHippo(['status'], cwd);
|
|
||||||
return { content: [{ type: 'text', text: result || 'No hippo store found.' }] };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Tools: hippo_context
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pi.registerTool({
|
|
||||||
name: 'hippo_context',
|
|
||||||
description: 'Get smart memory context based on current git state and task.',
|
|
||||||
promptSnippet: 'hippo_context: Smart context injection from git state',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
budget: { type: 'number', description: 'Token budget (default: 1500)' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, ctx: any) {
|
|
||||||
const cwd = ctx.cwd || process.cwd();
|
|
||||||
const budget = params.budget || config.contextBudget;
|
|
||||||
const result = runHippo(
|
|
||||||
['context', '--auto', '--budget', String(budget), '--framing', config.framing],
|
|
||||||
cwd,
|
|
||||||
);
|
|
||||||
return { content: [{ type: 'text', text: result || 'No context available.' }] };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+27
-122
@@ -3,17 +3,16 @@
|
|||||||
set -u
|
set -u
|
||||||
|
|
||||||
SCRIPT_DIR=$(CDPATH=; cd "$(dirname "$0")" && pwd) || exit 1
|
SCRIPT_DIR=$(CDPATH=; cd "$(dirname "$0")" && pwd) || exit 1
|
||||||
MANAGED_CONFIG_HELPERS=$SCRIPT_DIR/scripts/managed-config.sh
|
MY_PI_LIB=$SCRIPT_DIR/scripts/lib.sh
|
||||||
if [ ! -f "$MANAGED_CONFIG_HELPERS" ]; then
|
if [ ! -f "$MY_PI_LIB" ]; then
|
||||||
printf '错误: 找不到受管配置辅助脚本:%s\n' "$MANAGED_CONFIG_HELPERS" >&2
|
printf '错误: 找不到共享辅助脚本:%s\n' "$MY_PI_LIB" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# shellcheck source=scripts/managed-config.sh
|
# shellcheck source=scripts/lib.sh
|
||||||
. "$MANAGED_CONFIG_HELPERS"
|
. "$MY_PI_LIB"
|
||||||
|
|
||||||
PACKAGE_SOURCE=${PI_PACKAGE_SOURCE:-git:git@bitbucket.org:siakitem/my-pi.git}
|
PACKAGE_SOURCE=${PI_PACKAGE_SOURCE:-git:git@bitbucket.org:siakitem/my-pi.git}
|
||||||
POWERLEVEL10K_PRESET_FILE=$SCRIPT_DIR/config/p10k.zsh
|
POWERLEVEL10K_PRESET_FILE=$SCRIPT_DIR/config/p10k.zsh
|
||||||
HIPPO_MEMORY_VERSION_FILE=$SCRIPT_DIR/config/hippo-memory-version
|
|
||||||
CODEGRAPH_INSTALL_URL=https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh
|
CODEGRAPH_INSTALL_URL=https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh
|
||||||
KITTY_INSTALL_URL=https://sw.kovidgoyal.net/kitty/installer.sh
|
KITTY_INSTALL_URL=https://sw.kovidgoyal.net/kitty/installer.sh
|
||||||
OH_MY_ZSH_INSTALL_URL=https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh
|
OH_MY_ZSH_INSTALL_URL=https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh
|
||||||
@@ -22,14 +21,6 @@ ZSH_AUTOSUGGESTIONS_REPOSITORY=https://github.com/zsh-users/zsh-autosuggestions.
|
|||||||
ZSH_SYNTAX_HIGHLIGHTING_REPOSITORY=https://github.com/zsh-users/zsh-syntax-highlighting.git
|
ZSH_SYNTAX_HIGHLIGHTING_REPOSITORY=https://github.com/zsh-users/zsh-syntax-highlighting.git
|
||||||
install_failed=0
|
install_failed=0
|
||||||
|
|
||||||
say() {
|
|
||||||
printf '%s\n' "$*"
|
|
||||||
}
|
|
||||||
|
|
||||||
warn() {
|
|
||||||
printf '警告: %s\n' "$*" >&2
|
|
||||||
}
|
|
||||||
|
|
||||||
ask_yes_no() {
|
ask_yes_no() {
|
||||||
prompt=$1
|
prompt=$1
|
||||||
printf '%s [y/N] ' "$prompt"
|
printf '%s [y/N] ' "$prompt"
|
||||||
@@ -82,7 +73,13 @@ install_codegraph() {
|
|||||||
warn "安装 CodeGraph 需要 curl。"
|
warn "安装 CodeGraph 需要 curl。"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
curl -fsSL "$CODEGRAPH_INSTALL_URL" | sh || return 1
|
codegraph_installer=$(mktemp "${TMPDIR:-/tmp}/my-pi-codegraph.XXXXXX") || return 1
|
||||||
|
if ! curl -fsSL "$CODEGRAPH_INSTALL_URL" -o "$codegraph_installer" || \
|
||||||
|
! sh "$codegraph_installer"; then
|
||||||
|
rm -f "$codegraph_installer"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f "$codegraph_installer"
|
||||||
|
|
||||||
# 官方安装器默认使用 ~/.local/bin;让本次检查立即看到新命令。
|
# 官方安装器默认使用 ~/.local/bin;让本次检查立即看到新命令。
|
||||||
if [ -d "$HOME/.local/bin" ]; then
|
if [ -d "$HOME/.local/bin" ]; then
|
||||||
@@ -92,52 +89,15 @@ install_codegraph() {
|
|||||||
command -v codegraph >/dev/null 2>&1
|
command -v codegraph >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
read_hippo_memory_version() {
|
|
||||||
if [ ! -f "$HIPPO_MEMORY_VERSION_FILE" ]; then
|
|
||||||
warn "找不到 Hippo Memory 版本文件:$HIPPO_MEMORY_VERSION_FILE"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
hippo_target_version=$(sed -n '1p' "$HIPPO_MEMORY_VERSION_FILE")
|
|
||||||
case $hippo_target_version in
|
|
||||||
''|*[!0-9.]*)
|
|
||||||
warn "Hippo Memory 版本文件内容无效:$hippo_target_version"
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
printf '%s\n' "$hippo_target_version"
|
|
||||||
}
|
|
||||||
|
|
||||||
installed_hippo_memory_version() {
|
|
||||||
hippo --version 2>/dev/null | sed -n 's/^[^0-9]*\([0-9][0-9.]*\).*$/\1/p' | sed -n '1p'
|
|
||||||
}
|
|
||||||
|
|
||||||
install_hippo_memory() {
|
|
||||||
if ! command -v npm >/dev/null 2>&1; then
|
|
||||||
warn "安装 Hippo Memory CLI 需要 npm。"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
hippo_target_version=$(read_hippo_memory_version) || return 1
|
|
||||||
npm install -g "hippo-memory@$hippo_target_version" || return 1
|
|
||||||
if ! command -v hippo >/dev/null 2>&1; then
|
|
||||||
warn "npm 已完成安装,但当前 PATH 中仍找不到 hippo。"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
hippo_installed_version=$(installed_hippo_memory_version)
|
|
||||||
if [ "$hippo_installed_version" != "$hippo_target_version" ]; then
|
|
||||||
warn "Hippo Memory CLI 版本校验失败:期望 $hippo_target_version,实际 ${hippo_installed_version:-未知}。"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
install_kotlin_lsp() {
|
install_kotlin_lsp() {
|
||||||
require_brew || return 1
|
require_brew || return 1
|
||||||
brew install JetBrains/utils/kotlin-lsp || return 1
|
HOMEBREW_NO_ASK=1 brew install JetBrains/utils/kotlin-lsp || return 1
|
||||||
command -v kotlin-lsp >/dev/null 2>&1
|
command -v kotlin-lsp >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
install_java_21() {
|
install_java_21() {
|
||||||
require_brew || return 1
|
require_brew || return 1
|
||||||
brew install openjdk@21 || return 1
|
HOMEBREW_NO_ASK=1 brew install openjdk@21 || return 1
|
||||||
java_home=$(brew --prefix openjdk@21 2>/dev/null) || return 1
|
java_home=$(brew --prefix openjdk@21 2>/dev/null) || return 1
|
||||||
PATH="$java_home/bin:$PATH"
|
PATH="$java_home/bin:$PATH"
|
||||||
export PATH
|
export PATH
|
||||||
@@ -150,46 +110,33 @@ install_java_21() {
|
|||||||
|
|
||||||
install_jdtls() {
|
install_jdtls() {
|
||||||
require_brew || return 1
|
require_brew || return 1
|
||||||
brew install jdtls || return 1
|
HOMEBREW_NO_ASK=1 brew install jdtls || return 1
|
||||||
command -v jdtls >/dev/null 2>&1
|
command -v jdtls >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
detect_kitty() {
|
|
||||||
kitty_bin=
|
|
||||||
if command -v kitty >/dev/null 2>&1; then
|
|
||||||
kitty_bin=$(command -v kitty)
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
for candidate in /Applications/kitty.app/Contents/MacOS/kitty "$HOME/.local/kitty.app/bin/kitty"; do
|
|
||||||
if [ -x "$candidate" ]; then
|
|
||||||
kitty_bin=$candidate
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
install_kitty() {
|
install_kitty() {
|
||||||
case $(uname -s) in
|
case $(uname -s) in
|
||||||
Darwin)
|
Darwin)
|
||||||
require_brew || return 1
|
require_brew || return 1
|
||||||
brew install --cask kitty || return 1
|
HOMEBREW_NO_ASK=1 brew install --cask kitty || return 1
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
if ! command -v curl >/dev/null 2>&1; then
|
if ! command -v curl >/dev/null 2>&1; then
|
||||||
warn "安装 Kitty 需要 curl。"
|
warn "安装 Kitty 需要 curl。"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
curl -L "$KITTY_INSTALL_URL" | sh /dev/stdin || return 1
|
installer_file=$(mktemp "${TMPDIR:-/tmp}/my-pi-kitty.XXXXXX") || return 1
|
||||||
|
if ! curl -fsSL "$KITTY_INSTALL_URL" -o "$installer_file" || \
|
||||||
|
! sh "$installer_file"; then
|
||||||
|
rm -f "$installer_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f "$installer_file"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
detect_kitty
|
detect_kitty
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve_kitty_config_dir() {
|
|
||||||
kitty_config_dir=${KITTY_CONFIG_DIRECTORY:-${XDG_CONFIG_HOME:-$HOME/.config}/kitty}
|
|
||||||
}
|
|
||||||
|
|
||||||
kitty_solarized_dark_configured() {
|
kitty_solarized_dark_configured() {
|
||||||
resolve_kitty_config_dir
|
resolve_kitty_config_dir
|
||||||
[ -f "$kitty_config_dir/current-theme.conf" ] || return 1
|
[ -f "$kitty_config_dir/current-theme.conf" ] || return 1
|
||||||
@@ -203,14 +150,6 @@ configure_kitty_solarized_dark() {
|
|||||||
"$kitty_bin" +kitten themes --reload-in=none "Solarized Dark"
|
"$kitty_bin" +kitten themes --reload-in=none "Solarized Dark"
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve_zsh_paths() {
|
|
||||||
oh_my_zsh_dir=${ZSH:-$HOME/.oh-my-zsh}
|
|
||||||
zsh_custom_dir=${ZSH_CUSTOM:-$oh_my_zsh_dir/custom}
|
|
||||||
zshrc_file=${ZDOTDIR:-$HOME}/.zshrc
|
|
||||||
my_pi_config_dir=${XDG_CONFIG_HOME:-$HOME/.config}/my-pi
|
|
||||||
p10k_managed_config_file=$my_pi_config_dir/p10k.zsh
|
|
||||||
}
|
|
||||||
|
|
||||||
oh_my_zsh_installed() {
|
oh_my_zsh_installed() {
|
||||||
resolve_zsh_paths
|
resolve_zsh_paths
|
||||||
[ -f "$oh_my_zsh_dir/oh-my-zsh.sh" ]
|
[ -f "$oh_my_zsh_dir/oh-my-zsh.sh" ]
|
||||||
@@ -240,20 +179,6 @@ prepare_zshrc() {
|
|||||||
[ -f "$zshrc_file" ] || : > "$zshrc_file" || return 1
|
[ -f "$zshrc_file" ] || : > "$zshrc_file" || return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
replace_zsh_managed_block() {
|
|
||||||
managed_id=$1
|
|
||||||
managed_placement=$2
|
|
||||||
managed_anchor=$3
|
|
||||||
shift 3
|
|
||||||
managed_content=$(mktemp "${TMPDIR:-/tmp}/my-pi-zsh-block.XXXXXX") || return 1
|
|
||||||
printf '%s\n' "$@" > "$managed_content"
|
|
||||||
my_pi_replace_managed_block "$zshrc_file" "$managed_id" "$managed_content" \
|
|
||||||
"$managed_placement" "$managed_anchor"
|
|
||||||
managed_status=$?
|
|
||||||
rm -f "$managed_content"
|
|
||||||
return "$managed_status"
|
|
||||||
}
|
|
||||||
|
|
||||||
zshrc_has_oh_my_zsh_loader() {
|
zshrc_has_oh_my_zsh_loader() {
|
||||||
[ -f "$zshrc_file" ] || return 1
|
[ -f "$zshrc_file" ] || return 1
|
||||||
grep -E '^[[:space:]]*(source|\.)[[:space:]].*oh-my-zsh\.sh' "$zshrc_file" >/dev/null 2>&1
|
grep -E '^[[:space:]]*(source|\.)[[:space:]].*oh-my-zsh\.sh' "$zshrc_file" >/dev/null 2>&1
|
||||||
@@ -272,7 +197,7 @@ ensure_oh_my_zsh_loader() {
|
|||||||
set_powerlevel10k_theme() {
|
set_powerlevel10k_theme() {
|
||||||
prepare_zshrc || return 1
|
prepare_zshrc || return 1
|
||||||
replace_zsh_managed_block "powerlevel10k-theme" "before" \
|
replace_zsh_managed_block "powerlevel10k-theme" "before" \
|
||||||
'^(# >>> my-pi:oh-my-zsh-loader >>>|[[:space:]]*(source|\.)[[:space:]].*oh-my-zsh\.sh)' \
|
"$MY_PI_ZSH_LOADER_ANCHOR" \
|
||||||
'ZSH_THEME="powerlevel10k/powerlevel10k"'
|
'ZSH_THEME="powerlevel10k/powerlevel10k"'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +254,7 @@ add_zsh_plugin() {
|
|||||||
prepare_zshrc || return 1
|
prepare_zshrc || return 1
|
||||||
ensure_oh_my_zsh_loader || return 1
|
ensure_oh_my_zsh_loader || return 1
|
||||||
replace_zsh_managed_block "zsh-plugin-$plugin" "before" \
|
replace_zsh_managed_block "zsh-plugin-$plugin" "before" \
|
||||||
'^(# >>> my-pi:oh-my-zsh-loader >>>|[[:space:]]*(source|\.)[[:space:]].*oh-my-zsh\.sh)' \
|
"$MY_PI_ZSH_LOADER_ANCHOR" \
|
||||||
"plugins+=($plugin)"
|
"plugins+=($plugin)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,29 +296,9 @@ if ! pi install "$PACKAGE_SOURCE"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
say "Pi 组合包安装完成,开始检查终端环境和机器级 CLI 依赖。"
|
say "Pi 组合包安装完成。"
|
||||||
|
|
||||||
if command -v hippo >/dev/null 2>&1; then
|
say "开始检查终端环境和机器级 CLI 依赖。"
|
||||||
hippo_installed_version=$(installed_hippo_memory_version)
|
|
||||||
hippo_target_version=$(read_hippo_memory_version || true)
|
|
||||||
if [ -n "$hippo_installed_version" ] && [ "$hippo_installed_version" = "$hippo_target_version" ]; then
|
|
||||||
say "✓ Hippo Memory CLI $hippo_installed_version 已安装"
|
|
||||||
else
|
|
||||||
say "✓ Hippo Memory CLI ${hippo_installed_version:-未知版本} 已安装;组合包目标版本为 ${hippo_target_version:-未知},可运行 update.sh 同步。"
|
|
||||||
fi
|
|
||||||
elif ask_to_install "Hippo Memory CLI(官方 Pi 记忆扩展运行时)"; then
|
|
||||||
if install_hippo_memory; then
|
|
||||||
say "✓ Hippo Memory CLI 安装完成"
|
|
||||||
else
|
|
||||||
record_install_failure "Hippo Memory CLI"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
say "- 已跳过 Hippo Memory CLI;官方 Hippo Pi 扩展本次将不可用。"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v hippo >/dev/null 2>&1; then
|
|
||||||
say "- 未自动运行 hippo init;请由用户在需要启用记忆的项目目录中自行执行。"
|
|
||||||
fi
|
|
||||||
|
|
||||||
kitty_ok=0
|
kitty_ok=0
|
||||||
if detect_kitty; then
|
if detect_kitty; then
|
||||||
|
|||||||
Generated
+1301
-4727
File diff suppressed because it is too large
Load Diff
+104
-42
@@ -10,15 +10,76 @@
|
|||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"extensions",
|
"extensions",
|
||||||
"hippo-memory-pi",
|
"pi-ask-user/index.ts",
|
||||||
"pi-ask-user",
|
"pi-ask-user/src",
|
||||||
"pi-chrome",
|
"pi-ask-user/package.json",
|
||||||
"pi-extension-codex-fast-mode",
|
"pi-ask-user/README.md",
|
||||||
"pi-rtk-optimizer",
|
"pi-ask-user/LICENSE",
|
||||||
"pi-minimal-footer",
|
"pi-ask-user/UPSTREAM.md",
|
||||||
"pi-subagents",
|
"pi-compaction/index.ts",
|
||||||
"pi-notify",
|
"pi-compaction/src",
|
||||||
"pi-ssh",
|
"pi-compaction/package.json",
|
||||||
|
"pi-compaction/README.md",
|
||||||
|
"pi-compaction/CHANGELOG.md",
|
||||||
|
"pi-compaction/LICENSES",
|
||||||
|
"pi-compaction/UPSTREAM.md",
|
||||||
|
"pi-chrome/extensions",
|
||||||
|
"pi-chrome/package.json",
|
||||||
|
"pi-chrome/README.md",
|
||||||
|
"pi-chrome/CHANGELOG.md",
|
||||||
|
"pi-chrome/SECURITY.md",
|
||||||
|
"pi-chrome/LICENSE",
|
||||||
|
"pi-chrome/UPSTREAM.md",
|
||||||
|
"pi-hermes-memory/src",
|
||||||
|
"pi-hermes-memory/package.json",
|
||||||
|
"pi-hermes-memory/README.md",
|
||||||
|
"pi-hermes-memory/CHANGELOG.md",
|
||||||
|
"pi-hermes-memory/LICENSE",
|
||||||
|
"pi-hermes-memory/UPSTREAM.md",
|
||||||
|
"pi-rtk-optimizer/index.ts",
|
||||||
|
"pi-rtk-optimizer/src",
|
||||||
|
"pi-rtk-optimizer/package.json",
|
||||||
|
"pi-rtk-optimizer/README.md",
|
||||||
|
"pi-rtk-optimizer/CHANGELOG.md",
|
||||||
|
"pi-rtk-optimizer/LICENSE",
|
||||||
|
"pi-rtk-optimizer/UPSTREAM.md",
|
||||||
|
"pi-minimal-footer/index.ts",
|
||||||
|
"pi-minimal-footer/status.ts",
|
||||||
|
"pi-minimal-footer/settings.ts",
|
||||||
|
"pi-minimal-footer/assets",
|
||||||
|
"pi-minimal-footer/package.json",
|
||||||
|
"pi-minimal-footer/README.md",
|
||||||
|
"pi-minimal-footer/LICENSE",
|
||||||
|
"pi-minimal-footer/UPSTREAM.md",
|
||||||
|
"pi-subagents/src",
|
||||||
|
"pi-subagents/package.json",
|
||||||
|
"pi-subagents/README.md",
|
||||||
|
"pi-subagents/CHANGELOG.md",
|
||||||
|
"pi-subagents/SECURITY.md",
|
||||||
|
"pi-subagents/LICENSE",
|
||||||
|
"pi-subagents/UPSTREAM.md",
|
||||||
|
"pi-overflow/index.ts",
|
||||||
|
"pi-overflow/capture.ts",
|
||||||
|
"pi-overflow/settings.ts",
|
||||||
|
"pi-overflow/src",
|
||||||
|
"pi-overflow/package.json",
|
||||||
|
"pi-overflow/README.md",
|
||||||
|
"pi-overflow/LICENSE",
|
||||||
|
"pi-overflow/UPSTREAM.md",
|
||||||
|
"pi-notify/src",
|
||||||
|
"pi-notify/package.json",
|
||||||
|
"pi-notify/README.md",
|
||||||
|
"pi-notify/LICENSE",
|
||||||
|
"pi-notify/UPSTREAM.md",
|
||||||
|
"pi-ssh/index.ts",
|
||||||
|
"pi-ssh/permission-integration.ts",
|
||||||
|
"pi-ssh/src",
|
||||||
|
"pi-ssh/scripts",
|
||||||
|
"pi-ssh/package.json",
|
||||||
|
"pi-ssh/README.md",
|
||||||
|
"pi-ssh/CHANGELOG.md",
|
||||||
|
"pi-ssh/LICENSE",
|
||||||
|
"pi-ssh/UPSTREAM.md",
|
||||||
"pi-tool-search/extensions",
|
"pi-tool-search/extensions",
|
||||||
"pi-tool-search/docs",
|
"pi-tool-search/docs",
|
||||||
"pi-tool-search/package.json",
|
"pi-tool-search/package.json",
|
||||||
@@ -26,78 +87,82 @@
|
|||||||
"pi-tool-search/CHANGELOG.md",
|
"pi-tool-search/CHANGELOG.md",
|
||||||
"pi-tool-search/LICENSE",
|
"pi-tool-search/LICENSE",
|
||||||
"pi-tool-search/UPSTREAM.md",
|
"pi-tool-search/UPSTREAM.md",
|
||||||
"pi-permission-auto-review",
|
"pi-web-search/index.ts",
|
||||||
|
"pi-web-search/auth.ts",
|
||||||
|
"pi-web-search/router.ts",
|
||||||
|
"pi-web-search/clients",
|
||||||
|
"pi-web-search/package.json",
|
||||||
|
"pi-web-search/README.md",
|
||||||
|
"pi-web-search/LICENSE",
|
||||||
|
"pi-web-search/UPSTREAM.md",
|
||||||
|
"pi-worktree/index.ts",
|
||||||
|
"pi-worktree/src",
|
||||||
|
"pi-worktree/package.json",
|
||||||
|
"pi-worktree/README.md",
|
||||||
|
"pi-worktree/LICENSE",
|
||||||
|
"pi-worktree/UPSTREAM.md",
|
||||||
|
"pi-permission-system/LICENSES",
|
||||||
|
"pi-permission-system/docs/auto-review",
|
||||||
"pi-permission-system/src",
|
"pi-permission-system/src",
|
||||||
"pi-permission-system/config",
|
|
||||||
"pi-permission-system/schemas",
|
"pi-permission-system/schemas",
|
||||||
"pi-permission-system/package.json",
|
"pi-permission-system/package.json",
|
||||||
"pi-permission-system/README.md",
|
"pi-permission-system/README.md",
|
||||||
"pi-permission-system/CHANGELOG.md",
|
"pi-permission-system/CHANGELOG.md",
|
||||||
"pi-permission-system/LICENSE",
|
"pi-permission-system/LICENSE",
|
||||||
|
"pi-permission-system/UPSTREAM.md",
|
||||||
"config",
|
"config",
|
||||||
"scripts",
|
"scripts",
|
||||||
"install.sh",
|
"install.sh",
|
||||||
"update.sh",
|
"update.sh",
|
||||||
"uninstall.sh",
|
"uninstall.sh",
|
||||||
"search_config.sh",
|
"configure.sh",
|
||||||
"ssh_config.sh",
|
|
||||||
"README.md",
|
"README.md",
|
||||||
"AGENTS.md"
|
"AGENTS.md"
|
||||||
],
|
],
|
||||||
"pi": {
|
"pi": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"./extensions/search-config.ts",
|
"./pi-overflow/capture.ts",
|
||||||
"./extensions/fff-override.ts",
|
"./extensions/fff-override.ts",
|
||||||
"./extensions/tavily-override.ts",
|
"./extensions/bash-override.ts",
|
||||||
"./node_modules/@keenable/pi-search/src/index.ts",
|
"./pi-web-search/index.ts",
|
||||||
"./extensions/mcp.ts",
|
"./extensions/codegraph.ts",
|
||||||
"./extensions/plugin-init.ts",
|
|
||||||
"./pi-rtk-optimizer/index.ts",
|
"./pi-rtk-optimizer/index.ts",
|
||||||
"./extensions/hashline.ts",
|
"./extensions/hashline.ts",
|
||||||
"./node_modules/context-mode/build/adapters/pi/extension.js",
|
|
||||||
"./extensions/lsp.ts",
|
"./extensions/lsp.ts",
|
||||||
"./node_modules/pi-context-view/src/index.ts",
|
"./pi-hermes-memory/src/index.ts",
|
||||||
"./pi-extension-codex-fast-mode/index.ts",
|
"./pi-compaction/index.ts",
|
||||||
"./hippo-memory-pi/index.ts",
|
"./extensions/context-view.ts",
|
||||||
"./node_modules/@ogulcancelik/pi-codex-compaction/index.ts",
|
|
||||||
"./pi-minimal-footer/index.ts",
|
"./pi-minimal-footer/index.ts",
|
||||||
"./pi-notify/src/index.ts",
|
"./pi-notify/src/index.ts",
|
||||||
"./extensions/condense.ts",
|
|
||||||
"./pi-permission-auto-review/index.ts",
|
|
||||||
"./extensions/permission-system.ts",
|
"./extensions/permission-system.ts",
|
||||||
"./extensions/subagents.ts",
|
"./extensions/subagents.ts",
|
||||||
"./pi-ssh/index.ts",
|
"./pi-ssh/index.ts",
|
||||||
"./pi-ask-user/index.ts",
|
"./pi-ask-user/index.ts",
|
||||||
|
"./pi-worktree/index.ts",
|
||||||
"./pi-chrome/extensions/chrome-profile-bridge/index.ts",
|
"./pi-chrome/extensions/chrome-profile-bridge/index.ts",
|
||||||
"./extensions/tool-routing.ts",
|
"./pi-tool-search/extensions/index.ts",
|
||||||
"./pi-tool-search/extensions/index.ts"
|
"./pi-overflow/index.ts",
|
||||||
],
|
"./extensions/system-prompt.ts"
|
||||||
"skills": [
|
|
||||||
"./node_modules/context-mode/skills",
|
|
||||||
"./node_modules/@keenable/pi-search/skills"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ff-labs/pi-fff": "0.10.5",
|
"@ff-labs/pi-fff": "0.10.5",
|
||||||
"@firstpick/pi-extension-codex-fast-mode": "file:./pi-extension-codex-fast-mode",
|
|
||||||
"@gotgenes/pi-permission-system": "file:./pi-permission-system",
|
"@gotgenes/pi-permission-system": "file:./pi-permission-system",
|
||||||
"@keenable/pi-search": "0.1.2",
|
|
||||||
"@ogulcancelik/pi-codex-compaction": "0.1.3",
|
|
||||||
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
|
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
|
||||||
"@smoose/pi-notify": "file:./pi-notify",
|
"@smoose/pi-notify": "file:./pi-notify",
|
||||||
"@tavily/pi-extension": "0.1.2",
|
|
||||||
"@tintinweb/pi-subagents": "file:./pi-subagents",
|
"@tintinweb/pi-subagents": "file:./pi-subagents",
|
||||||
"context-mode": "1.0.169",
|
|
||||||
"jiti": "2.7.0",
|
"jiti": "2.7.0",
|
||||||
"pi-ask-user": "file:./pi-ask-user",
|
"pi-ask-user": "file:./pi-ask-user",
|
||||||
"pi-chrome": "file:./pi-chrome",
|
"pi-chrome": "file:./pi-chrome",
|
||||||
"pi-condense": "2.9.1",
|
"pi-compaction": "file:./pi-compaction",
|
||||||
"pi-context-view": "0.4.2",
|
|
||||||
"pi-hashline-edit": "0.8.3",
|
"pi-hashline-edit": "0.8.3",
|
||||||
|
"pi-hermes-memory": "file:./pi-hermes-memory",
|
||||||
|
"pi-overflow": "file:./pi-overflow",
|
||||||
"pi-lsp": "0.1.7",
|
"pi-lsp": "0.1.7",
|
||||||
"pi-mcp-adapter": "2.26.0",
|
|
||||||
"pi-ssh": "file:./pi-ssh",
|
"pi-ssh": "file:./pi-ssh",
|
||||||
"pi-tool-search": "file:./pi-tool-search",
|
"pi-tool-search": "file:./pi-tool-search",
|
||||||
|
"pi-web-search": "file:./pi-web-search",
|
||||||
|
"pi-worktree": "file:./pi-worktree",
|
||||||
"ssh2": "1.17.0",
|
"ssh2": "1.17.0",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-language-server": "5.3.0",
|
"typescript-language-server": "5.3.0",
|
||||||
@@ -125,8 +190,5 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.19.0"
|
"node": ">=22.19.0"
|
||||||
},
|
|
||||||
"allowScripts": {
|
|
||||||
"better-sqlite3@12.11.1": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# my-pi 维护约定(pi-ask-user)
|
||||||
|
|
||||||
|
- 扩展入口是 `pi-ask-user/index.ts`,schema、归一化、TUI、RPC extension UI bridge 与工具注册位于 `src/`,纯 helper 测试保留在 `test/`;根组合包直接加载源码并通过本地 `file:` 依赖打包。
|
||||||
|
- 工具名必须保持 `ask_user_question`,并由 Tool Search 的独立 `user-interaction` 组按需加载;根加载顺序必须位于 `pi-tool-search` 之前。
|
||||||
|
- `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具统一支持单题、多题、选择、自由文本、自定义回答与复核;顺序执行和最多 8 题;不加载 `/qna`。
|
||||||
|
- 参数必须有界并在运行时复核 question id、题型、选项和值的有效性;TUI 必须顺序执行、监听 abort、按 width 失效缓存,并把外层 `Focusable` 状态传播给内嵌 Editor;RPC 必须只依赖 Pi 标准 `select` / `input` extension UI、逐题执行、传递 abort signal,并把 dismiss/abort 归一化为取消;JSON/print 等无 UI 模式保持拒绝。
|
||||||
|
- 取消或 abort 必须返回 `cancelled: true`,不得伪造答案;成功结果的 `details` 必须保持 `version`、`cancelled: false` 与非空 `answers`,每个 answer 必须提供原始 `question` 和用户 `answer`,以匹配 AutoReview 已有的可信结构化用户交互边界;取消结果不得成为权限授权证据。修改结果结构时同步验证 `pi-permission-system/src/auto-review/transcript.ts` 解析。
|
||||||
|
- 同步上游示例时从明确 commit 移植并保留 `LICENSE`、`UPSTREAM.md` 与本地整合差异;不得恢复 `/qna`、重复的单题工具或多个扩展入口。
|
||||||
|
- 修改后至少运行包内 `npm test` / `npm run check`、Tool Search 的 typecheck/test/build、AutoReview transcript 测试、根扩展联合加载和 packed tarball 隔离安装验证。
|
||||||
+10
-7
@@ -4,16 +4,17 @@ A locally maintained Pi extension that consolidates the upstream `question.ts` a
|
|||||||
|
|
||||||
## Tool
|
## Tool
|
||||||
|
|
||||||
The extension registers `ask_user_question`. The model can present one to eight questions in one sequential TUI interaction:
|
The extension registers `ask_user_question`. The model can present one to eight questions through an interactive Pi UI:
|
||||||
|
|
||||||
- `select`: one to eight stable `{ value, label, description? }` choices, with an optional free-form choice;
|
- `select`: one to eight stable `{ value, label, description? }` choices, always followed by a free-form choice that the model cannot disable;
|
||||||
- `text`: a free-form Editor answer;
|
- `text`: a free-form answer;
|
||||||
- `required: false`: lets the user explicitly skip the question;
|
- `required: false`: lets the user explicitly skip the question;
|
||||||
- multiple questions: progress tabs plus a final review page.
|
- TUI mode: one sequential custom interaction with progress tabs and a final review page;
|
||||||
|
- RPC mode: standard Pi `select` / `input` dialogs shown one question at a time by compatible clients such as Paseo.
|
||||||
|
|
||||||
The model should call the tool only when a missing decision, preference, or clarification is needed to continue. It should not repeat questions already answered in the user's direct messages.
|
The model should call the tool only when a missing decision, preference, or clarification is needed to continue. It should not repeat questions already answered in the user's direct messages.
|
||||||
|
|
||||||
## Controls
|
## TUI controls
|
||||||
|
|
||||||
- `↑` / `↓`: move through choices;
|
- `↑` / `↓`: move through choices;
|
||||||
- `Enter`: select, edit, or submit;
|
- `Enter`: select, edit, or submit;
|
||||||
@@ -22,6 +23,8 @@ The model should call the tool only when a missing decision, preference, or clar
|
|||||||
|
|
||||||
The outer component propagates focus to the embedded Editor for IME cursor positioning.
|
The outer component propagates focus to the embedded Editor for IME cursor positioning.
|
||||||
|
|
||||||
|
RPC clients use their native dialog controls. Dismissing a dialog or aborting the tool returns `cancelled: true`; required blank text is requested again, while optional blank text is recorded as an explicit skip.
|
||||||
|
|
||||||
## Result contract
|
## Result contract
|
||||||
|
|
||||||
Successful results include readable text and structured `details`:
|
Successful results include readable text and structured `details`:
|
||||||
@@ -46,9 +49,9 @@ Successful results include readable text and structured `details`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The tool name and the `question` / `answer` fields intentionally match `pi-permission-auto-review`'s trusted structured user-interaction envelope. Cancelled results are never treated as authorization evidence.
|
The tool name and the `question` / `answer` fields intentionally match `pi-permission-system`'s integrated AutoReview trusted structured user-interaction envelope. Cancelled results are never treated as authorization evidence.
|
||||||
|
|
||||||
The tool requires Pi's interactive TUI mode. RPC, JSON, and print modes receive a tool error rather than an invented answer.
|
The tool requires dialog-capable interactive UI. TUI mode uses the custom questionnaire, RPC mode uses Pi's standard extension UI bridge, and JSON/print modes receive a tool error rather than an invented answer.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Upstream sources
|
# 上游来源(多个)
|
||||||
|
|
||||||
`pi-ask-user` is maintained directly in this repository. Its initial UI and tool design was adapted from the following MIT-licensed Pi examples:
|
`pi-ask-user` 由本仓库直接维护。其初始 UI 与工具设计改编自以下 MIT 许可的 Pi 示例:
|
||||||
|
|
||||||
- <https://github.com/earendil-works/pi/blob/dcd461925db2edf69a43c8135db1180d418afd54/packages/coding-agent/examples/extensions/question.ts>
|
- <https://github.com/earendil-works/pi/blob/dcd461925db2edf69a43c8135db1180d418afd54/packages/coding-agent/examples/extensions/question.ts>
|
||||||
- <https://github.com/earendil-works/pi/blob/dcd461925db2edf69a43c8135db1180d418afd54/packages/coding-agent/examples/extensions/questionnaire.ts>
|
- <https://github.com/earendil-works/pi/blob/dcd461925db2edf69a43c8135db1180d418afd54/packages/coding-agent/examples/extensions/questionnaire.ts>
|
||||||
|
|
||||||
Initial reference snapshot: `dcd461925db2edf69a43c8135db1180d418afd54` (`main`, inspected 2026-08-24).
|
初始参考快照:`dcd461925db2edf69a43c8135db1180d418afd54`(`main`,查看于 2026-08-24)。
|
||||||
|
|
||||||
The upstream `qna.ts` command is intentionally not included: this package only supports model-initiated questions. Local changes consolidate single and multi-question flows into one tool, add text questions, validation, bounded schemas, cancellation/abort handling, width-aware rendering, and IME focus propagation.
|
上游的 `qna.ts` 命令有意不包含:本包只支持由模型主动发起的提问。本地改动把单题与多题流程整合为一个工具,新增文本题,在每道选择型问题上保留用户可控的自由文本逃生口,并实现校验、有界 schema、取消/中止处理、宽度感知渲染、IME 焦点传播,以及为支持对话框的 RPC 客户端提供标准 Pi extension-UI 回退。
|
||||||
|
|
||||||
The copied MIT license and original copyright notice are retained in `LICENSE`.
|
复制来的 MIT 许可证与原始版权声明保留在 `LICENSE` 中。
|
||||||
|
|||||||
@@ -45,9 +45,6 @@ export function normalizeQuestions(input: readonly QuestionInput[]): NormalizedQ
|
|||||||
throw new Error(`questions[${index}].type must be select or text`);
|
throw new Error(`questions[${index}].type must be select or text`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (question.allowCustom !== undefined && typeof question.allowCustom !== "boolean") {
|
|
||||||
throw new Error(`questions[${index}].allowCustom must be a boolean`);
|
|
||||||
}
|
|
||||||
if (question.required !== undefined && typeof question.required !== "boolean") {
|
if (question.required !== undefined && typeof question.required !== "boolean") {
|
||||||
throw new Error(`questions[${index}].required must be a boolean`);
|
throw new Error(`questions[${index}].required must be a boolean`);
|
||||||
}
|
}
|
||||||
@@ -77,7 +74,6 @@ export function normalizeQuestions(input: readonly QuestionInput[]): NormalizedQ
|
|||||||
prompt,
|
prompt,
|
||||||
type: question.type,
|
type: question.type,
|
||||||
options,
|
options,
|
||||||
allowCustom: question.type === "select" && question.allowCustom !== false,
|
|
||||||
required: question.required !== false,
|
required: question.required !== false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { NormalizedQuestion, QuestionOptionInput, UserAnswer, AskUserResult } from "./types.ts";
|
||||||
|
|
||||||
|
interface RpcDialogOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RpcQuestionUI {
|
||||||
|
select(title: string, options: string[], opts?: RpcDialogOptions): Promise<string | undefined>;
|
||||||
|
input(title: string, placeholder?: string, opts?: RpcDialogOptions): Promise<string | undefined>;
|
||||||
|
notify(message: string, type?: "info" | "warning" | "error"): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RpcChoice =
|
||||||
|
| { kind: "option"; display: string; option: QuestionOptionInput }
|
||||||
|
| { kind: "custom"; display: string }
|
||||||
|
| { kind: "skip"; display: string };
|
||||||
|
|
||||||
|
type TextResponse =
|
||||||
|
| { kind: "answer"; text: string }
|
||||||
|
| { kind: "skipped" }
|
||||||
|
| { kind: "cancelled" };
|
||||||
|
|
||||||
|
function dialogTitle(question: NormalizedQuestion, index: number, total: number): string {
|
||||||
|
const progress = total > 1 ? `[${index + 1}/${total}] ` : "";
|
||||||
|
return `${progress}${question.label}: ${question.prompt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function choicesFor(question: NormalizedQuestion): RpcChoice[] {
|
||||||
|
const choices: RpcChoice[] = question.options.map((option, index) => ({
|
||||||
|
kind: "option",
|
||||||
|
display: `${index + 1}. ${option.label}${option.description ? ` — ${option.description}` : ""}`,
|
||||||
|
option,
|
||||||
|
}));
|
||||||
|
choices.push({ kind: "custom", display: "Type something." });
|
||||||
|
if (!question.required) choices.push({ kind: "skip", display: "Skip this question." });
|
||||||
|
return choices;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTextAnswer(question: NormalizedQuestion, text: string, custom: boolean): UserAnswer {
|
||||||
|
return {
|
||||||
|
id: question.id,
|
||||||
|
question: question.prompt,
|
||||||
|
type: question.type,
|
||||||
|
answer: text,
|
||||||
|
value: text,
|
||||||
|
label: text,
|
||||||
|
custom,
|
||||||
|
skipped: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSkippedAnswer(question: NormalizedQuestion): UserAnswer {
|
||||||
|
return {
|
||||||
|
id: question.id,
|
||||||
|
question: question.prompt,
|
||||||
|
type: question.type,
|
||||||
|
answer: "",
|
||||||
|
value: "",
|
||||||
|
label: "Skipped",
|
||||||
|
custom: false,
|
||||||
|
skipped: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelledResult(questions: NormalizedQuestion[], answers: UserAnswer[]): AskUserResult {
|
||||||
|
return { version: 1, cancelled: true, questions, answers };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestText(
|
||||||
|
ui: RpcQuestionUI,
|
||||||
|
title: string,
|
||||||
|
required: boolean,
|
||||||
|
placeholder: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<TextResponse> {
|
||||||
|
while (true) {
|
||||||
|
if (signal?.aborted) return { kind: "cancelled" };
|
||||||
|
const response = await ui.input(title, placeholder, { signal });
|
||||||
|
if (signal?.aborted || response === undefined) return { kind: "cancelled" };
|
||||||
|
|
||||||
|
const text = response.trim();
|
||||||
|
if (text) return { kind: "answer", text };
|
||||||
|
if (!required) return { kind: "skipped" };
|
||||||
|
ui.notify("An answer is required.", "warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRpcQuestions(
|
||||||
|
ui: RpcQuestionUI,
|
||||||
|
questions: NormalizedQuestion[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<AskUserResult> {
|
||||||
|
const answers: UserAnswer[] = [];
|
||||||
|
|
||||||
|
for (const [index, question] of questions.entries()) {
|
||||||
|
if (signal?.aborted) return cancelledResult(questions, answers);
|
||||||
|
const title = dialogTitle(question, index, questions.length);
|
||||||
|
|
||||||
|
if (question.type === "text") {
|
||||||
|
const response = await requestText(
|
||||||
|
ui,
|
||||||
|
title,
|
||||||
|
question.required,
|
||||||
|
question.required ? "Type your answer" : "Type an answer, or leave blank to skip",
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (response.kind === "cancelled") return cancelledResult(questions, answers);
|
||||||
|
answers.push(response.kind === "skipped" ? saveSkippedAnswer(question) : saveTextAnswer(question, response.text, false));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const choices = choicesFor(question);
|
||||||
|
const selected = await ui.select(title, choices.map((choice) => choice.display), { signal });
|
||||||
|
if (signal?.aborted || selected === undefined) return cancelledResult(questions, answers);
|
||||||
|
const choice = choices.find((item) => item.display === selected);
|
||||||
|
if (!choice) throw new Error("RPC UI returned an unknown choice");
|
||||||
|
|
||||||
|
if (choice.kind === "skip") {
|
||||||
|
answers.push(saveSkippedAnswer(question));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (choice.kind === "custom") {
|
||||||
|
const response = await requestText(ui, `${title} — custom answer`, true, "Type a different answer", signal);
|
||||||
|
if (response.kind !== "answer") return cancelledResult(questions, answers);
|
||||||
|
answers.push(saveTextAnswer(question, response.text, true));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
answers.push({
|
||||||
|
id: question.id,
|
||||||
|
question: question.prompt,
|
||||||
|
type: question.type,
|
||||||
|
answer: choice.option.label,
|
||||||
|
value: choice.option.value,
|
||||||
|
label: choice.option.label,
|
||||||
|
custom: false,
|
||||||
|
skipped: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { version: 1, cancelled: false, questions, answers };
|
||||||
|
}
|
||||||
@@ -20,9 +20,8 @@ const QuestionSchema = Type.Object({
|
|||||||
type: Type.String({ enum: ["select", "text"], description: "select for choices; text for a free-form answer" }),
|
type: Type.String({ enum: ["select", "text"], description: "select for choices; text for a free-form answer" }),
|
||||||
options: Type.Optional(Type.Array(OptionSchema, {
|
options: Type.Optional(Type.Array(OptionSchema, {
|
||||||
maxItems: MAX_OPTIONS,
|
maxItems: MAX_OPTIONS,
|
||||||
description: "Required and non-empty for select questions; omit for text questions",
|
description: "Required and non-empty for select questions; omit for text questions. The tool always appends a free-form answer choice.",
|
||||||
})),
|
})),
|
||||||
allowCustom: Type.Optional(Type.Boolean({ description: "For select questions, append a free-form answer choice (default: true)" })),
|
|
||||||
required: Type.Optional(Type.Boolean({ description: "Whether the question must be answered (default: true); optional questions can be skipped" })),
|
required: Type.Optional(Type.Boolean({ description: "Whether the question must be answered (default: true); optional questions can be skipped" })),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|||||||
|
|
||||||
import { formatAnswers, normalizeQuestions } from "./normalize.ts";
|
import { formatAnswers, normalizeQuestions } from "./normalize.ts";
|
||||||
import { AskUserParamsSchema } from "./schema.ts";
|
import { AskUserParamsSchema } from "./schema.ts";
|
||||||
|
import { runRpcQuestions } from "./rpc.ts";
|
||||||
import type { AskUserResult, QuestionInput } from "./types.ts";
|
import type { AskUserResult, QuestionInput } from "./types.ts";
|
||||||
import { AskUserView } from "./view.ts";
|
import { AskUserView } from "./view.ts";
|
||||||
|
|
||||||
@@ -15,23 +16,27 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
|
|||||||
name: "ask_user_question",
|
name: "ask_user_question",
|
||||||
label: "Ask User",
|
label: "Ask User",
|
||||||
description:
|
description:
|
||||||
"Ask the user one or more structured questions in an interactive TUI when their input is genuinely needed to continue. Supports choices, free-form text, optional custom answers, review, and cancellation. Do not ask for information already stated by the user.",
|
"Ask the user one or more structured questions in an interactive UI when their input is genuinely needed to continue. Supports TUI and RPC clients, choices, free-form text, optional custom answers, and cancellation. Do not ask for information already stated by the user.",
|
||||||
promptSnippet: "Use ask_user_question to pause and collect missing user decisions instead of guessing",
|
promptSnippet: "Use ask_user_question to pause and collect missing user decisions instead of guessing",
|
||||||
parameters: AskUserParamsSchema,
|
parameters: AskUserParamsSchema,
|
||||||
executionMode: "sequential",
|
executionMode: "sequential",
|
||||||
|
|
||||||
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||||
const questions = normalizeQuestions(params.questions as QuestionInput[]);
|
const questions = normalizeQuestions(params.questions as QuestionInput[]);
|
||||||
if (ctx.mode !== "tui") throw new Error("ask_user_question requires interactive TUI mode");
|
if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")) {
|
||||||
|
throw new Error("ask_user_question requires interactive TUI or RPC UI mode");
|
||||||
|
}
|
||||||
|
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
const result: AskUserResult = { version: 1, cancelled: true, questions, answers: [] };
|
const result: AskUserResult = { version: 1, cancelled: true, questions, answers: [] };
|
||||||
return { content: [{ type: "text", text: "User interaction was cancelled" }], details: result };
|
return { content: [{ type: "text", text: "User interaction was cancelled" }], details: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await ctx.ui.custom<AskUserResult>((tui, theme, _keybindings, done) =>
|
const result = ctx.mode === "tui"
|
||||||
|
? await ctx.ui.custom<AskUserResult>((tui, theme, _keybindings, done) =>
|
||||||
new AskUserView(tui, theme, questions, done, signal),
|
new AskUserView(tui, theme, questions, done, signal),
|
||||||
);
|
)
|
||||||
|
: await runRpcQuestions(ctx.ui, questions, signal);
|
||||||
|
|
||||||
if (result.cancelled) {
|
if (result.cancelled) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export interface QuestionInput {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
type: QuestionType;
|
type: QuestionType;
|
||||||
options?: QuestionOptionInput[];
|
options?: QuestionOptionInput[];
|
||||||
allowCustom?: boolean;
|
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,12 +21,11 @@ export interface NormalizedQuestion {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
type: QuestionType;
|
type: QuestionType;
|
||||||
options: QuestionOptionInput[];
|
options: QuestionOptionInput[];
|
||||||
allowCustom: boolean;
|
|
||||||
required: boolean;
|
required: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `question` and `answer` intentionally match pi-permission-auto-review's
|
* `question` and `answer` intentionally match pi-permission-system's integrated AutoReview
|
||||||
* trusted user-interaction envelope for ask_user_question.
|
* trusted user-interaction envelope for ask_user_question.
|
||||||
*/
|
*/
|
||||||
export interface UserAnswer {
|
export interface UserAnswer {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export class AskUserView implements Component, Focusable {
|
|||||||
const question = this.currentQuestion();
|
const question = this.currentQuestion();
|
||||||
if (!question || question.type !== "select") return [];
|
if (!question || question.type !== "select") return [];
|
||||||
const options: RenderOption[] = question.options.map((option) => ({ ...option, kind: "option" }));
|
const options: RenderOption[] = question.options.map((option) => ({ ...option, kind: "option" }));
|
||||||
if (question.allowCustom) options.push({ value: "", label: "Type something.", kind: "custom" });
|
options.push({ value: "", label: "Type something.", kind: "custom" });
|
||||||
if (!question.required) options.push({ value: "", label: "Skip this question.", kind: "skip" });
|
if (!question.required) options.push({ value: "", label: "Skip this question.", kind: "skip" });
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { formatAnswers, normalizeQuestions, orderedAnswers } from "../src/normalize.ts";
|
import { formatAnswers, normalizeQuestions, orderedAnswers } from "../src/normalize.ts";
|
||||||
import type { UserAnswer } from "../src/types.ts";
|
import type { QuestionInput, UserAnswer } from "../src/types.ts";
|
||||||
|
|
||||||
test("normalizes select and text questions with bounded defaults", () => {
|
test("normalizes select and text questions with bounded defaults", () => {
|
||||||
const questions = normalizeQuestions([
|
const questions = normalizeQuestions([
|
||||||
@@ -27,14 +27,26 @@ test("normalizes select and text questions with bounded defaults", () => {
|
|||||||
prompt: "Choose a language",
|
prompt: "Choose a language",
|
||||||
type: "select",
|
type: "select",
|
||||||
options: [{ value: "ts", label: "TypeScript" }],
|
options: [{ value: "ts", label: "TypeScript" }],
|
||||||
allowCustom: true,
|
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
assert.equal(questions[1]?.label, "Q2");
|
assert.equal(questions[1]?.label, "Q2");
|
||||||
assert.equal(questions[1]?.allowCustom, false);
|
|
||||||
assert.equal(questions[1]?.required, false);
|
assert.equal(questions[1]?.required, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("ignores the removed allowCustom input from legacy callers", () => {
|
||||||
|
const legacyQuestion = {
|
||||||
|
id: "language",
|
||||||
|
prompt: "Choose a language",
|
||||||
|
type: "select",
|
||||||
|
options: [{ value: "ts", label: "TypeScript" }],
|
||||||
|
allowCustom: false,
|
||||||
|
} satisfies QuestionInput & { allowCustom: boolean };
|
||||||
|
|
||||||
|
const [question] = normalizeQuestions([legacyQuestion]);
|
||||||
|
assert.ok(question);
|
||||||
|
assert.equal("allowCustom" in question, false);
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects invalid ids, duplicate ids, invalid option combinations, and duplicate values", () => {
|
test("rejects invalid ids, duplicate ids, invalid option combinations, and duplicate values", () => {
|
||||||
assert.throws(() => normalizeQuestions([{ id: "bad id", prompt: "Bad", type: "text" }]), /must start with a letter/);
|
assert.throws(() => normalizeQuestions([{ id: "bad id", prompt: "Bad", type: "text" }]), /must start with a letter/);
|
||||||
assert.throws(() => normalizeQuestions([null as never]), /must be an object/);
|
assert.throws(() => normalizeQuestions([null as never]), /must be an object/);
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { normalizeQuestions } from "../src/normalize.ts";
|
||||||
|
import { runRpcQuestions, type RpcQuestionUI } from "../src/rpc.ts";
|
||||||
|
|
||||||
|
function mockUi(responses: {
|
||||||
|
select?: Array<string | undefined>;
|
||||||
|
input?: Array<string | undefined>;
|
||||||
|
}): RpcQuestionUI & {
|
||||||
|
calls: Array<{ kind: "select" | "input"; title: string; options?: string[] }>;
|
||||||
|
notifications: Array<{ message: string; type?: "info" | "warning" | "error" }>;
|
||||||
|
} {
|
||||||
|
const selectResponses = [...(responses.select ?? [])];
|
||||||
|
const inputResponses = [...(responses.input ?? [])];
|
||||||
|
const calls: Array<{ kind: "select" | "input"; title: string; options?: string[] }> = [];
|
||||||
|
const notifications: Array<{ message: string; type?: "info" | "warning" | "error" }> = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
notifications,
|
||||||
|
async select(title, options) {
|
||||||
|
calls.push({ kind: "select", title, options });
|
||||||
|
assert.ok(selectResponses.length > 0, "unexpected select dialog");
|
||||||
|
return selectResponses.shift();
|
||||||
|
},
|
||||||
|
async input(title) {
|
||||||
|
calls.push({ kind: "input", title });
|
||||||
|
assert.ok(inputResponses.length > 0, "unexpected input dialog");
|
||||||
|
return inputResponses.shift();
|
||||||
|
},
|
||||||
|
notify(message, type) {
|
||||||
|
notifications.push({ message, type });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("asks RPC questions sequentially and preserves stable option values", async () => {
|
||||||
|
const questions = normalizeQuestions([
|
||||||
|
{
|
||||||
|
id: "language",
|
||||||
|
label: "Language",
|
||||||
|
prompt: "Which language?",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "ts", label: "TypeScript", description: "Strict typing" },
|
||||||
|
{ value: "js", label: "JavaScript" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: "notes", prompt: "Anything else?", type: "text", required: false },
|
||||||
|
]);
|
||||||
|
const ui = mockUi({ select: ["2. JavaScript"], input: [" "] });
|
||||||
|
|
||||||
|
const result = await runRpcQuestions(ui, questions);
|
||||||
|
|
||||||
|
assert.equal(result.cancelled, false);
|
||||||
|
assert.deepEqual(result.answers, [
|
||||||
|
{
|
||||||
|
id: "language",
|
||||||
|
question: "Which language?",
|
||||||
|
type: "select",
|
||||||
|
answer: "JavaScript",
|
||||||
|
value: "js",
|
||||||
|
label: "JavaScript",
|
||||||
|
custom: false,
|
||||||
|
skipped: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notes",
|
||||||
|
question: "Anything else?",
|
||||||
|
type: "text",
|
||||||
|
answer: "",
|
||||||
|
value: "",
|
||||||
|
label: "Skipped",
|
||||||
|
custom: false,
|
||||||
|
skipped: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.match(ui.calls[0]?.title ?? "", /^\[1\/2\] Language:/);
|
||||||
|
assert.ok(ui.calls[0]?.options?.includes("1. TypeScript — Strict typing"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports custom RPC answers and repeats required blank input", async () => {
|
||||||
|
const questions = normalizeQuestions([{
|
||||||
|
id: "other",
|
||||||
|
prompt: "Choose or write one",
|
||||||
|
type: "select",
|
||||||
|
options: [{ value: "known", label: "Known" }],
|
||||||
|
}]);
|
||||||
|
const ui = mockUi({ select: ["Type something."], input: [" ", " Custom value "] });
|
||||||
|
|
||||||
|
const result = await runRpcQuestions(ui, questions);
|
||||||
|
|
||||||
|
assert.equal(result.cancelled, false);
|
||||||
|
assert.equal(result.answers[0]?.answer, "Custom value");
|
||||||
|
assert.equal(result.answers[0]?.custom, true);
|
||||||
|
assert.deepEqual(ui.notifications, [{ message: "An answer is required.", type: "warning" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns cancelled with partial answers when an RPC dialog is dismissed", async () => {
|
||||||
|
const questions = normalizeQuestions([
|
||||||
|
{ id: "name", prompt: "Name?", type: "text" },
|
||||||
|
{ id: "confirm", prompt: "Continue?", type: "select", options: [{ value: "yes", label: "Yes" }] },
|
||||||
|
]);
|
||||||
|
const ui = mockUi({ input: ["Ada"], select: [undefined] });
|
||||||
|
|
||||||
|
const result = await runRpcQuestions(ui, questions);
|
||||||
|
|
||||||
|
assert.equal(result.cancelled, true);
|
||||||
|
assert.equal(result.answers.length, 1);
|
||||||
|
assert.equal(result.answers[0]?.answer, "Ada");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not open RPC dialogs after abort", async () => {
|
||||||
|
const questions = normalizeQuestions([{ id: "name", prompt: "Name?", type: "text" }]);
|
||||||
|
const ui = mockUi({});
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const result = await runRpcQuestions(ui, questions, controller.signal);
|
||||||
|
|
||||||
|
assert.equal(result.cancelled, true);
|
||||||
|
assert.deepEqual(result.answers, []);
|
||||||
|
assert.deepEqual(ui.calls, []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# my-pi 维护约定(pi-chrome)
|
||||||
|
|
||||||
|
- 扩展入口是 `pi-chrome/extensions/chrome-profile-bridge/index.ts`,Companion MV3 扩展位于 `browser-extension/`,测试保留在 `test/` 与 `test-suite/`,来源记录是 `UPSTREAM.md`。
|
||||||
|
- 同步上游必须从官方 `tianrendong/pi-chrome` 的明确 tag/commit 移植并保留 README、CHANGELOG、SECURITY、文档、测试、作者和 LICENSE;不得导入嵌套 `.git`、`node_modules` 或构建产物。
|
||||||
|
- 根包必须通过 `file:./pi-chrome` 提供依赖并直接加载仓库源码,不得同时安装或加载 npm 预编译入口。
|
||||||
|
- 控制默认锁定,每个 Pi session 必须显式 `/chrome authorize`;未授权时不注册 Chrome 工具。显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。授权撤销或过期时必须停用全部 21 个工具,包括 `chrome_find` / `chrome_inspect`;Tool Search 重新激活 schema 不能替代每次执行时的授权校验。
|
||||||
|
- `chrome_snapshot` 与带 `includeSnapshot=true` 的动作把快照 JSON 直接放进工具结果文本返回;超长结果的展示截断由 pi-overflow 统一负责(原文落盘,模型用 `overflow_query` 的 jsonPath/search 提取)。`details` 只放元素数、匹配数等小型元数据,不得把完整快照写进 `details`(会进入会话文件),也不再写 Session 临时快照文件。`chrome_find` 只返回有界匹配摘要,不保存文件。
|
||||||
|
- 修改 bridge、Companion 扩展、授权 lifecycle、快照存储或加载入口后至少运行包内 `npm test`、根 Tool Routing/Tool Search/扩展联合加载测试、`npm pack --dry-run --json` 和实际 packed tarball 隔离安装验证;未经明确要求不得连接或控制真实 Chrome。
|
||||||
+4
-10
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
You: "Find my open GitHub PR tab, summarize review state, and screenshot failing CI."
|
You: "Find my open GitHub PR tab, summarize review state, and screenshot failing CI."
|
||||||
Agent: chrome_tab(list) → chrome_snapshot(...) → ctx_execute_file(capture) → chrome_screenshot(...)
|
Agent: chrome_tab(list) → chrome_snapshot(...) → overflow_query(jsonPath) → chrome_screenshot(...)
|
||||||
✓ 3 reviewers, 1 change requested, CI red on iOS. Saved → .pi/chrome-screenshots/ci.png
|
✓ 3 reviewers, 1 change requested, CI red on iOS. Saved → .pi/chrome-screenshots/ci.png
|
||||||
You: [keeps coding — agent never asked you to log in]
|
You: [keeps coding — agent never asked you to log in]
|
||||||
```
|
```
|
||||||
@@ -74,17 +74,11 @@ Second doctor run should show all checks passing.
|
|||||||
|
|
||||||
Tool parameters and gotchas are documented inline in Pi.
|
Tool parameters and gotchas are documented inline in Pi.
|
||||||
|
|
||||||
### Session-local snapshots and Context Mode
|
### Snapshots and pi-overflow
|
||||||
|
|
||||||
In this maintained build, `chrome_snapshot`, `chrome_find`, and actions with `includeSnapshot=true` keep the full structured page snapshot out of the normal tool result. They atomically save only the latest capture for each target under:
|
In this maintained build, `chrome_snapshot` and actions with `includeSnapshot=true` return the full structured page snapshot as JSON directly in the tool result. `chrome_find` returns only up to 20 compact ranked matches.
|
||||||
|
|
||||||
```text
|
Large snapshots are bounded by the bundle's `pi-overflow` extension: the result keeps a bounded view and the full original is stored, marked with `[pi-overflow id=…]`. Extract the uids, text, or state you need with `overflow_query` — a `jsonPath` such as `elements[0].uid` or `summary.focused`, or a regex `search` — instead of taking another snapshot. Any page interaction makes earlier snapshots stale, so take a fresh one (or pass `includeSnapshot=true` on the action) when the page has changed.
|
||||||
.pi/chrome-context/<session-hash>/<target-hash>/snapshot.json
|
|
||||||
```
|
|
||||||
|
|
||||||
The tool returns a workspace-relative path plus bounded metadata. Analyze that path immediately with `ctx_execute_file` and print only the uids, text, or state needed for the next browser action. Do **not** use `read` or `cat` on the capture, because that would put the raw page back into model context.
|
|
||||||
|
|
||||||
Captures are deliberately ephemeral: a successful page interaction invalidates the old target capture, and session shutdown, `/chrome revoke`, or authorization expiry removes the session files. `chrome_find` still returns up to 20 compact ranked matches for direct use while storing the full capture privately.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+16
-16
@@ -1,23 +1,23 @@
|
|||||||
# Upstream source
|
# 上游来源
|
||||||
|
|
||||||
- Official repository: <https://github.com/tianrendong/pi-chrome>
|
- 官方仓库:<https://github.com/tianrendong/pi-chrome>
|
||||||
- Imported npm version: `pi-chrome@0.15.46`
|
- 导入 npm 版本:`pi-chrome@0.15.46`
|
||||||
- Imported commit: `017ff4b9a639f0b8b213e58a3f30613fc38edcc8`
|
- 导入 commit:`017ff4b9a639f0b8b213e58a3f30613fc38edcc8`
|
||||||
- Commit date: `2026-07-07T01:30:54-04:00`
|
- Commit 日期:`2026-07-07T01:30:54-04:00`
|
||||||
- Import basis: the `gitHead` recorded in the official npm metadata for `pi-chrome@0.15.46`; no upstream tag points at this commit.
|
- 导入依据:`pi-chrome@0.15.46` 官方 npm 元数据中记录的 `gitHead`;没有任何上游 tag 指向该 commit。
|
||||||
- Initial local import: 2026-08-28
|
- 初始本地导入:2026-08-28
|
||||||
|
|
||||||
The tracked upstream source, tests, documentation, scripts, and license were imported from the official Git repository. Nested `.git`, dependency directories, coverage, and build output are not retained.
|
被跟踪的上游源码、测试、文档、脚本和许可证均从官方 Git 仓库导入。不保留嵌套 `.git`、依赖目录、覆盖率与构建产物。
|
||||||
|
|
||||||
## Local maintenance
|
## 本地维护
|
||||||
|
|
||||||
This directory is maintained directly by the `my-pi` bundle and is not a submodule. The root package loads `extensions/chrome-profile-bridge/index.ts` from this directory and depends on it through `file:./pi-chrome`; the npm prebuilt extension entry must not be loaded in parallel.
|
本目录由 `my-pi` 组合包直接维护,不是 submodule。根包加载本目录中的 `extensions/chrome-profile-bridge/index.ts`,并通过 `file:./pi-chrome` 依赖它;不得并行加载 npm 预编译扩展入口。
|
||||||
|
|
||||||
Local changes after the import:
|
导入后的本地改动:
|
||||||
|
|
||||||
- Chrome snapshots are written atomically to owner-only, session-local files under the active project's `.pi/chrome-context/` directory instead of returning raw page structure to model context.
|
- Chrome 快照与 `includeSnapshot=true` 的动作结果把快照 JSON 直接放入工具文本返回,`details` 只保留元素数/匹配数等小型元数据;展示截断由组合包 pi-overflow 统一负责(原文落盘 + `overflow_query` 查询)。`chrome_find` 只返回紧凑排序匹配,不保存文件。早期版本曾把快照原子写入 `.pi/chrome-context/` 临时文件并引导 `ctx_execute_file` 分析;该 Session 临时文件机制已随 context-mode 一起移除,各项目中残留的 `.pi/chrome-context/` 目录不再创建,可手动删除。
|
||||||
- Each session/target keeps only its latest snapshot; browser interactions invalidate stale captures and session shutdown removes the session directory.
|
- 授权后的 `before_agent_start` 不再返回 `systemPrompt`:Chrome 使用提示写入 `event.systemPromptOptions.sections.chrome_profile_bridge` 结构化分区,由组合包单一提示词出口 `extensions/system-prompt.ts` 统一组装,顺序由 `config/prompt/sections.jsonc` 控制;未授权时不贡献任何提示词内容。
|
||||||
- `chrome_find` returns compact ranked matches while keeping the complete structured snapshot in the session-local capture for optional `ctx_execute_file` analysis.
|
- 移除 `PI_CHROME_BRIDGE_HOST`/`PI_CHROME_BRIDGE_PORT` 环境变量,host/port 固定为 `127.0.0.1:17318`(Companion 扩展 `service_worker.js` 同样硬编码该地址,单侧可配只会断连且非回环 host 有安全风险)。
|
||||||
- `includeSnapshot=true` on click/type/fill/key saves the post-action snapshot and returns its capture path rather than embedding the snapshot in the tool result.
|
- 移除 `MAX_TEXT_CHARS`/`truncateText` 的 30K 字符文本截断:chrome_* 工具返回完整文本,超长结果的展示截断由 pi-overflow 统一负责;`MAX_ELEMENTS` 等条数上限保留不变。
|
||||||
|
|
||||||
When syncing upstream, compare against the imported commit and port changes selectively. Do not overwrite these local lifecycle, privacy, and Context Mode integrations wholesale.
|
同步上游时,请以导入 commit 为基准比较并选择性移植改动。不得整体覆盖这些本地生命周期和组合包集成改动。
|
||||||
|
|||||||
@@ -4,11 +4,6 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|||||||
import { mkdir, writeFile } from "node:fs/promises";
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import {
|
|
||||||
SessionSnapshotStore,
|
|
||||||
type SnapshotCaptureMetadata,
|
|
||||||
type SnapshotTarget,
|
|
||||||
} from "../../src/session-snapshot-store.ts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Existing-profile Chrome bridge for pi.
|
* Existing-profile Chrome bridge for pi.
|
||||||
@@ -64,17 +59,11 @@ const PI_CHROME_GLOBAL_KEY = "__piChromeProfileBridgeLoaded__";
|
|||||||
// reload) so a /reload — which tears down and re-evaluates the module — does not silently drop
|
// reload) so a /reload — which tears down and re-evaluates the module — does not silently drop
|
||||||
// an active /chrome authorize grant.
|
// an active /chrome authorize grant.
|
||||||
const PI_CHROME_AUTH_KEY = "__piChromeProfileBridgeAuth__";
|
const PI_CHROME_AUTH_KEY = "__piChromeProfileBridgeAuth__";
|
||||||
const DEFAULT_HOST = process.env.PI_CHROME_BRIDGE_HOST ?? "127.0.0.1";
|
const DEFAULT_HOST = "127.0.0.1";
|
||||||
const DEFAULT_PORT = Number(process.env.PI_CHROME_BRIDGE_PORT ?? "17318");
|
const DEFAULT_PORT = 17318;
|
||||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||||
const MAX_TEXT_CHARS = 30_000;
|
|
||||||
const MAX_ELEMENTS = 80;
|
const MAX_ELEMENTS = 80;
|
||||||
|
|
||||||
function truncateText(text: string, maxChars = MAX_TEXT_CHARS): string {
|
|
||||||
if (text.length <= maxChars) return text;
|
|
||||||
return `${text.slice(0, maxChars)}\n\n[truncated ${text.length - maxChars} characters]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeJson(value: unknown): string {
|
function safeJson(value: unknown): string {
|
||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(value, null, 2);
|
||||||
}
|
}
|
||||||
@@ -95,13 +84,28 @@ function snapshotFromIncludedResult(raw: unknown): unknown {
|
|||||||
return raw && typeof raw === "object" ? (raw as { snapshot?: unknown }).snapshot : undefined;
|
return raw && typeof raw === "object" ? (raw as { snapshot?: unknown }).snapshot : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSnapshotCapture(metadata: SnapshotCaptureMetadata): string {
|
/**
|
||||||
const kib = Math.max(1, Math.ceil(metadata.bytes / 1024));
|
* Small, session-file-safe metadata describing a returned snapshot. The full
|
||||||
return [
|
* snapshot goes to the model as text — details must stay small because Pi
|
||||||
`Chrome snapshot saved to ${metadata.path} (${kib} KiB, ${metadata.elementCount} elements${metadata.matchCount ? `, ${metadata.matchCount} matches` : ""}).`,
|
* persists them in the session file.
|
||||||
"Analyze it immediately with ctx_execute_file; FILE_CONTENT is JSON and the page data is under .snapshot.",
|
*/
|
||||||
"This capture is replaced by the next snapshot for the same target and becomes stale after page interaction.",
|
export function snapshotStats(snapshot: unknown): { elementCount: number; matchCount: number } {
|
||||||
].join("\n");
|
const object = snapshot && typeof snapshot === "object" && !Array.isArray(snapshot)
|
||||||
|
? snapshot as { elements?: unknown; matches?: unknown }
|
||||||
|
: {};
|
||||||
|
return {
|
||||||
|
elementCount: Array.isArray(object.elements) ? object.elements.length : 0,
|
||||||
|
matchCount: Array.isArray(object.matches) ? object.matches.length : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action summary followed by the inline snapshot JSON. Oversized results are
|
||||||
|
* bounded by pi-overflow and recoverable through overflow_query, so no local
|
||||||
|
* capture file is written.
|
||||||
|
*/
|
||||||
|
export function formatActionResultText(text: string, snapshot: unknown | undefined): string {
|
||||||
|
return snapshot === undefined ? text : `${text}\n\n${safeJson(snapshot)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatChromeFindMatches(snapshot: any): string {
|
export function formatChromeFindMatches(snapshot: any): string {
|
||||||
@@ -142,7 +146,7 @@ function formatChromeInspect(inspect: any): string {
|
|||||||
lines.push("\n## Ancestors");
|
lines.push("\n## Ancestors");
|
||||||
for (const a of inspect.ancestors.slice(0, 6)) lines.push(`- ${a.uid} ${a.role || a.tag} ${compactLine(a.label || a.selector, 120)}`);
|
for (const a of inspect.ancestors.slice(0, 6)) lines.push(`- ${a.uid} ${a.role || a.tag} ${compactLine(a.label || a.selector, 120)}`);
|
||||||
}
|
}
|
||||||
return truncateText(lines.join("\n"));
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function extensionRoot(): string {
|
function extensionRoot(): string {
|
||||||
@@ -648,7 +652,6 @@ export default function (pi: ExtensionAPI): void {
|
|||||||
let countdownInterval: NodeJS.Timeout | undefined;
|
let countdownInterval: NodeJS.Timeout | undefined;
|
||||||
// Remembered so bridge sends can tag tabs with this session's group even when ctx isn't handy.
|
// Remembered so bridge sends can tag tabs with this session's group even when ctx isn't handy.
|
||||||
let sessionCtx: ExtensionContext | undefined;
|
let sessionCtx: ExtensionContext | undefined;
|
||||||
let snapshotStore: SessionSnapshotStore | undefined;
|
|
||||||
|
|
||||||
const clearAuthExpiryTimer = (): void => {
|
const clearAuthExpiryTimer = (): void => {
|
||||||
if (!authExpiryTimer) return;
|
if (!authExpiryTimer) return;
|
||||||
@@ -722,7 +725,6 @@ export default function (pi: ExtensionAPI): void {
|
|||||||
persistAuth();
|
persistAuth();
|
||||||
// Revoking control ends pi-chrome's automation for this session; tidy up the target we own.
|
// Revoking control ends pi-chrome's automation for this session; tidy up the target we own.
|
||||||
cleanupAutomationTargetBestEffort();
|
cleanupAutomationTargetBestEffort();
|
||||||
void snapshotStore?.clear().catch(() => undefined);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const authSummary = (): string => {
|
const authSummary = (): string => {
|
||||||
@@ -853,45 +855,22 @@ export default function (pi: ExtensionAPI): void {
|
|||||||
return { ...params, foreground: !background } as T;
|
return { ...params, foreground: !background } as T;
|
||||||
};
|
};
|
||||||
|
|
||||||
const snapshotTargetFrom = (params: Record<string, unknown>): SnapshotTarget => ({
|
const formatActionResult = (raw: unknown, params: Record<string, unknown>, text: string): ToolTextResult => {
|
||||||
targetId: typeof params.targetId === "string" ? params.targetId : undefined,
|
|
||||||
urlIncludes: typeof params.urlIncludes === "string" ? params.urlIncludes : undefined,
|
|
||||||
titleIncludes: typeof params.titleIncludes === "string" ? params.titleIncludes : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveSnapshotCapture = async (snapshot: unknown, params: Record<string, unknown>): Promise<SnapshotCaptureMetadata> => {
|
|
||||||
if (!snapshotStore) throw new Error("Chrome snapshot storage is unavailable before session_start.");
|
|
||||||
const stored = await snapshotStore.save(snapshot, snapshotTargetFrom(params));
|
|
||||||
return stored.metadata;
|
|
||||||
};
|
|
||||||
|
|
||||||
const invalidateSnapshotCapture = async (params?: Record<string, unknown>): Promise<void> => {
|
|
||||||
if (!snapshotStore) return;
|
|
||||||
if (!params) await snapshotStore.clear();
|
|
||||||
else await snapshotStore.invalidate(snapshotTargetFrom(params));
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatActionWithCapture = async (raw: unknown, params: Record<string, unknown>, text: string): Promise<ToolTextResult> => {
|
|
||||||
const snapshot = snapshotFromIncludedResult(raw);
|
const snapshot = snapshotFromIncludedResult(raw);
|
||||||
const result = params.includeSnapshot && raw && typeof raw === "object"
|
const result = params.includeSnapshot && raw && typeof raw === "object"
|
||||||
? (raw as { result?: unknown }).result
|
? (raw as { result?: unknown }).result
|
||||||
: raw;
|
: raw;
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
await invalidateSnapshotCapture(params);
|
|
||||||
return { content: [{ type: "text", text }], details: { result: result as Json } };
|
return { content: [{ type: "text", text }], details: { result: result as Json } };
|
||||||
}
|
}
|
||||||
const capture = await saveSnapshotCapture(snapshot, params);
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `${text}\n\n${formatSnapshotCapture(capture)}` }],
|
content: [{ type: "text", text: formatActionResultText(text, snapshot) }],
|
||||||
details: { result: result as Json, capture },
|
details: { result: result as Json, snapshot: snapshotStats(snapshot) },
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
sessionCtx = ctx;
|
sessionCtx = ctx;
|
||||||
const sessionId = ctx.sessionManager.getSessionId?.() || "unknown-session";
|
|
||||||
snapshotStore = new SessionSnapshotStore(workspaceCwd(ctx), sessionId);
|
|
||||||
await snapshotStore.clear();
|
|
||||||
await bridge.start();
|
await bridge.start();
|
||||||
// Reestablish in-memory state after a /reload restored chromeAuthorizedUntil from globalThis.
|
// Reestablish in-memory state after a /reload restored chromeAuthorizedUntil from globalThis.
|
||||||
if (chromeControlAuthorized()) {
|
if (chromeControlAuthorized()) {
|
||||||
@@ -909,10 +888,6 @@ export default function (pi: ExtensionAPI): void {
|
|||||||
pi.on("session_shutdown", async (event) => {
|
pi.on("session_shutdown", async (event) => {
|
||||||
clearAuthExpiryTimer();
|
clearAuthExpiryTimer();
|
||||||
clearCountdownInterval();
|
clearCountdownInterval();
|
||||||
// Snapshot files are intentionally ephemeral. A reload must take a fresh observation too,
|
|
||||||
// because the browser page may have changed while the extension was reloading.
|
|
||||||
await snapshotStore?.clear().catch(() => undefined);
|
|
||||||
snapshotStore = undefined;
|
|
||||||
// Keep the owned automation target across /reload, but clean it up on real session end.
|
// Keep the owned automation target across /reload, but clean it up on real session end.
|
||||||
if (event?.reason !== "reload") cleanupAutomationTargetBestEffort();
|
if (event?.reason !== "reload") cleanupAutomationTargetBestEffort();
|
||||||
bridge.stop();
|
bridge.stop();
|
||||||
@@ -923,11 +898,12 @@ export default function (pi: ExtensionAPI): void {
|
|||||||
|
|
||||||
pi.on("before_agent_start", (event) => {
|
pi.on("before_agent_start", (event) => {
|
||||||
if (!chromeToolsRegistered || !chromeControlAuthorized()) {
|
if (!chromeToolsRegistered || !chromeControlAuthorized()) {
|
||||||
return { systemPrompt: event.systemPrompt };
|
return;
|
||||||
}
|
}
|
||||||
const primer = `
|
// Contribute structurally: the bundle's single system-prompt owner
|
||||||
<chrome-profile-bridge>
|
// (extensions/system-prompt.ts) composes the final prompt; extensions
|
||||||
Chrome control is available through the chrome_* tools via a companion Chrome extension installed in the user's normal Chrome profile. Tools target the existing signed-in profile: no remote-debug port, no throwaway profile.
|
// must not return `systemPrompt` themselves.
|
||||||
|
const primer = `Chrome control is available through the chrome_* tools via a companion Chrome extension installed in the user's normal Chrome profile. Tools target the existing signed-in profile: no remote-debug port, no throwaway profile.
|
||||||
|
|
||||||
Tab/window isolation (important):
|
Tab/window isolation (important):
|
||||||
- pi-chrome owns a dedicated automation window/tab. When a chrome_* tool runs with no explicit target, it acts on that pi-chrome-owned target — it never reuses or overwrites the user's currently active tab. The dedicated target is created on first use and reused afterward.
|
- pi-chrome owns a dedicated automation window/tab. When a chrome_* tool runs with no explicit target, it acts on that pi-chrome-owned target — it never reuses or overwrites the user's currently active tab. The dedicated target is created on first use and reused afterward.
|
||||||
@@ -938,19 +914,22 @@ Capability model (important):
|
|||||||
- Interactive controls (click/type/fill/key/hover/drag/scroll/tap) use Chrome's real input layer via chrome.debugger / CDP. Events satisfy normal user-activation gates.
|
- Interactive controls (click/type/fill/key/hover/drag/scroll/tap) use Chrome's real input layer via chrome.debugger / CDP. Events satisfy normal user-activation gates.
|
||||||
- Input bypasses page CSP because it is injected at browser input layer, not page JavaScript. Chrome may show the “Pi Chrome Connector started debugging this browser” banner while attached.
|
- Input bypasses page CSP because it is injected at browser input layer, not page JavaScript. Chrome may show the “Pi Chrome Connector started debugging this browser” banner while attached.
|
||||||
- \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via **CDP \`Runtime.evaluate\`**, which is not subject to the page's Content-Security-Policy. They work even on strict-CSP pages (e.g. github.com, many bank/SaaS apps) that block \`'unsafe-eval'\`. \`chrome_navigate initScript\` likewise injects at document_start via CDP and bypasses CSP. \`chrome_screenshot\`, \`chrome_tab\`, and Chrome input also work under any CSP.
|
- \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via **CDP \`Runtime.evaluate\`**, which is not subject to the page's Content-Security-Policy. They work even on strict-CSP pages (e.g. github.com, many bank/SaaS apps) that block \`'unsafe-eval'\`. \`chrome_navigate initScript\` likewise injects at document_start via CDP and bypasses CSP. \`chrome_screenshot\`, \`chrome_tab\`, and Chrome input also work under any CSP.
|
||||||
- \`chrome_snapshot\` and snapshots requested with \`includeSnapshot=true\` are written to a private session-local JSON capture; raw page data is never returned inline. The tool result gives the path for immediate \`ctx_execute_file\` analysis.
|
- \`chrome_snapshot\` and snapshots requested with \`includeSnapshot=true\` return the page snapshot as JSON directly in the tool result. When the result is large it carries a \`[pi-overflow id=…]\` marker — extract only the uids, text, or state you need with \`overflow_query\` (jsonPath such as \`elements[0].uid\` or \`summary.focused\`, or a \`search\`) instead of taking another snapshot.
|
||||||
|
|
||||||
Usage rules:
|
Usage rules:
|
||||||
1. If a chrome_* tool says Chrome control is locked, ask the user to run \`/chrome authorize\` before retrying.
|
1. If a chrome_* tool says Chrome control is locked, ask the user to run \`/chrome authorize\` before retrying.
|
||||||
2. Call \`chrome_snapshot\`, then immediately call \`ctx_execute_file\` on the returned path and print only the uids/state needed for the next action. Never use read/cat on the full capture.
|
2. Call \`chrome_snapshot\` and read the returned JSON (or query a pi-overflow marker with \`overflow_query\`); keep only the uids/state needed for the next action.
|
||||||
3. Prefer \`uid\` over \`selector\`. After any page interaction, treat the old capture as stale. Use \`includeSnapshot=true\` when a fresh post-action capture is needed.
|
3. Prefer \`uid\` over \`selector\`. After any page interaction, earlier snapshots are stale — take a fresh one (or pass \`includeSnapshot=true\` on the action) instead of reusing old uids.
|
||||||
4. If \`chrome_evaluate\` returns null when you expected a value, the expression evaluated to null/undefined in the page; surface the value via \`JSON.stringify\` to confirm.
|
4. If \`chrome_evaluate\` returns null when you expected a value, the expression evaluated to null/undefined in the page; surface the value via \`JSON.stringify\` to confirm.
|
||||||
5. \`chrome_navigate\` supports an optional \`initScript\` that runs at document_start in MAIN world for the next navigation (good for seeding localStorage or stubbing Date.now).
|
5. \`chrome_navigate\` supports an optional \`initScript\` that runs at document_start in MAIN world for the next navigation (good for seeding localStorage or stubbing Date.now).
|
||||||
6. By default chrome_* tools run in the background without focusing Chrome; pass \`background=false\` or run /chrome background off when the user wants to watch Chrome work.
|
6. By default chrome_* tools run in the background without focusing Chrome; pass \`background=false\` or run /chrome background off when the user wants to watch Chrome work.
|
||||||
7. If you hit a native file-picker or privileged browser prompt gate, tell the user; generic clicks/typing/CSP gates are handled by Chrome input.
|
7. If you hit a native file-picker or privileged browser prompt gate, tell the user; generic clicks/typing/CSP gates are handled by Chrome input.
|
||||||
8. Run /chrome doctor when in doubt about connectivity or capabilities.
|
8. Run /chrome doctor when in doubt about connectivity or capabilities.`;
|
||||||
</chrome-profile-bridge>`;
|
const promptOptions = event.systemPromptOptions as unknown as
|
||||||
return { systemPrompt: event.systemPrompt + primer };
|
| { sections?: Record<string, string> }
|
||||||
|
| undefined;
|
||||||
|
if (!promptOptions) return; // hosts without structured options just skip the section
|
||||||
|
(promptOptions.sections ??= {}).chrome_profile_bridge = primer;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Shared handlers, dispatched by the unified /chrome command below.
|
// Shared handlers, dispatched by the unified /chrome command below.
|
||||||
@@ -1333,7 +1312,6 @@ Usage rules:
|
|||||||
const text = tabs.map((tab) => `${tab.id}\t${tab.active ? "*" : " "}\t${tab.group?.title ? `[${tab.group.title}] ` : ""}${tab.title || "(untitled)"}\t${tab.url}`).join("\n") || "No tabs.";
|
const text = tabs.map((tab) => `${tab.id}\t${tab.active ? "*" : " "}\t${tab.group?.title ? `[${tab.group.title}] ` : ""}${tab.title || "(untitled)"}\t${tab.url}`).join("\n") || "No tabs.";
|
||||||
return { content: [{ type: "text", text }], details: { tabs } };
|
return { content: [{ type: "text", text }], details: { tabs } };
|
||||||
}
|
}
|
||||||
if (params.action !== "version") await invalidateSnapshotCapture();
|
|
||||||
return { content: [{ type: "text", text: safeJson(result) }], details: { result: result as Json } };
|
return { content: [{ type: "text", text: safeJson(result) }], details: { result: result as Json } };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1342,8 +1320,8 @@ Usage rules:
|
|||||||
name: "chrome_snapshot",
|
name: "chrome_snapshot",
|
||||||
label: "Chrome Snapshot",
|
label: "Chrome Snapshot",
|
||||||
description:
|
description:
|
||||||
"Capture the current Chrome page into a private, session-local JSON file instead of placing the raw DOM snapshot in model context. The result returns a path for immediate ctx_execute_file analysis. Each target keeps only its latest capture; page interaction makes it stale. Use mode/query/nearUid to control what Chrome captures. Runs in the background by default; pass background=false so the user can watch.",
|
"Capture the current Chrome page and return the snapshot as JSON directly in the result. When it is large the result carries a [pi-overflow id=…] marker — extract only the uids, text, or state you need with overflow_query (jsonPath or search). Page interaction makes a snapshot stale; take a fresh one after navigating or acting. Use mode/query/nearUid to control what Chrome captures. Runs in the background by default; pass background=false so the user can watch.",
|
||||||
promptSnippet: "Save the current Chrome page to a session-local JSON capture, then analyze that path with ctx_execute_file for only the required uids and state.",
|
promptSnippet: "Capture the current Chrome page and return its JSON snapshot; query large results via overflow_query.",
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
targetId: Type.Optional(Type.String()),
|
targetId: Type.Optional(Type.String()),
|
||||||
urlIncludes: Type.Optional(Type.String()),
|
urlIncludes: Type.Optional(Type.String()),
|
||||||
@@ -1368,8 +1346,7 @@ Usage rules:
|
|||||||
DEFAULT_TIMEOUT_MS,
|
DEFAULT_TIMEOUT_MS,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
const capture = await saveSnapshotCapture(snapshot, params);
|
return { content: [{ type: "text", text: safeJson(snapshot) }], details: { snapshot: snapshotStats(snapshot) } };
|
||||||
return { content: [{ type: "text", text: formatSnapshotCapture(capture) }], details: { capture } };
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1377,8 +1354,8 @@ Usage rules:
|
|||||||
name: "chrome_find",
|
name: "chrome_find",
|
||||||
label: "Chrome Find",
|
label: "Chrome Find",
|
||||||
description:
|
description:
|
||||||
"Find elements, regions, or text on the live Chrome page. Returns only compact ranked matches with stable uids and coordinates; the full structured snapshot is saved to the same private session-local file used by chrome_snapshot for optional ctx_execute_file analysis.",
|
"Find elements, regions, or text on the live Chrome page. Returns only compact ranked matches with stable uids and coordinates.",
|
||||||
promptSnippet: "Find live Chrome controls/text/regions and return compact stable-uid matches while keeping the full snapshot out of model context.",
|
promptSnippet: "Find live Chrome controls/text/regions and return compact stable-uid matches.",
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
query: Type.String({ description: "What to find, e.g. 'merge button', 'email error', 'approve PR', 'search box'." }),
|
query: Type.String({ description: "What to find, e.g. 'merge button', 'email error', 'approve PR', 'search box'." }),
|
||||||
mode: Type.Optional(StringEnum(snapshotModeValues)),
|
mode: Type.Optional(StringEnum(snapshotModeValues)),
|
||||||
@@ -1399,10 +1376,9 @@ Usage rules:
|
|||||||
DEFAULT_TIMEOUT_MS,
|
DEFAULT_TIMEOUT_MS,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
const capture = await saveSnapshotCapture(snapshot, params);
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `${formatChromeFindMatches(snapshot)}\n\n${formatSnapshotCapture(capture)}` }],
|
content: [{ type: "text", text: formatChromeFindMatches(snapshot) }],
|
||||||
details: { capture },
|
details: { snapshot: snapshotStats(snapshot) },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1448,9 +1424,8 @@ Usage rules:
|
|||||||
DEFAULT_TIMEOUT_MS,
|
DEFAULT_TIMEOUT_MS,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
const capture = await saveSnapshotCapture(snapshot, params);
|
const text = `chrome_inspect fallback: loaded Chrome extension does not yet support page.inspect; reload it at chrome://extensions for deep inspect.\n\n${safeJson(snapshot)}`;
|
||||||
const text = `chrome_inspect fallback: loaded Chrome extension does not yet support page.inspect; reload it at chrome://extensions for deep inspect.\n\n${formatSnapshotCapture(capture)}`;
|
return { content: [{ type: "text", text }], details: { snapshot: snapshotStats(snapshot), fallback: "page.snapshot" } };
|
||||||
return { content: [{ type: "text", text }], details: { capture, fallback: "page.snapshot" } };
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1477,7 +1452,6 @@ Usage rules:
|
|||||||
}),
|
}),
|
||||||
async execute(_id, params, signal): Promise<ToolTextResult> {
|
async execute(_id, params, signal): Promise<ToolTextResult> {
|
||||||
const result = await authorizedBridgeSend("page.navigate", withBackground(params), (params.timeoutMs ?? 15_000) + 2_000, signal);
|
const result = await authorizedBridgeSend("page.navigate", withBackground(params), (params.timeoutMs ?? 15_000) + 2_000, signal);
|
||||||
await invalidateSnapshotCapture(params);
|
|
||||||
return { content: [{ type: "text", text: `Navigated to ${params.url}${params.initScript ? " (with initScript)" : ""}` }], details: { result: result as Json } };
|
return { content: [{ type: "text", text: `Navigated to ${params.url}${params.initScript ? " (with initScript)" : ""}` }], details: { result: result as Json } };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1502,13 +1476,12 @@ Usage rules:
|
|||||||
}),
|
}),
|
||||||
async execute(_id, params, signal): Promise<ToolTextResult> {
|
async execute(_id, params, signal): Promise<ToolTextResult> {
|
||||||
const value = await authorizedBridgeSend("page.evaluate", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
const value = await authorizedBridgeSend("page.evaluate", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
||||||
await invalidateSnapshotCapture(params);
|
|
||||||
const text = value === undefined
|
const text = value === undefined
|
||||||
? "undefined"
|
? "undefined"
|
||||||
: typeof value === "string"
|
: typeof value === "string"
|
||||||
? value
|
? value
|
||||||
: safeJson(value) ?? "undefined";
|
: safeJson(value) ?? "undefined";
|
||||||
return { content: [{ type: "text", text: truncateText(text) }], details: { value: value as Json } };
|
return { content: [{ type: "text", text }], details: { value: value as Json } };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1516,7 +1489,7 @@ Usage rules:
|
|||||||
name: "chrome_click",
|
name: "chrome_click",
|
||||||
label: "Chrome Click",
|
label: "Chrome Click",
|
||||||
description:
|
description:
|
||||||
"Click a snapshot uid, CSS selector, or viewport coordinate using Chrome's real input layer. The previous capture becomes stale. Pass includeSnapshot=true to save a fresh post-click snapshot to the session-local file and return its ctx_execute_file path, never the raw snapshot.",
|
"Click a snapshot uid, CSS selector, or viewport coordinate using Chrome's real input layer. Earlier snapshots become stale after the click. Pass includeSnapshot=true to get a fresh post-click snapshot JSON inline.",
|
||||||
promptSnippet: "Click page elements in Chrome by snapshot uid, selector, or viewport coordinate.",
|
promptSnippet: "Click page elements in Chrome by snapshot uid, selector, or viewport coordinate.",
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot. Prefer uid over selector after taking a snapshot." })),
|
uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot. Prefer uid over selector after taking a snapshot." })),
|
||||||
@@ -1524,7 +1497,7 @@ Usage rules:
|
|||||||
x: Type.Optional(Type.Number({ description: "Viewport x coordinate if uid/selector is omitted." })),
|
x: Type.Optional(Type.Number({ description: "Viewport x coordinate if uid/selector is omitted." })),
|
||||||
y: Type.Optional(Type.Number({ description: "Viewport y coordinate if uid/selector is omitted." })),
|
y: Type.Optional(Type.Number({ description: "Viewport y coordinate if uid/selector is omitted." })),
|
||||||
domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM-dispatched click if Chrome's CDP input path is blocked by another extension overlay or debugger failure." })),
|
domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM-dispatched click if Chrome's CDP input path is blocked by another extension overlay or debugger failure." })),
|
||||||
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, save a fresh post-click snapshot to the session-local capture file and return its path." })),
|
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, return a fresh post-click snapshot JSON inline after the action summary." })),
|
||||||
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
|
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
|
||||||
targetId: Type.Optional(Type.String()),
|
targetId: Type.Optional(Type.String()),
|
||||||
urlIncludes: Type.Optional(Type.String()),
|
urlIncludes: Type.Optional(Type.String()),
|
||||||
@@ -1541,7 +1514,7 @@ Usage rules:
|
|||||||
const summary = summarizeActionResult(result);
|
const summary = summarizeActionResult(result);
|
||||||
const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
|
const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
|
||||||
const text = summary ? `Clicked ${target} — ${summary}` : `Clicked ${target}`;
|
const text = summary ? `Clicked ${target} — ${summary}` : `Clicked ${target}`;
|
||||||
return formatActionWithCapture(raw, params, text);
|
return formatActionResult(raw, params, text);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1549,13 +1522,13 @@ Usage rules:
|
|||||||
name: "chrome_type",
|
name: "chrome_type",
|
||||||
label: "Chrome Type",
|
label: "Chrome Type",
|
||||||
description:
|
description:
|
||||||
"Focus an optional snapshot uid or CSS selector, then type text using Chrome's real keyboard input. The previous capture becomes stale. Pass includeSnapshot=true to save a fresh post-type snapshot and return its ctx_execute_file path.",
|
"Focus an optional snapshot uid or CSS selector, then type text using Chrome's real keyboard input. Earlier snapshots become stale after typing. Pass includeSnapshot=true to get a fresh post-type snapshot JSON inline.",
|
||||||
promptSnippet: "Type text into Chrome, optionally focusing a snapshot uid or selector first.",
|
promptSnippet: "Type text into Chrome, optionally focusing a snapshot uid or selector first.",
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
text: Type.String(),
|
text: Type.String(),
|
||||||
uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
|
uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
|
||||||
selector: Type.Optional(Type.String({ description: "CSS selector to focus before typing." })),
|
selector: Type.Optional(Type.String({ description: "CSS selector to focus before typing." })),
|
||||||
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, save a fresh post-type snapshot to the session-local capture file and return its path." })),
|
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, return a fresh post-type snapshot JSON inline after the action summary." })),
|
||||||
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
|
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
|
||||||
pressEnter: Type.Optional(Type.Boolean()),
|
pressEnter: Type.Optional(Type.Boolean()),
|
||||||
targetId: Type.Optional(Type.String()),
|
targetId: Type.Optional(Type.String()),
|
||||||
@@ -1574,7 +1547,7 @@ Usage rules:
|
|||||||
const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
|
const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
|
||||||
const base = `Typed ${params.text.length} character(s)${into}.`;
|
const base = `Typed ${params.text.length} character(s)${into}.`;
|
||||||
const text = summary ? `${base} (${summary})` : base;
|
const text = summary ? `${base} (${summary})` : base;
|
||||||
return formatActionWithCapture(raw, params, text);
|
return formatActionResult(raw, params, text);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1582,7 +1555,7 @@ Usage rules:
|
|||||||
name: "chrome_fill",
|
name: "chrome_fill",
|
||||||
label: "Chrome Fill",
|
label: "Chrome Fill",
|
||||||
description:
|
description:
|
||||||
"Set the full value of a text input, textarea, or contenteditable element using Chrome input. The previous capture becomes stale. Pass includeSnapshot=true to save a fresh post-fill snapshot and return its ctx_execute_file path.",
|
"Set the full value of a text input, textarea, or contenteditable element using Chrome input. Earlier snapshots become stale after filling. Pass includeSnapshot=true to get a fresh post-fill snapshot JSON inline.",
|
||||||
promptSnippet: "Fill a Chrome form field by snapshot uid or selector, optionally returning a fresh snapshot.",
|
promptSnippet: "Fill a Chrome form field by snapshot uid or selector, optionally returning a fresh snapshot.",
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
text: Type.String(),
|
text: Type.String(),
|
||||||
@@ -1590,7 +1563,7 @@ Usage rules:
|
|||||||
selector: Type.Optional(Type.String({ description: "CSS selector to fill if uid is omitted." })),
|
selector: Type.Optional(Type.String({ description: "CSS selector to fill if uid is omitted." })),
|
||||||
submit: Type.Optional(Type.Boolean({ description: "If true, press Enter after filling." })),
|
submit: Type.Optional(Type.Boolean({ description: "If true, press Enter after filling." })),
|
||||||
domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM value-setting if Chrome's CDP input path is blocked by another extension overlay or debugger failure." })),
|
domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM value-setting if Chrome's CDP input path is blocked by another extension overlay or debugger failure." })),
|
||||||
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, save a fresh post-fill snapshot to the session-local capture file and return its path." })),
|
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, return a fresh post-fill snapshot JSON inline after the action summary." })),
|
||||||
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
|
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
|
||||||
targetId: Type.Optional(Type.String()),
|
targetId: Type.Optional(Type.String()),
|
||||||
urlIncludes: Type.Optional(Type.String()),
|
urlIncludes: Type.Optional(Type.String()),
|
||||||
@@ -1608,7 +1581,7 @@ Usage rules:
|
|||||||
const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
|
const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
|
||||||
const base = `Filled ${params.text.length} character(s)${into}.`;
|
const base = `Filled ${params.text.length} character(s)${into}.`;
|
||||||
const text = summary ? `${base} (${summary})` : base;
|
const text = summary ? `${base} (${summary})` : base;
|
||||||
return formatActionWithCapture(raw, params, text);
|
return formatActionResult(raw, params, text);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1616,7 +1589,7 @@ Usage rules:
|
|||||||
name: "chrome_key",
|
name: "chrome_key",
|
||||||
label: "Chrome Key",
|
label: "Chrome Key",
|
||||||
description:
|
description:
|
||||||
"Send a keyboard key to an existing Chrome tab. The previous capture becomes stale. Pass includeSnapshot=true to save a fresh post-key snapshot and return its ctx_execute_file path. Runs in the background by default; pass background=false so the user can watch.",
|
"Send a keyboard key to an existing Chrome tab. Earlier snapshots become stale after the key press. Pass includeSnapshot=true to get a fresh post-key snapshot JSON inline. Runs in the background by default; pass background=false so the user can watch.",
|
||||||
promptSnippet: "Press keys in Chrome through the companion extension.",
|
promptSnippet: "Press keys in Chrome through the companion extension.",
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
key: Type.String(),
|
key: Type.String(),
|
||||||
@@ -1626,7 +1599,7 @@ Usage rules:
|
|||||||
altKey: Type.Optional(Type.Boolean()),
|
altKey: Type.Optional(Type.Boolean()),
|
||||||
metaKey: Type.Optional(Type.Boolean()),
|
metaKey: Type.Optional(Type.Boolean()),
|
||||||
}, { description: "Modifier keys to hold while pressing the key (chord)." })),
|
}, { description: "Modifier keys to hold while pressing the key (chord)." })),
|
||||||
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, save a fresh post-key snapshot to the session-local capture file and return its path." })),
|
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, return a fresh post-key snapshot JSON inline after the action summary." })),
|
||||||
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
|
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
|
||||||
targetId: Type.Optional(Type.String()),
|
targetId: Type.Optional(Type.String()),
|
||||||
urlIncludes: Type.Optional(Type.String()),
|
urlIncludes: Type.Optional(Type.String()),
|
||||||
@@ -1643,7 +1616,7 @@ Usage rules:
|
|||||||
const summary = summarizeActionResult(result);
|
const summary = summarizeActionResult(result);
|
||||||
const base = `Pressed ${params.key}.`;
|
const base = `Pressed ${params.key}.`;
|
||||||
const text = summary ? `${base} (${summary})` : base;
|
const text = summary ? `${base} (${summary})` : base;
|
||||||
return formatActionWithCapture(raw, params, text);
|
return formatActionResult(raw, params, text);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1665,7 +1638,6 @@ Usage rules:
|
|||||||
}),
|
}),
|
||||||
async execute(_id, params, signal): Promise<ToolTextResult> {
|
async execute(_id, params, signal): Promise<ToolTextResult> {
|
||||||
const result = await authorizedBridgeSend("page.waitFor", params, (params.timeoutMs ?? 10_000) + 2_000, signal);
|
const result = await authorizedBridgeSend("page.waitFor", params, (params.timeoutMs ?? 10_000) + 2_000, signal);
|
||||||
await invalidateSnapshotCapture(params);
|
|
||||||
return { content: [{ type: "text", text: `Observed ${params.kind}: ${params.value}` }], details: { result: result as Json } };
|
return { content: [{ type: "text", text: `Observed ${params.kind}: ${params.value}` }], details: { result: result as Json } };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1687,7 +1659,7 @@ Usage rules:
|
|||||||
}),
|
}),
|
||||||
async execute(_id, params, signal): Promise<ToolTextResult> {
|
async execute(_id, params, signal): Promise<ToolTextResult> {
|
||||||
const result = await authorizedBridgeSend("page.console.list", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
const result = await authorizedBridgeSend("page.console.list", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
||||||
return { content: [{ type: "text", text: truncateText(safeJson(result)) }], details: { result: result as Json } };
|
return { content: [{ type: "text", text: safeJson(result) }], details: { result: result as Json } };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1709,7 +1681,7 @@ Usage rules:
|
|||||||
}),
|
}),
|
||||||
async execute(_id, params, signal): Promise<ToolTextResult> {
|
async execute(_id, params, signal): Promise<ToolTextResult> {
|
||||||
const result = await authorizedBridgeSend("page.network.list", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
const result = await authorizedBridgeSend("page.network.list", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
||||||
return { content: [{ type: "text", text: truncateText(safeJson(result)) }], details: { result: result as Json } };
|
return { content: [{ type: "text", text: safeJson(result) }], details: { result: result as Json } };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1729,7 +1701,7 @@ Usage rules:
|
|||||||
}),
|
}),
|
||||||
async execute(_id, params, signal): Promise<ToolTextResult> {
|
async execute(_id, params, signal): Promise<ToolTextResult> {
|
||||||
const result = await authorizedBridgeSend("page.network.get", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
const result = await authorizedBridgeSend("page.network.get", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
||||||
return { content: [{ type: "text", text: truncateText(safeJson(result)) }], details: { result: result as Json } };
|
return { content: [{ type: "text", text: safeJson(result) }], details: { result: result as Json } };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1809,7 +1781,6 @@ Usage rules:
|
|||||||
}),
|
}),
|
||||||
async execute(_id, params, signal): Promise<ToolTextResult> {
|
async execute(_id, params, signal): Promise<ToolTextResult> {
|
||||||
const result = await authorizedBridgeSend("page.hover", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
const result = await authorizedBridgeSend("page.hover", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
||||||
await invalidateSnapshotCapture(params);
|
|
||||||
return { content: [{ type: "text", text: `Hovered ${params.uid ?? params.selector ?? `${params.x},${params.y}`}` }], details: { result: result as Json } };
|
return { content: [{ type: "text", text: `Hovered ${params.uid ?? params.selector ?? `${params.x},${params.y}`}` }], details: { result: result as Json } };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1836,7 +1807,6 @@ Usage rules:
|
|||||||
}),
|
}),
|
||||||
async execute(_id, params, signal): Promise<ToolTextResult> {
|
async execute(_id, params, signal): Promise<ToolTextResult> {
|
||||||
const result = await authorizedBridgeSend("page.drag", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
const result = await authorizedBridgeSend("page.drag", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
||||||
await invalidateSnapshotCapture(params);
|
|
||||||
return { content: [{ type: "text", text: `Dragged from ${params.fromUid ?? params.fromSelector} to ${params.toUid ?? params.toSelector}` }], details: { result: result as Json } };
|
return { content: [{ type: "text", text: `Dragged from ${params.fromUid ?? params.fromSelector} to ${params.toUid ?? params.toSelector}` }], details: { result: result as Json } };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1859,7 +1829,6 @@ Usage rules:
|
|||||||
}),
|
}),
|
||||||
async execute(_id, params, signal): Promise<ToolTextResult> {
|
async execute(_id, params, signal): Promise<ToolTextResult> {
|
||||||
const result = await authorizedBridgeSend("page.tap", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
const result = await authorizedBridgeSend("page.tap", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
||||||
await invalidateSnapshotCapture(params);
|
|
||||||
const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
|
const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
|
||||||
return { content: [{ type: "text", text: `Tapped ${target} (touch)` }], details: { result: result as Json } };
|
return { content: [{ type: "text", text: `Tapped ${target} (touch)` }], details: { result: result as Json } };
|
||||||
},
|
},
|
||||||
@@ -1883,7 +1852,6 @@ Usage rules:
|
|||||||
}),
|
}),
|
||||||
async execute(_id, params, signal): Promise<ToolTextResult> {
|
async execute(_id, params, signal): Promise<ToolTextResult> {
|
||||||
const result = await authorizedBridgeSend("page.scroll", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
const result = await authorizedBridgeSend("page.scroll", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
|
||||||
await invalidateSnapshotCapture(params);
|
|
||||||
return { content: [{ type: "text", text: `Scrolled dy=${params.deltaY ?? 0} dx=${params.deltaX ?? 0}` }], details: { result: result as Json } };
|
return { content: [{ type: "text", text: `Scrolled dy=${params.deltaY ?? 0} dx=${params.deltaX ?? 0}` }], details: { result: result as Json } };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1906,7 +1874,6 @@ Usage rules:
|
|||||||
const cwd = workspaceCwd(ctx);
|
const cwd = workspaceCwd(ctx);
|
||||||
const paths = params.paths.map((p) => resolve(cwd, p));
|
const paths = params.paths.map((p) => resolve(cwd, p));
|
||||||
const result = await authorizedBridgeSend("page.upload", withBackground({ ...params, paths }), DEFAULT_TIMEOUT_MS, signal);
|
const result = await authorizedBridgeSend("page.upload", withBackground({ ...params, paths }), DEFAULT_TIMEOUT_MS, signal);
|
||||||
await invalidateSnapshotCapture(params);
|
|
||||||
return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ${params.uid ?? params.selector}` }], details: { result: result as Json } };
|
return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ${params.uid ?? params.selector}` }], details: { result: result as Json } };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "pi-chrome",
|
"name": "pi-chrome",
|
||||||
"version": "0.15.46",
|
"version": "0.15.46",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && jiti test/session-snapshot-store.test.ts",
|
"test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && jiti test/snapshot-result.test.ts",
|
||||||
"version": "node scripts/sync-manifest-version.js",
|
"version": "node scripts/sync-manifest-version.js",
|
||||||
"prepublishOnly": "node scripts/sync-manifest-version.js"
|
"prepublishOnly": "node scripts/sync-manifest-version.js"
|
||||||
},
|
},
|
||||||
@@ -52,7 +52,6 @@
|
|||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"files": [
|
"files": [
|
||||||
"extensions",
|
"extensions",
|
||||||
"src",
|
|
||||||
"test",
|
"test",
|
||||||
"docs",
|
"docs",
|
||||||
"test-suite",
|
"test-suite",
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
import { createHash, randomUUID } from "node:crypto";
|
|
||||||
import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
||||||
import { join, relative, resolve, sep } from "node:path";
|
|
||||||
|
|
||||||
type SnapshotObject = Record<string, unknown>;
|
|
||||||
|
|
||||||
export type SnapshotTarget = {
|
|
||||||
targetId?: string;
|
|
||||||
urlIncludes?: string;
|
|
||||||
titleIncludes?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SnapshotCaptureMetadata = {
|
|
||||||
version: 1;
|
|
||||||
captureId: string;
|
|
||||||
capturedAt: string;
|
|
||||||
path: string;
|
|
||||||
bytes: number;
|
|
||||||
targetKey: string;
|
|
||||||
targetId?: string | number;
|
|
||||||
mode?: string;
|
|
||||||
elementCount: number;
|
|
||||||
matchCount: number;
|
|
||||||
staleAfterInteraction: true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StoredSnapshotCapture = {
|
|
||||||
metadata: SnapshotCaptureMetadata;
|
|
||||||
absolutePath: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SnapshotCaptureFile = {
|
|
||||||
version: 1;
|
|
||||||
captureId: string;
|
|
||||||
capturedAt: string;
|
|
||||||
targetKey: string;
|
|
||||||
snapshot: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
function hashSegment(value: string): string {
|
|
||||||
return createHash("sha256").update(value).digest("hex").slice(0, 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRelativePath(path: string): string {
|
|
||||||
return path.split(sep).join("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotObject(snapshot: unknown): SnapshotObject | undefined {
|
|
||||||
return snapshot && typeof snapshot === "object" && !Array.isArray(snapshot)
|
|
||||||
? snapshot as SnapshotObject
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function snapshotTargetKey(target: SnapshotTarget = {}): string {
|
|
||||||
if (target.targetId) return `target:${target.targetId}`;
|
|
||||||
if (target.urlIncludes) return `url:${target.urlIncludes}`;
|
|
||||||
if (target.titleIncludes) return `title:${target.titleIncludes}`;
|
|
||||||
return "default";
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SessionSnapshotStore {
|
|
||||||
readonly workspaceRoot: string;
|
|
||||||
readonly sessionRoot: string;
|
|
||||||
readonly sessionHash: string;
|
|
||||||
|
|
||||||
constructor(workspaceRoot: string, sessionId: string) {
|
|
||||||
this.workspaceRoot = resolve(workspaceRoot);
|
|
||||||
this.sessionHash = hashSegment(sessionId || "unknown-session");
|
|
||||||
this.sessionRoot = join(this.workspaceRoot, ".pi", "chrome-context", this.sessionHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
async save(snapshot: unknown, target: SnapshotTarget = {}): Promise<StoredSnapshotCapture> {
|
|
||||||
const targetKey = snapshotTargetKey(target);
|
|
||||||
const targetDirectory = join(this.sessionRoot, hashSegment(targetKey));
|
|
||||||
const absolutePath = join(targetDirectory, "snapshot.json");
|
|
||||||
const temporaryPath = join(targetDirectory, `.snapshot.${process.pid}.${randomUUID()}.tmp`);
|
|
||||||
const captureId = randomUUID();
|
|
||||||
const capturedAt = new Date().toISOString();
|
|
||||||
const capture: SnapshotCaptureFile = {
|
|
||||||
version: 1,
|
|
||||||
captureId,
|
|
||||||
capturedAt,
|
|
||||||
targetKey,
|
|
||||||
snapshot,
|
|
||||||
};
|
|
||||||
const serialized = `${JSON.stringify(capture, null, 2)}\n`;
|
|
||||||
|
|
||||||
await mkdir(targetDirectory, { recursive: true, mode: 0o700 });
|
|
||||||
await chmod(this.sessionRoot, 0o700).catch(() => undefined);
|
|
||||||
await chmod(targetDirectory, 0o700).catch(() => undefined);
|
|
||||||
try {
|
|
||||||
await writeFile(temporaryPath, serialized, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
||||||
await chmod(temporaryPath, 0o600).catch(() => undefined);
|
|
||||||
await rename(temporaryPath, absolutePath);
|
|
||||||
await chmod(absolutePath, 0o600).catch(() => undefined);
|
|
||||||
} catch (error) {
|
|
||||||
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const object = snapshotObject(snapshot);
|
|
||||||
const elements = Array.isArray(object?.elements) ? object.elements : [];
|
|
||||||
const matches = Array.isArray(object?.matches) ? object.matches : [];
|
|
||||||
const targetId = typeof object?.targetId === "string" || typeof object?.targetId === "number"
|
|
||||||
? object.targetId
|
|
||||||
: undefined;
|
|
||||||
const mode = typeof object?.mode === "string" ? object.mode : undefined;
|
|
||||||
const path = normalizeRelativePath(relative(this.workspaceRoot, absolutePath));
|
|
||||||
|
|
||||||
return {
|
|
||||||
absolutePath,
|
|
||||||
metadata: {
|
|
||||||
version: 1,
|
|
||||||
captureId,
|
|
||||||
capturedAt,
|
|
||||||
path,
|
|
||||||
bytes: Buffer.byteLength(serialized),
|
|
||||||
targetKey,
|
|
||||||
targetId,
|
|
||||||
mode,
|
|
||||||
elementCount: elements.length,
|
|
||||||
matchCount: matches.length,
|
|
||||||
staleAfterInteraction: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async invalidate(target?: SnapshotTarget): Promise<void> {
|
|
||||||
if (!target) {
|
|
||||||
await this.clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const targetDirectory = join(this.sessionRoot, hashSegment(snapshotTargetKey(target)));
|
|
||||||
await rm(targetDirectory, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async clear(): Promise<void> {
|
|
||||||
await rm(this.sessionRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { mkdtemp, readFile, stat } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import test from "node:test";
|
|
||||||
import { SessionSnapshotStore, snapshotTargetKey } from "../src/session-snapshot-store.ts";
|
|
||||||
import { CHROME_TOOL_NAMES, formatChromeFindMatches, formatSnapshotCapture } from "../extensions/chrome-profile-bridge/index.ts";
|
|
||||||
|
|
||||||
test("snapshotTargetKey prefers explicit target selectors", () => {
|
|
||||||
assert.equal(snapshotTargetKey(), "default");
|
|
||||||
assert.equal(snapshotTargetKey({ titleIncludes: "Inbox" }), "title:Inbox");
|
|
||||||
assert.equal(snapshotTargetKey({ urlIncludes: "example.com" }), "url:example.com");
|
|
||||||
assert.equal(snapshotTargetKey({ targetId: "42", urlIncludes: "ignored" }), "target:42");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("SessionSnapshotStore atomically keeps only the latest capture per target", async () => {
|
|
||||||
const workspace = await mkdtemp(join(tmpdir(), "pi-chrome-snapshot-"));
|
|
||||||
const store = new SessionSnapshotStore(workspace, "session/private/id");
|
|
||||||
const first = await store.save({ mode: "interactive", targetId: 42, elements: [{ uid: "el-1" }], matches: [] });
|
|
||||||
const second = await store.save({ mode: "changes", targetId: 42, elements: [{ uid: "el-2" }], matches: [{ uid: "el-2" }] });
|
|
||||||
|
|
||||||
assert.equal(first.metadata.path, second.metadata.path);
|
|
||||||
assert.notEqual(first.metadata.captureId, second.metadata.captureId);
|
|
||||||
assert.equal(second.metadata.elementCount, 1);
|
|
||||||
assert.equal(second.metadata.matchCount, 1);
|
|
||||||
assert.equal(second.metadata.targetId, 42);
|
|
||||||
assert.ok(!second.metadata.path.includes("private"));
|
|
||||||
|
|
||||||
const stored = JSON.parse(await readFile(second.absolutePath, "utf8"));
|
|
||||||
assert.equal(stored.version, 1);
|
|
||||||
assert.equal(stored.captureId, second.metadata.captureId);
|
|
||||||
assert.equal(stored.snapshot.elements[0].uid, "el-2");
|
|
||||||
|
|
||||||
if (process.platform !== "win32") {
|
|
||||||
assert.equal((await stat(store.sessionRoot)).mode & 0o777, 0o700);
|
|
||||||
assert.equal((await stat(second.absolutePath)).mode & 0o777, 0o600);
|
|
||||||
}
|
|
||||||
|
|
||||||
await store.clear();
|
|
||||||
await assert.rejects(stat(second.absolutePath), { code: "ENOENT" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("target invalidation and session cleanup do not affect another session", async () => {
|
|
||||||
const workspace = await mkdtemp(join(tmpdir(), "pi-chrome-isolation-"));
|
|
||||||
const firstSession = new SessionSnapshotStore(workspace, "session-a");
|
|
||||||
const secondSession = new SessionSnapshotStore(workspace, "session-b");
|
|
||||||
const firstTarget = { targetId: "101" };
|
|
||||||
const secondTarget = { targetId: "202" };
|
|
||||||
const first = await firstSession.save({ elements: [] }, firstTarget);
|
|
||||||
const otherTarget = await firstSession.save({ elements: [] }, secondTarget);
|
|
||||||
const otherSession = await secondSession.save({ elements: [] }, firstTarget);
|
|
||||||
|
|
||||||
await firstSession.invalidate(firstTarget);
|
|
||||||
await assert.rejects(stat(first.absolutePath), { code: "ENOENT" });
|
|
||||||
await stat(otherTarget.absolutePath);
|
|
||||||
await stat(otherSession.absolutePath);
|
|
||||||
|
|
||||||
await firstSession.clear();
|
|
||||||
await assert.rejects(stat(otherTarget.absolutePath), { code: "ENOENT" });
|
|
||||||
await stat(otherSession.absolutePath);
|
|
||||||
await secondSession.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("snapshot result formatting stays bounded and points to Context Mode", () => {
|
|
||||||
const text = formatSnapshotCapture({
|
|
||||||
version: 1,
|
|
||||||
captureId: "capture-1",
|
|
||||||
capturedAt: "2026-08-28T00:00:00.000Z",
|
|
||||||
path: ".pi/chrome-context/session/target/snapshot.json",
|
|
||||||
bytes: 80_000,
|
|
||||||
targetKey: "target:42",
|
|
||||||
targetId: 42,
|
|
||||||
mode: "interactive",
|
|
||||||
elementCount: 80,
|
|
||||||
matchCount: 3,
|
|
||||||
staleAfterInteraction: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.match(text, /ctx_execute_file/);
|
|
||||||
assert.match(text, /\.pi\/chrome-context\/session\/target\/snapshot\.json/);
|
|
||||||
assert.doesNotMatch(text, /raw-secret-page-content/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("chrome_find formatting is capped and authorization tracks all 21 tools", () => {
|
|
||||||
const matches = Array.from({ length: 25 }, (_, index) => ({
|
|
||||||
kind: "element",
|
|
||||||
uid: `el-${index}`,
|
|
||||||
role: "button",
|
|
||||||
label: `Button ${index}`,
|
|
||||||
rect: { x: index, y: index, width: 10, height: 10 },
|
|
||||||
}));
|
|
||||||
const text = formatChromeFindMatches({ query: "button", matches });
|
|
||||||
|
|
||||||
assert.match(text, /el-0/);
|
|
||||||
assert.match(text, /el-19/);
|
|
||||||
assert.doesNotMatch(text, /el-20/);
|
|
||||||
assert.equal(CHROME_TOOL_NAMES.length, 21);
|
|
||||||
assert.ok(CHROME_TOOL_NAMES.includes("chrome_find"));
|
|
||||||
assert.ok(CHROME_TOOL_NAMES.includes("chrome_inspect"));
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
CHROME_TOOL_NAMES,
|
||||||
|
formatActionResultText,
|
||||||
|
formatChromeFindMatches,
|
||||||
|
snapshotStats,
|
||||||
|
} from "../extensions/chrome-profile-bridge/index.ts";
|
||||||
|
|
||||||
|
test("formatActionResultText appends the inline snapshot JSON", () => {
|
||||||
|
const snapshot = { elements: [{ uid: "el-1", role: "button", label: "Save" }], matches: [] };
|
||||||
|
const text = formatActionResultText("Clicked el-1", snapshot);
|
||||||
|
const [summary, json] = text.split("\n\n");
|
||||||
|
assert.equal(summary, "Clicked el-1");
|
||||||
|
assert.deepEqual(JSON.parse(json), snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatActionResultText returns the summary alone without a snapshot", () => {
|
||||||
|
assert.equal(formatActionResultText("Clicked el-1", undefined), "Clicked el-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("snapshotStats stays small: only element and match counts", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
snapshotStats({ elements: [{ uid: "a" }, { uid: "b" }], matches: [{ uid: "a" }] }),
|
||||||
|
{ elementCount: 2, matchCount: 1 },
|
||||||
|
);
|
||||||
|
assert.deepEqual(snapshotStats({ elements: "nope" }), { elementCount: 0, matchCount: 0 });
|
||||||
|
assert.deepEqual(snapshotStats(undefined), { elementCount: 0, matchCount: 0 });
|
||||||
|
assert.deepEqual(snapshotStats([1, 2, 3]), { elementCount: 0, matchCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chrome_find formatting is capped and authorization tracks all 21 tools", () => {
|
||||||
|
const matches = Array.from({ length: 25 }, (_, index) => ({
|
||||||
|
kind: "element",
|
||||||
|
uid: `el-${index}`,
|
||||||
|
role: "button",
|
||||||
|
label: `Button ${index}`,
|
||||||
|
rect: { x: index, y: index, width: 10, height: 10 },
|
||||||
|
}));
|
||||||
|
const text = formatChromeFindMatches({ query: "button", matches });
|
||||||
|
|
||||||
|
assert.match(text, /el-0/);
|
||||||
|
assert.match(text, /el-19/);
|
||||||
|
assert.doesNotMatch(text, /el-20/);
|
||||||
|
assert.equal(CHROME_TOOL_NAMES.length, 21);
|
||||||
|
assert.ok(CHROME_TOOL_NAMES.includes("chrome_find"));
|
||||||
|
assert.ok(CHROME_TOOL_NAMES.includes("chrome_inspect"));
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# my-pi 维护约定(pi-compaction)
|
||||||
|
|
||||||
|
- 扩展入口是 `pi-compaction/index.ts`,遮蔽规划、投影索引、遮蔽台账、服务与 Codex native 实现在 `src/`,测试位于 `test/`,双上游来源记录是 `UPSTREAM.md`。
|
||||||
|
- 根包必须通过 `file:./pi-compaction` 提供依赖并直接加载源码;不得重新声明或加载 `pi-condense`、npm `@ogulcancelik/pi-codex-compaction` 或 npm `pi-context-view`。
|
||||||
|
- `pi-compaction` 只支持自己的 version 1 记录和 `config/pi-compaction.json` 配置,不读取或迁移旧 `context-prune-*`、`contextPrune`、`openai-codex-native-compaction` 或旧 `segment-summary` 状态;切换后应使用新 Session。配置不写回 `settings.json`。
|
||||||
|
- Context Authority 是 compaction 唯一生产投影者。普通 Provider、Codex native、`/context`、`/compaction-status` 和 footer 必须消费同一个不可变 projection snapshot;`pi-minimal-footer` 优先显示同一 effective usage,snapshot 尚未建立时才回退宿主原始 usage。
|
||||||
|
- `pi-compaction` 负责已经进入 Session 的历史投影,与 pi-overflow 的新结果入口截断职责互补。首次 Session hydration 允许一次完整扫描;后续普通 `context` hot path 必须复用不可变 snapshot 或只处理 delta,不得调用 summarizer、写 Session 或重新构建完整 branch。
|
||||||
|
- L2 遮蔽只用 Pi 原生 `context_edit`(`{ type: "context_edit", targetId, replacement }`)处理保护窗口外的旧 toolResult:成批提交、受 trigger/target 回差与 `minClearTokens` 约束,不逐条即时替换;不为遮蔽写 `segment-summary` 等 custom entry,原始条目、UI 与导出保持不变。
|
||||||
|
- 不得改写最近 `keepRecentTurns` 轮;不得遮蔽 user/assistant 消息、`excludeTools` 中的工具、`SKILL.md` 的 `read` 结果、已遮蔽或已省略的条目、以及小于 `minEntryTokens` 的结果。
|
||||||
|
- 遮蔽占位符必须确定性、单行、以 `MASK_PREFIX`(`"[pi-compaction] Earlier "`)开头,并在文本中指向 `context_tree_query` 恢复;`context_tree_query` 与 native checkpoint 的 portable summary 都必须有界。被遮蔽的 toolResult 若带 `details.overflow.id`(pi-overflow 已存原文),占位符末尾追加 `overflow_query` 提示——只读该公开字段,不得 import pi-overflow 内部实现。
|
||||||
|
- L3 整段摘要只能从空闲的 `agent_settled` 通过 `ctx.compact` 触发(`ctx.compact` 会中止当前运行,非空闲时调用会破坏运行);同一 leaf 不重复触发。阈值以绝对 token 计并受窗口比例约束(`effectiveThreshold = min(tokens, floor(ratio × contextWindow))`)。
|
||||||
|
- `context` 钩子不得改写消息——非 checkpoint 路径只更新 snapshot 并返回 undefined;唯一例外是 native checkpoint 路径:checkpoint 之后的条目在投影时必须应用各 target 最新的 `context_edit`(null 省略、字符串规范成 text block),因为 `sessionEntryToContextMessages` 不处理 edits。
|
||||||
|
- `session_before_compact` 只负责生成 portable/native 摘要,不得按自身阈值取消 Pi 的压缩(Pi 的 `contextTokens > window - reserveTokens` 是最终安全网)。
|
||||||
|
- effective token 默认使用 Provider usage-backed baseline 加 projected delta;无可靠 baseline 时使用带上下界的估算。遮蔽台账在 `session_start` / `session_tree` / `session_compact` 从当前 branch 的 `context_edit` 条目重建,指标暴露 `maskedResults` 与 `reclaimedTokens`。
|
||||||
|
- Codex native compaction 必须直接消费当前 projection 生成的 ResponseItems,不得从 raw branch 重建输入;native checkpoint 同时保存有界 portable summary 供模型或 Provider 切换。
|
||||||
|
- 原生远程压缩限定为 `openai-codex` + `openai-codex-responses` 与 `sub-api` + `openai-responses`,默认由 L3 阈值或 Pi 自身阈值触发。`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,不能用请求头绕过。
|
||||||
|
- 同步 Codex 协议代码必须从官方 `ogulcancelik/pi-extensions` 明确 commit 移植并保留对应 MIT 许可证;不得从 npm tarball、缓存、`node_modules` 或构建产物导入。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 在本目录内至少运行 `npm test`、`npm run check` 和 `npm pack --dry-run --json`;涉及依赖、加载入口、context writer、footer 或 observer 时还要验证根 `npm install` 幂等、锁文件仅包含预期本地 link 变化、根扩展联合加载、性能回归,以及实际 packed 根 tarball 的隔离安装/加载。
|
||||||
|
- 修改 projection、token accounting、遮蔽规划/台账、threshold、checkpoint、模型切换或恢复索引后,至少运行 `npm test`、`npm run check`、`npm pack --dry-run --json`、根扩展联合加载、packed 根 tarball 隔离加载和性能回归。性能门禁为 cache-hit P95 <2ms、delta projection P95 <10ms,普通 hot path 不得扫描完整 branch。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Add native remote compaction v2 for `sub-api` with `openai-responses`, using the configured public Responses URL and gateway authentication.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- Introduce one incremental Context Authority for provider requests, threshold decisions, UI metrics, and native compaction.
|
||||||
|
- Add deterministic tool-result compaction with source-backed recovery.
|
||||||
|
- Add a bounded asynchronous semantic-summary worker.
|
||||||
|
- Add Codex remote compaction using the effective projection rather than the raw Session branch.
|
||||||
|
- Add portable checkpoint text for provider/model switching.
|
||||||
|
- Add `context_tree_query` and `/compaction-status`.
|
||||||
|
- Deliberately omit legacy pi-condense and Codex checkpoint/config compatibility.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Can Celik
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 championswimmer (original pi-context-prune)
|
||||||
|
Copyright (c) 2026 Jacek Juraszek (pi-condense)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# pi-compaction
|
||||||
|
|
||||||
|
Pressure-aware context masking and threshold compaction for Pi.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
Pi context management is layered; this extension owns the middle two layers:
|
||||||
|
|
||||||
|
- **L1 — entry truncation** (future `pi-overflow`): truncate new tool results as they are written. Not implemented here.
|
||||||
|
- **L2 — observation masking** (this extension): under real context pressure, replace old tool results outside a protected recent window with deterministic one-line placeholders via Pi `context_edit` entries. Originals stay in the session untouched and are recoverable with `context_tree_query`.
|
||||||
|
- **L3 — whole-context summary** (this extension): when the session idles (`agent_settled`) above the summary threshold, trigger `ctx.compact` once per leaf.
|
||||||
|
- **Pi native threshold compaction** is the final safety net and is never blocked; this extension no longer cancels `session_before_compact` based on its own thresholds.
|
||||||
|
|
||||||
|
Additionally it:
|
||||||
|
|
||||||
|
- publishes one effective-context snapshot for normal provider requests and Codex/SubAPI native compaction;
|
||||||
|
- maintains a masked-results ledger so `/context`, `/compaction-status`, the footer, and AutoReview see the same placeholders the model sees.
|
||||||
|
|
||||||
|
pi-overflow bounds new tool results as they arrive. RTK remains responsible for ordinary tool-output cleanup.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration lives in the installed bundle's `config/pi-compaction.json` (the bundle repository's `config/` directory is the single source of truth; `pi update` refreshes the installed copy). The extension never reads `settings.json` and never writes config at runtime:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"masking": {
|
||||||
|
"enabled": true,
|
||||||
|
"triggerTokens": 200000,
|
||||||
|
"triggerRatio": 0.6,
|
||||||
|
"targetTokens": 100000,
|
||||||
|
"targetRatio": 0.35,
|
||||||
|
"minClearTokens": 50000,
|
||||||
|
"keepRecentTurns": 10,
|
||||||
|
"minEntryTokens": 256,
|
||||||
|
"excludeTools": ["ask_user_question"]
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"triggerTokens": 400000,
|
||||||
|
"triggerRatio": 0.9,
|
||||||
|
"thinking": "low"
|
||||||
|
},
|
||||||
|
"native": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All thresholds are absolute token counts constrained by a context-window ratio: `effectiveThreshold = min(tokens, floor(ratio × contextWindow))`. Masking fires when `currentTokens ≥ triggerEff` and clears the oldest eligible results until `currentTokens - cleared ≤ targetEff`; if the clearable volume stays under `min(minClearTokens, triggerEff - targetEff)` nothing is masked, so masking is rare, batched, and hysteretic. `masking.target` is normalized below `masking.trigger` and `summary.trigger` above `masking.trigger` when a config would violate that ordering. Invalid or missing fields fall back to defaults in memory.
|
||||||
|
|
||||||
|
Masking never touches the most recent `keepRecentTurns` assistant turns, user/assistant messages, `excludeTools`, `SKILL.md` reads, entries already masked, or results smaller than `minEntryTokens`.
|
||||||
|
|
||||||
|
## Native providers
|
||||||
|
|
||||||
|
Native compaction is enabled by the `native.enabled` config key and uses the model selected in the session when compaction starts. It does not select a separate summarization model.
|
||||||
|
|
||||||
|
| Provider | API | Native transport |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `openai-codex` | `openai-codex-responses` | Existing Codex Responses endpoint and JWT/account-ID authentication |
|
||||||
|
| `sub-api` | `openai-responses` | Public Responses endpoint and resolved gateway headers or API key |
|
||||||
|
|
||||||
|
For SubAPI, keep the existing `models.json` API as `openai-responses`. The adapter requires an explicit HTTP(S) `baseUrl`: for example, `https://gateway.example/v1` becomes `https://gateway.example/v1/responses`; an existing `/responses` suffix is not duplicated. It preserves configured headers and merges the native compaction beta feature, without decoding the API key or adding a ChatGPT account ID. No gateway hostname or credentials are bundled.
|
||||||
|
|
||||||
|
SubAPI preserves a non-empty explicit `Authorization` header (case-insensitively) ahead of the API key. Without it, a non-blank API key supplies Bearer authentication; if no key is available, a non-empty `cf-aig-authorization` header also supports proxy-managed authentication. Header-only credentials do not create a dummy Bearer token. Missing or blank credentials fail before any network request and return control to Pi. Official Codex still requires its API key/JWT even when explicit authentication headers are present.
|
||||||
|
|
||||||
|
Both transports send the current projection as ResponseItems, append `compaction_trigger`, and parse the SSE compaction item. SubAPI must support Sub2API native remote compaction v2 on its public Responses endpoint. This is not a client for the separate `/v1/responses/compact` endpoint; supporting only that endpoint or ordinary Responses chat is insufficient.
|
||||||
|
|
||||||
|
Request interception also restores SubAPI native checkpoints into subsequent Responses input. Checkpoints retain a bounded portable summary for switching models/providers, and their version 1 format is unchanged. Native authentication, transport or response failures return control to Pi's normal compaction fallback. Other provider/API pairs and `native.enabled: false` retain the existing portable-summary path. Setting `native.enabled` to false does not discard an already-created native checkpoint.
|
||||||
|
|
||||||
|
The gateway must have a compatible deployed version, model and available upstream account. Offline tests cover the protocol and lifecycle; they do not establish live gateway availability. No settings migration, provider rename or Pi core modification is required.
|
||||||
|
|
||||||
|
## Session format
|
||||||
|
|
||||||
|
The extension writes no `segment-summary` or other custom entries for masking: L2 uses Pi's native `context_edit` entries (`{ type: "context_edit", targetId, replacement }`), which alter only future model context. The raw session entries — and therefore UI, exports, and the evidence ledger — are unchanged; every masked result remains recoverable through `context_tree_query`. Masking placeholders are deterministic single lines starting with `[pi-compaction] Earlier `.
|
||||||
|
|
||||||
|
Compaction entries still carry `pi-compaction` version 1 details; native checkpoints use the same kind/version. This implementation intentionally does not read legacy `context-prune-*`, `openai-codex-native-compaction`, or older `segment-summary` entries. Start a new session after switching to this extension.
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
- `context_tree_query`: recover the original output of a tool result that pi-compaction cleared from context, by tool call ID (1–20 ids per call, bounded output). Use it when a `[pi-compaction] Earlier` placeholder lacks evidence needed for the current task.
|
||||||
|
- pi-overflow linkage: when the masked tool result carries `details.overflow.id` (pi-overflow stored the oversized original), the placeholder ends with `Full original stored: overflow_query {"id":"…"}`. pi-compaction only reads that public field and never imports pi-overflow internals.
|
||||||
|
- `/compaction-status`: show raw/effective tokens, reclaimed tokens, confidence, segments, masked results, and generation.
|
||||||
|
- Service `my-pi.pi-compaction.service.v1`: `getSnapshot`, `getMetrics`, and `query(sessionId, ids)` — `query` returns a placeholder record only for tool call IDs that are currently masked, so consumers such as the permission system's AutoReview see the same evidence the model does.
|
||||||
|
|
||||||
|
## Performance model
|
||||||
|
|
||||||
|
The first session hydration scans the branch once. Appended turns use delta projection and immutable cached snapshots; the `context` hook performs no model call, no session write, and returns the message list unchanged except on the native-checkpoint path, where post-checkpoint `context_edit` entries are applied during projection. Masking drafts are produced in `turn_end` in one batch and only when both the trigger and the `minClear` hysteresis pass, so the hot path stays allocation-free for unprompted turns.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 上游来源(多个)
|
||||||
|
|
||||||
|
本包由 `my-pi` 直接维护,组合了两份独立许可的来源,并新增了一套本地 Context Authority。
|
||||||
|
|
||||||
|
## pi-condense 参考
|
||||||
|
|
||||||
|
- 官方仓库:https://github.com/jjuraszek/pi-condense
|
||||||
|
- 快照:`59c95110aacd8131db71e21296765dd80b72fae8`
|
||||||
|
- Tag/版本:`v2.9.1`
|
||||||
|
- 作为行为与摘要器可靠性参考使用。新的 segment/index/runtime 实现为本地实现,且有意不保留旧格式。
|
||||||
|
- 许可证:`LICENSES/pi-condense-MIT.txt`
|
||||||
|
|
||||||
|
## Pi Codex compaction 源码
|
||||||
|
|
||||||
|
- 官方仓库:https://github.com/ogulcancelik/pi-extensions
|
||||||
|
- 包路径:`packages/pi-codex-compaction/`
|
||||||
|
- 快照:`c4d6ca8fb484ba51684296ba8b4715f8c7b3ac91`
|
||||||
|
- 快照时版本:`0.1.4`
|
||||||
|
- 导入源码:`src/native-compaction.ts` 中的远端压缩协议、Response API 转换、保留辅助函数、重试解析和请求净化。
|
||||||
|
- 本地改动:统一 checkpoint kind/version,移除 raw-branch effective-input 构造与旧 checkpoint 解析,导出共享消息投影,使保留用户预算可配置,并重命名客户端 user agent。
|
||||||
|
- 许可证:`LICENSES/pi-codex-compaction-MIT.txt`
|
||||||
|
|
||||||
|
不得以 npm tarball、npm 缓存、`node_modules`、构建产物或嵌套 Git 元数据作为导入源。
|
||||||
|
|
||||||
|
## Sub2API 协议参考
|
||||||
|
|
||||||
|
- 官方仓库:https://github.com/Wei-Shaw/sub2api
|
||||||
|
- 参考 commit:`ab99d56e9626e6cd731592dae8553c9758a0efa2`
|
||||||
|
- 协议证据:`backend/internal/service/openai_compact_body_signal.go` 指出原生远端压缩 v2 为 Responses 加 `stream: true` 与 `compaction_trigger`,并处理 Codex beta 特性。
|
||||||
|
- 本地 `src/native-provider.ts` 适配器是针对该协议独立实现的。没有导入任何 Sub2API 源码;复用现有 Codex 序列化器、流解析器和 checkpoint 格式。
|
||||||
|
- 该参考描述的是上游能力,不构成对任何特定部署、模型或账号的保证。本适配器不使用单独的 Responses compact 端点。
|
||||||
|
|
||||||
|
## 本地改动记录
|
||||||
|
|
||||||
|
- 上下文管理重构为 L2/L3:L2 在上下文压力下经 `turn_end` 提交 Pi 原生 `context_edit`,把保护窗口外的旧 toolResult 批量遮蔽为指向 `context_tree_query` 的确定性占位符(原始条目保留、不写 custom entry);L3 只在空闲 `agent_settled` 且达到绝对 token 阈值(受窗口比例约束)时调用一次 `ctx.compact`。删除逐条确定性存根、后台语义 worker、`softRatio`/`hardRatio`/`targetRatio` 配置与 `session_before_compact` 的自行阈值取消;Pi 原生阈值压缩保留为最终安全网。
|
||||||
|
- L2 遮蔽占位符联动 pi-overflow:被遮蔽的 toolResult 若携带 `details.overflow.id`,占位符末尾追加 `overflow_query {"id":"…"}` 恢复提示。
|
||||||
@@ -0,0 +1,685 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { Type } from "typebox";
|
||||||
|
import {
|
||||||
|
buildSessionContext,
|
||||||
|
convertToLlm,
|
||||||
|
estimateTokens,
|
||||||
|
generateSummaryWithUsage,
|
||||||
|
sessionEntryToContextMessages,
|
||||||
|
truncateHead,
|
||||||
|
DEFAULT_MAX_BYTES,
|
||||||
|
DEFAULT_MAX_LINES,
|
||||||
|
type ExtensionAPI,
|
||||||
|
type ExtensionContext,
|
||||||
|
type SessionEntry,
|
||||||
|
} from "@earendil-works/pi-coding-agent";
|
||||||
|
import { DEFAULT_CONFIG, loadConfig } from "./src/config.js";
|
||||||
|
import {
|
||||||
|
applyContextEdits,
|
||||||
|
planMasking,
|
||||||
|
rebuildMaskedLedger,
|
||||||
|
type MaskedLedgerEntry,
|
||||||
|
} from "./src/masking.js";
|
||||||
|
import { buildNativeCompactionTransport, isNativeCompactionModel } from "./src/native-provider.js";
|
||||||
|
import {
|
||||||
|
buildCompactionRequestBody,
|
||||||
|
buildReplacementHistory,
|
||||||
|
buildToolPayload,
|
||||||
|
callRemoteCompaction,
|
||||||
|
findNativeCheckpoint,
|
||||||
|
isJsonObject,
|
||||||
|
mergeFeatureHeader,
|
||||||
|
messagesToResponseItems,
|
||||||
|
modelKey,
|
||||||
|
NATIVE_COMPACTION_KIND,
|
||||||
|
NATIVE_COMPACTION_VERSION,
|
||||||
|
stripInputFromPayload,
|
||||||
|
type JsonObject,
|
||||||
|
type NativeCompactionDetails,
|
||||||
|
type ResponseItem,
|
||||||
|
} from "./src/native-compaction.js";
|
||||||
|
import { contentText, SegmentIndex } from "./src/segment-index.js";
|
||||||
|
import {
|
||||||
|
CompactionServiceRegistry,
|
||||||
|
publishCompactionService,
|
||||||
|
unpublishCompactionService,
|
||||||
|
} from "./src/service.js";
|
||||||
|
import { effectiveThreshold } from "./src/token-accounting.js";
|
||||||
|
import {
|
||||||
|
COMPACTION_CUSTOM_TYPE,
|
||||||
|
COMPACTION_VERSION,
|
||||||
|
QUERY_TOOL_NAME,
|
||||||
|
type MaskedResultRecord,
|
||||||
|
type PiCompactionConfig,
|
||||||
|
type ProjectionSnapshot,
|
||||||
|
} from "./src/types.js";
|
||||||
|
|
||||||
|
interface SessionRuntime {
|
||||||
|
index: SegmentIndex;
|
||||||
|
config: PiCompactionConfig;
|
||||||
|
codexCache?: { fingerprint: string; modelKey: string; toolsHash: string; items: ResponseItem[] };
|
||||||
|
masked: Map<string, MaskedLedgerEntry>;
|
||||||
|
lastAutoCompactLeafId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedPayload {
|
||||||
|
modelKey: string;
|
||||||
|
payload: JsonObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnvironmentValue {
|
||||||
|
hash: string;
|
||||||
|
tokens: number;
|
||||||
|
contextWindow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnvironmentCacheEntry extends EnvironmentValue {
|
||||||
|
prompt: string;
|
||||||
|
modelIdentity: string;
|
||||||
|
activeNames: string[];
|
||||||
|
tools: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hash(value: string): string {
|
||||||
|
return createHash("sha256").update(value).digest("hex").slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
function environment(
|
||||||
|
pi: ExtensionAPI,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
cache: Map<string, EnvironmentCacheEntry>,
|
||||||
|
): EnvironmentValue {
|
||||||
|
const sessionId = ctx.sessionManager.getSessionId();
|
||||||
|
const prompt = ctx.getSystemPrompt();
|
||||||
|
const activeNames = pi.getActiveTools();
|
||||||
|
const active = new Set(activeNames);
|
||||||
|
const tools = pi.getAllTools().filter((tool) => active.has(tool.name));
|
||||||
|
const contextWindow = ctx.model?.contextWindow ?? 0;
|
||||||
|
const modelIdentity = ctx.model ? modelKey(ctx.model) : "none";
|
||||||
|
const previous = cache.get(sessionId);
|
||||||
|
if (
|
||||||
|
previous
|
||||||
|
&& previous.prompt === prompt
|
||||||
|
&& previous.contextWindow === contextWindow
|
||||||
|
&& previous.modelIdentity === modelIdentity
|
||||||
|
&& previous.activeNames.length === activeNames.length
|
||||||
|
&& previous.activeNames.every((name, index) => name === activeNames[index])
|
||||||
|
&& previous.tools.length === tools.length
|
||||||
|
&& previous.tools.every((tool, index) => tool === tools[index])
|
||||||
|
) return previous;
|
||||||
|
|
||||||
|
const serializedTools = JSON.stringify(tools.map((tool) => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: tool.parameters,
|
||||||
|
})));
|
||||||
|
const text = `${modelIdentity}\n${prompt}\n${serializedTools}`;
|
||||||
|
const value: EnvironmentCacheEntry = {
|
||||||
|
prompt,
|
||||||
|
modelIdentity,
|
||||||
|
activeNames: [...activeNames],
|
||||||
|
tools,
|
||||||
|
hash: hash(text),
|
||||||
|
tokens: Math.ceil(text.length / 4),
|
||||||
|
contextWindow,
|
||||||
|
};
|
||||||
|
cache.set(sessionId, value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function portableSummary(messages: readonly any[], maxChars = 24_000): string {
|
||||||
|
const sections: string[] = ["Portable context checkpoint generated by pi-compaction."];
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message?.role === "toolResult") {
|
||||||
|
const text = Array.isArray(message.content)
|
||||||
|
? message.content.flatMap((part: any) => part?.type === "text" ? [part.text] : []).join("\n")
|
||||||
|
: "";
|
||||||
|
if (text) sections.push(`[Tool ${message.toolName ?? "unknown"}] ${text}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (message?.role === "user") {
|
||||||
|
const text = typeof message.content === "string"
|
||||||
|
? message.content
|
||||||
|
: Array.isArray(message.content)
|
||||||
|
? message.content.flatMap((part: any) => part?.type === "text" ? [part.text] : []).join("\n")
|
||||||
|
: "";
|
||||||
|
if (text) sections.push(`[User] ${text}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (message?.role === "assistant" && Array.isArray(message.content)) {
|
||||||
|
const text = message.content.flatMap((part: any) => part?.type === "text" ? [part.text] : []).join("\n");
|
||||||
|
if (text) sections.push(`[Assistant] ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const combined = sections.join("\n\n");
|
||||||
|
if (combined.length <= maxChars) return combined;
|
||||||
|
const head = Math.floor(maxChars * 0.35);
|
||||||
|
const tail = maxChars - head - 32;
|
||||||
|
return `${combined.slice(0, head)}\n\n… checkpoint truncated …\n\n${combined.slice(-tail)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asPortableMessage(summary: string): any {
|
||||||
|
return {
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: summary }],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFeatureHeader(headers: Record<string, string | null>): void {
|
||||||
|
const existing = Object.entries(headers).find(([name]) => name.toLowerCase() === "x-codex-beta-features");
|
||||||
|
if (existing) headers[existing[0]] = mergeFeatureHeader(existing[1]);
|
||||||
|
else headers["x-codex-beta-features"] = mergeFeatureHeader(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanHeaders(headers: Record<string, string | null> | undefined): Record<string, string> | undefined {
|
||||||
|
if (!headers) return undefined;
|
||||||
|
return Object.fromEntries(Object.entries(headers).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeToolsHash(pi: ExtensionAPI): string {
|
||||||
|
return hash(pi.getActiveTools().join("\0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function piCompactionExtension(pi: ExtensionAPI): void {
|
||||||
|
const runtimes = new Map<string, SessionRuntime>();
|
||||||
|
const payloads = new Map<string, CachedPayload>();
|
||||||
|
const environments = new Map<string, EnvironmentCacheEntry>();
|
||||||
|
const service = new CompactionServiceRegistry();
|
||||||
|
publishCompactionService(service);
|
||||||
|
|
||||||
|
const maskedRecords = (sessionId: string, ids: string[]): MaskedResultRecord[] => {
|
||||||
|
const masked = runtimes.get(sessionId)?.masked;
|
||||||
|
if (!masked) return [];
|
||||||
|
return ids.flatMap((id) => {
|
||||||
|
const entry = masked.get(id);
|
||||||
|
return entry
|
||||||
|
? [{
|
||||||
|
toolCallId: id,
|
||||||
|
resultTimestamp: entry.resultTimestamp,
|
||||||
|
deterministicSummary: entry.placeholder,
|
||||||
|
}]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtimeFor = (ctx: ExtensionContext): SessionRuntime => {
|
||||||
|
const sessionId = ctx.sessionManager.getSessionId();
|
||||||
|
let runtime = runtimes.get(sessionId);
|
||||||
|
if (!runtime) {
|
||||||
|
runtime = {
|
||||||
|
index: new SegmentIndex(sessionId),
|
||||||
|
config: structuredClone(DEFAULT_CONFIG),
|
||||||
|
masked: rebuildLedger(ctx),
|
||||||
|
lastAutoCompactLeafId: null,
|
||||||
|
};
|
||||||
|
runtimes.set(sessionId, runtime);
|
||||||
|
service.setSession(sessionId, (ids) => maskedRecords(sessionId, ids));
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ledgerStats = (runtime: SessionRuntime): { maskedResults: number; reclaimedTokens: number } => {
|
||||||
|
let reclaimedTokens = 0;
|
||||||
|
for (const entry of runtime.masked.values()) {
|
||||||
|
reclaimedTokens += Math.max(0, entry.originalTokens - entry.placeholderTokens);
|
||||||
|
}
|
||||||
|
return { maskedResults: runtime.masked.size, reclaimedTokens };
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectMessages = (
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
runtime: SessionRuntime,
|
||||||
|
messages: readonly any[],
|
||||||
|
): ProjectionSnapshot => {
|
||||||
|
const env = environment(pi, ctx, environments);
|
||||||
|
const stats = ledgerStats(runtime);
|
||||||
|
const snapshot = runtime.index.project(messages, {
|
||||||
|
environmentHash: env.hash,
|
||||||
|
environmentTokens: env.tokens,
|
||||||
|
contextWindow: env.contextWindow,
|
||||||
|
maskedResults: stats.maskedResults,
|
||||||
|
reclaimedTokens: stats.reclaimedTokens,
|
||||||
|
});
|
||||||
|
service.setSnapshot(snapshot);
|
||||||
|
return snapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rebuildLedger = (ctx: ExtensionContext): Map<string, MaskedLedgerEntry> =>
|
||||||
|
rebuildMaskedLedger(
|
||||||
|
ctx.sessionManager.getBranch(),
|
||||||
|
ctx.sessionManager.getLeafId(),
|
||||||
|
(message) => estimateTokens(message as any),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hydrateRuntime = (ctx: ExtensionContext, runtime: SessionRuntime): void => {
|
||||||
|
runtime.index.hydrate();
|
||||||
|
runtime.masked = rebuildLedger(ctx);
|
||||||
|
runtime.lastAutoCompactLeafId = null;
|
||||||
|
runtime.codexCache = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const branchMessages = (
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
runtime: SessionRuntime,
|
||||||
|
branch: SessionEntry[],
|
||||||
|
excludeLastAssistantError = false,
|
||||||
|
): { snapshot: ProjectionSnapshot; checkpoint: ReturnType<typeof findNativeCheckpoint> } => {
|
||||||
|
const checkpoint = findNativeCheckpoint(branch);
|
||||||
|
let messages: any[];
|
||||||
|
if (checkpoint.status === "valid") {
|
||||||
|
// sessionEntryToContextMessages sees only raw entries; applyContextEdits
|
||||||
|
// additionally replays the latest context_edit per target (omissions and
|
||||||
|
// placeholder replacements included).
|
||||||
|
messages = applyContextEdits(branch.slice(checkpoint.checkpoint.entryIndex + 1));
|
||||||
|
if (checkpoint.checkpoint.details.modelKey !== (ctx.model ? modelKey(ctx.model) : "")) {
|
||||||
|
messages = [asPortableMessage(checkpoint.checkpoint.details.portableSummary), ...messages];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
messages = buildSessionContext(branch).messages;
|
||||||
|
}
|
||||||
|
if (excludeLastAssistantError) {
|
||||||
|
const index = messages.findLastIndex((message) => message.role === "assistant");
|
||||||
|
if (index >= 0 && messages[index]?.stopReason === "error") messages = messages.toSpliced(index, 1);
|
||||||
|
}
|
||||||
|
return { snapshot: projectMessages(ctx, runtime, messages), checkpoint };
|
||||||
|
};
|
||||||
|
|
||||||
|
const codexInput = (
|
||||||
|
piModel: any,
|
||||||
|
runtime: SessionRuntime,
|
||||||
|
snapshot: ProjectionSnapshot,
|
||||||
|
checkpoint?: NativeCompactionDetails,
|
||||||
|
): ResponseItem[] => {
|
||||||
|
const toolsHash = activeToolsHash(pi);
|
||||||
|
const key = modelKey(piModel);
|
||||||
|
const cached = runtime.codexCache;
|
||||||
|
if (cached && cached.fingerprint === snapshot.fingerprint && cached.modelKey === key && cached.toolsHash === toolsHash) {
|
||||||
|
return cached.items;
|
||||||
|
}
|
||||||
|
const tail = messagesToResponseItems(piModel, convertToLlm(snapshot.messages as any), pi.getAllTools());
|
||||||
|
const items = checkpoint && checkpoint.modelKey === key
|
||||||
|
? [...structuredClone(checkpoint.replacementHistory), ...tail]
|
||||||
|
: tail;
|
||||||
|
runtime.codexCache = { fingerprint: snapshot.fingerprint, modelKey: key, toolsHash, items };
|
||||||
|
snapshot.codexItems = items;
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
publishCompactionService(service);
|
||||||
|
const sessionId = ctx.sessionManager.getSessionId();
|
||||||
|
const config = await loadConfig();
|
||||||
|
const runtime = runtimes.get(sessionId) ?? {
|
||||||
|
index: new SegmentIndex(sessionId),
|
||||||
|
config,
|
||||||
|
masked: new Map<string, MaskedLedgerEntry>(),
|
||||||
|
lastAutoCompactLeafId: null,
|
||||||
|
};
|
||||||
|
runtime.config = config;
|
||||||
|
runtime.index.hydrate();
|
||||||
|
runtime.masked = rebuildLedger(ctx);
|
||||||
|
runtime.lastAutoCompactLeafId = null;
|
||||||
|
runtimes.set(sessionId, runtime);
|
||||||
|
service.setSession(sessionId, (ids) => maskedRecords(sessionId, ids));
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_shutdown", (_event, ctx) => {
|
||||||
|
const sessionId = ctx.sessionManager.getSessionId();
|
||||||
|
runtimes.delete(sessionId);
|
||||||
|
payloads.delete(sessionId);
|
||||||
|
environments.delete(sessionId);
|
||||||
|
service.deleteSession(sessionId);
|
||||||
|
if (runtimes.size === 0) unpublishCompactionService(service);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_tree", (_event, ctx) => {
|
||||||
|
const runtime = runtimeFor(ctx);
|
||||||
|
hydrateRuntime(ctx, runtime);
|
||||||
|
service.clearSnapshot(ctx.sessionManager.getSessionId());
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_compact", (_event, ctx) => {
|
||||||
|
const runtime = runtimeFor(ctx);
|
||||||
|
hydrateRuntime(ctx, runtime);
|
||||||
|
service.clearSnapshot(ctx.sessionManager.getSessionId());
|
||||||
|
payloads.delete(ctx.sessionManager.getSessionId());
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("model_select", (_event, ctx) => {
|
||||||
|
const sessionId = ctx.sessionManager.getSessionId();
|
||||||
|
payloads.delete(sessionId);
|
||||||
|
environments.delete(sessionId);
|
||||||
|
const runtime = runtimes.get(sessionId);
|
||||||
|
if (runtime) runtime.codexCache = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("turn_end", (event, ctx) => {
|
||||||
|
const runtime = runtimeFor(ctx);
|
||||||
|
if (!runtime.config.enabled) return undefined;
|
||||||
|
|
||||||
|
const previous = runtime.index.getSnapshot();
|
||||||
|
const usage = ctx.getContextUsage();
|
||||||
|
if (usage?.tokens && previous) {
|
||||||
|
const env = environment(pi, ctx, environments);
|
||||||
|
runtime.index.recordUsage(
|
||||||
|
usage.tokens,
|
||||||
|
previous.messageEstimate + estimateTokens(event.message as any),
|
||||||
|
env.hash,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTokens = usage?.tokens
|
||||||
|
?? (event.context?.contextMessages ?? []).reduce((sum, message) => sum + estimateTokens(message as any), 0)
|
||||||
|
+ environment(pi, ctx, environments).tokens;
|
||||||
|
const plan = planMasking({
|
||||||
|
contextEntries: event.context?.contextEntries ?? [],
|
||||||
|
currentTokens,
|
||||||
|
contextWindow: ctx.model?.contextWindow ?? 0,
|
||||||
|
masking: runtime.config.masking,
|
||||||
|
estimate: (message) => estimateTokens(message as any),
|
||||||
|
});
|
||||||
|
if (!plan.drafts.length) return undefined;
|
||||||
|
for (const result of plan.masked) {
|
||||||
|
runtime.masked.set(result.toolCallId, {
|
||||||
|
placeholder: result.placeholder,
|
||||||
|
originalTokens: result.originalTokens,
|
||||||
|
placeholderTokens: result.placeholderTokens,
|
||||||
|
resultTimestamp: result.resultTimestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { entries: [...(event.entries ?? []), ...plan.drafts] };
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("agent_settled", (_event, ctx) => {
|
||||||
|
const runtime = runtimeFor(ctx);
|
||||||
|
if (!runtime.config.enabled || ctx.isIdle?.() === false) return;
|
||||||
|
const snapshot = runtime.index.getSnapshot();
|
||||||
|
const tokens = ctx.getContextUsage()?.tokens ?? snapshot?.effectiveTokens;
|
||||||
|
if (tokens == null) return;
|
||||||
|
const contextWindow = ctx.model?.contextWindow ?? snapshot?.contextWindow ?? 0;
|
||||||
|
const threshold = effectiveThreshold(
|
||||||
|
runtime.config.summary.triggerTokens,
|
||||||
|
runtime.config.summary.triggerRatio,
|
||||||
|
contextWindow,
|
||||||
|
);
|
||||||
|
if (tokens < threshold) return;
|
||||||
|
const leafId = ctx.sessionManager.getLeafId();
|
||||||
|
if (leafId !== null && runtime.lastAutoCompactLeafId === leafId) return;
|
||||||
|
runtime.lastAutoCompactLeafId = leafId;
|
||||||
|
ctx.compact({
|
||||||
|
onError: (error) => {
|
||||||
|
if (ctx.hasUI) ctx.ui.notify(`pi-compaction auto summary failed: ${errorMessage(error)}`, "warning");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("context", (event, ctx) => {
|
||||||
|
const runtime = runtimeFor(ctx);
|
||||||
|
const branch = ctx.sessionManager.getBranch() as SessionEntry[];
|
||||||
|
const checkpoint = findNativeCheckpoint(branch);
|
||||||
|
if (checkpoint.status === "invalid") {
|
||||||
|
ctx.abort();
|
||||||
|
if (ctx.hasUI) ctx.ui.notify("pi-compaction checkpoint is malformed; request blocked.", "error");
|
||||||
|
return { messages: [] };
|
||||||
|
}
|
||||||
|
if (checkpoint.status === "valid") {
|
||||||
|
const projected = branchMessages(ctx, runtime, branch);
|
||||||
|
return { messages: projected.snapshot.messages as any };
|
||||||
|
}
|
||||||
|
// Non-checkpoint path only refreshes the shared snapshot; the message list
|
||||||
|
// is returned to Pi unchanged — masking is append-only via context_edit.
|
||||||
|
projectMessages(ctx, runtime, event.messages);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("before_provider_headers", (event, ctx) => {
|
||||||
|
if (isNativeCompactionModel(ctx.model)) setFeatureHeader(event.headers);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("before_provider_request", (event, ctx) => {
|
||||||
|
const model = ctx.model;
|
||||||
|
if (!model || !isNativeCompactionModel(model) || !isJsonObject(event.payload)) return undefined;
|
||||||
|
const runtime = runtimeFor(ctx);
|
||||||
|
const sessionId = ctx.sessionManager.getSessionId();
|
||||||
|
payloads.set(sessionId, { modelKey: modelKey(model), payload: stripInputFromPayload(event.payload) });
|
||||||
|
const checkpoint = findNativeCheckpoint(ctx.sessionManager.getBranch() as SessionEntry[]);
|
||||||
|
if (checkpoint.status !== "valid" || checkpoint.checkpoint.details.modelKey !== modelKey(model)) return undefined;
|
||||||
|
const snapshot = runtime.index.getSnapshot();
|
||||||
|
if (!snapshot) return undefined;
|
||||||
|
const payload: JsonObject = {
|
||||||
|
...event.payload,
|
||||||
|
input: codexInput(model, runtime, snapshot, checkpoint.checkpoint.details),
|
||||||
|
};
|
||||||
|
delete payload.messages;
|
||||||
|
delete payload.previous_response_id;
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_before_compact", async (event, ctx) => {
|
||||||
|
const runtime = runtimeFor(ctx);
|
||||||
|
if (!runtime.config.enabled) return undefined;
|
||||||
|
const { snapshot, checkpoint } = branchMessages(
|
||||||
|
ctx,
|
||||||
|
runtime,
|
||||||
|
event.branchEntries as SessionEntry[],
|
||||||
|
event.reason === "overflow" && event.willRetry,
|
||||||
|
);
|
||||||
|
|
||||||
|
const model = ctx.model;
|
||||||
|
if (!model) return undefined;
|
||||||
|
if (!runtime.config.native.enabled || !isNativeCompactionModel(model)) {
|
||||||
|
try {
|
||||||
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
||||||
|
if (!auth.ok) return undefined;
|
||||||
|
const result = await generateSummaryWithUsage(
|
||||||
|
snapshot.messages as any,
|
||||||
|
model,
|
||||||
|
16_384,
|
||||||
|
auth.apiKey,
|
||||||
|
cleanHeaders(auth.headers),
|
||||||
|
event.signal,
|
||||||
|
event.customInstructions,
|
||||||
|
undefined,
|
||||||
|
runtime.config.summary.thinking,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
compaction: {
|
||||||
|
summary: result.text,
|
||||||
|
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||||
|
tokensBefore: snapshot.effectiveTokens,
|
||||||
|
usage: result.usage,
|
||||||
|
details: {
|
||||||
|
kind: COMPACTION_CUSTOM_TYPE,
|
||||||
|
version: COMPACTION_VERSION,
|
||||||
|
checkpoint: "portable",
|
||||||
|
sourceFingerprint: snapshot.fingerprint,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!event.signal.aborted) console.error("pi-compaction: portable checkpoint failed:", error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
||||||
|
if (!auth.ok) throw new Error(auth.error);
|
||||||
|
const currentCheckpoint = checkpoint.status === "valid" ? checkpoint.checkpoint.details : undefined;
|
||||||
|
const input = codexInput(model, runtime, snapshot, currentCheckpoint);
|
||||||
|
const sessionId = ctx.sessionManager.getSessionId();
|
||||||
|
const cached = payloads.get(sessionId);
|
||||||
|
const body = buildCompactionRequestBody({
|
||||||
|
basePayload: cached?.modelKey === modelKey(model) ? cached.payload : undefined,
|
||||||
|
model,
|
||||||
|
input,
|
||||||
|
instructions: ctx.getSystemPrompt(),
|
||||||
|
tools: buildToolPayload(pi.getAllTools(), pi.getActiveTools()),
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
if (ctx.hasUI) ctx.ui.notify(`pi-compaction: creating ${model.provider} native checkpoint...`, "info");
|
||||||
|
const remote = await callRemoteCompaction({
|
||||||
|
...buildNativeCompactionTransport({ model, apiKey: auth.apiKey, headers: cleanHeaders(auth.headers), sessionId }),
|
||||||
|
body,
|
||||||
|
signal: event.signal,
|
||||||
|
});
|
||||||
|
const details: NativeCompactionDetails = {
|
||||||
|
kind: NATIVE_COMPACTION_KIND,
|
||||||
|
version: NATIVE_COMPACTION_VERSION,
|
||||||
|
checkpoint: "native",
|
||||||
|
modelKey: modelKey(model),
|
||||||
|
replacementHistory: buildReplacementHistory(
|
||||||
|
input,
|
||||||
|
remote.compactionItem,
|
||||||
|
Math.min(64_000, Math.max(4_096, Math.floor(snapshot.contextWindow * 0.25))),
|
||||||
|
),
|
||||||
|
portableSummary: portableSummary(snapshot.messages),
|
||||||
|
sourceFingerprint: snapshot.fingerprint,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
compaction: {
|
||||||
|
summary: `Pi native checkpoint ${randomUUID()}`,
|
||||||
|
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||||
|
tokensBefore: snapshot.effectiveTokens,
|
||||||
|
usage: remote.usage,
|
||||||
|
details,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!event.signal.aborted && ctx.hasUI) {
|
||||||
|
ctx.ui.notify(`pi-compaction native checkpoint failed; falling back to Pi: ${errorMessage(error)}`, "warning");
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_before_tree", async (event, ctx) => {
|
||||||
|
const model = ctx.model;
|
||||||
|
if (!event.preparation.userWantsSummary || !model) return undefined;
|
||||||
|
const runtime = runtimeFor(ctx);
|
||||||
|
if (!runtime.config.enabled) return undefined;
|
||||||
|
const messages = event.preparation.entriesToSummarize.flatMap((entry) => sessionEntryToContextMessages(entry));
|
||||||
|
const snapshot = projectMessages(ctx, runtime, messages);
|
||||||
|
try {
|
||||||
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
||||||
|
if (!auth.ok) return undefined;
|
||||||
|
const result = await generateSummaryWithUsage(
|
||||||
|
snapshot.messages as any,
|
||||||
|
model,
|
||||||
|
16_384,
|
||||||
|
auth.apiKey,
|
||||||
|
cleanHeaders(auth.headers),
|
||||||
|
event.signal,
|
||||||
|
event.preparation.customInstructions,
|
||||||
|
undefined,
|
||||||
|
runtime.config.summary.thinking,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
summary: result.text,
|
||||||
|
usage: result.usage,
|
||||||
|
details: {
|
||||||
|
kind: COMPACTION_CUSTOM_TYPE,
|
||||||
|
version: COMPACTION_VERSION,
|
||||||
|
checkpoint: "portable",
|
||||||
|
sourceFingerprint: snapshot.fingerprint,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!event.signal.aborted) console.error("pi-compaction: branch summary failed:", error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: QUERY_TOOL_NAME,
|
||||||
|
label: "Query Compacted Tool History",
|
||||||
|
description: "Recover the original output of a tool result that pi-compaction cleared from context, by tool call id.",
|
||||||
|
promptSnippet: "Recover original tool output cleared from context",
|
||||||
|
promptGuidelines: [
|
||||||
|
"Use context_tree_query when you see a \"[pi-compaction] Earlier\" placeholder and still need the original tool output.",
|
||||||
|
],
|
||||||
|
parameters: Type.Object({
|
||||||
|
toolCallIds: Type.Array(Type.String(), { minItems: 1, maxItems: 20 }),
|
||||||
|
}),
|
||||||
|
async execute(_id, params, _signal, _update, ctx) {
|
||||||
|
const branch = ctx.sessionManager.getBranch() as SessionEntry[];
|
||||||
|
const calls = new Map<string, { toolName: string; args: unknown }>();
|
||||||
|
const results = new Map<string, any>();
|
||||||
|
for (const entry of branch) {
|
||||||
|
const message = (entry as any)?.message;
|
||||||
|
if ((entry as any)?.type !== "message" || !message) continue;
|
||||||
|
if (message.role === "assistant" && Array.isArray(message.content)) {
|
||||||
|
for (const block of message.content) {
|
||||||
|
if (block?.type === "toolCall" && typeof block.id === "string") {
|
||||||
|
calls.set(block.id, {
|
||||||
|
toolName: String(block.name ?? "unknown"),
|
||||||
|
args: block.arguments ?? block.input ?? block.args ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (message.role === "toolResult" && typeof message.toolCallId === "string") {
|
||||||
|
results.set(message.toolCallId, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const found: string[] = [];
|
||||||
|
const blocks: string[] = [];
|
||||||
|
for (const id of params.toolCallIds) {
|
||||||
|
const result = results.get(id);
|
||||||
|
if (!result) continue;
|
||||||
|
found.push(id);
|
||||||
|
const call = calls.get(id) ?? { toolName: String(result.toolName ?? "unknown"), args: {} };
|
||||||
|
const perResultBytes = Math.max(1_024, Math.floor(DEFAULT_MAX_BYTES / Math.max(1, params.toolCallIds.length)));
|
||||||
|
const args = truncateHead(JSON.stringify(call.args ?? {}), { maxLines: 20, maxBytes: 2_048 }).content;
|
||||||
|
const rawText = contentText(result.content);
|
||||||
|
const truncated = truncateHead(rawText, {
|
||||||
|
maxLines: Math.max(20, Math.floor(DEFAULT_MAX_LINES / Math.max(1, params.toolCallIds.length))),
|
||||||
|
maxBytes: perResultBytes,
|
||||||
|
});
|
||||||
|
blocks.push([
|
||||||
|
`## ${id}`,
|
||||||
|
`Tool: ${call.toolName}`,
|
||||||
|
`Args: ${args}`,
|
||||||
|
`Status: ${result.isError ? "ERROR" : "OK"}`,
|
||||||
|
"",
|
||||||
|
truncated.content,
|
||||||
|
...(truncated.truncated ? [`[truncated: ${truncated.outputLines}/${truncated.totalLines} lines]`] : []),
|
||||||
|
].join("\n"));
|
||||||
|
}
|
||||||
|
const missing = params.toolCallIds.filter((id: string) => !results.has(id));
|
||||||
|
for (const id of missing) blocks.push(`## ${id}\n(not found)`);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: blocks.join("\n\n---\n\n") }],
|
||||||
|
details: { toolCallIds: found, missing },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("compaction-status", {
|
||||||
|
description: "Show effective/raw context usage and pi-compaction state",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
const metrics = service.getMetrics(ctx.sessionManager.getSessionId());
|
||||||
|
const text = metrics
|
||||||
|
? [
|
||||||
|
`Effective: ${metrics.effectiveTokens}/${metrics.contextWindow} (${metrics.percent.toFixed(1)}%, ${metrics.confidence})`,
|
||||||
|
`Raw: ${metrics.rawTokens}/${metrics.contextWindow}`,
|
||||||
|
`Reclaimed: ${metrics.reclaimedTokens}`,
|
||||||
|
`Segments: ${metrics.segmentCount}; masked tool results: ${metrics.maskedResults}; generation: ${metrics.generation}`,
|
||||||
|
].join("\n")
|
||||||
|
: "pi-compaction has no projection snapshot yet.";
|
||||||
|
if (ctx.hasUI) ctx.ui.notify(text, "info");
|
||||||
|
else process.stderr.write(`${text}\n`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-compaction",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "High-performance unified context projection and compaction for Pi",
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.ts",
|
||||||
|
"files": [
|
||||||
|
"index.ts",
|
||||||
|
"src",
|
||||||
|
"README.md",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"UPSTREAM.md",
|
||||||
|
"LICENSES"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --import jiti/register --test test/*.test.ts",
|
||||||
|
"check": "tsc --noEmit -p tsconfig.json",
|
||||||
|
"pack:check": "npm pack --dry-run --json"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "*",
|
||||||
|
"typebox": "*"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.19.0"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { PiCompactionConfig } from "./types.js";
|
||||||
|
|
||||||
|
// Bundle-level `config/` is the single source of truth: the extension reads
|
||||||
|
// `config/pi-compaction.json` from inside the installed bundle (resolved
|
||||||
|
// relative to this source file). It never reads `settings.json` and never
|
||||||
|
// writes config at runtime.
|
||||||
|
export const CONFIG_PATH = fileURLToPath(
|
||||||
|
new URL("../../config/pi-compaction.json", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIG: PiCompactionConfig = {
|
||||||
|
enabled: true,
|
||||||
|
masking: {
|
||||||
|
enabled: true,
|
||||||
|
triggerTokens: 200_000,
|
||||||
|
triggerRatio: 0.6,
|
||||||
|
targetTokens: 100_000,
|
||||||
|
targetRatio: 0.35,
|
||||||
|
minClearTokens: 50_000,
|
||||||
|
keepRecentTurns: 10,
|
||||||
|
minEntryTokens: 256,
|
||||||
|
excludeTools: ["ask_user_question"],
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
triggerTokens: 400_000,
|
||||||
|
triggerRatio: 0.9,
|
||||||
|
thinking: "low",
|
||||||
|
},
|
||||||
|
native: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
||||||
|
|
||||||
|
function ratio(value: unknown, fallback: number): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) && value > 0 && value < 1 ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function integer(value: unknown, fallback: number, min = 0, max = Number.MAX_SAFE_INTEGER): number {
|
||||||
|
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(value: unknown, fallback: number): number {
|
||||||
|
return integer(value, fallback, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function object(value: unknown): Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringArray(value: unknown, fallback: string[]): string[] {
|
||||||
|
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) return fallback;
|
||||||
|
return value.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeConfig(value: unknown): PiCompactionConfig {
|
||||||
|
const input = object(value);
|
||||||
|
const masking = object(input.masking);
|
||||||
|
const summary = object(input.summary);
|
||||||
|
const native = object(input.native);
|
||||||
|
|
||||||
|
const normalized: PiCompactionConfig = {
|
||||||
|
enabled: typeof input.enabled === "boolean" ? input.enabled : DEFAULT_CONFIG.enabled,
|
||||||
|
masking: {
|
||||||
|
enabled: typeof masking.enabled === "boolean" ? masking.enabled : DEFAULT_CONFIG.masking.enabled,
|
||||||
|
triggerTokens: positiveInteger(masking.triggerTokens, DEFAULT_CONFIG.masking.triggerTokens),
|
||||||
|
triggerRatio: ratio(masking.triggerRatio, DEFAULT_CONFIG.masking.triggerRatio),
|
||||||
|
targetTokens: positiveInteger(masking.targetTokens, DEFAULT_CONFIG.masking.targetTokens),
|
||||||
|
targetRatio: ratio(masking.targetRatio, DEFAULT_CONFIG.masking.targetRatio),
|
||||||
|
minClearTokens: positiveInteger(masking.minClearTokens, DEFAULT_CONFIG.masking.minClearTokens),
|
||||||
|
keepRecentTurns: integer(masking.keepRecentTurns, DEFAULT_CONFIG.masking.keepRecentTurns, 1, 100),
|
||||||
|
minEntryTokens: integer(masking.minEntryTokens, DEFAULT_CONFIG.masking.minEntryTokens, 0, 100_000),
|
||||||
|
excludeTools: stringArray(masking.excludeTools, DEFAULT_CONFIG.masking.excludeTools),
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
triggerTokens: positiveInteger(summary.triggerTokens, DEFAULT_CONFIG.summary.triggerTokens),
|
||||||
|
triggerRatio: ratio(summary.triggerRatio, DEFAULT_CONFIG.summary.triggerRatio),
|
||||||
|
thinking: THINKING_LEVELS.includes(String(summary.thinking))
|
||||||
|
? summary.thinking as PiCompactionConfig["summary"]["thinking"]
|
||||||
|
: DEFAULT_CONFIG.summary.thinking,
|
||||||
|
},
|
||||||
|
native: {
|
||||||
|
enabled: typeof native.enabled === "boolean" ? native.enabled : DEFAULT_CONFIG.native.enabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger/target must keep their hysteresis ordering; fix up rather than
|
||||||
|
// letting a degenerate pair mask-or-compact every turn.
|
||||||
|
if (normalized.masking.targetTokens >= normalized.masking.triggerTokens) {
|
||||||
|
normalized.masking.targetTokens = Math.floor(normalized.masking.triggerTokens / 2);
|
||||||
|
}
|
||||||
|
if (normalized.masking.targetRatio >= normalized.masking.triggerRatio) {
|
||||||
|
normalized.masking.targetRatio = normalized.masking.triggerRatio / 2;
|
||||||
|
}
|
||||||
|
if (normalized.summary.triggerTokens <= normalized.masking.triggerTokens) {
|
||||||
|
normalized.summary.triggerTokens = normalized.masking.triggerTokens * 2;
|
||||||
|
}
|
||||||
|
if (normalized.summary.triggerRatio <= normalized.masking.triggerRatio) {
|
||||||
|
normalized.summary.triggerRatio = Math.min(0.99, normalized.masking.triggerRatio + 0.01);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadConfig(
|
||||||
|
configPath: string = CONFIG_PATH,
|
||||||
|
): Promise<PiCompactionConfig> {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await readFile(configPath, "utf8"));
|
||||||
|
return normalizeConfig(parsed);
|
||||||
|
} catch {
|
||||||
|
return structuredClone(DEFAULT_CONFIG);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import {
|
||||||
|
buildContextEntries,
|
||||||
|
sessionEntryToContextMessages,
|
||||||
|
type ContextEditEntryDraft,
|
||||||
|
} from "@earendil-works/pi-coding-agent";
|
||||||
|
import { contentText, timestampOf } from "./segment-index.js";
|
||||||
|
import { effectiveThreshold } from "./token-accounting.js";
|
||||||
|
import type { MaskingConfig } from "./types.js";
|
||||||
|
|
||||||
|
// Every placeholder written through a context_edit starts with this prefix so
|
||||||
|
// re-masking, ledger rebuilds, and the model-facing recovery hint all key off
|
||||||
|
// one marker.
|
||||||
|
export const MASK_PREFIX = "[pi-compaction] Earlier ";
|
||||||
|
|
||||||
|
const MAX_PLACEHOLDER_ARGS_CHARS = 300;
|
||||||
|
const EDITABLE_ROLES = new Set(["user", "assistant", "toolResult", "custom"]);
|
||||||
|
|
||||||
|
export interface MaskingProjectedEntry {
|
||||||
|
sourceEntry: { id?: string; type?: string; message?: any };
|
||||||
|
messages: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaskedResult {
|
||||||
|
toolCallId: string;
|
||||||
|
entryId: string;
|
||||||
|
placeholder: string;
|
||||||
|
originalTokens: number;
|
||||||
|
placeholderTokens: number;
|
||||||
|
resultTimestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaskingPlan {
|
||||||
|
drafts: ContextEditEntryDraft[];
|
||||||
|
clearedTokens: number;
|
||||||
|
masked: MaskedResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaskedLedgerEntry {
|
||||||
|
placeholder: string;
|
||||||
|
originalTokens: number;
|
||||||
|
placeholderTokens: number;
|
||||||
|
resultTimestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_PLAN: MaskingPlan = { drafts: [], clearedTokens: 0, masked: [] };
|
||||||
|
|
||||||
|
function json(value: unknown): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value) ?? "undefined";
|
||||||
|
} catch {
|
||||||
|
return "[unserializable]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskPlaceholder(
|
||||||
|
toolName: string,
|
||||||
|
args: unknown,
|
||||||
|
toolCallId: string,
|
||||||
|
originalTokens: number,
|
||||||
|
overflowId?: string,
|
||||||
|
): string {
|
||||||
|
let argsText = json(args);
|
||||||
|
if (argsText.length > MAX_PLACEHOLDER_ARGS_CHARS) {
|
||||||
|
argsText = `${argsText.slice(0, MAX_PLACEHOLDER_ARGS_CHARS)}…`;
|
||||||
|
}
|
||||||
|
// `details.overflow.id` is pi-overflow's public contract — read the field
|
||||||
|
// only, never import its implementation.
|
||||||
|
const overflowHint = typeof overflowId === "string" && overflowId.length > 0
|
||||||
|
? ` Full original stored: overflow_query {"id":"${overflowId}"}.`
|
||||||
|
: "";
|
||||||
|
return `${MASK_PREFIX}${toolName} result cleared to save context (~${originalTokens} tokens). ` +
|
||||||
|
`Call args: ${argsText}. Re-run the call if still needed, or recover the original with ` +
|
||||||
|
`context_tree_query {"toolCallIds":["${toolCallId}"]}.${overflowHint}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSkillRead(toolName: string, args: unknown): boolean {
|
||||||
|
if (toolName !== "read" || args === null || typeof args !== "object") return false;
|
||||||
|
const record = args as Record<string, unknown>;
|
||||||
|
const raw = record.path ?? record.filePath ?? record.file;
|
||||||
|
if (typeof raw !== "string") return false;
|
||||||
|
return (raw.split(/[\\/]/).pop() ?? raw) === "SKILL.md";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolResultCalls(entries: readonly MaskingProjectedEntry[]): Map<string, { toolName: string; args: unknown }> {
|
||||||
|
const calls = new Map<string, { toolName: string; args: unknown }>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
for (const message of entry.messages ?? []) {
|
||||||
|
if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
|
||||||
|
for (const block of message.content) {
|
||||||
|
if (block?.type === "toolCall" && typeof block.id === "string") {
|
||||||
|
calls.set(block.id, {
|
||||||
|
toolName: String(block.name ?? "unknown"),
|
||||||
|
args: block.arguments ?? block.input ?? block.args ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planMasking(params: {
|
||||||
|
contextEntries: readonly MaskingProjectedEntry[];
|
||||||
|
currentTokens: number;
|
||||||
|
contextWindow: number;
|
||||||
|
masking: MaskingConfig;
|
||||||
|
estimate: (message: any) => number;
|
||||||
|
}): MaskingPlan {
|
||||||
|
const { masking } = params;
|
||||||
|
const triggerEff = effectiveThreshold(masking.triggerTokens, masking.triggerRatio, params.contextWindow);
|
||||||
|
if (!masking.enabled || params.currentTokens < triggerEff) return EMPTY_PLAN;
|
||||||
|
const targetEff = effectiveThreshold(masking.targetTokens, masking.targetRatio, params.contextWindow);
|
||||||
|
const minClear = Math.min(masking.minClearTokens, Math.max(0, triggerEff - targetEff));
|
||||||
|
|
||||||
|
const entries = params.contextEntries;
|
||||||
|
// The last keepRecentTurns assistant messages and everything after them stay
|
||||||
|
// untouched; when fewer turns exist, the whole context is protected.
|
||||||
|
let protectedIndex = 0;
|
||||||
|
let turns = 0;
|
||||||
|
for (let index = entries.length - 1; index >= 0; index--) {
|
||||||
|
const entry = entries[index];
|
||||||
|
if (entry?.sourceEntry?.type === "message" && (entry.messages ?? []).some((m) => m?.role === "assistant")) {
|
||||||
|
turns++;
|
||||||
|
if (turns >= masking.keepRecentTurns) {
|
||||||
|
protectedIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const calls = toolResultCalls(entries);
|
||||||
|
const needed = params.currentTokens - targetEff;
|
||||||
|
const masked: MaskedResult[] = [];
|
||||||
|
let clearedTokens = 0;
|
||||||
|
for (let index = 0; index < protectedIndex && clearedTokens < needed; index++) {
|
||||||
|
const entry = entries[index];
|
||||||
|
const entryId = entry?.sourceEntry?.id;
|
||||||
|
if (entry?.sourceEntry?.type !== "message" || typeof entryId !== "string") continue;
|
||||||
|
for (const message of entry.messages ?? []) {
|
||||||
|
if (clearedTokens >= needed) break;
|
||||||
|
if (message?.role !== "toolResult" || typeof message.toolCallId !== "string") continue;
|
||||||
|
const call = calls.get(message.toolCallId);
|
||||||
|
const toolName = call?.toolName ?? String(message.toolName ?? "unknown");
|
||||||
|
if (masking.excludeTools.includes(toolName)) continue;
|
||||||
|
const args = call?.args ?? {};
|
||||||
|
if (isSkillRead(toolName, args)) continue;
|
||||||
|
if (contentText(message.content).startsWith(MASK_PREFIX)) continue;
|
||||||
|
const originalTokens = params.estimate(message);
|
||||||
|
if (originalTokens < masking.minEntryTokens) continue;
|
||||||
|
const overflowId = (message.details as { overflow?: { id?: unknown } } | undefined)?.overflow?.id;
|
||||||
|
const placeholder = maskPlaceholder(
|
||||||
|
toolName,
|
||||||
|
args,
|
||||||
|
message.toolCallId,
|
||||||
|
originalTokens,
|
||||||
|
typeof overflowId === "string" ? overflowId : undefined,
|
||||||
|
);
|
||||||
|
const placeholderTokens = params.estimate({ ...message, content: [{ type: "text", text: placeholder }] });
|
||||||
|
clearedTokens += originalTokens - placeholderTokens;
|
||||||
|
masked.push({
|
||||||
|
toolCallId: message.toolCallId,
|
||||||
|
entryId,
|
||||||
|
placeholder,
|
||||||
|
originalTokens,
|
||||||
|
placeholderTokens,
|
||||||
|
resultTimestamp: timestampOf(message.timestamp),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clearedTokens < minClear) return EMPTY_PLAN;
|
||||||
|
return {
|
||||||
|
drafts: masked.map((result) => ({
|
||||||
|
type: "context_edit",
|
||||||
|
targetId: result.entryId,
|
||||||
|
replacement: { content: result.placeholder },
|
||||||
|
})),
|
||||||
|
clearedTokens,
|
||||||
|
masked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the masked-results ledger from a session branch after hydration:
|
||||||
|
* the latest context_edit per target wins, only edits carrying this
|
||||||
|
* extension's placeholder prefix count as masked, and only targets that are
|
||||||
|
* still part of the active context (post-compaction kept set) are counted.
|
||||||
|
*/
|
||||||
|
export function rebuildMaskedLedger(
|
||||||
|
branch: readonly any[],
|
||||||
|
leafId: string | null | undefined,
|
||||||
|
estimate: (message: any) => number,
|
||||||
|
): Map<string, MaskedLedgerEntry> {
|
||||||
|
const latestEdits = new Map<string, any>();
|
||||||
|
const targets = new Map<string, any>();
|
||||||
|
for (const entry of branch) {
|
||||||
|
if (entry?.type === "context_edit" && typeof entry.targetId === "string") {
|
||||||
|
latestEdits.set(entry.targetId, entry);
|
||||||
|
} else if (entry?.type === "message" && typeof entry.id === "string") {
|
||||||
|
targets.set(entry.id, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const contextIds = new Set(
|
||||||
|
buildContextEntries(branch as any, leafId).map((entry) => entry.id),
|
||||||
|
);
|
||||||
|
const masked = new Map<string, MaskedLedgerEntry>();
|
||||||
|
for (const [targetId, edit] of latestEdits) {
|
||||||
|
if (!contextIds.has(targetId)) continue;
|
||||||
|
const text = contentText(edit.replacement?.content);
|
||||||
|
if (!text.startsWith(MASK_PREFIX)) continue;
|
||||||
|
const message = targets.get(targetId)?.message;
|
||||||
|
if (message?.role !== "toolResult" || typeof message.toolCallId !== "string") continue;
|
||||||
|
masked.set(message.toolCallId, {
|
||||||
|
placeholder: text,
|
||||||
|
originalTokens: estimate(message),
|
||||||
|
placeholderTokens: estimate({ ...message, content: [{ type: "text", text }] }),
|
||||||
|
resultTimestamp: timestampOf(message.timestamp),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the latest context_edit per target the way buildSessionProjection does
|
||||||
|
* for message entries `sessionEntryToContextMessages` alone would miss: null
|
||||||
|
* omits the entry, a string replacement becomes one text block for
|
||||||
|
* assistant/toolResult roles.
|
||||||
|
*/
|
||||||
|
export function applyContextEdits(entries: readonly any[]): any[] {
|
||||||
|
const edits = new Map<string, { content: unknown } | null>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry?.type === "context_edit" && typeof entry.targetId === "string") {
|
||||||
|
edits.set(entry.targetId, entry.replacement ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const projected: any[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const messages = sessionEntryToContextMessages(entry);
|
||||||
|
const edit = edits.get(entry?.id);
|
||||||
|
if (edit === undefined) {
|
||||||
|
projected.push(...messages);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (edit === null) continue;
|
||||||
|
for (const message of messages) {
|
||||||
|
if (!EDITABLE_ROLES.has(message?.role)) {
|
||||||
|
projected.push(message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const content = (message.role === "assistant" || message.role === "toolResult")
|
||||||
|
&& typeof edit.content === "string"
|
||||||
|
? [{ type: "text", text: edit.content }]
|
||||||
|
: edit.content;
|
||||||
|
projected.push({ ...message, content });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return projected;
|
||||||
|
}
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { type SessionEntry, type ToolInfo } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
export type Message = any;
|
||||||
|
export interface Model<_TApi = any> {
|
||||||
|
provider: string;
|
||||||
|
api: string;
|
||||||
|
id: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
contextWindow?: number;
|
||||||
|
input: string[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
export interface Usage {
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
cacheRead: number;
|
||||||
|
cacheWrite: number;
|
||||||
|
totalTokens: number;
|
||||||
|
cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NATIVE_COMPACTION_KIND = "pi-compaction";
|
||||||
|
export const NATIVE_COMPACTION_VERSION = 1;
|
||||||
|
export const REMOTE_COMPACTION_FEATURE = "remote_compaction_v2";
|
||||||
|
export const RETAINED_USER_TOKEN_BUDGET = 64_000;
|
||||||
|
|
||||||
|
const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||||
|
const MAX_REMOTE_RETRIES = 2;
|
||||||
|
|
||||||
|
export type JsonObject = Record<string, unknown>;
|
||||||
|
export type ResponseItem = JsonObject & { type?: string };
|
||||||
|
|
||||||
|
export interface NativeCompactionDetails {
|
||||||
|
kind: typeof NATIVE_COMPACTION_KIND;
|
||||||
|
version: typeof NATIVE_COMPACTION_VERSION;
|
||||||
|
checkpoint: "native";
|
||||||
|
modelKey: string;
|
||||||
|
replacementHistory: ResponseItem[];
|
||||||
|
portableSummary: string;
|
||||||
|
sourceFingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NativeCheckpoint = {
|
||||||
|
entryIndex: number;
|
||||||
|
entryId: string;
|
||||||
|
details: NativeCompactionDetails;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckpointLookup =
|
||||||
|
| { status: "none" }
|
||||||
|
| { status: "invalid"; entryIndex: number; entryId: string }
|
||||||
|
| { status: "valid"; checkpoint: NativeCheckpoint };
|
||||||
|
|
||||||
|
export type RemoteCompactionResult = {
|
||||||
|
compactionItem: ResponseItem;
|
||||||
|
usage?: Usage;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isJsonObject(value: unknown): value is JsonObject {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOpenAICodexModel(model: unknown): model is Model<"openai-codex-responses"> {
|
||||||
|
if (!isJsonObject(model)) return false;
|
||||||
|
return model.provider === "openai-codex" && model.api === "openai-codex-responses";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelKey(model: Pick<Model<any>, "provider" | "api" | "id">): string {
|
||||||
|
return `${model.provider}:${model.api}:${model.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneItem<T>(value: T): T {
|
||||||
|
return structuredClone(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isResponseItem(value: unknown): value is ResponseItem {
|
||||||
|
if (!isJsonObject(value)) return false;
|
||||||
|
return typeof value.type === "string" || (
|
||||||
|
typeof value.role === "string" && (typeof value.content === "string" || Array.isArray(value.content))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNativeCompactionDetails(value: unknown): NativeCompactionDetails | undefined {
|
||||||
|
if (!isJsonObject(value)) return undefined;
|
||||||
|
if (value.kind !== NATIVE_COMPACTION_KIND || value.version !== NATIVE_COMPACTION_VERSION) return undefined;
|
||||||
|
if (value.checkpoint !== "native" || typeof value.modelKey !== "string") return undefined;
|
||||||
|
if (typeof value.portableSummary !== "string" || typeof value.sourceFingerprint !== "string") return undefined;
|
||||||
|
if (!Array.isArray(value.replacementHistory)) return undefined;
|
||||||
|
|
||||||
|
const replacementHistory = value.replacementHistory.filter(isResponseItem);
|
||||||
|
if (replacementHistory.length !== value.replacementHistory.length || replacementHistory.length === 0) return undefined;
|
||||||
|
const compactionItems = replacementHistory.filter((item) => item.type === "compaction");
|
||||||
|
if (
|
||||||
|
compactionItems.length !== 1 ||
|
||||||
|
typeof compactionItems[0]?.encrypted_content !== "string" ||
|
||||||
|
replacementHistory.at(-1)?.type !== "compaction"
|
||||||
|
) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: NATIVE_COMPACTION_KIND,
|
||||||
|
version: NATIVE_COMPACTION_VERSION,
|
||||||
|
checkpoint: "native",
|
||||||
|
modelKey: value.modelKey,
|
||||||
|
replacementHistory: replacementHistory.map(cloneItem),
|
||||||
|
portableSummary: value.portableSummary,
|
||||||
|
sourceFingerprint: value.sourceFingerprint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNativeCheckpoint(branch: SessionEntry[]): CheckpointLookup {
|
||||||
|
for (let index = branch.length - 1; index >= 0; index--) {
|
||||||
|
const entry = branch[index];
|
||||||
|
if (!entry) continue;
|
||||||
|
|
||||||
|
if (entry.type !== "compaction") continue;
|
||||||
|
if (!isJsonObject(entry.details) || entry.details.kind !== NATIVE_COMPACTION_KIND) {
|
||||||
|
return { status: "none" };
|
||||||
|
}
|
||||||
|
const rawDetails: unknown = entry.details;
|
||||||
|
if ((rawDetails as JsonObject).checkpoint === "portable") return { status: "none" };
|
||||||
|
|
||||||
|
const details = parseNativeCompactionDetails(rawDetails);
|
||||||
|
if (!details) return { status: "invalid", entryIndex: index, entryId: entry.id };
|
||||||
|
return {
|
||||||
|
status: "valid",
|
||||||
|
checkpoint: { entryIndex: index, entryId: entry.id, details },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: "none" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortHash(value: string): string {
|
||||||
|
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedItemId(value: string | undefined): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const sanitized = value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64).replace(/_+$/, "");
|
||||||
|
return sanitized.startsWith("fc_") ? sanitized : `fc_${sanitized}`.slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textSignature(value: unknown): { id?: string; phase?: "commentary" | "final_answer" } {
|
||||||
|
if (typeof value !== "string" || !value) return {};
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as JsonObject;
|
||||||
|
return {
|
||||||
|
id: typeof parsed.id === "string" ? parsed.id : undefined,
|
||||||
|
phase: parsed.phase === "commentary" || parsed.phase === "final_answer" ? parsed.phase : undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { id: value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentToUserParts(content: unknown): unknown[] {
|
||||||
|
if (typeof content === "string") return content ? [{ type: "input_text", text: content }] : [];
|
||||||
|
if (!Array.isArray(content)) return [];
|
||||||
|
const parts: unknown[] = [];
|
||||||
|
for (const part of content) {
|
||||||
|
if (!isJsonObject(part)) continue;
|
||||||
|
if (part.type === "text" && typeof part.text === "string") {
|
||||||
|
parts.push({ type: "input_text", text: part.text });
|
||||||
|
} else if (part.type === "image" && typeof part.data === "string" && typeof part.mimeType === "string") {
|
||||||
|
parts.push({ type: "input_image", detail: "auto", image_url: `data:${part.mimeType};base64,${part.data}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolResultOutput(message: JsonObject, model: Model<any>): unknown {
|
||||||
|
const content = Array.isArray(message.content) ? message.content : [];
|
||||||
|
const text = content
|
||||||
|
.flatMap((part) => isJsonObject(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [])
|
||||||
|
.join("\n");
|
||||||
|
const images = content.filter((part) => isJsonObject(part) && part.type === "image");
|
||||||
|
if (images.length === 0 || !model.input.includes("image")) {
|
||||||
|
return text || (images.length > 0 ? "(see attached image)" : "(no tool output)");
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...(text ? [{ type: "input_text", text }] : []),
|
||||||
|
...images.flatMap((part) =>
|
||||||
|
typeof part.data === "string" && typeof part.mimeType === "string"
|
||||||
|
? [{ type: "input_image", detail: "auto", image_url: `data:${part.mimeType};base64,${part.data}` }]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseTool(tool: ToolInfo, deferLoading = false): JsonObject {
|
||||||
|
return {
|
||||||
|
type: "function",
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: tool.parameters as unknown,
|
||||||
|
strict: null,
|
||||||
|
...(deferLoading ? { defer_loading: true } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messagesToResponseItems(model: Model<any>, messages: Message[], tools: ToolInfo[]): ResponseItem[] {
|
||||||
|
const items: ResponseItem[] = [];
|
||||||
|
const toolsByName = new Map(tools.map((tool) => [tool.name, tool]));
|
||||||
|
const pendingToolCalls = new Map<string, string>();
|
||||||
|
const flushOrphanedToolCalls = () => {
|
||||||
|
for (const callId of pendingToolCalls.values()) {
|
||||||
|
items.push({ type: "function_call_output", call_id: callId, output: "No result provided" });
|
||||||
|
}
|
||||||
|
pendingToolCalls.clear();
|
||||||
|
};
|
||||||
|
let messageIndex = 0;
|
||||||
|
|
||||||
|
for (const message of messages as unknown as JsonObject[]) {
|
||||||
|
if (message.role === "user") {
|
||||||
|
flushOrphanedToolCalls();
|
||||||
|
const content = contentToUserParts(message.content);
|
||||||
|
if (content.length > 0) items.push({ role: "user", content });
|
||||||
|
} else if (message.role === "assistant" && Array.isArray(message.content)) {
|
||||||
|
flushOrphanedToolCalls();
|
||||||
|
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
||||||
|
messageIndex++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const isSameProviderAndApi = message.provider === model.provider && message.api === model.api;
|
||||||
|
let textIndex = 0;
|
||||||
|
for (const block of message.content) {
|
||||||
|
if (!isJsonObject(block)) continue;
|
||||||
|
if (block.type === "thinking" && typeof block.thinkingSignature === "string") {
|
||||||
|
if (isSameProviderAndApi) {
|
||||||
|
try {
|
||||||
|
const reasoning = JSON.parse(block.thinkingSignature);
|
||||||
|
if (isJsonObject(reasoning) && reasoning.type === "reasoning") {
|
||||||
|
const item = cloneItem(reasoning);
|
||||||
|
delete item.status;
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (block.type === "text" && typeof block.text === "string") {
|
||||||
|
const signature = isSameProviderAndApi ? textSignature(block.textSignature) : {};
|
||||||
|
const fallbackId = textIndex === 0 ? `msg_pi_${messageIndex}` : `msg_pi_${messageIndex}_${textIndex}`;
|
||||||
|
textIndex++;
|
||||||
|
const rawId = signature.id || fallbackId;
|
||||||
|
const id = rawId.length <= 64 ? rawId : `msg_${shortHash(rawId)}`;
|
||||||
|
items.push({
|
||||||
|
type: "message",
|
||||||
|
role: "assistant",
|
||||||
|
id,
|
||||||
|
content: [{ type: "output_text", text: block.text, annotations: [] }],
|
||||||
|
...(signature.phase ? { phase: signature.phase } : {}),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (block.type === "toolCall" && typeof block.id === "string") {
|
||||||
|
const [callId, rawItemId] = block.id.split("|");
|
||||||
|
const itemId = isSameProviderAndApi ? normalizedItemId(rawItemId) : undefined;
|
||||||
|
pendingToolCalls.set(block.id, callId);
|
||||||
|
items.push({
|
||||||
|
type: "function_call",
|
||||||
|
call_id: callId,
|
||||||
|
...(itemId ? { id: itemId } : {}),
|
||||||
|
name: String(block.name ?? ""),
|
||||||
|
arguments: JSON.stringify(block.arguments ?? {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (message.role === "toolResult" && typeof message.toolCallId === "string") {
|
||||||
|
const [callId] = message.toolCallId.split("|");
|
||||||
|
pendingToolCalls.delete(message.toolCallId);
|
||||||
|
items.push({ type: "function_call_output", call_id: callId, output: toolResultOutput(message, model) });
|
||||||
|
|
||||||
|
const addedTools = Array.isArray(message.addedToolNames)
|
||||||
|
? message.addedToolNames.flatMap((name) => typeof name === "string" && toolsByName.has(name) ? [toolsByName.get(name)!] : [])
|
||||||
|
: [];
|
||||||
|
if (addedTools.length > 0) {
|
||||||
|
const searchCallId = `pi_tool_load_${shortHash(`${message.toolCallId}:${addedTools.map((tool) => tool.name).join(",")}`)}`;
|
||||||
|
items.push({
|
||||||
|
type: "tool_search_call",
|
||||||
|
call_id: searchCallId,
|
||||||
|
execution: "client",
|
||||||
|
status: "completed",
|
||||||
|
arguments: { query: addedTools.map((tool) => tool.name).join(" "), limit: addedTools.length },
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
type: "tool_search_output",
|
||||||
|
call_id: searchCallId,
|
||||||
|
execution: "client",
|
||||||
|
status: "completed",
|
||||||
|
tools: addedTools.map((tool) => responseTool(tool, true)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messageIndex++;
|
||||||
|
}
|
||||||
|
flushOrphanedToolCalls();
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function responseItemText(item: ResponseItem): string {
|
||||||
|
if (item.type !== "message" && item.type !== undefined) return "";
|
||||||
|
if (typeof item.content === "string") return item.content;
|
||||||
|
if (!Array.isArray(item.content)) return "";
|
||||||
|
return item.content
|
||||||
|
.flatMap((part) =>
|
||||||
|
isJsonObject(part) && typeof part.text === "string" ? [part.text] : [],
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function approximateTokens(item: ResponseItem): number {
|
||||||
|
return Math.max(1, Math.ceil(responseItemText(item).length / 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateMiddle(text: string, maxCharacters: number): string {
|
||||||
|
if (text.length <= maxCharacters) return text;
|
||||||
|
if (maxCharacters <= 1) return text.slice(-maxCharacters);
|
||||||
|
const marker = "…";
|
||||||
|
const available = Math.max(0, maxCharacters - marker.length);
|
||||||
|
const head = Math.ceil(available / 2);
|
||||||
|
const tail = Math.floor(available / 2);
|
||||||
|
return `${text.slice(0, head)}${marker}${tail > 0 ? text.slice(-tail) : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateMessage(item: ResponseItem, maxTokens: number): ResponseItem | undefined {
|
||||||
|
if ((item.type !== "message" && item.type !== undefined) || maxTokens <= 0) return undefined;
|
||||||
|
const copy = cloneItem(item);
|
||||||
|
let remainingCharacters = maxTokens * 4;
|
||||||
|
|
||||||
|
if (typeof copy.content === "string") {
|
||||||
|
copy.content = truncateMiddle(copy.content, remainingCharacters);
|
||||||
|
return copy.content ? copy : undefined;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(copy.content)) return copy;
|
||||||
|
|
||||||
|
const content = copy.content;
|
||||||
|
const textParts = content.filter((part) => isJsonObject(part) && typeof part.text === "string");
|
||||||
|
const totalText = textParts.reduce((sum, part) => sum + String(part.text).length, 0);
|
||||||
|
let consumed = 0;
|
||||||
|
const truncatedContent = content.flatMap((part) => {
|
||||||
|
if (!isJsonObject(part) || typeof part.text !== "string") return [part];
|
||||||
|
const remainingText = totalText - consumed;
|
||||||
|
const partBudget = remainingText === 0 ? 0 : Math.floor((part.text.length / remainingText) * remainingCharacters);
|
||||||
|
const text = truncateMiddle(part.text, partBudget);
|
||||||
|
consumed += part.text.length;
|
||||||
|
remainingCharacters -= partBudget;
|
||||||
|
return text ? [{ ...part, text }] : [];
|
||||||
|
});
|
||||||
|
copy.content = truncatedContent;
|
||||||
|
return truncatedContent.length > 0 ? copy : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retainRecentUserMessages(items: ResponseItem[], maxTokens = RETAINED_USER_TOKEN_BUDGET): ResponseItem[] {
|
||||||
|
let remaining = maxTokens;
|
||||||
|
const retained: ResponseItem[] = [];
|
||||||
|
for (const item of [...items].reverse()) {
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
if ((item.type !== "message" && item.type !== undefined) || item.role !== "user" || !responseItemText(item).trim()) continue;
|
||||||
|
const tokens = approximateTokens(item);
|
||||||
|
if (tokens <= remaining) {
|
||||||
|
retained.push(cloneItem(item));
|
||||||
|
remaining -= tokens;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const truncated = truncateMessage(item, remaining);
|
||||||
|
if (truncated) retained.push(truncated);
|
||||||
|
remaining = 0;
|
||||||
|
}
|
||||||
|
return retained.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReplacementHistory(
|
||||||
|
preCompactionInput: ResponseItem[],
|
||||||
|
compactionItem: ResponseItem,
|
||||||
|
maxRetainedTokens = RETAINED_USER_TOKEN_BUDGET,
|
||||||
|
): ResponseItem[] {
|
||||||
|
if (compactionItem.type !== "compaction" || typeof compactionItem.encrypted_content !== "string") {
|
||||||
|
throw new Error("OpenAI Codex did not return a valid compaction item.");
|
||||||
|
}
|
||||||
|
return [...retainRecentUserMessages(preCompactionInput, maxRetainedTokens), cloneItem(compactionItem)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildToolPayload(allTools: ToolInfo[], activeToolNames: string[]): unknown[] | undefined {
|
||||||
|
const active = new Set(activeToolNames);
|
||||||
|
const tools = allTools.filter((tool) => active.has(tool.name));
|
||||||
|
return tools.length > 0 ? tools.map((tool) => responseTool(tool)) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCompactionRequestBody(params: {
|
||||||
|
basePayload?: JsonObject;
|
||||||
|
model: Model<any>;
|
||||||
|
input: ResponseItem[];
|
||||||
|
instructions: string;
|
||||||
|
tools?: unknown[];
|
||||||
|
sessionId: string;
|
||||||
|
}): JsonObject {
|
||||||
|
const base = params.basePayload ? cloneItem(params.basePayload) : {};
|
||||||
|
const previousText = isJsonObject(base.text) ? base.text : undefined;
|
||||||
|
const include = Array.isArray(base.include)
|
||||||
|
? [...new Set([...base.include.filter((value): value is string => typeof value === "string"), "reasoning.encrypted_content"])]
|
||||||
|
: ["reasoning.encrypted_content"];
|
||||||
|
|
||||||
|
const body: JsonObject = {
|
||||||
|
...base,
|
||||||
|
model: params.model.id,
|
||||||
|
store: false,
|
||||||
|
stream: true,
|
||||||
|
instructions: params.instructions,
|
||||||
|
input: [...params.input.map(cloneItem), { type: "compaction_trigger" }],
|
||||||
|
tool_choice: "auto",
|
||||||
|
parallel_tool_calls: true,
|
||||||
|
include,
|
||||||
|
prompt_cache_key: params.sessionId,
|
||||||
|
text: previousText && typeof previousText.verbosity === "string"
|
||||||
|
? { verbosity: previousText.verbosity }
|
||||||
|
: { verbosity: "low" },
|
||||||
|
};
|
||||||
|
if (params.tools) body.tools = params.tools;
|
||||||
|
else delete body.tools;
|
||||||
|
delete body.messages;
|
||||||
|
delete body.previous_response_id;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCodexResponsesUrl(baseUrl?: string): string {
|
||||||
|
const normalized = (baseUrl?.trim() || DEFAULT_CODEX_BASE_URL).replace(/\/+$/, "");
|
||||||
|
if (normalized.endsWith("/codex/responses")) return normalized;
|
||||||
|
if (normalized.endsWith("/codex")) return `${normalized}/responses`;
|
||||||
|
return `${normalized}/codex/responses`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractCodexAccountId(token: string): string {
|
||||||
|
try {
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) throw new Error("Invalid token");
|
||||||
|
const payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8")) as JsonObject;
|
||||||
|
const auth = payload["https://api.openai.com/auth"];
|
||||||
|
if (!isJsonObject(auth) || typeof auth.chatgpt_account_id !== "string") throw new Error("Missing account ID");
|
||||||
|
return auth.chatgpt_account_id;
|
||||||
|
} catch {
|
||||||
|
throw new Error("Failed to extract the ChatGPT account ID from the OpenAI Codex token.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeFeatureHeader(existing: string | null | undefined): string {
|
||||||
|
const features = (existing ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return [...new Set([...features, REMOTE_COMPACTION_FEATURE])].join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCodexHeaders(params: {
|
||||||
|
apiKey: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
sessionId: string;
|
||||||
|
}): Headers {
|
||||||
|
const headers = new Headers(params.headers);
|
||||||
|
headers.set("authorization", `Bearer ${params.apiKey}`);
|
||||||
|
headers.set("chatgpt-account-id", extractCodexAccountId(params.apiKey));
|
||||||
|
headers.set("originator", "pi");
|
||||||
|
headers.set("user-agent", "pi-compaction");
|
||||||
|
headers.set("OpenAI-Beta", "responses=experimental");
|
||||||
|
headers.set("accept", "text/event-stream");
|
||||||
|
headers.set("content-type", "application/json");
|
||||||
|
headers.set("session-id", params.sessionId);
|
||||||
|
headers.set("x-client-request-id", params.sessionId);
|
||||||
|
headers.set("x-codex-beta-features", mergeFeatureHeader(headers.get("x-codex-beta-features")));
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRetryDelay(response: Response): number | undefined {
|
||||||
|
const milliseconds = Number(response.headers.get("retry-after-ms"));
|
||||||
|
if (Number.isFinite(milliseconds) && milliseconds >= 0) return milliseconds;
|
||||||
|
const retryAfter = response.headers.get("retry-after");
|
||||||
|
if (!retryAfter) return undefined;
|
||||||
|
const seconds = Number(retryAfter);
|
||||||
|
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
||||||
|
const date = Date.parse(retryAfter);
|
||||||
|
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableStatus(status: number): boolean {
|
||||||
|
return status === 408 || status === 409 || status === 429 || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
class NonRetryableCompactionError extends Error {}
|
||||||
|
class RetryableCompactionStreamError extends Error {}
|
||||||
|
|
||||||
|
async function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
if (ms <= 0) return;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
cleanup();
|
||||||
|
reject(signal?.reason instanceof Error ? signal.reason : new Error("Compaction aborted"));
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseSseResponse(response: Response): Promise<{ item: ResponseItem; usage?: unknown }> {
|
||||||
|
if (!response.body) throw new Error("OpenAI Codex returned an empty compaction stream.");
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let completed = false;
|
||||||
|
let usage: unknown;
|
||||||
|
const compactionItems: ResponseItem[] = [];
|
||||||
|
|
||||||
|
const processBlock = (block: string) => {
|
||||||
|
const data = block
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.startsWith("data:"))
|
||||||
|
.map((line) => line.slice(5).trimStart())
|
||||||
|
.join("\n")
|
||||||
|
.trim();
|
||||||
|
if (!data || data === "[DONE]") return;
|
||||||
|
let event: unknown;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
throw new NonRetryableCompactionError("OpenAI Codex returned malformed compaction SSE data.");
|
||||||
|
}
|
||||||
|
if (!isJsonObject(event)) return;
|
||||||
|
if (event.type === "error") {
|
||||||
|
if (typeof event.message !== "string" || !event.message.trim()) {
|
||||||
|
throw new RetryableCompactionStreamError("OpenAI Codex compaction failed.");
|
||||||
|
}
|
||||||
|
throw new NonRetryableCompactionError(event.message);
|
||||||
|
}
|
||||||
|
if (event.type === "response.failed") {
|
||||||
|
throw new NonRetryableCompactionError("OpenAI Codex compaction ended with response.failed.");
|
||||||
|
}
|
||||||
|
if (event.type === "response.incomplete") {
|
||||||
|
throw new RetryableCompactionStreamError("OpenAI Codex compaction ended with response.incomplete.");
|
||||||
|
}
|
||||||
|
if (event.type === "response.output_item.done" && isResponseItem(event.item) && event.item.type === "compaction") {
|
||||||
|
compactionItems.push(event.item);
|
||||||
|
}
|
||||||
|
if (event.type === "response.completed" || event.type === "response.done") {
|
||||||
|
completed = true;
|
||||||
|
usage = isJsonObject(event.response) ? event.response.usage : undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
buffer += decoder.decode(value, { stream: !done });
|
||||||
|
buffer = buffer.replace(/\r\n/g, "\n");
|
||||||
|
let boundary = buffer.indexOf("\n\n");
|
||||||
|
while (boundary >= 0) {
|
||||||
|
processBlock(buffer.slice(0, boundary));
|
||||||
|
buffer = buffer.slice(boundary + 2);
|
||||||
|
boundary = buffer.indexOf("\n\n");
|
||||||
|
}
|
||||||
|
if (done) break;
|
||||||
|
}
|
||||||
|
if (buffer.trim()) processBlock(buffer);
|
||||||
|
if (!completed) {
|
||||||
|
throw new RetryableCompactionStreamError(
|
||||||
|
"OpenAI Codex compaction stream closed before response.completed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (compactionItems.length !== 1) {
|
||||||
|
throw new NonRetryableCompactionError(
|
||||||
|
`OpenAI Codex returned ${compactionItems.length} compaction items; expected exactly one.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const item = compactionItems[0]!;
|
||||||
|
if (typeof item.encrypted_content !== "string") {
|
||||||
|
throw new NonRetryableCompactionError(
|
||||||
|
"OpenAI Codex returned a compaction item without encrypted_content.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { item, usage };
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageFromResponse(value: unknown): Usage | undefined {
|
||||||
|
if (!isJsonObject(value)) return undefined;
|
||||||
|
const inputTokens = typeof value.input_tokens === "number" ? value.input_tokens : 0;
|
||||||
|
const outputTokens = typeof value.output_tokens === "number" ? value.output_tokens : 0;
|
||||||
|
const details = isJsonObject(value.input_tokens_details) ? value.input_tokens_details : undefined;
|
||||||
|
const cacheRead = typeof details?.cached_tokens === "number" ? details.cached_tokens : 0;
|
||||||
|
const cacheWrite = typeof details?.cache_write_tokens === "number" ? details.cache_write_tokens : 0;
|
||||||
|
const usage: Usage = {
|
||||||
|
input: Math.max(0, inputTokens - cacheRead - cacheWrite),
|
||||||
|
output: outputTokens,
|
||||||
|
cacheRead,
|
||||||
|
cacheWrite,
|
||||||
|
totalTokens: typeof value.total_tokens === "number" ? value.total_tokens : inputTokens + outputTokens,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
};
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callRemoteCompaction(params: {
|
||||||
|
url: string;
|
||||||
|
headers: Headers;
|
||||||
|
body: JsonObject;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
}): Promise<RemoteCompactionResult> {
|
||||||
|
const fetchImpl = params.fetchImpl ?? fetch;
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt <= MAX_REMOTE_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(params.url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: params.headers,
|
||||||
|
body: JSON.stringify(params.body),
|
||||||
|
signal: params.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => "");
|
||||||
|
const message = `OpenAI Codex compaction failed (${response.status}): ${body || response.statusText}`;
|
||||||
|
if (!isRetryableStatus(response.status)) throw new NonRetryableCompactionError(message);
|
||||||
|
const error = new Error(message);
|
||||||
|
if (attempt === MAX_REMOTE_RETRIES) throw error;
|
||||||
|
lastError = error;
|
||||||
|
await delay(parseRetryDelay(response) ?? 1000 * 2 ** attempt, params.signal);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = await parseSseResponse(response);
|
||||||
|
return { compactionItem: parsed.item, usage: usageFromResponse(parsed.usage) };
|
||||||
|
} catch (error) {
|
||||||
|
if (params.signal?.aborted || error instanceof NonRetryableCompactionError) throw error;
|
||||||
|
lastError = error;
|
||||||
|
if (attempt === MAX_REMOTE_RETRIES) throw error;
|
||||||
|
await delay(1000 * 2 ** attempt, params.signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error("OpenAI Codex compaction failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripInputFromPayload(payload: JsonObject): JsonObject {
|
||||||
|
const shape = cloneItem(payload);
|
||||||
|
delete shape.input;
|
||||||
|
delete shape.messages;
|
||||||
|
delete shape.previous_response_id;
|
||||||
|
return shape;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
buildCodexHeaders,
|
||||||
|
isJsonObject,
|
||||||
|
isOpenAICodexModel,
|
||||||
|
mergeFeatureHeader,
|
||||||
|
resolveCodexResponsesUrl,
|
||||||
|
type Model,
|
||||||
|
} from "./native-compaction.js";
|
||||||
|
|
||||||
|
export function isNativeCompactionModel(model: unknown): model is Model<any> {
|
||||||
|
return isOpenAICodexModel(model) || (
|
||||||
|
isJsonObject(model) && model.provider === "sub-api" && model.api === "openai-responses"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNativeCompactionTransport(params: {
|
||||||
|
model: Pick<Model<any>, "provider" | "api" | "baseUrl">;
|
||||||
|
apiKey?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
sessionId: string;
|
||||||
|
}): { url: string; headers: Headers } {
|
||||||
|
if (isOpenAICodexModel(params.model)) {
|
||||||
|
if (!params.apiKey) throw new Error("Codex native compaction authentication requires an API key.");
|
||||||
|
return {
|
||||||
|
url: resolveCodexResponsesUrl(params.model.baseUrl),
|
||||||
|
headers: buildCodexHeaders({ ...params, apiKey: params.apiKey }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!isNativeCompactionModel(params.model)) {
|
||||||
|
throw new Error("Native compaction is not supported for this provider and API.");
|
||||||
|
}
|
||||||
|
if (!params.model.baseUrl?.trim()) {
|
||||||
|
throw new Error("SubAPI native compaction requires an explicit baseUrl.");
|
||||||
|
}
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(params.model.baseUrl.trim());
|
||||||
|
} catch {
|
||||||
|
throw new Error("SubAPI native compaction requires a valid HTTP(S) baseUrl.");
|
||||||
|
}
|
||||||
|
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.hash) {
|
||||||
|
throw new Error("SubAPI native compaction requires an HTTP(S) baseUrl without credentials or a fragment.");
|
||||||
|
}
|
||||||
|
const path = url.pathname.replace(/\/+$/, "");
|
||||||
|
url.pathname = path.endsWith("/responses") ? path : `${path}/responses`;
|
||||||
|
|
||||||
|
// Sub2API accepts native compaction v2 through its public Responses endpoint.
|
||||||
|
// The gateway owns upstream Codex credentials; honor the client's resolved authentication.
|
||||||
|
const headers = new Headers(params.headers);
|
||||||
|
if (!headers.get("authorization")?.trim()) {
|
||||||
|
if (params.apiKey?.trim()) {
|
||||||
|
headers.set("authorization", `Bearer ${params.apiKey}`);
|
||||||
|
} else if (!headers.get("cf-aig-authorization")?.trim()) {
|
||||||
|
throw new Error("SubAPI native compaction authentication requires an API key or a non-empty Authorization or cf-aig-authorization header.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
headers.set("content-type", "application/json");
|
||||||
|
headers.set("accept", "text/event-stream");
|
||||||
|
headers.set("OpenAI-Beta", "responses=experimental");
|
||||||
|
headers.set("originator", "pi");
|
||||||
|
headers.set("user-agent", "pi-compaction");
|
||||||
|
headers.set("session-id", params.sessionId);
|
||||||
|
headers.set("x-client-request-id", params.sessionId);
|
||||||
|
headers.set("x-codex-beta-features", mergeFeatureHeader(headers.get("x-codex-beta-features")));
|
||||||
|
return { url: url.toString(), headers };
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { estimateTokens } from "@earendil-works/pi-coding-agent";
|
||||||
|
import type { ProjectionSnapshot, UsageBaseline } from "./types.js";
|
||||||
|
|
||||||
|
export function timestampOf(value: unknown): number {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contentText(content: unknown): string {
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (!Array.isArray(content)) return "";
|
||||||
|
return content
|
||||||
|
.flatMap((part) => part && typeof part === "object" && (part as any).type === "text" && typeof (part as any).text === "string"
|
||||||
|
? [(part as any).text]
|
||||||
|
: [])
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hash(value: string): string {
|
||||||
|
return createHash("sha256").update(value).digest("hex").slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageKey(message: any): string {
|
||||||
|
const role = String(message?.role ?? "unknown");
|
||||||
|
const timestamp = timestampOf(message?.timestamp);
|
||||||
|
if (role === "toolResult") return `${role}:${message.toolCallId ?? ""}:${timestamp}`;
|
||||||
|
if (role === "assistant") {
|
||||||
|
const blocks = Array.isArray(message.content) ? message.content : [];
|
||||||
|
const ids = blocks.flatMap((block: any) => block?.type === "toolCall" ? [block.id] : []).join(",");
|
||||||
|
return `${role}:${timestamp}:${ids}:${blocks.length}`;
|
||||||
|
}
|
||||||
|
return `${role}:${timestamp}:${typeof message?.content === "string" ? message.content.length : (message?.content?.length ?? 0)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incremental context projection: tracks the branch's message list, estimates
|
||||||
|
* tokens, and maintains the usage-backed effective-token baseline. Projection
|
||||||
|
* is identity — pi-compaction never rewrites messages on this path; context
|
||||||
|
* pressure reduction happens through append-only context_edit entries.
|
||||||
|
*/
|
||||||
|
export class SegmentIndex {
|
||||||
|
private generation = 0;
|
||||||
|
private usageBaseline?: UsageBaseline;
|
||||||
|
private sourceMessages: any[] = [];
|
||||||
|
private sourceKeys: string[] = [];
|
||||||
|
private projectedMessages: any[] = [];
|
||||||
|
private rawEstimates: number[] = [];
|
||||||
|
private effectiveEstimates: number[] = [];
|
||||||
|
private cached?: ProjectionSnapshot;
|
||||||
|
|
||||||
|
readonly sessionId: string;
|
||||||
|
|
||||||
|
constructor(sessionId: string) {
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrate(): void {
|
||||||
|
this.reset();
|
||||||
|
this.generation++;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.generation = 0;
|
||||||
|
this.usageBaseline = undefined;
|
||||||
|
this.sourceMessages = [];
|
||||||
|
this.sourceKeys = [];
|
||||||
|
this.projectedMessages = [];
|
||||||
|
this.rawEstimates = [];
|
||||||
|
this.effectiveEstimates = [];
|
||||||
|
this.cached = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordUsage(tokens: number, messageEstimate: number, environmentHash: string): void {
|
||||||
|
if (!Number.isFinite(tokens) || tokens <= 0) return;
|
||||||
|
this.usageBaseline = { tokens, messageEstimate, environmentHash };
|
||||||
|
this.cached = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
project(messages: readonly any[], params: {
|
||||||
|
environmentHash: string;
|
||||||
|
environmentTokens: number;
|
||||||
|
contextWindow: number;
|
||||||
|
maskedResults: number;
|
||||||
|
reclaimedTokens: number;
|
||||||
|
}): ProjectionSnapshot {
|
||||||
|
const sameTail = messages.length === this.sourceMessages.length
|
||||||
|
&& messages.length > 0
|
||||||
|
&& messageKey(messages[messages.length - 1]) === this.sourceKeys[this.sourceKeys.length - 1];
|
||||||
|
if (this.cached && sameTail && this.cached.environmentHash === params.environmentHash) {
|
||||||
|
if (this.cached.maskedResults === params.maskedResults
|
||||||
|
&& this.cached.reclaimedTokens === params.reclaimedTokens) {
|
||||||
|
return this.cached;
|
||||||
|
}
|
||||||
|
// Masking stats changed but the message list did not: publish a new
|
||||||
|
// immutable snapshot object instead of re-projecting or mutating.
|
||||||
|
this.cached = {
|
||||||
|
...this.cached,
|
||||||
|
maskedResults: params.maskedResults,
|
||||||
|
reclaimedTokens: params.reclaimedTokens,
|
||||||
|
rawTokens: this.cached.effectiveTokens + params.reclaimedTokens,
|
||||||
|
};
|
||||||
|
return this.cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendOnly = this.sourceMessages.length > 0
|
||||||
|
&& messages.length >= this.sourceMessages.length
|
||||||
|
&& messageKey(messages[this.sourceMessages.length - 1]) === this.sourceKeys[this.sourceKeys.length - 1]
|
||||||
|
&& this.cached?.environmentHash === params.environmentHash;
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
if (!appendOnly) {
|
||||||
|
this.sourceMessages = [];
|
||||||
|
this.sourceKeys = [];
|
||||||
|
this.projectedMessages = [];
|
||||||
|
this.rawEstimates = [];
|
||||||
|
this.effectiveEstimates = [];
|
||||||
|
} else {
|
||||||
|
start = this.sourceMessages.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = start; index < messages.length; index++) {
|
||||||
|
const message = messages[index];
|
||||||
|
this.sourceMessages.push(message);
|
||||||
|
this.sourceKeys.push(messageKey(message));
|
||||||
|
this.projectedMessages.push(message);
|
||||||
|
this.rawEstimates.push(estimateTokens(message as any));
|
||||||
|
this.effectiveEstimates.push(estimateTokens(message as any));
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveMessageTokens = this.effectiveEstimates.reduce((sum, value) => sum + value, 0);
|
||||||
|
const baseline = this.usageBaseline;
|
||||||
|
const usageBacked = baseline?.environmentHash === params.environmentHash;
|
||||||
|
const effectiveTokens = usageBacked
|
||||||
|
? Math.max(0, baseline.tokens + effectiveMessageTokens - baseline.messageEstimate)
|
||||||
|
: effectiveMessageTokens + params.environmentTokens;
|
||||||
|
const confidence = usageBacked ? "usage-backed" as const : "estimated" as const;
|
||||||
|
const lowerBound = Math.max(0, Math.floor(effectiveTokens * (usageBacked ? 0.98 : 0.9)));
|
||||||
|
const upperBound = Math.ceil(effectiveTokens * (usageBacked ? 1.05 : 1.2));
|
||||||
|
const fingerprint = hash(`${this.sessionId}:${this.generation}:${params.environmentHash}:${this.sourceKeys.join("|")}`);
|
||||||
|
|
||||||
|
this.cached = {
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
generation: this.generation,
|
||||||
|
fingerprint,
|
||||||
|
environmentHash: params.environmentHash,
|
||||||
|
messages: this.projectedMessages,
|
||||||
|
messageEstimate: effectiveMessageTokens,
|
||||||
|
rawTokens: effectiveTokens + params.reclaimedTokens,
|
||||||
|
effectiveTokens,
|
||||||
|
reclaimedTokens: params.reclaimedTokens,
|
||||||
|
contextWindow: params.contextWindow,
|
||||||
|
confidence,
|
||||||
|
lowerBound,
|
||||||
|
upperBound,
|
||||||
|
segmentCount: this.sourceMessages.length,
|
||||||
|
maskedResults: params.maskedResults,
|
||||||
|
};
|
||||||
|
return this.cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(): ProjectionSnapshot | undefined {
|
||||||
|
return this.cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
SERVICE_SYMBOL,
|
||||||
|
type CompactionMetrics,
|
||||||
|
type CompactionService,
|
||||||
|
type MaskedResultRecord,
|
||||||
|
type ProjectionSnapshot,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
interface GlobalWithCompactionService {
|
||||||
|
[SERVICE_SYMBOL]?: CompactionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = globalThis as GlobalWithCompactionService;
|
||||||
|
|
||||||
|
export class CompactionServiceRegistry implements CompactionService {
|
||||||
|
private readonly snapshots = new Map<string, ProjectionSnapshot>();
|
||||||
|
private readonly queryHandlers = new Map<string, (ids: string[]) => MaskedResultRecord[]>();
|
||||||
|
|
||||||
|
setSession(
|
||||||
|
sessionId: string,
|
||||||
|
query: (ids: string[]) => MaskedResultRecord[],
|
||||||
|
): void {
|
||||||
|
this.queryHandlers.set(sessionId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnapshot(snapshot: ProjectionSnapshot): void {
|
||||||
|
this.snapshots.set(snapshot.sessionId, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSnapshot(sessionId: string): void {
|
||||||
|
this.snapshots.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSession(sessionId: string): void {
|
||||||
|
this.snapshots.delete(sessionId);
|
||||||
|
this.queryHandlers.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.snapshots.clear();
|
||||||
|
this.queryHandlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(sessionId: string): ProjectionSnapshot | undefined {
|
||||||
|
return this.snapshots.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetrics(sessionId: string): CompactionMetrics | undefined {
|
||||||
|
const snapshot = this.snapshots.get(sessionId);
|
||||||
|
if (!snapshot) return undefined;
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
generation: snapshot.generation,
|
||||||
|
rawTokens: snapshot.rawTokens,
|
||||||
|
effectiveTokens: snapshot.effectiveTokens,
|
||||||
|
reclaimedTokens: snapshot.reclaimedTokens,
|
||||||
|
contextWindow: snapshot.contextWindow,
|
||||||
|
percent: snapshot.contextWindow > 0 ? 100 * snapshot.effectiveTokens / snapshot.contextWindow : 0,
|
||||||
|
confidence: snapshot.confidence,
|
||||||
|
segmentCount: snapshot.segmentCount,
|
||||||
|
maskedResults: snapshot.maskedResults,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
query(sessionId: string, ids: string[]): MaskedResultRecord[] {
|
||||||
|
return this.queryHandlers.get(sessionId)?.(ids) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishCompactionService(service: CompactionService): void {
|
||||||
|
root[SERVICE_SYMBOL] = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unpublishCompactionService(service: CompactionService): void {
|
||||||
|
if (root[SERVICE_SYMBOL] === service) delete root[SERVICE_SYMBOL];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCompactionService(): CompactionService | undefined {
|
||||||
|
return root[SERVICE_SYMBOL];
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Effective trigger for one configured (tokens, ratio) pair: the absolute
|
||||||
|
// token figure wins on large windows, the ratio caps it on small ones.
|
||||||
|
export function effectiveThreshold(tokens: number, ratio: number, contextWindow: number): number {
|
||||||
|
return contextWindow > 0 ? Math.min(tokens, Math.floor(ratio * contextWindow)) : tokens;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { ResponseItem } from "./native-compaction.js";
|
||||||
|
|
||||||
|
export const COMPACTION_CUSTOM_TYPE = "pi-compaction";
|
||||||
|
export const COMPACTION_VERSION = 1 as const;
|
||||||
|
export const QUERY_TOOL_NAME = "context_tree_query";
|
||||||
|
export const SERVICE_SYMBOL = Symbol.for("my-pi.pi-compaction.service.v1");
|
||||||
|
|
||||||
|
export interface MaskingConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
triggerTokens: number;
|
||||||
|
triggerRatio: number;
|
||||||
|
targetTokens: number;
|
||||||
|
targetRatio: number;
|
||||||
|
minClearTokens: number;
|
||||||
|
keepRecentTurns: number;
|
||||||
|
minEntryTokens: number;
|
||||||
|
excludeTools: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SummaryConfig {
|
||||||
|
triggerTokens: number;
|
||||||
|
triggerRatio: number;
|
||||||
|
thinking: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PiCompactionConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
masking: MaskingConfig;
|
||||||
|
summary: SummaryConfig;
|
||||||
|
native: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageBaseline {
|
||||||
|
tokens: number;
|
||||||
|
messageEstimate: number;
|
||||||
|
environmentHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectionSnapshot {
|
||||||
|
sessionId: string;
|
||||||
|
generation: number;
|
||||||
|
fingerprint: string;
|
||||||
|
environmentHash: string;
|
||||||
|
messages: any[];
|
||||||
|
messageEstimate: number;
|
||||||
|
rawTokens: number;
|
||||||
|
effectiveTokens: number;
|
||||||
|
reclaimedTokens: number;
|
||||||
|
contextWindow: number;
|
||||||
|
confidence: "usage-backed" | "estimated";
|
||||||
|
lowerBound: number;
|
||||||
|
upperBound: number;
|
||||||
|
segmentCount: number;
|
||||||
|
maskedResults: number;
|
||||||
|
codexItems?: ResponseItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NativeCheckpointDetails {
|
||||||
|
kind: typeof COMPACTION_CUSTOM_TYPE;
|
||||||
|
version: typeof COMPACTION_VERSION;
|
||||||
|
checkpoint: "native";
|
||||||
|
modelKey: string;
|
||||||
|
replacementHistory: ResponseItem[];
|
||||||
|
portableSummary: string;
|
||||||
|
sourceFingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortableCheckpointDetails {
|
||||||
|
kind: typeof COMPACTION_CUSTOM_TYPE;
|
||||||
|
version: typeof COMPACTION_VERSION;
|
||||||
|
checkpoint: "portable";
|
||||||
|
sourceFingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the service returns for one masked tool result — the same placeholder the model sees. */
|
||||||
|
export interface MaskedResultRecord {
|
||||||
|
toolCallId: string;
|
||||||
|
resultTimestamp: number;
|
||||||
|
deterministicSummary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompactionMetrics {
|
||||||
|
sessionId: string;
|
||||||
|
generation: number;
|
||||||
|
rawTokens: number;
|
||||||
|
effectiveTokens: number;
|
||||||
|
reclaimedTokens: number;
|
||||||
|
contextWindow: number;
|
||||||
|
percent: number;
|
||||||
|
confidence: ProjectionSnapshot["confidence"];
|
||||||
|
segmentCount: number;
|
||||||
|
maskedResults: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompactionService {
|
||||||
|
getSnapshot(sessionId: string): ProjectionSnapshot | undefined;
|
||||||
|
getMetrics(sessionId: string): CompactionMetrics | undefined;
|
||||||
|
query(sessionId: string, ids: string[]): MaskedResultRecord[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { DEFAULT_CONFIG, normalizeConfig } from "../src/config.ts";
|
||||||
|
|
||||||
|
test("normalizes a full pi-compaction config", () => {
|
||||||
|
const config = normalizeConfig({
|
||||||
|
enabled: false,
|
||||||
|
masking: {
|
||||||
|
enabled: false,
|
||||||
|
triggerTokens: 150_000,
|
||||||
|
triggerRatio: 0.5,
|
||||||
|
targetTokens: 60_000,
|
||||||
|
targetRatio: 0.2,
|
||||||
|
minClearTokens: 30_000,
|
||||||
|
keepRecentTurns: 5,
|
||||||
|
minEntryTokens: 512,
|
||||||
|
excludeTools: ["bash", "read"],
|
||||||
|
},
|
||||||
|
summary: { triggerTokens: 300_000, triggerRatio: 0.8, thinking: "medium" },
|
||||||
|
native: { enabled: false },
|
||||||
|
});
|
||||||
|
assert.equal(config.enabled, false);
|
||||||
|
assert.deepEqual(config.masking, {
|
||||||
|
enabled: false,
|
||||||
|
triggerTokens: 150_000,
|
||||||
|
triggerRatio: 0.5,
|
||||||
|
targetTokens: 60_000,
|
||||||
|
targetRatio: 0.2,
|
||||||
|
minClearTokens: 30_000,
|
||||||
|
keepRecentTurns: 5,
|
||||||
|
minEntryTokens: 512,
|
||||||
|
excludeTools: ["bash", "read"],
|
||||||
|
});
|
||||||
|
assert.equal(config.summary.triggerTokens, 300_000);
|
||||||
|
assert.equal(config.summary.triggerRatio, 0.8);
|
||||||
|
assert.equal(config.summary.thinking, "medium");
|
||||||
|
assert.equal(config.native.enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid fields fail safe to bounded defaults", () => {
|
||||||
|
const config = normalizeConfig({
|
||||||
|
enabled: "yes",
|
||||||
|
masking: {
|
||||||
|
triggerTokens: -1,
|
||||||
|
triggerRatio: 2,
|
||||||
|
targetTokens: "a lot",
|
||||||
|
targetRatio: 0,
|
||||||
|
minClearTokens: 0,
|
||||||
|
keepRecentTurns: 500,
|
||||||
|
minEntryTokens: -3,
|
||||||
|
excludeTools: "read",
|
||||||
|
},
|
||||||
|
summary: { triggerTokens: Number.NaN, triggerRatio: 1.5, thinking: "insane" },
|
||||||
|
native: { enabled: 1 },
|
||||||
|
});
|
||||||
|
assert.equal(config.enabled, DEFAULT_CONFIG.enabled);
|
||||||
|
assert.deepEqual(config.masking, DEFAULT_CONFIG.masking);
|
||||||
|
assert.equal(config.summary.triggerTokens, DEFAULT_CONFIG.summary.triggerTokens);
|
||||||
|
assert.equal(config.summary.triggerRatio, DEFAULT_CONFIG.summary.triggerRatio);
|
||||||
|
assert.equal(config.summary.thinking, DEFAULT_CONFIG.summary.thinking);
|
||||||
|
assert.equal(config.native.enabled, DEFAULT_CONFIG.native.enabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("masking target is corrected below trigger and summary above masking", () => {
|
||||||
|
const config = normalizeConfig({
|
||||||
|
masking: { triggerTokens: 80_000, triggerRatio: 0.5, targetTokens: 120_000, targetRatio: 0.9 },
|
||||||
|
summary: { triggerTokens: 50_000, triggerRatio: 0.4 },
|
||||||
|
});
|
||||||
|
assert.equal(config.masking.targetTokens, 40_000);
|
||||||
|
assert.equal(config.masking.targetRatio, 0.25);
|
||||||
|
assert.equal(config.summary.triggerTokens, 160_000);
|
||||||
|
assert.ok(config.summary.triggerRatio > config.masking.triggerRatio);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("minClearTokens and excludeTools accept valid overrides", () => {
|
||||||
|
const config = normalizeConfig({
|
||||||
|
masking: { minClearTokens: 1, keepRecentTurns: 1, minEntryTokens: 0, excludeTools: [] },
|
||||||
|
});
|
||||||
|
assert.equal(config.masking.minClearTokens, 1);
|
||||||
|
assert.equal(config.masking.keepRecentTurns, 1);
|
||||||
|
assert.equal(config.masking.minEntryTokens, 0);
|
||||||
|
assert.deepEqual(config.masking.excludeTools, []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import piCompaction from "../index.ts";
|
||||||
|
import { getCompactionService } from "../src/service.ts";
|
||||||
|
import { MASK_PREFIX } from "../src/masking.ts";
|
||||||
|
|
||||||
|
function harness() {
|
||||||
|
const handlers = new Map<string, Function[]>();
|
||||||
|
const tools = new Map<string, any>();
|
||||||
|
const commands = new Map<string, any>();
|
||||||
|
const entries: Array<{ customType: string; data: unknown }> = [];
|
||||||
|
const toolDefinitions = [{ name: "bash", description: "shell", parameters: { type: "object" } }];
|
||||||
|
const pi: any = {
|
||||||
|
on(name: string, handler: Function) {
|
||||||
|
const list = handlers.get(name) ?? [];
|
||||||
|
list.push(handler);
|
||||||
|
handlers.set(name, list);
|
||||||
|
},
|
||||||
|
appendEntry(customType: string, data: unknown) { entries.push({ customType, data }); },
|
||||||
|
registerTool(tool: any) { tools.set(tool.name, tool); },
|
||||||
|
registerCommand(name: string, command: any) { commands.set(name, command); },
|
||||||
|
getActiveTools() { return ["bash"]; },
|
||||||
|
getAllTools() { return toolDefinitions; },
|
||||||
|
};
|
||||||
|
return { pi, handlers, tools, commands, entries };
|
||||||
|
}
|
||||||
|
|
||||||
|
function context(branch: any[] = [], tokens = 1_000) {
|
||||||
|
const compactCalls: any[] = [];
|
||||||
|
const sessionId = `session-${Math.random().toString(36).slice(2)}`;
|
||||||
|
const ctx = {
|
||||||
|
sessionManager: {
|
||||||
|
getSessionId: () => sessionId,
|
||||||
|
getBranch: () => branch,
|
||||||
|
getLeafId: () => "leaf-1",
|
||||||
|
},
|
||||||
|
model: { provider: "test", api: "test", id: "model", contextWindow: 100_000 },
|
||||||
|
modelRegistry: {},
|
||||||
|
getSystemPrompt: () => "system",
|
||||||
|
getContextUsage: () => ({ tokens, contextWindow: 100_000, percent: 1 }),
|
||||||
|
compact(options: unknown) { compactCalls.push(options); },
|
||||||
|
isIdle: () => true,
|
||||||
|
hasUI: false,
|
||||||
|
abort() {},
|
||||||
|
} as any;
|
||||||
|
return { ctx, compactCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectedEntry(id: string, message: any) {
|
||||||
|
return { sourceEntry: { id, type: "message", message }, messages: [message] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantMessage(id: string, calls: Array<{ id: string; name: string; args?: unknown }>) {
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content: calls.map((call) => ({ type: "toolCall", id: call.id, name: call.name, arguments: call.args ?? {} })),
|
||||||
|
timestamp: id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolResultMessage(id: string, toolCallId: string, text: string, toolName = "bash") {
|
||||||
|
return {
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId,
|
||||||
|
toolName,
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
timestamp: id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default config: masking trigger = min(200K, 60% of window); at the harness
|
||||||
|
// 100K window that is 60K tokens and the target is 35K, so 60K used needs
|
||||||
|
// ~25K tokens of maskable results outside the 10-turn protected window.
|
||||||
|
function pressurizedContextEntries() {
|
||||||
|
const entries = [
|
||||||
|
projectedEntry("a1", assistantMessage("a1", [{ id: "c1", name: "bash", args: { command: "npm test" } }])),
|
||||||
|
projectedEntry("r1", toolResultMessage("r1", "c1", `failure evidence\n${"x".repeat(120_000)}\nstack tail`)),
|
||||||
|
];
|
||||||
|
for (let index = 0; index < 10; index++) {
|
||||||
|
entries.push(projectedEntry(`keep-${index}`, assistantMessage(`keep-${index}`, [])));
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("turn_end masks old tool results under pressure and preserves prior drafts", async () => {
|
||||||
|
const h = harness();
|
||||||
|
piCompaction(h.pi);
|
||||||
|
const { ctx } = context([], 60_000);
|
||||||
|
await h.handlers.get("session_start")![0]!({}, ctx);
|
||||||
|
|
||||||
|
const prior = { type: "custom", customType: "other-extension", data: {} };
|
||||||
|
const result = await h.handlers.get("turn_end")![0]!({
|
||||||
|
entries: [prior],
|
||||||
|
message: assistantMessage("a1", []),
|
||||||
|
toolResults: [],
|
||||||
|
context: { contextEntries: pressurizedContextEntries(), contextMessages: [] },
|
||||||
|
}, ctx);
|
||||||
|
|
||||||
|
assert.ok(result?.entries);
|
||||||
|
assert.strictEqual(result.entries[0], prior);
|
||||||
|
const edits = result.entries.slice(1);
|
||||||
|
assert.equal(edits.length, 1);
|
||||||
|
assert.equal(edits[0]!.type, "context_edit");
|
||||||
|
assert.equal(edits[0]!.targetId, "r1");
|
||||||
|
assert.ok(edits[0]!.replacement.content.startsWith(MASK_PREFIX));
|
||||||
|
// Masking is append-only context_edit; nothing is written as custom entries.
|
||||||
|
assert.equal(h.entries.length, 0);
|
||||||
|
|
||||||
|
await h.handlers.get("session_shutdown")![0]!({}, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("turn_end below the masking trigger returns undefined", async () => {
|
||||||
|
const h = harness();
|
||||||
|
piCompaction(h.pi);
|
||||||
|
const { ctx } = context([], 40_000);
|
||||||
|
await h.handlers.get("session_start")![0]!({}, ctx);
|
||||||
|
|
||||||
|
const result = await h.handlers.get("turn_end")![0]!({
|
||||||
|
entries: [],
|
||||||
|
message: assistantMessage("a1", []),
|
||||||
|
toolResults: [],
|
||||||
|
context: { contextEntries: pressurizedContextEntries(), contextMessages: [] },
|
||||||
|
}, ctx);
|
||||||
|
assert.equal(result, undefined);
|
||||||
|
await h.handlers.get("session_shutdown")![0]!({}, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("context hook returns messages untouched on the non-checkpoint path", async () => {
|
||||||
|
const h = harness();
|
||||||
|
piCompaction(h.pi);
|
||||||
|
const { ctx } = context();
|
||||||
|
await h.handlers.get("session_start")![0]!({}, ctx);
|
||||||
|
|
||||||
|
const messages = [assistantMessage("a1", []), toolResultMessage("r1", "c1", "visible\nfile-a.ts")];
|
||||||
|
const projected = await h.handlers.get("context")![0]!({ messages }, ctx);
|
||||||
|
assert.equal(projected, undefined);
|
||||||
|
await h.handlers.get("session_shutdown")![0]!({}, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("agent_settled compacts once above the summary threshold and never repeats on the same leaf", async () => {
|
||||||
|
const h = harness();
|
||||||
|
piCompaction(h.pi);
|
||||||
|
const { ctx, compactCalls } = context([], 95_000);
|
||||||
|
await h.handlers.get("session_start")![0]!({}, ctx);
|
||||||
|
|
||||||
|
h.handlers.get("agent_settled")![0]!({ type: "agent_settled", aborted: false }, ctx);
|
||||||
|
assert.equal(compactCalls.length, 1);
|
||||||
|
assert.equal(typeof compactCalls[0].onError, "function");
|
||||||
|
|
||||||
|
h.handlers.get("agent_settled")![0]!({ type: "agent_settled", aborted: false }, ctx);
|
||||||
|
assert.equal(compactCalls.length, 1);
|
||||||
|
|
||||||
|
ctx.sessionManager.getLeafId = () => "leaf-2";
|
||||||
|
h.handlers.get("agent_settled")![0]!({ type: "agent_settled", aborted: false }, ctx);
|
||||||
|
assert.equal(compactCalls.length, 2);
|
||||||
|
await h.handlers.get("session_shutdown")![0]!({}, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("agent_settled stays idle below the summary threshold", async () => {
|
||||||
|
const h = harness();
|
||||||
|
piCompaction(h.pi);
|
||||||
|
const { ctx, compactCalls } = context([], 50_000);
|
||||||
|
await h.handlers.get("session_start")![0]!({}, ctx);
|
||||||
|
|
||||||
|
h.handlers.get("agent_settled")![0]!({ type: "agent_settled", aborted: false }, ctx);
|
||||||
|
assert.equal(compactCalls.length, 0);
|
||||||
|
await h.handlers.get("session_shutdown")![0]!({}, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("context_tree_query recovers the original tool output from the branch", async () => {
|
||||||
|
const h = harness();
|
||||||
|
piCompaction(h.pi);
|
||||||
|
const branch = [
|
||||||
|
{ type: "message", id: "a1", parentId: null, timestamp: "t", message: assistantMessage("a1", [{ id: "c1", name: "bash", args: { command: "npm test" } }]) },
|
||||||
|
{ type: "message", id: "r1", parentId: "a1", timestamp: "t", message: toolResultMessage("r1", "c1", "failure evidence\nstack tail") },
|
||||||
|
];
|
||||||
|
const { ctx } = context(branch);
|
||||||
|
await h.handlers.get("session_start")![0]!({}, ctx);
|
||||||
|
|
||||||
|
const query = h.tools.get("context_tree_query")!;
|
||||||
|
const recovered = await query.execute("q", { toolCallIds: ["c1"] }, new AbortController().signal, undefined, ctx);
|
||||||
|
assert.match(recovered.content[0].text, /failure evidence/);
|
||||||
|
assert.match(recovered.content[0].text, /stack tail/);
|
||||||
|
assert.deepEqual(recovered.details.missing, []);
|
||||||
|
|
||||||
|
const missing = await query.execute("q", { toolCallIds: ["nope"] }, new AbortController().signal, undefined, ctx);
|
||||||
|
assert.match(missing.content[0].text, /not found/);
|
||||||
|
await h.handlers.get("session_shutdown")![0]!({}, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("service query returns placeholders only for masked tool call ids", async () => {
|
||||||
|
const h = harness();
|
||||||
|
piCompaction(h.pi);
|
||||||
|
const { ctx } = context([], 60_000);
|
||||||
|
await h.handlers.get("session_start")![0]!({}, ctx);
|
||||||
|
const sessionId = ctx.sessionManager.getSessionId();
|
||||||
|
|
||||||
|
await h.handlers.get("turn_end")![0]!({
|
||||||
|
entries: [],
|
||||||
|
message: assistantMessage("a1", []),
|
||||||
|
toolResults: [],
|
||||||
|
context: { contextEntries: pressurizedContextEntries(), contextMessages: [] },
|
||||||
|
}, ctx);
|
||||||
|
|
||||||
|
const records = getCompactionService()!.query(sessionId, ["c1", "never-masked"]);
|
||||||
|
assert.equal(records.length, 1);
|
||||||
|
assert.equal(records[0]!.toolCallId, "c1");
|
||||||
|
assert.ok(records[0]!.deterministicSummary.startsWith(MASK_PREFIX));
|
||||||
|
assert.equal(typeof records[0]!.resultTimestamp, "number");
|
||||||
|
await h.handlers.get("session_shutdown")![0]!({}, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hydrating a branch with this extension's context_edit rebuilds masking metrics", async () => {
|
||||||
|
const h = harness();
|
||||||
|
piCompaction(h.pi);
|
||||||
|
const placeholder = `${MASK_PREFIX}bash result cleared to save context (~1_000 tokens). Call args: {}. Re-run the call if still needed, or recover the original with context_tree_query {"toolCallIds":["c1"]}.`;
|
||||||
|
const branch = [
|
||||||
|
{ type: "message", id: "a1", parentId: null, timestamp: "t", message: assistantMessage("a1", [{ id: "c1", name: "bash", args: { command: "npm test" } }]) },
|
||||||
|
{ type: "message", id: "r1", parentId: "a1", timestamp: "t", message: toolResultMessage("r1", "c1", "x".repeat(8_000)) },
|
||||||
|
{ type: "context_edit", id: "e1", parentId: "r1", timestamp: "t", targetId: "r1", replacement: { content: placeholder } },
|
||||||
|
];
|
||||||
|
const { ctx } = context(branch);
|
||||||
|
await h.handlers.get("session_start")![0]!({}, ctx);
|
||||||
|
const sessionId = ctx.sessionManager.getSessionId();
|
||||||
|
|
||||||
|
await h.handlers.get("context")![0]!({ messages: [toolResultMessage("r1", "c1", "x".repeat(8_000))] }, ctx);
|
||||||
|
const metrics = getCompactionService()!.getMetrics(sessionId);
|
||||||
|
assert.equal(metrics!.maskedResults, 1);
|
||||||
|
assert.ok(metrics!.reclaimedTokens > 0);
|
||||||
|
assert.ok(metrics!.rawTokens > metrics!.effectiveTokens);
|
||||||
|
await h.handlers.get("session_shutdown")![0]!({}, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("session_before_compact no longer cancels threshold compactions", async () => {
|
||||||
|
const h = harness();
|
||||||
|
piCompaction(h.pi);
|
||||||
|
const { ctx } = context();
|
||||||
|
await h.handlers.get("session_start")![0]!({}, ctx);
|
||||||
|
ctx.modelRegistry.getApiKeyAndHeaders = async () => ({ ok: false, error: "no credentials" });
|
||||||
|
|
||||||
|
const result = await h.handlers.get("session_before_compact")![0]!({
|
||||||
|
reason: "threshold", branchEntries: [], preparation: { firstKeptEntryId: "u1" },
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
}, ctx);
|
||||||
|
assert.equal(result, undefined);
|
||||||
|
await h.handlers.get("session_shutdown")![0]!({}, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("native checkpoint projection applies context edits made after the checkpoint", async () => {
|
||||||
|
const h = harness();
|
||||||
|
piCompaction(h.pi);
|
||||||
|
const details = {
|
||||||
|
kind: "pi-compaction",
|
||||||
|
version: 1,
|
||||||
|
checkpoint: "native",
|
||||||
|
modelKey: "test:test:model",
|
||||||
|
replacementHistory: [{ type: "compaction", encrypted_content: "opaque" }],
|
||||||
|
portableSummary: "checkpoint summary",
|
||||||
|
sourceFingerprint: "fp",
|
||||||
|
};
|
||||||
|
const branch = [
|
||||||
|
{ type: "compaction", id: "c1", parentId: null, timestamp: "t", summary: "marker", firstKeptEntryId: "r1", tokensBefore: 1_000, details },
|
||||||
|
{ type: "message", id: "r1", parentId: "c1", timestamp: "t", message: toolResultMessage("r1", "c1", "ORIGINAL-CONTENT-HERE") },
|
||||||
|
{ type: "context_edit", id: "e1", parentId: "r1", timestamp: "t", targetId: "r1", replacement: { content: `${MASK_PREFIX}bash result cleared to save context (~5 tokens). Call args: {}.` } },
|
||||||
|
{ type: "message", id: "u1", parentId: "e1", timestamp: "t", message: { role: "user", content: "next question", timestamp: 30 } },
|
||||||
|
] as any[];
|
||||||
|
const { ctx } = context(branch);
|
||||||
|
await h.handlers.get("session_start")![0]!({}, ctx);
|
||||||
|
|
||||||
|
const projected = await h.handlers.get("context")![0]!({ messages: [] }, ctx);
|
||||||
|
const serialized = JSON.stringify(projected.messages);
|
||||||
|
assert.doesNotMatch(serialized, /ORIGINAL-CONTENT-HERE/);
|
||||||
|
assert.match(serialized, /result cleared to save context/);
|
||||||
|
assert.match(serialized, /next question/);
|
||||||
|
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);
|
||||||
|
const details = {
|
||||||
|
kind: "pi-compaction",
|
||||||
|
version: 1,
|
||||||
|
checkpoint: "native",
|
||||||
|
modelKey: "openai-codex/openai-codex-responses/gpt-old",
|
||||||
|
replacementHistory: [{ type: "compaction", encrypted_content: "opaque" }],
|
||||||
|
portableSummary: "decisions and files from the old model",
|
||||||
|
sourceFingerprint: "old",
|
||||||
|
};
|
||||||
|
const branch = [
|
||||||
|
{ type: "compaction", id: "c1", parentId: null, timestamp: new Date().toISOString(), summary: "marker", firstKeptEntryId: "m0", tokensBefore: 1_000, details },
|
||||||
|
{ type: "message", id: "m1", parentId: "c1", timestamp: new Date().toISOString(), message: { role: "user", content: "continue", timestamp: 20 } },
|
||||||
|
] as any[];
|
||||||
|
const { ctx } = context(branch);
|
||||||
|
await h.handlers.get("session_start")![0]!({}, ctx);
|
||||||
|
const projected = await h.handlers.get("context")![0]!({ messages: [] }, ctx);
|
||||||
|
assert.match(projected.messages[0].content[0].text, /decisions and files/);
|
||||||
|
assert.equal(projected.messages[1].content, "continue");
|
||||||
|
await h.handlers.get("session_shutdown")![0]!({}, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const provider of ["sub-api", "openai-codex"]) {
|
||||||
|
test(`${provider} native compaction uses the selected model and restores its checkpoint`, async (t) => {
|
||||||
|
const h = harness();
|
||||||
|
piCompaction(h.pi);
|
||||||
|
const branch: any[] = [{
|
||||||
|
type: "message", id: "u1", parentId: null,
|
||||||
|
message: { role: "user", content: "Remember the test decision.", timestamp: 1 },
|
||||||
|
}];
|
||||||
|
const { ctx } = context(branch);
|
||||||
|
ctx.model = {
|
||||||
|
provider, api: provider === "sub-api" ? "openai-responses" : "openai-codex-responses",
|
||||||
|
id: "selected-model", input: ["text"], contextWindow: 100_000,
|
||||||
|
baseUrl: provider === "sub-api" ? "https://gateway.example/v1" : "https://codex.example/codex",
|
||||||
|
};
|
||||||
|
const jwt = Buffer.from(JSON.stringify({
|
||||||
|
"https://api.openai.com/auth": { chatgpt_account_id: "test-account" },
|
||||||
|
})).toString("base64url");
|
||||||
|
const apiKey = provider === "sub-api" ? "ordinary-test-key" : `header.${jwt}.signature`;
|
||||||
|
ctx.modelRegistry.getApiKeyAndHeaders = async (model: unknown) => {
|
||||||
|
assert.equal(model, ctx.model);
|
||||||
|
return { ok: true, apiKey, headers: { "x-test-route": "preserved" } };
|
||||||
|
};
|
||||||
|
const requests: Array<{ url: string; headers: Headers; body: any }> = [];
|
||||||
|
t.mock.method(globalThis, "fetch", async (url: string, init: RequestInit) => {
|
||||||
|
requests.push({ url: String(url), headers: new Headers(init.headers), body: JSON.parse(String(init.body)) });
|
||||||
|
const events = [
|
||||||
|
{ type: "response.output_item.done", item: { type: "compaction", encrypted_content: "opaque-checkpoint" } },
|
||||||
|
{ type: "response.completed", response: { usage: { input_tokens: 12, output_tokens: 3 } } },
|
||||||
|
];
|
||||||
|
return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await h.handlers.get("context")![0]!({ messages: branch.map((entry) => entry.message) }, ctx);
|
||||||
|
const headers: Record<string, string> = { "x-codex-beta-features": "existing-feature" };
|
||||||
|
await h.handlers.get("before_provider_headers")![0]!({ headers }, ctx);
|
||||||
|
assert.match(headers["x-codex-beta-features"]!, /existing-feature/);
|
||||||
|
assert.notEqual(headers["x-codex-beta-features"], "existing-feature");
|
||||||
|
await h.handlers.get("before_provider_request")![0]!({ payload: {
|
||||||
|
model: "stale-cached-model", input: [{ role: "user", content: "STALE-RAW-HISTORY" }],
|
||||||
|
store: true, stream: false,
|
||||||
|
} }, ctx);
|
||||||
|
const result = await h.handlers.get("session_before_compact")![0]!({
|
||||||
|
reason: "manual", branchEntries: branch, signal: new AbortController().signal,
|
||||||
|
preparation: { firstKeptEntryId: "u1" },
|
||||||
|
}, ctx);
|
||||||
|
assert.ok(result?.compaction, "native compaction should return a checkpoint");
|
||||||
|
assert.equal(requests.length, 1);
|
||||||
|
const request = requests[0]!;
|
||||||
|
assert.equal(request.url, provider === "sub-api"
|
||||||
|
? "https://gateway.example/v1/responses" : "https://codex.example/codex/responses");
|
||||||
|
assert.equal(request.headers.get("authorization"), `Bearer ${apiKey}`);
|
||||||
|
assert.equal(request.headers.get("x-test-route"), "preserved");
|
||||||
|
assert.equal(request.headers.get("chatgpt-account-id"), provider === "sub-api" ? null : "test-account");
|
||||||
|
assert.equal(request.body.model, "selected-model");
|
||||||
|
assert.equal(request.body.stream, true);
|
||||||
|
assert.equal(request.body.store, false);
|
||||||
|
assert.deepEqual(request.body.input.at(-1), { type: "compaction_trigger" });
|
||||||
|
assert.match(JSON.stringify(request.body.input), /Remember the test decision/);
|
||||||
|
assert.doesNotMatch(JSON.stringify(request.body.input), /STALE-RAW-HISTORY/);
|
||||||
|
const details = result.compaction.details;
|
||||||
|
assert.equal(details.checkpoint, "native");
|
||||||
|
assert.equal(details.modelKey, `${provider}:${ctx.model.api}:selected-model`);
|
||||||
|
assert.ok(details.replacementHistory.some((item: any) => item.encrypted_content === "opaque-checkpoint"));
|
||||||
|
assert.ok(details.portableSummary.length > 0);
|
||||||
|
branch.push(
|
||||||
|
{ type: "compaction", id: "c1", parentId: "u1", ...result.compaction },
|
||||||
|
{ type: "message", id: "u2", parentId: "c1", message: { role: "user", content: "Continue after compaction.", timestamp: 2 } },
|
||||||
|
);
|
||||||
|
const projected = await h.handlers.get("context")![0]!({ messages: [] }, ctx);
|
||||||
|
assert.doesNotMatch(JSON.stringify(projected.messages), /opaque-checkpoint/);
|
||||||
|
const payload = await h.handlers.get("before_provider_request")![0]!({
|
||||||
|
payload: { model: ctx.model.id, input: [], messages: [], previous_response_id: "stale-response" },
|
||||||
|
}, ctx);
|
||||||
|
assert.ok(payload.input.some((item: any) => item.encrypted_content === "opaque-checkpoint"));
|
||||||
|
assert.match(JSON.stringify(payload.input), /Continue after compaction/);
|
||||||
|
assert.equal(payload.previous_response_id, undefined);
|
||||||
|
assert.equal(payload.messages, undefined);
|
||||||
|
ctx.model = { ...ctx.model, id: "another-model" };
|
||||||
|
const switched = await h.handlers.get("context")![0]!({ messages: [] }, ctx);
|
||||||
|
assert.match(JSON.stringify(switched.messages), /Remember the test decision/);
|
||||||
|
assert.equal(await h.handlers.get("before_provider_request")![0]!({ payload: { input: [] } }, ctx), undefined);
|
||||||
|
} finally {
|
||||||
|
await h.handlers.get("session_shutdown")![0]!({}, ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("SubAPI native failures and cancellation return control to Pi without a checkpoint", async (t) => {
|
||||||
|
const h = harness();
|
||||||
|
piCompaction(h.pi);
|
||||||
|
const branch: any[] = [{
|
||||||
|
type: "message", id: "u1", parentId: null,
|
||||||
|
message: { role: "user", content: "Test only.", timestamp: 1 },
|
||||||
|
}];
|
||||||
|
const { ctx } = context(branch);
|
||||||
|
ctx.model = { provider: "sub-api", api: "openai-responses", id: "test-model", input: ["text"], baseUrl: "https://gateway.example/v1" };
|
||||||
|
ctx.modelRegistry.getApiKeyAndHeaders = async () => ({ ok: true, apiKey: "test-key" });
|
||||||
|
const responses = [
|
||||||
|
new Response('{"error":{"message":"unsupported"}}', { status: 400 }),
|
||||||
|
new Response('data: {"type":"response.completed","response":{}}\n\n', { headers: { "content-type": "text/event-stream" } }),
|
||||||
|
];
|
||||||
|
let calls = 0;
|
||||||
|
t.mock.method(globalThis, "fetch", async (_url: string, init: RequestInit) => {
|
||||||
|
init.signal?.throwIfAborted();
|
||||||
|
calls++;
|
||||||
|
return responses.shift()!;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
assert.equal(await h.handlers.get("session_before_compact")![0]!({
|
||||||
|
reason: "manual", branchEntries: branch, preparation: { firstKeptEntryId: "u1" },
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
}, ctx), undefined);
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
assert.equal(await h.handlers.get("session_before_compact")![0]!({
|
||||||
|
reason: "manual", branchEntries: branch, preparation: { firstKeptEntryId: "u1" }, signal: controller.signal,
|
||||||
|
}, ctx), undefined);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.equal(branch.some((entry) => entry.type === "compaction"), false);
|
||||||
|
} finally {
|
||||||
|
await h.handlers.get("session_shutdown")![0]!({}, ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("other providers and SubAPI Chat Completions keep their provider requests unchanged", async () => {
|
||||||
|
const h = harness();
|
||||||
|
piCompaction(h.pi);
|
||||||
|
const { ctx } = context();
|
||||||
|
try {
|
||||||
|
for (const model of [
|
||||||
|
{ provider: "other", api: "openai-responses", id: "test" },
|
||||||
|
{ provider: "sub-api", api: "openai-completions", id: "test" },
|
||||||
|
]) {
|
||||||
|
ctx.model = model;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
await h.handlers.get("before_provider_headers")![0]!({ headers }, ctx);
|
||||||
|
assert.equal(headers["x-codex-beta-features"], undefined);
|
||||||
|
assert.equal(await h.handlers.get("before_provider_request")![0]!({ payload: { input: [] } }, ctx), undefined);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await h.handlers.get("session_shutdown")![0]!({}, ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("native runtime delegates authentication to each provider without blocking SubAPI header-only auth", async (t) => {
|
||||||
|
const cases: Array<{ name: string; provider?: string; auth: any; succeeds: boolean; authorization?: string; gatewayAuthorization?: string }> = [
|
||||||
|
{ name: "explicit header", auth: { ok: true, apiKey: "dummy-key", headers: { Authorization: "Bearer explicit" } }, succeeds: true, authorization: "Bearer explicit" },
|
||||||
|
{ name: "header only", auth: { ok: true, headers: { authorization: "Bearer header-only" } }, succeeds: true, authorization: "Bearer header-only" },
|
||||||
|
{ name: "proxy header only", auth: { ok: true, headers: { "CF-AIG-Authorization": "Bearer proxy" } }, succeeds: true, gatewayAuthorization: "Bearer proxy" },
|
||||||
|
{ name: "blank header", auth: { ok: true, headers: { Authorization: " " } }, succeeds: false },
|
||||||
|
{ name: "missing credentials", auth: { ok: true }, succeeds: false },
|
||||||
|
{ name: "registry failure", auth: { ok: false, error: "credentials unavailable" }, succeeds: false },
|
||||||
|
{ name: "Codex header only", provider: "openai-codex", auth: { ok: true, headers: { Authorization: "Bearer header-only" } }, succeeds: false },
|
||||||
|
{ name: "Codex invalid JWT", provider: "openai-codex", auth: { ok: true, apiKey: "invalid", headers: { Authorization: "Bearer header-only" } }, succeeds: false },
|
||||||
|
];
|
||||||
|
for (const scenario of cases) {
|
||||||
|
await t.test(scenario.name, async (t) => {
|
||||||
|
const h = harness();
|
||||||
|
piCompaction(h.pi);
|
||||||
|
const branch: any[] = [{
|
||||||
|
type: "message", id: "u1", parentId: null,
|
||||||
|
message: { role: "user", content: "Synthetic authentication test.", timestamp: 1 },
|
||||||
|
}];
|
||||||
|
const { ctx } = context(branch);
|
||||||
|
const provider = scenario.provider ?? "sub-api";
|
||||||
|
ctx.model = {
|
||||||
|
provider, api: provider === "sub-api" ? "openai-responses" : "openai-codex-responses",
|
||||||
|
id: "test-model", input: ["text"], baseUrl: "https://gateway.example/v1",
|
||||||
|
};
|
||||||
|
ctx.modelRegistry.getApiKeyAndHeaders = async () => scenario.auth;
|
||||||
|
let calls = 0;
|
||||||
|
t.mock.method(globalThis, "fetch", async (_url: string, init: RequestInit) => {
|
||||||
|
calls++;
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
assert.equal(headers.get("authorization"), scenario.authorization ?? null);
|
||||||
|
assert.equal(headers.get("cf-aig-authorization"), scenario.gatewayAuthorization ?? null);
|
||||||
|
const events = [
|
||||||
|
{ type: "response.output_item.done", item: { type: "compaction", encrypted_content: "test-checkpoint" } },
|
||||||
|
{ type: "response.completed", response: {} },
|
||||||
|
];
|
||||||
|
return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await h.handlers.get("session_before_compact")![0]!({
|
||||||
|
reason: "manual", branchEntries: branch, preparation: { firstKeptEntryId: "u1" },
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
}, ctx);
|
||||||
|
assert.equal(Boolean(result?.compaction), scenario.succeeds);
|
||||||
|
assert.equal(calls, scenario.succeeds ? 1 : 0);
|
||||||
|
} finally {
|
||||||
|
await h.handlers.get("session_shutdown")![0]!({}, ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { MASK_PREFIX, planMasking, rebuildMaskedLedger } from "../src/masking.ts";
|
||||||
|
import { effectiveThreshold } from "../src/token-accounting.ts";
|
||||||
|
import type { MaskingConfig } from "../src/types.ts";
|
||||||
|
|
||||||
|
const MASKING: MaskingConfig = {
|
||||||
|
enabled: true,
|
||||||
|
triggerTokens: 1_000,
|
||||||
|
triggerRatio: 0.9,
|
||||||
|
targetTokens: 400,
|
||||||
|
targetRatio: 0.35,
|
||||||
|
minClearTokens: 200,
|
||||||
|
keepRecentTurns: 2,
|
||||||
|
minEntryTokens: 10,
|
||||||
|
excludeTools: ["ask_user_question"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const estimate = (message: any): number => Math.ceil(JSON.stringify(message).length / 4);
|
||||||
|
|
||||||
|
function assistantMessage(id: string, calls: Array<{ id: string; name: string; args?: unknown }>) {
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content: calls.map((call) => ({ type: "toolCall", id: call.id, name: call.name, arguments: call.args ?? {} })),
|
||||||
|
timestamp: id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolResultMessage(id: string, toolCallId: string, text: string, toolName = "bash") {
|
||||||
|
return {
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId,
|
||||||
|
toolName,
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
timestamp: id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function entry(id: string, message: any, messages = [message]) {
|
||||||
|
return { sourceEntry: { id, type: "message", message }, messages };
|
||||||
|
}
|
||||||
|
|
||||||
|
function protect(turns = MASKING.keepRecentTurns) {
|
||||||
|
return Array.from({ length: turns }, (_unused, index) =>
|
||||||
|
entry(`recent-${index}`, { role: "assistant", content: [{ type: "text", text: "ok" }], timestamp: `recent-${index}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("below the effective trigger plans no drafts", () => {
|
||||||
|
const entries = [
|
||||||
|
entry("e1", assistantMessage("a1", [{ id: "c1", name: "bash", args: { command: "ls" } }])),
|
||||||
|
entry("e2", toolResultMessage("r1", "c1", "x".repeat(8_000))),
|
||||||
|
...protect(),
|
||||||
|
];
|
||||||
|
const plan = planMasking({ contextEntries: entries, currentTokens: 999, contextWindow: 0, masking: MASKING, estimate });
|
||||||
|
assert.equal(plan.drafts.length, 0);
|
||||||
|
assert.equal(plan.masked.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("above the trigger masks oldest first until context fits under target", () => {
|
||||||
|
const entries = [
|
||||||
|
entry("e1", assistantMessage("a1", [
|
||||||
|
{ id: "c1", name: "bash", args: { command: "one" } },
|
||||||
|
{ id: "c2", name: "bash", args: { command: "two" } },
|
||||||
|
{ id: "c3", name: "bash", args: { command: "three" } },
|
||||||
|
])),
|
||||||
|
entry("e2", toolResultMessage("r1", "c1", "a".repeat(4_000))),
|
||||||
|
entry("e3", toolResultMessage("r2", "c2", "b".repeat(4_000))),
|
||||||
|
entry("e4", toolResultMessage("r3", "c3", "c".repeat(4_000))),
|
||||||
|
entry("e5", toolResultMessage("r4", "c4", "recent result".repeat(200))),
|
||||||
|
...protect(),
|
||||||
|
];
|
||||||
|
// needed = 2_000 - 400 = 1_600; each result clears ~980 tokens.
|
||||||
|
const plan = planMasking({ contextEntries: entries, currentTokens: 2_000, contextWindow: 0, masking: MASKING, estimate });
|
||||||
|
assert.deepEqual(plan.drafts.map((draft) => draft.targetId), ["e2", "e3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("results inside the recent keepRecentTurns window are never masked", () => {
|
||||||
|
const entries = [
|
||||||
|
entry("e1", toolResultMessage("r1", "c1", "a".repeat(4_000))),
|
||||||
|
entry("recent-a", assistantMessage("ra", [{ id: "rc", name: "bash", args: {} }])),
|
||||||
|
entry("recent-r", toolResultMessage("rr", "rc", "b".repeat(4_000))),
|
||||||
|
entry("recent-b", assistantMessage("rb", [])),
|
||||||
|
];
|
||||||
|
const plan = planMasking({ contextEntries: entries, currentTokens: 3_500, contextWindow: 0, masking: MASKING, estimate });
|
||||||
|
assert.deepEqual(plan.drafts.map((draft) => draft.targetId), ["e1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("excluded tools, SKILL.md reads, already-masked and small results are skipped", () => {
|
||||||
|
const entries = [
|
||||||
|
entry("e1", assistantMessage("a1", [
|
||||||
|
{ id: "c1", name: "ask_user_question", args: { question: "x" } },
|
||||||
|
{ id: "c2", name: "read", args: { path: "/repo/skills/foo/SKILL.md" } },
|
||||||
|
{ id: "c3", name: "bash", args: { command: "old" } },
|
||||||
|
{ id: "c4", name: "bash", args: { command: "tiny" } },
|
||||||
|
])),
|
||||||
|
entry("e2", toolResultMessage("r1", "c1", "a".repeat(4_000), "ask_user_question")),
|
||||||
|
entry("e3", toolResultMessage("r2", "c2", "b".repeat(4_000), "read")),
|
||||||
|
entry("e4", toolResultMessage("r3", "c3", `${MASK_PREFIX}bash result cleared to save context (~900 tokens).`), ),
|
||||||
|
entry("e5", toolResultMessage("r4", "c4", "tiny")),
|
||||||
|
entry("e6", toolResultMessage("r5", "c5", "d".repeat(4_000))),
|
||||||
|
...protect(),
|
||||||
|
];
|
||||||
|
const masking = { ...MASKING, minClearTokens: 100, minEntryTokens: 100 };
|
||||||
|
const plan = planMasking({ contextEntries: entries, currentTokens: 3_500, contextWindow: 0, masking, estimate });
|
||||||
|
assert.deepEqual(plan.drafts.map((draft) => draft.targetId), ["e6"]);
|
||||||
|
assert.equal(plan.masked[0]!.toolCallId, "c5");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("when clearable volume stays under minClear nothing is masked", () => {
|
||||||
|
const entries = [
|
||||||
|
entry("e1", toolResultMessage("r1", "c1", "a".repeat(600))),
|
||||||
|
...protect(),
|
||||||
|
];
|
||||||
|
const plan = planMasking({ contextEntries: entries, currentTokens: 3_500, contextWindow: 0, masking: MASKING, estimate });
|
||||||
|
// ~130 tokens clearable, below minClearTokens (200) → not worth invalidating cache.
|
||||||
|
assert.equal(plan.drafts.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("placeholder is deterministic, names the tool and call id, and truncates args at 300 chars", () => {
|
||||||
|
const longArgs = { command: `run ${"v".repeat(600)}` };
|
||||||
|
const entries = [
|
||||||
|
entry("e1", assistantMessage("a1", [{ id: "c1", name: "bash", args: longArgs }])),
|
||||||
|
entry("e2", toolResultMessage("r1", "c1", "a".repeat(4_000))),
|
||||||
|
...protect(),
|
||||||
|
];
|
||||||
|
const masking = { ...MASKING, minClearTokens: 1 };
|
||||||
|
const first = planMasking({ contextEntries: entries, currentTokens: 3_500, contextWindow: 0, masking, estimate });
|
||||||
|
const second = planMasking({ contextEntries: entries, currentTokens: 3_500, contextWindow: 0, masking, estimate });
|
||||||
|
assert.equal(first.drafts.length, 1);
|
||||||
|
const placeholder = first.masked[0]!.placeholder;
|
||||||
|
assert.equal(placeholder, second.masked[0]!.placeholder);
|
||||||
|
assert.ok(placeholder.startsWith(MASK_PREFIX));
|
||||||
|
assert.match(placeholder, /bash result cleared/);
|
||||||
|
assert.match(placeholder, /"toolCallIds":\["c1"\]/);
|
||||||
|
const argsText = placeholder.match(/Call args: (.*)\. Re-run/)?.[1] ?? "";
|
||||||
|
assert.ok(argsText.length <= 301, `args excerpt ${argsText.length} exceeded 300+ellipsis`);
|
||||||
|
assert.ok(placeholder.includes("…"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a masked result carrying details.overflow.id gets an overflow_query hint in the placeholder", () => {
|
||||||
|
const withOverflow = {
|
||||||
|
...toolResultMessage("r1", "c1", "a".repeat(4_000)),
|
||||||
|
details: { overflow: { id: "0123456789ab", totalLines: 5, totalBytes: 4000, truncated: true } },
|
||||||
|
};
|
||||||
|
const withoutOverflow = toolResultMessage("r2", "c2", "b".repeat(4_000));
|
||||||
|
const entries = [
|
||||||
|
entry("e1", assistantMessage("a1", [{ id: "c1", name: "bash" }, { id: "c2", name: "bash" }])),
|
||||||
|
entry("e2", withOverflow),
|
||||||
|
entry("e3", withoutOverflow),
|
||||||
|
...protect(),
|
||||||
|
];
|
||||||
|
const masking = { ...MASKING, minClearTokens: 1 };
|
||||||
|
const plan = planMasking({ contextEntries: entries, currentTokens: 3_500, contextWindow: 0, masking, estimate });
|
||||||
|
assert.equal(plan.masked.length, 2);
|
||||||
|
const first = plan.masked.find((m) => m.toolCallId === "c1")!.placeholder;
|
||||||
|
const second = plan.masked.find((m) => m.toolCallId === "c2")!.placeholder;
|
||||||
|
assert.ok(first.includes(` Full original stored: overflow_query {"id":"0123456789ab"}.`));
|
||||||
|
assert.ok(!second.includes("overflow_query"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the small-window ratio caps both trigger and target", () => {
|
||||||
|
const masking: MaskingConfig = {
|
||||||
|
...MASKING,
|
||||||
|
triggerTokens: 200_000,
|
||||||
|
triggerRatio: 0.6,
|
||||||
|
targetTokens: 100_000,
|
||||||
|
targetRatio: 0.35,
|
||||||
|
minClearTokens: 50_000,
|
||||||
|
};
|
||||||
|
assert.equal(effectiveThreshold(masking.triggerTokens, masking.triggerRatio, 272_000), 163_200);
|
||||||
|
assert.equal(effectiveThreshold(masking.targetTokens, masking.targetRatio, 272_000), 95_200);
|
||||||
|
const entries = [
|
||||||
|
entry("e1", toolResultMessage("r1", "c1", "a".repeat(300_000))),
|
||||||
|
...protect(),
|
||||||
|
];
|
||||||
|
const below = planMasking({ contextEntries: entries, currentTokens: 163_199, contextWindow: 272_000, masking, estimate });
|
||||||
|
assert.equal(below.drafts.length, 0);
|
||||||
|
const above = planMasking({ contextEntries: entries, currentTokens: 163_200, contextWindow: 272_000, masking, estimate });
|
||||||
|
assert.equal(above.drafts.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ledger rebuild counts only masked targets still inside the active context", () => {
|
||||||
|
const placeholder = (id: string) =>
|
||||||
|
`${MASK_PREFIX}bash result cleared to save context (~100 tokens). Call args: {}. Re-run the call if still needed, or recover the original with context_tree_query {"toolCallIds":["${id}"]}.`;
|
||||||
|
const branch = [
|
||||||
|
{ type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "start", timestamp: 1 } },
|
||||||
|
{ type: "message", id: "r-old", parentId: "u1", timestamp: "t", message: toolResultMessage("m-old", "c-old", "o".repeat(2_000)) },
|
||||||
|
{ type: "context_edit", id: "e-old", parentId: "r-old", timestamp: "t", targetId: "r-old", replacement: { content: placeholder("c-old") } },
|
||||||
|
{ type: "message", id: "m-keep", parentId: "e-old", timestamp: "t", message: { role: "user", content: "kept question", timestamp: 2 } },
|
||||||
|
{ type: "compaction", id: "c1", parentId: "m-keep", timestamp: "t", summary: "summary", firstKeptEntryId: "m-keep", tokensBefore: 9_999 },
|
||||||
|
{ type: "message", id: "a-keep", parentId: "c1", timestamp: "t", message: assistantMessage("a-keep", []) },
|
||||||
|
{ type: "message", id: "r-new", parentId: "a-keep", timestamp: "t", message: toolResultMessage("m-new", "c-new", "n".repeat(2_000)) },
|
||||||
|
{ type: "context_edit", id: "e-new", parentId: "r-new", timestamp: "t", targetId: "r-new", replacement: { content: placeholder("c-new") } },
|
||||||
|
];
|
||||||
|
const ledger = rebuildMaskedLedger(branch, "e-new", estimate);
|
||||||
|
assert.equal(ledger.size, 1);
|
||||||
|
assert.ok(ledger.has("c-new"));
|
||||||
|
assert.ok(!ledger.has("c-old"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("user and assistant messages are never masked", () => {
|
||||||
|
const entries = [
|
||||||
|
entry("e1", { role: "user", content: "u".repeat(8_000), timestamp: "u1" }),
|
||||||
|
entry("e2", { role: "assistant", content: [{ type: "text", text: "a".repeat(8_000) }], timestamp: "a1" }),
|
||||||
|
...protect(),
|
||||||
|
];
|
||||||
|
const plan = planMasking({ contextEntries: entries, currentTokens: 9_000, contextWindow: 0, masking: { ...MASKING, keepRecentTurns: 1, minClearTokens: 1 }, estimate });
|
||||||
|
assert.equal(plan.drafts.length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
buildReplacementHistory,
|
||||||
|
findNativeCheckpoint,
|
||||||
|
parseNativeCompactionDetails,
|
||||||
|
type NativeCompactionDetails,
|
||||||
|
} from "../src/native-compaction.ts";
|
||||||
|
|
||||||
|
const details: NativeCompactionDetails = {
|
||||||
|
kind: "pi-compaction",
|
||||||
|
version: 1,
|
||||||
|
checkpoint: "native",
|
||||||
|
modelKey: "openai-codex/openai-codex-responses/gpt-test",
|
||||||
|
replacementHistory: [{ type: "compaction", encrypted_content: "opaque" }],
|
||||||
|
portableSummary: "portable",
|
||||||
|
sourceFingerprint: "abc",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("recognizes only the unified native checkpoint format", () => {
|
||||||
|
assert.deepEqual(parseNativeCompactionDetails(details), details);
|
||||||
|
assert.equal(parseNativeCompactionDetails({ ...details, kind: "openai-codex-native-compaction" }), undefined);
|
||||||
|
assert.equal(parseNativeCompactionDetails({ ...details, portableSummary: undefined }), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("latest Pi compaction entry is the authoritative native checkpoint", () => {
|
||||||
|
const branch = [
|
||||||
|
{ type: "message", id: "m1" },
|
||||||
|
{ type: "compaction", id: "c1", details },
|
||||||
|
] as any;
|
||||||
|
const found = findNativeCheckpoint(branch);
|
||||||
|
assert.equal(found.status, "valid");
|
||||||
|
if (found.status === "valid") assert.equal(found.checkpoint.entryIndex, 1);
|
||||||
|
|
||||||
|
const shadowed = findNativeCheckpoint([...branch, { type: "compaction", id: "c2", details: { kind: "other" } }] as any);
|
||||||
|
assert.equal(shadowed.status, "none");
|
||||||
|
const portable = findNativeCheckpoint([
|
||||||
|
...branch,
|
||||||
|
{ type: "compaction", id: "c3", details: { kind: "pi-compaction", version: 1, checkpoint: "portable" } },
|
||||||
|
] as any);
|
||||||
|
assert.equal(portable.status, "none");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replacement history ends with exactly one native item", () => {
|
||||||
|
const history = buildReplacementHistory([
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "recent" }] },
|
||||||
|
], { type: "compaction", encrypted_content: "opaque" }, 1_000);
|
||||||
|
assert.equal(history.at(-1)?.type, "compaction");
|
||||||
|
assert.equal(history.filter((item) => item.type === "compaction").length, 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { buildCodexHeaders, mergeFeatureHeader } from "../src/native-compaction.ts";
|
||||||
|
import { buildNativeCompactionTransport, isNativeCompactionModel } from "../src/native-provider.ts";
|
||||||
|
|
||||||
|
const subApi = {
|
||||||
|
provider: "sub-api",
|
||||||
|
api: "openai-responses",
|
||||||
|
id: "gpt-test",
|
||||||
|
baseUrl: "https://gateway.example/v1",
|
||||||
|
};
|
||||||
|
const params = { model: subApi, apiKey: "test-api-key", sessionId: "session-test" };
|
||||||
|
|
||||||
|
test("native compaction eligibility is limited to the supported provider/API pairs", () => {
|
||||||
|
assert.equal(isNativeCompactionModel(subApi), true);
|
||||||
|
assert.equal(isNativeCompactionModel({ provider: "openai-codex", api: "openai-codex-responses" }), true);
|
||||||
|
for (const model of [
|
||||||
|
null, {},
|
||||||
|
{ ...subApi, provider: "another-gateway" },
|
||||||
|
{ ...subApi, api: "openai-completions" },
|
||||||
|
{ ...subApi, api: "openai-codex-responses" },
|
||||||
|
{ provider: "openai-codex", api: "openai-responses" },
|
||||||
|
]) assert.equal(isNativeCompactionModel(model), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SubAPI uses its public Responses URL and API key without Codex account parsing", () => {
|
||||||
|
const { url, headers } = buildNativeCompactionTransport({
|
||||||
|
...params,
|
||||||
|
headers: { "x-gateway-route": "test", "x-codex-beta-features": "existing-feature" },
|
||||||
|
});
|
||||||
|
assert.equal(url, "https://gateway.example/v1/responses");
|
||||||
|
assert.equal(headers.get("authorization"), "Bearer test-api-key");
|
||||||
|
assert.equal(headers.has("chatgpt-account-id"), false);
|
||||||
|
assert.equal(headers.get("x-gateway-route"), "test");
|
||||||
|
assert.equal(headers.get("content-type"), "application/json");
|
||||||
|
assert.equal(headers.get("accept"), "text/event-stream");
|
||||||
|
assert.equal(headers.get("session-id"), "session-test");
|
||||||
|
assert.equal(headers.get("x-client-request-id"), "session-test");
|
||||||
|
assert.equal(headers.get("x-codex-beta-features"), mergeFeatureHeader("existing-feature"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SubAPI URL normalization preserves prefixes and avoids duplicating responses", () => {
|
||||||
|
for (const [baseUrl, expected] of [
|
||||||
|
["https://gateway.example/v1/", "https://gateway.example/v1/responses"],
|
||||||
|
["https://gateway.example/v1/responses", "https://gateway.example/v1/responses"],
|
||||||
|
["https://gateway.example/v1/responses/", "https://gateway.example/v1/responses"],
|
||||||
|
["https://gateway.example/prefix/v1?route=test", "https://gateway.example/prefix/v1/responses?route=test"],
|
||||||
|
["http://localhost:8080", "http://localhost:8080/responses"],
|
||||||
|
]) {
|
||||||
|
assert.equal(buildNativeCompactionTransport({ ...params, model: { ...subApi, baseUrl } }).url, expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid SubAPI endpoints and unsupported providers fail without a Codex fallback", () => {
|
||||||
|
for (const baseUrl of [undefined, "", " ", "/v1", "invalid", "file:///tmp/test", "https://user:secret@gateway.example/v1", "https://gateway.example/v1#fragment"]) {
|
||||||
|
assert.throws(() => buildNativeCompactionTransport({ ...params, model: { ...subApi, baseUrl } }), /SubAPI/);
|
||||||
|
}
|
||||||
|
assert.throws(() => buildNativeCompactionTransport({
|
||||||
|
...params, model: { ...subApi, provider: "other" },
|
||||||
|
}), /not supported/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("official Codex transport retains its URL, JWT account ID and headers", () => {
|
||||||
|
const payload = Buffer.from(JSON.stringify({
|
||||||
|
"https://api.openai.com/auth": { chatgpt_account_id: "test-account" },
|
||||||
|
})).toString("base64url");
|
||||||
|
const codexParams = {
|
||||||
|
...params,
|
||||||
|
model: { provider: "openai-codex", api: "openai-codex-responses", id: "gpt-test" },
|
||||||
|
apiKey: `header.${payload}.signature`,
|
||||||
|
headers: { "x-custom": "preserved" },
|
||||||
|
};
|
||||||
|
const transport = buildNativeCompactionTransport(codexParams);
|
||||||
|
assert.equal(transport.url, "https://chatgpt.com/backend-api/codex/responses");
|
||||||
|
assert.deepEqual([...transport.headers], [...buildCodexHeaders(codexParams)]);
|
||||||
|
assert.equal(transport.headers.get("chatgpt-account-id"), "test-account");
|
||||||
|
assert.throws(() => buildNativeCompactionTransport({ ...codexParams, apiKey: "ordinary-key" }), /account ID/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SubAPI preserves explicit Authorization ahead of its API key, case-insensitively", () => {
|
||||||
|
for (const name of ["Authorization", "authorization", "AUTHORIZATION"]) {
|
||||||
|
const { headers } = buildNativeCompactionTransport({
|
||||||
|
...params, headers: { [name]: "Bearer explicit-token" },
|
||||||
|
});
|
||||||
|
assert.equal(headers.get("authorization"), "Bearer explicit-token");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SubAPI accepts header-only authentication without synthesizing a bearer token", () => {
|
||||||
|
for (const name of ["Authorization", "CF-AIG-Authorization"]) {
|
||||||
|
const { headers } = buildNativeCompactionTransport({
|
||||||
|
...params, apiKey: undefined, headers: { [name]: "Bearer header-only-token" },
|
||||||
|
});
|
||||||
|
assert.equal(headers.get(name), "Bearer header-only-token");
|
||||||
|
if (name === "CF-AIG-Authorization") assert.equal(headers.has("authorization"), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SubAPI rejects absent or blank credentials but can fall back to a real API key", () => {
|
||||||
|
const candidates: Record<string, string>[] = [{}, { Authorization: " " }, { "cf-aig-authorization": " " }, { "x-api-key": "not-supported" }];
|
||||||
|
for (const headers of candidates) {
|
||||||
|
for (const apiKey of [undefined, "", " "]) {
|
||||||
|
assert.throws(() => buildNativeCompactionTransport({ ...params, apiKey, headers }), /authentication/i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = buildNativeCompactionTransport({ ...params, headers: { Authorization: " " } });
|
||||||
|
assert.equal(result.headers.get("authorization"), "Bearer test-api-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("official Codex still requires an API key even with explicit header credentials", () => {
|
||||||
|
assert.throws(() => buildNativeCompactionTransport({
|
||||||
|
...params, apiKey: undefined,
|
||||||
|
model: { provider: "openai-codex", api: "openai-codex-responses" },
|
||||||
|
headers: { Authorization: "Bearer header-only-token" },
|
||||||
|
}), /Codex.*authentication/i);
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
import { DEFAULT_CONFIG } from "../src/config.ts";
|
||||||
|
import { SegmentIndex } from "../src/segment-index.ts";
|
||||||
|
|
||||||
|
function message(index: number) {
|
||||||
|
return { role: "user", content: `message-${index}`, timestamp: index + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("cached projection and append-only delta stay inside the hot-path budget", () => {
|
||||||
|
const index = new SegmentIndex("perf");
|
||||||
|
index.hydrate();
|
||||||
|
const messages = Array.from({ length: 10_000 }, (_, value) => message(value));
|
||||||
|
const params = { environmentHash: "env", environmentTokens: 2_000, contextWindow: 300_000, maskedResults: 0, reclaimedTokens: 0 };
|
||||||
|
index.project(messages, params);
|
||||||
|
|
||||||
|
const cacheStart = performance.now();
|
||||||
|
for (let iteration = 0; iteration < 1_000; iteration++) index.project(messages, params);
|
||||||
|
const cacheAverageMs = (performance.now() - cacheStart) / 1_000;
|
||||||
|
|
||||||
|
const appended = [...messages, message(10_000)];
|
||||||
|
const deltaStart = performance.now();
|
||||||
|
index.project(appended, params);
|
||||||
|
const deltaMs = performance.now() - deltaStart;
|
||||||
|
|
||||||
|
console.log(`cache-hit average ${cacheAverageMs.toFixed(3)}ms; delta projection ${deltaMs.toFixed(3)}ms`);
|
||||||
|
assert.ok(cacheAverageMs < 2, `cache-hit average ${cacheAverageMs.toFixed(3)}ms exceeded 2ms`);
|
||||||
|
assert.ok(deltaMs < 10, `delta projection ${deltaMs.toFixed(3)}ms exceeded 10ms`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { SegmentIndex } from "../src/segment-index.ts";
|
||||||
|
|
||||||
|
const assistant = {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: "call-1", name: "bash", arguments: { command: "npm test" } }],
|
||||||
|
timestamp: 10,
|
||||||
|
};
|
||||||
|
const result = {
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
toolName: "bash",
|
||||||
|
content: [{ type: "text", text: `failure\n${"x".repeat(12_000)}\nstack tail` }],
|
||||||
|
isError: true,
|
||||||
|
timestamp: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Regression: a ~14.5K-char read result projected under a 1M window must reach
|
||||||
|
// the model byte-for-byte unchanged — masking only happens via context_edit
|
||||||
|
// drafts under real pressure, never on the hot projection path.
|
||||||
|
test("projection is byte-for-byte identity for a 300-line read result under a 1M window", () => {
|
||||||
|
const readBody = Array.from({ length: 300 }, (_unused, index) => `line ${index + 1}: ${"y".repeat(38)}`).join("\n");
|
||||||
|
const readResult = {
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "call-read",
|
||||||
|
toolName: "read",
|
||||||
|
content: [{ type: "text", text: readBody }],
|
||||||
|
timestamp: 12,
|
||||||
|
};
|
||||||
|
assert.ok(readBody.length > 14_000 && readBody.length < 15_000, `fixture ${readBody.length} chars`);
|
||||||
|
|
||||||
|
const index = new SegmentIndex("session-1");
|
||||||
|
index.hydrate();
|
||||||
|
const snapshot = index.project([assistant, readResult], {
|
||||||
|
environmentHash: "env",
|
||||||
|
environmentTokens: 100,
|
||||||
|
contextWindow: 1_000_000,
|
||||||
|
maskedResults: 0,
|
||||||
|
reclaimedTokens: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(snapshot.messages[1], readResult);
|
||||||
|
assert.strictEqual(snapshot.messages[1]!.content[0].text, readBody);
|
||||||
|
assert.ok(snapshot.messages[1]!.content[0].text.includes("line 150:"));
|
||||||
|
assert.equal(snapshot.reclaimedTokens, 0);
|
||||||
|
assert.equal(snapshot.maskedResults, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("projection caches identical input and handles append-only deltas", () => {
|
||||||
|
const index = new SegmentIndex("session-2");
|
||||||
|
index.hydrate();
|
||||||
|
const messages = [assistant, result];
|
||||||
|
const params = { environmentHash: "env", environmentTokens: 100, contextWindow: 300_000, maskedResults: 0, reclaimedTokens: 0 };
|
||||||
|
const first = index.project(messages, params);
|
||||||
|
const second = index.project(messages, params);
|
||||||
|
assert.strictEqual(first, second);
|
||||||
|
assert.ok(first.rawTokens > 0);
|
||||||
|
|
||||||
|
const appended = index.project([...messages, { role: "user", content: "next", timestamp: 13 }], params);
|
||||||
|
assert.notStrictEqual(appended, first);
|
||||||
|
assert.equal(appended.messages.length, 3);
|
||||||
|
assert.strictEqual(appended.messages[1], result);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changed masking stats publish a new snapshot without mutating the old one", () => {
|
||||||
|
const index = new SegmentIndex("session-stats");
|
||||||
|
index.hydrate();
|
||||||
|
const params = {
|
||||||
|
environmentHash: "env",
|
||||||
|
environmentTokens: 100,
|
||||||
|
contextWindow: 300_000,
|
||||||
|
maskedResults: 0,
|
||||||
|
reclaimedTokens: 0,
|
||||||
|
};
|
||||||
|
const first = index.project([assistant, result], params);
|
||||||
|
const same = index.project([assistant, result], params);
|
||||||
|
assert.strictEqual(same, first);
|
||||||
|
|
||||||
|
const updated = index.project([assistant, result], { ...params, maskedResults: 1, reclaimedTokens: 500 });
|
||||||
|
assert.notStrictEqual(updated, first);
|
||||||
|
assert.equal(updated.maskedResults, 1);
|
||||||
|
assert.equal(updated.reclaimedTokens, 500);
|
||||||
|
assert.equal(updated.rawTokens, updated.effectiveTokens + 500);
|
||||||
|
// The previously published object stays immutable.
|
||||||
|
assert.equal(first.maskedResults, 0);
|
||||||
|
assert.equal(first.reclaimedTokens, 0);
|
||||||
|
// Unchanged stats keep returning the same cached object.
|
||||||
|
assert.strictEqual(index.project([assistant, result], { ...params, maskedResults: 1, reclaimedTokens: 500 }), updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("usage-backed baseline is adjusted only by projected delta", () => {
|
||||||
|
const index = new SegmentIndex("session-3");
|
||||||
|
index.hydrate();
|
||||||
|
const initial = index.project([assistant], {
|
||||||
|
environmentHash: "env",
|
||||||
|
environmentTokens: 50,
|
||||||
|
contextWindow: 100_000,
|
||||||
|
maskedResults: 0,
|
||||||
|
reclaimedTokens: 0,
|
||||||
|
});
|
||||||
|
index.recordUsage(1_000, initial.messageEstimate, "env");
|
||||||
|
const next = index.project([assistant, { role: "user", content: "next", timestamp: 12 }], {
|
||||||
|
environmentHash: "env",
|
||||||
|
environmentTokens: 50,
|
||||||
|
contextWindow: 100_000,
|
||||||
|
maskedResults: 0,
|
||||||
|
reclaimedTokens: 0,
|
||||||
|
});
|
||||||
|
assert.equal(next.confidence, "usage-backed");
|
||||||
|
assert.ok(next.effectiveTokens >= 1_000);
|
||||||
|
assert.ok(next.effectiveTokens < 1_100);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { effectiveThreshold } from "../src/token-accounting.ts";
|
||||||
|
|
||||||
|
test("absolute token threshold wins on large windows", () => {
|
||||||
|
assert.equal(effectiveThreshold(200_000, 0.6, 1_000_000), 200_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("window ratio caps the threshold on small windows", () => {
|
||||||
|
assert.equal(effectiveThreshold(200_000, 0.6, 272_000), 163_200);
|
||||||
|
assert.equal(effectiveThreshold(100_000, 0.35, 272_000), 95_200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a missing context window falls back to the absolute threshold", () => {
|
||||||
|
assert.equal(effectiveThreshold(200_000, 0.6, 0), 200_000);
|
||||||
|
assert.equal(effectiveThreshold(200_000, 0.6, -1), 200_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["index.ts", "src/**/*.ts", "test/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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.
|
|
||||||
|
|
||||||
Available tools:
|
|
||||||
- read: Read file contents
|
|
||||||
- bash: Execute bash commands (ls, grep, find, etc.)
|
|
||||||
- edit: Make precise file edits with exact text replacement, including multiple disjoint edits in one call
|
|
||||||
- write: Create or overwrite files
|
|
||||||
|
|
||||||
In addition to the tools above, you may have access to other custom tools depending on the project.
|
|
||||||
|
|
||||||
Guidelines:
|
|
||||||
- Use bash for file operations like ls, rg, find
|
|
||||||
- Use read to examine files instead of cat or sed.
|
|
||||||
- You can inspect PI_* environment variables for current model and session details.
|
|
||||||
- Use edit for precise changes (edits[].oldText must match exactly)
|
|
||||||
- When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls
|
|
||||||
- Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.
|
|
||||||
- Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.
|
|
||||||
- Use write only for new files or complete rewrites.
|
|
||||||
- Be concise in your responses
|
|
||||||
- Show file paths clearly when working with files
|
|
||||||
|
|
||||||
Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):
|
|
||||||
- Main documentation: /Users/sia/.nvm/versions/node/v24.16.0/lib/node_modules/@earendil-works/pi-coding-agent/README.md
|
|
||||||
- Additional docs: /Users/sia/.nvm/versions/node/v24.16.0/lib/node_modules/@earendil-works/pi-coding-agent/docs
|
|
||||||
- Examples: /Users/sia/.nvm/versions/node/v24.16.0/lib/node_modules/@earendil-works/pi-coding-agent/examples (extensions, custom tools, SDK)
|
|
||||||
- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory
|
|
||||||
- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md)
|
|
||||||
- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing
|
|
||||||
- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)
|
|
||||||
Current working directory: /Users/sia/code/my-pi
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Development guide: Codex Fast Mode for Pi
|
|
||||||
|
|
||||||
Contributor implementation and validation notes for the locally maintained fork.
|
|
||||||
|
|
||||||
[Back to README](README.md) · [Technical reference](TECHNICAL.md) · [Upstream provenance](UPSTREAM.md)
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test
|
|
||||||
npm run check
|
|
||||||
npm run smoke
|
|
||||||
npm pack --dry-run --json
|
|
||||||
```
|
|
||||||
|
|
||||||
Tests cover command grammar and completions, branch restoration, cross-session defaults, owner-only atomic config writes, invalid-config fallback, persistence failures, busy mutation guards, status publication, exact provider/API isolation, malformed payload passthrough, non-mutation, and tier overwrite. They make no authenticated or paid model requests.
|
|
||||||
|
|
||||||
## State model
|
|
||||||
|
|
||||||
The effective state is resolved in this order:
|
|
||||||
|
|
||||||
1. Read the global default from `<agentDir>/extensions/pi-extension-codex-fast-mode/config.json`; missing or invalid data defaults to off.
|
|
||||||
2. Reconstruct the latest valid `codex-fast-mode` custom entry on the active session branch.
|
|
||||||
3. Use the branch entry when present; otherwise use the global default.
|
|
||||||
|
|
||||||
A successful mode command appends a session entry when the branch value changes and atomically writes the global default even when the branch already has the requested value. This allows an explicit `/fast-mode on` or `/fast-mode off` to repair or recreate global configuration.
|
|
||||||
|
|
||||||
If the global write fails, the branch state still changes and the user receives a warning. Concurrent Pi processes use atomic replacement; the last successful command determines the default for future sessions.
|
|
||||||
|
|
||||||
## Request isolation
|
|
||||||
|
|
||||||
When enabled, the extension shallow-copies only a plain serialized payload whose provider is exactly `openai-codex` and API is exactly `openai-codex-responses`, then writes `service_tier: "priority"`. It preserves all other top-level fields and does not mutate the original payload.
|
|
||||||
|
|
||||||
## Maintenance
|
|
||||||
|
|
||||||
This directory is maintained directly in `my-pi`, is not a submodule, and must not acquire a nested `.git`, `node_modules`, or generated build output. Compare local changes with the snapshot recorded in [UPSTREAM.md](UPSTREAM.md) before porting later upstream work.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Codex Fast Mode for Pi
|
|
||||||
|
|
||||||
A locally maintained fork of `@firstpick/pi-extension-codex-fast-mode@0.1.1` that adds a cross-session Fast mode default.
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
- `/fast-mode on` and `/fast-mode off` update the current session and the global default for future sessions.
|
|
||||||
- A new session inherits the global default.
|
|
||||||
- A resumed session branch keeps its own latest recorded setting, overriding the global default.
|
|
||||||
- Only `openai-codex` requests using `openai-codex-responses` receive `service_tier: "priority"`.
|
|
||||||
- Other providers, APIs, payload fields, and authentication remain unchanged.
|
|
||||||
|
|
||||||
The global preference is stored at:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<Pi agent dir>/extensions/pi-extension-codex-fast-mode/config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Normally this is `~/.pi/agent/extensions/pi-extension-codex-fast-mode/config.json`. The directory and file are restricted to owner-only permissions (`0700` and `0600`). Missing, unreadable, or invalid configuration fails safely to Fast mode off.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
- `/fast-mode` toggles Fast mode and saves the new global default.
|
|
||||||
- `/fast-mode on` enables it and makes new sessions default to on.
|
|
||||||
- `/fast-mode off` disables it and makes new sessions default to off.
|
|
||||||
- `/fast-mode status` reports the active branch setting and the default for new sessions.
|
|
||||||
|
|
||||||
Mode changes are rejected while Pi is busy; status remains available.
|
|
||||||
|
|
||||||
## Installation in my-pi
|
|
||||||
|
|
||||||
The root bundle loads this directory directly and declares it as a local `file:` dependency. Do not also install the upstream npm package, or Pi may load the command twice.
|
|
||||||
|
|
||||||
## Source and technical details
|
|
||||||
|
|
||||||
See [UPSTREAM.md](UPSTREAM.md) for provenance, [TECHNICAL.md](TECHNICAL.md) for runtime behavior, and [DEVELOPMENT.md](DEVELOPMENT.md) for validation. The upstream MIT license is retained in [LICENSE](LICENSE).
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user