refactor: 移除 context-mode,Chrome 快照改为直接返回

context-mode 的 ctx_execute 系工具以真实 shell 执行且权限为 allow,绕过了
bash 策略与 protectedWrites;其每轮注入、独立 MCP 子进程与 skills 也与组合包
提示词单一所有权冲突。大输出职责已由 pi-overflow(入口截断)与
pi-compaction(压力遮蔽)承担:
- 删除 context-mode 依赖、wrapper/过滤器、skills,以及 ctx_* 的权限、
  tools.jsonc、路由规则、tool-search 常驻与 4 个分组
- better-sqlite3@12 仅为 context-mode 传递依赖,随之移除根 allowScripts
- pi-chrome:按用户决定移除快照隐私规则与 .pi/chrome-context 临时文件机制,
  快照 JSON 直接作为工具结果返回、details 只留小型元数据,超长由 pi-overflow
  截断并用 overflow_query 提取;授权生命周期不变

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-21 20:45:33 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 2a6bf8c929
commit 56cbc83296
36 changed files with 178 additions and 2142 deletions
+1 -2
View File
@@ -22,7 +22,6 @@
| `pi-overflow/` | 新工具结果统一截断:超阈值原文落盘 + 有界视图 + `overflow_query` |
| `extensions/fff-override.ts` | FFF `override` 模式接管 `find`/`grep`/`multi_grep` 与 `@` 补全 |
| `extensions/codegraph.ts` | 原生 `codegraph_explore`,自有 stdio JSON-RPC 直连 `codegraph serve`(`codegraph-client.ts`) |
| `extensions/context-mode.ts` | 经 `prompt-guard.ts` 加载 context-mode adapter 与 skills(`context-mode-filter.ts` 过滤冲突锚点) |
| `extensions/hashline.ts` | 部署 `config/pi-hashline-edit.json` 后加载 pi-hashline-edit 覆盖 `read`/`edit`(`hashline-config.ts`) |
| `extensions/permission-system.ts` | 直接加载仓库内 `pi-permission-system/src/index.ts` |
| `extensions/lsp.ts` | 物化包内 typescript-language-server、部署 LSP 配置并加载 pi-lsp(`lsp-config.ts`) |
@@ -37,7 +36,7 @@
- 系统提示词单一所有权:`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`。
- 加载顺序:`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` 之前。
- 依赖:外部依赖在根 `package.json` 用精确版本,自维护扩展一律 `file:` 本地依赖并更新锁文件;Pi 核心包只作 optional host peer,根 runtime deps 不含 Pi runtime,任何目录不得再装一套 Pi runtime;各子包用于测试的 `@earendil-works/*` devDependencies 一律写 `"latest"`,经 `scripts/update-pi-devdeps.sh` 统一刷新;原生构建依赖只按锁定版本进根 `allowScripts`(当前仅 `better-sqlite3@12.11.1`;pi-hermes-memory 的 `better-sqlite3@13` 用包内 N-API 预编译,不新增放行)。不要用仓库级 `.pi/settings.json` 代替组合包依赖。根 `files` 对子包按运行时子路径白名单列出(源码、package.json、README/LICENSE/CHANGELOG/UPSTREAM),不得整目录列入,以免打入嵌套 node_modules、dist 和测试;由 `tests/package-files.test.ts` 守护。
- 依赖:外部依赖在根 `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` 守护。
- 配置:`config/` 下全部文件是权威源、必须提交;不得读写 `settings.json` 自定义段、agent 目录独立配置文件或以环境变量作持久默认值;`config/` 只放机器无关值,路径类默认值在代码中运行时计算。`<agentDir>/my-pi/` 只放可丢弃运行时状态(日志、缓存),连同旧 `extensions/pi-permission-system/` 残留、构建产物与依赖目录都不提交;用户数据(记忆、会话、`auth.json` 等)不属于组合包配置,也不得放入该目录。
- 组合包不加载任何 MCP adapter/SDK:Exa 与 CodeGraph 均用自有直连客户端。
- 导入/同步上游:只从官方 Git 仓库的明确 tag/commit 取源码快照,禁止 npm tarball/缓存/`node_modules`/`dist/` 等预编译产物作导入源;保留上游 LICENSE、作者、测试与来源说明,移除嵌套 `.git`、`.pi` 状态、`node_modules` 与构建产物;同步前先核对本地改动再按明确范围移植,不得整体覆盖。各目录的维护约定见 `AGENTS.md`,每个自维护目录必须有中文 `UPSTREAM.md` 记录来源,上游原文文件不翻译不改写。
+14 -19
View File
@@ -4,7 +4,6 @@
- `@ff-labs/pi-fff@0.10.5`:使用官方 `override` 模式接管 `find`、`grep` 和 `multi_grep`。
- 本仓库维护的 `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)。
- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性启动常驻 `codegraph serve --mcp` 子进程,由组合包自有 stdio JSON-RPC 客户端直连(无 MCP adapter/SDK);serve 的文件监听自动同步索引,无结果缓存;未初始化项目经确认后才 `codegraph init`,拒绝记入会话不再重复询问。
- `extensions/context-view.ts`:通过 `/context` 只读显示 `pi-compaction` 的 effective context,不建立第二套上下文口径。
@@ -17,7 +16,7 @@
- 本仓库维护的 `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-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-system`:只读取一份全局普通策略与一份独立 Hard Guards,按 pattern 具体度裁决 allow/ask/deny,并整合 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底;自动评审模型完全由配置文件指定,未配置时提示并转人工审批;工具显隐仍只由 Tool Search 管理。
- 本仓库维护的 `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-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
@@ -142,7 +141,7 @@ CodeGraph 的更新或遥测设置。
在线搜索默认按任务只选一个服务:关键词不确定、新闻/近期话题或需要较多候选来源时优先 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/` 属于本地诊断输出,默认不提交。
@@ -150,7 +149,7 @@ CodeGraph 的更新或遥测设置。
`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、Context Mode)经 `extensions/` 下的包装文件与 `prompt-guard.ts` 代理加载,拦截直接的提示词改写并按 `tools.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/`。
@@ -160,7 +159,7 @@ CodeGraph 的更新或遥测设置。
授权后注册的 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
@@ -168,7 +167,7 @@ CodeGraph 的更新或遥测设置。
本地源码直接使用 `@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 管理。不可用、已固定或尚未授权而未注册的可选工具会自动从动态组中滤除。
新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics`、六个记忆工具、`context_tree_query`、`overflow_query` 和 `tool_search`;Windows 上额外把内置 `powershell` 与 `bash` 作为同类本地 Shell 核心工具固定启用,非 Windows 平台则从目录中过滤它。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、Chrome 导航/交互/调试、用户交互、CodeGraph/LSP、Tavily、Exa、Keenable、Memory 查询/维护与 Skill 管理。不可用、已固定或尚未授权而未注册的可选工具会自动从动态组中滤除。
标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具,并且当前调用不是精确加载已有分组时,才可使用当前已认证模型补充目录;精确已有分组始终立即按当前确定性目录激活,不等待无关未知工具的模型增强。模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `my-pi/tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会恢复预置目录,`/tool-search-status` 显示目录来源、容量、LRU 顺序和最近淘汰原因。
@@ -181,16 +180,14 @@ Tool Search 的配置文件是已安装组合包内的 `config/tool-search.json`
"alwaysEnabled": [
"codegraph_explore",
"lsp_diagnostics",
"ctx_execute",
"ctx_execute_file",
"ctx_batch_execute",
"memory_search",
"session_search",
"memory_add",
"memory_replace",
"memory_remove",
"skill_manage",
"context_tree_query"
"context_tree_query",
"overflow_query"
],
"showToolSearchFooterStatus": false,
"maxActiveGroups": 5,
@@ -206,7 +203,7 @@ Tool Search 的配置文件是已安装组合包内的 `config/tool-search.json`
组合包固定加载 `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
@@ -221,14 +218,12 @@ RTK 保留 Bash、read、build、test、lint 和 Git 输出压缩。默认 `read
仍关闭;可用 `/rtk` 查看或调整。旧机器已有的 RTK 配置若没有
`commandRewritingEnabled` 字段,也会按 `false` 归一化,不需要迁移配置。
### 大输出归 Context Mode
### 大输出归 pi-overflow
Context Mode 直接加载 npm 包内置的 Pi adapter 和 skills,不需要额外写入
`~/.pi/agent/mcp.json`,也不需要全局安装 `context-mode` 命令。它提供 `ctx_*` 工具,
将批量读取、命令研究和网页内容先在隔离进程中处理,再把收敛后的结果送入上下文。
FFF 仍负责精确字面搜索;RTK 继续压缩未走 Context Mode 的 Bash、build、test、lint
和 Git 输出。Context Mode 不替换这两个扩展的现有配置。
新工具结果的展示截断统一由 `pi-overflow` 负责:超阈值的原文落盘、模型只看到
「头 + 信号行 + 尾」的有界视图,需要原文时用 `overflow_query` 检索(详见下文
「Pi Overflow」小节)。FFF 仍负责精确字面搜索;RTK 继续对 Bash、build、test、lint
和 Git 输出做语义压缩,其兜底硬截断已关闭。
### SSH 服务器配置
@@ -386,7 +381,7 @@ compaction 和排队 follow-up 完成前不会提前通知;同时监听 `pi-pe
原生远程压缩同时支持 `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。
本实现不读取或迁移旧 `contextPrune`、`context-prune-*` 或旧 Codex native checkpoint;切换后应创建新 Session。它与 Context Mode 职责互补:Context Mode 防止大型原始内容进入会话,Pi Compaction 管理已经进入会话的长期历史。
本实现不读取或迁移旧 `contextPrune`、`context-prune-*` 或旧 Codex native checkpoint;切换后应创建新 Session。它与 pi-overflow 职责互补:pi-overflow 在新工具结果进入会话时即截断展示,Pi Compaction 管理已经进入会话的长期历史。
### Pi Overflow
-8
View File
@@ -45,13 +45,6 @@
"lsp_references": "allow",
"lsp_hover": "allow",
"lsp_symbols": "allow",
"ctx_execute": "allow",
"ctx_execute_file": "allow",
"ctx_batch_execute": "allow",
"ctx_search": "allow",
"ctx_index": "allow",
"ctx_stats": "allow",
"ctx_doctor": "allow",
"tool_search": "allow",
"ask_user_question": "allow",
// Launching, steering or reading a subagent has no side effect itself;
@@ -92,7 +85,6 @@
// Model-chosen URLs can exfiltrate data — same reason curl/wget ask.
"web_fetch": "ask",
"ctx_fetch_and_index": "ask",
"ssh_connect": "ask",
"ssh_cd": "ask",
+2 -14
View File
@@ -50,20 +50,8 @@
"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": ["web_fetch", "ctx_fetch_and_index"],
"text": "Read a web page with web_fetch. Use ctx_fetch_and_index only when the content should be indexed into the context-mode knowledge base for repeated ctx_search queries."
},
{
"whenAny": ["ctx_execute", "ctx_execute_file", "ctx_batch_execute"],
"text": "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 one ctx_batch_execute over several ctx_execute calls for multi-command research, and print only the derived answer needed for the task."
},
{
"whenAny": ["ctx_execute_file"],
"text": "ctx_execute_file analyzes a file and never edits it; make changes with read + edit. It 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 boundary. Use another explicitly authorized tool for those files."
},
{
"whenAll": ["chrome_snapshot", "ctx_execute_file"],
"text": "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."
"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"],
-12
View File
@@ -43,18 +43,6 @@
"web_search": { "inherit": true },
"web_fetch": { "inherit": true },
"ctx_execute": { "inherit": true },
"ctx_execute_file": { "inherit": true },
"ctx_batch_execute": { "inherit": true },
"ctx_index": { "inherit": true },
"ctx_search": { "inherit": true },
"ctx_fetch_and_index": { "inherit": true },
"ctx_stats": { "inherit": true },
"ctx_doctor": { "inherit": true },
"ctx_insight": { "inherit": true },
"ctx_upgrade": { "inherit": true },
"ctx_purge": { "inherit": true },
"memory_search": { "inherit": true },
"session_search": { "inherit": true },
"memory_add": { "inherit": true },
-3
View File
@@ -2,9 +2,6 @@
"alwaysEnabled": [
"codegraph_explore",
"lsp_diagnostics",
"ctx_execute",
"ctx_execute_file",
"ctx_batch_execute",
"memory_search",
"session_search",
"memory_add",
-1
View File
@@ -27,7 +27,6 @@
- `fff-override.ts`:强制 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 `@` 补全;FFF 独占本地字面搜索,远端服务器搜索由 `ssh_find` / `ssh_grep` 负责,RTK 不得接管搜索工具(见 `pi-rtk-optimizer/AGENTS.md`)。显式 CLI flag 仍遵循 FFF 官方优先级。
- `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`)职责已并入本文件的惰性确认,不得恢复。
- `context-mode.ts` + `context-mode-filter.ts`:经 `prompt-guard.ts` 代理加载上游 `context-mode` Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性;Context Mode 负责避免批量读取、命令研究和网页原始内容直接撑大上下文。filter 只丢弃其 context 注入中与本组合包路由重复且冲突的锚点段。
- `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` 配置,根包不安装这两个系统可执行文件。
-36
View File
@@ -1,36 +0,0 @@
/**
* Pure filter applied after context-mode's `context` handler: its upstream
* build appends one `{ role: "user", content }` message whose parts are
* `"\n\n"`-joined and whose first part is a routing anchor that duplicates —
* and contradicts — the bundle-owned `config/prompt/routing.jsonc`. The anchor
* part is stripped; remaining parts (active memory, resume snapshot) stay.
* No other message is touched; when nothing changes the input array is
* returned unchanged (same reference).
*/
export const CONTEXT_MODE_ANCHOR_PREFIX = "context-mode active. Hierarchy:";
type MessageLike = { role?: unknown; content?: unknown } & Record<string, unknown>;
export function filterContextModeMessages<T extends MessageLike>(
before: readonly T[],
after: readonly T[],
): T[] {
if (after.length <= before.length) return after as T[];
let result: T[] | null = null;
for (let i = after.length - 1; i >= before.length; i--) {
const message = after[i];
if (message === null || typeof message !== "object") continue;
if (message.role !== "user" || typeof message.content !== "string") continue;
const parts = message.content.split("\n\n");
const kept = parts.filter((part) => !part.startsWith(CONTEXT_MODE_ANCHOR_PREFIX));
if (kept.length === parts.length) continue;
result ??= after.slice();
if (kept.length === 0) {
result.splice(i, 1);
} else {
result[i] = { ...message, content: kept.join("\n\n") };
}
}
return result ?? (after as T[]);
}
-18
View File
@@ -1,18 +0,0 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
// The adapter is a prebuilt .js bundle, not a TypeScript source.
import piExtension from "../node_modules/context-mode/build/adapters/pi/extension.js";
import { filterContextModeMessages } from "./context-mode-filter.ts";
import { guardExtensionApi } from "./prompt-guard.ts";
/**
* Load context-mode through the prompt guard: the bundle keeps sole ownership
* of the system prompt, so the upstream routing anchor appended to the
* `context` message list is filtered out (bundle routing lives in
* `config/prompt/routing.jsonc`), while upstream active-memory/resume parts
* pass through untouched. The upstream code this filter depends on is pinned
* by `tests/system-prompt.test.ts` against the exact `context-mode` version.
*/
export default function bundledContextModeExtension(pi: ExtensionAPI): void {
piExtension(guardExtensionApi(pi, { contextFilter: filterContextModeMessages }));
}
+11 -1526
View File
File diff suppressed because it is too large Load Diff
-9
View File
@@ -24,7 +24,6 @@
"pi-compaction/LICENSES",
"pi-compaction/UPSTREAM.md",
"pi-chrome/extensions",
"pi-chrome/src",
"pi-chrome/package.json",
"pi-chrome/README.md",
"pi-chrome/CHANGELOG.md",
@@ -125,7 +124,6 @@
"./extensions/codegraph.ts",
"./pi-rtk-optimizer/index.ts",
"./extensions/hashline.ts",
"./extensions/context-mode.ts",
"./extensions/lsp.ts",
"./pi-hermes-memory/src/index.ts",
"./pi-compaction/index.ts",
@@ -141,9 +139,6 @@
"./pi-tool-search/extensions/index.ts",
"./pi-overflow/index.ts",
"./extensions/system-prompt.ts"
],
"skills": [
"./node_modules/context-mode/skills"
]
},
"dependencies": {
@@ -152,7 +147,6 @@
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
"@smoose/pi-notify": "file:./pi-notify",
"@tintinweb/pi-subagents": "file:./pi-subagents",
"context-mode": "1.0.169",
"jiti": "2.7.0",
"pi-ask-user": "file:./pi-ask-user",
"pi-chrome": "file:./pi-chrome",
@@ -192,8 +186,5 @@
},
"engines": {
"node": ">=22.19.0"
},
"allowScripts": {
"better-sqlite3@12.11.1": true
}
}
+2 -3
View File
@@ -1,9 +1,8 @@
# my-pi 维护约定(pi-chrome)
- 扩展入口是 `pi-chrome/extensions/chrome-profile-bridge/index.ts`,Companion MV3 扩展位于 `browser-extension/`,Session 快照存储位于 `src/session-snapshot-store.ts`,测试保留在 `test/` 与 `test-suite/`,来源记录是 `UPSTREAM.md`。
- 扩展入口是 `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 不得进入普通工具文本或 `details`;只能原子写入当前项目 `.pi/chrome-context/<session>/<target>/snapshot.json`,使用哈希目录、POSIX `700` 目录 / `600` 文件,并只返回工作区相对路径和有界元数据。快照不得跨 Session 持久化,Session 启动/关闭、撤销和授权过期必须清理。
- `chrome_snapshot` 和带 `includeSnapshot=true` 的动作必须引导模型立即使用 `ctx_execute_file`,不得使用 `read` / `cat` 把原始 JSON 重新塞入上下文;任何可能改变页面或目标的成功操作必须使旧快照失效,`chrome_find` 只可额外返回有界匹配摘要。
- `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
View File
@@ -6,7 +6,7 @@
```text
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
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.
### 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
.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.
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.
---
+2 -5
View File
@@ -15,12 +15,9 @@
导入后的本地改动:
- Chrome 快照以原子方式写入当前项目 `.pi/chrome-context/` 目录下的 owner-only、Session 临时文件,而不是把原始页面结构返回给模型上下文。
- 每个 Session/目标只保留最新快照;浏览器交互会使陈旧捕获失效,Session 关闭时移除该 Session 目录。
- `chrome_find` 返回紧凑的排序匹配,同时把完整结构化快照保留在 Session 本地捕获中,供可选的 `ctx_execute_file` 分析使用。
- click/type/fill/key 上的 `includeSnapshot=true` 会保存动作后的快照并返回其捕获路径,而不是把快照嵌入工具结果。
- Chrome 快照与 `includeSnapshot=true` 的动作结果把快照 JSON 直接放入工具文本返回,`details` 只保留元素数/匹配数等小型元数据;展示截断由组合包 pi-overflow 统一负责(原文落盘 + `overflow_query` 查询)。`chrome_find` 只返回紧凑排序匹配,不保存文件。早期版本曾把快照原子写入 `.pi/chrome-context/` 临时文件并引导 `ctx_execute_file` 分析;该 Session 临时文件机制已随 context-mode 一起移除,各项目中残留的 `.pi/chrome-context/` 目录不再创建,可手动删除。
- 授权后的 `before_agent_start` 不再返回 `systemPrompt`:Chrome 使用提示写入 `event.systemPromptOptions.sections.chrome_profile_bridge` 结构化分区,由组合包单一提示词出口 `extensions/system-prompt.ts` 统一组装,顺序由 `config/prompt/sections.jsonc` 控制;未授权时不贡献任何提示词内容。
- 移除 `PI_CHROME_BRIDGE_HOST`/`PI_CHROME_BRIDGE_PORT` 环境变量,host/port 固定为 `127.0.0.1:17318`(Companion 扩展 `service_worker.js` 同样硬编码该地址,单侧可配只会断连且非回环 host 有安全风险)。
- 移除 `MAX_TEXT_CHARS`/`truncateText` 的 30K 字符文本截断:chrome_* 工具返回完整文本,超长结果的展示截断由 pi-overflow 统一负责;`MAX_ELEMENTS` 等条数上限保留不变。
同步上游时,请以导入 commit 为基准比较并选择性移植改动。不得整体覆盖这些本地生命周期、隐私和 Context Mode 集成。
同步上游时,请以导入 commit 为基准比较并选择性移植改动。不得整体覆盖这些本地生命周期和组合包集成改动。
@@ -4,11 +4,6 @@ import { existsSync, readFileSync, statSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
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.
@@ -89,13 +84,28 @@ function snapshotFromIncludedResult(raw: unknown): unknown {
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));
return [
`Chrome snapshot saved to ${metadata.path} (${kib} KiB, ${metadata.elementCount} elements${metadata.matchCount ? `, ${metadata.matchCount} matches` : ""}).`,
"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.",
].join("\n");
/**
* Small, session-file-safe metadata describing a returned snapshot. The full
* snapshot goes to the model as text — details must stay small because Pi
* persists them in the session file.
*/
export function snapshotStats(snapshot: unknown): { elementCount: number; matchCount: number } {
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 {
@@ -642,7 +652,6 @@ export default function (pi: ExtensionAPI): void {
let countdownInterval: NodeJS.Timeout | undefined;
// Remembered so bridge sends can tag tabs with this session's group even when ctx isn't handy.
let sessionCtx: ExtensionContext | undefined;
let snapshotStore: SessionSnapshotStore | undefined;
const clearAuthExpiryTimer = (): void => {
if (!authExpiryTimer) return;
@@ -716,7 +725,6 @@ export default function (pi: ExtensionAPI): void {
persistAuth();
// Revoking control ends pi-chrome's automation for this session; tidy up the target we own.
cleanupAutomationTargetBestEffort();
void snapshotStore?.clear().catch(() => undefined);
};
const authSummary = (): string => {
@@ -847,45 +855,22 @@ export default function (pi: ExtensionAPI): void {
return { ...params, foreground: !background } as T;
};
const snapshotTargetFrom = (params: Record<string, unknown>): SnapshotTarget => ({
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 formatActionResult = (raw: unknown, params: Record<string, unknown>, text: string): ToolTextResult => {
const snapshot = snapshotFromIncludedResult(raw);
const result = params.includeSnapshot && raw && typeof raw === "object"
? (raw as { result?: unknown }).result
: raw;
if (!snapshot) {
await invalidateSnapshotCapture(params);
return { content: [{ type: "text", text }], details: { result: result as Json } };
}
const capture = await saveSnapshotCapture(snapshot, params);
return {
content: [{ type: "text", text: `${text}\n\n${formatSnapshotCapture(capture)}` }],
details: { result: result as Json, capture },
content: [{ type: "text", text: formatActionResultText(text, snapshot) }],
details: { result: result as Json, snapshot: snapshotStats(snapshot) },
};
};
pi.on("session_start", async (_event, ctx) => {
sessionCtx = ctx;
const sessionId = ctx.sessionManager.getSessionId?.() || "unknown-session";
snapshotStore = new SessionSnapshotStore(workspaceCwd(ctx), sessionId);
await snapshotStore.clear();
await bridge.start();
// Reestablish in-memory state after a /reload restored chromeAuthorizedUntil from globalThis.
if (chromeControlAuthorized()) {
@@ -903,10 +888,6 @@ export default function (pi: ExtensionAPI): void {
pi.on("session_shutdown", async (event) => {
clearAuthExpiryTimer();
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.
if (event?.reason !== "reload") cleanupAutomationTargetBestEffort();
bridge.stop();
@@ -933,12 +914,12 @@ 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.
- 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_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:
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.
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.
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, 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.
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.
@@ -1331,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.";
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 } };
},
});
@@ -1340,8 +1320,8 @@ Usage rules:
name: "chrome_snapshot",
label: "Chrome Snapshot",
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.",
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.",
"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: "Capture the current Chrome page and return its JSON snapshot; query large results via overflow_query.",
parameters: Type.Object({
targetId: Type.Optional(Type.String()),
urlIncludes: Type.Optional(Type.String()),
@@ -1366,8 +1346,7 @@ Usage rules:
DEFAULT_TIMEOUT_MS,
signal,
);
const capture = await saveSnapshotCapture(snapshot, params);
return { content: [{ type: "text", text: formatSnapshotCapture(capture) }], details: { capture } };
return { content: [{ type: "text", text: safeJson(snapshot) }], details: { snapshot: snapshotStats(snapshot) } };
},
});
@@ -1375,8 +1354,8 @@ Usage rules:
name: "chrome_find",
label: "Chrome Find",
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.",
promptSnippet: "Find live Chrome controls/text/regions and return compact stable-uid matches while keeping the full snapshot out of model context.",
"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.",
parameters: Type.Object({
query: Type.String({ description: "What to find, e.g. 'merge button', 'email error', 'approve PR', 'search box'." }),
mode: Type.Optional(StringEnum(snapshotModeValues)),
@@ -1397,10 +1376,9 @@ Usage rules:
DEFAULT_TIMEOUT_MS,
signal,
);
const capture = await saveSnapshotCapture(snapshot, params);
return {
content: [{ type: "text", text: `${formatChromeFindMatches(snapshot)}\n\n${formatSnapshotCapture(capture)}` }],
details: { capture },
content: [{ type: "text", text: formatChromeFindMatches(snapshot) }],
details: { snapshot: snapshotStats(snapshot) },
};
},
});
@@ -1446,9 +1424,8 @@ Usage rules:
DEFAULT_TIMEOUT_MS,
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${formatSnapshotCapture(capture)}`;
return { content: [{ type: "text", text }], details: { capture, fallback: "page.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${safeJson(snapshot)}`;
return { content: [{ type: "text", text }], details: { snapshot: snapshotStats(snapshot), fallback: "page.snapshot" } };
}
},
});
@@ -1475,7 +1452,6 @@ Usage rules:
}),
async execute(_id, params, signal): Promise<ToolTextResult> {
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 } };
},
});
@@ -1500,7 +1476,6 @@ Usage rules:
}),
async execute(_id, params, signal): Promise<ToolTextResult> {
const value = await authorizedBridgeSend("page.evaluate", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
await invalidateSnapshotCapture(params);
const text = value === undefined
? "undefined"
: typeof value === "string"
@@ -1514,7 +1489,7 @@ Usage rules:
name: "chrome_click",
label: "Chrome Click",
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.",
parameters: Type.Object({
uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot. Prefer uid over selector after taking a snapshot." })),
@@ -1522,7 +1497,7 @@ Usage rules:
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." })),
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." })),
targetId: Type.Optional(Type.String()),
urlIncludes: Type.Optional(Type.String()),
@@ -1539,7 +1514,7 @@ Usage rules:
const summary = summarizeActionResult(result);
const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
const text = summary ? `Clicked ${target} — ${summary}` : `Clicked ${target}`;
return formatActionWithCapture(raw, params, text);
return formatActionResult(raw, params, text);
},
});
@@ -1547,13 +1522,13 @@ Usage rules:
name: "chrome_type",
label: "Chrome Type",
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.",
parameters: Type.Object({
text: Type.String(),
uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
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." })),
pressEnter: Type.Optional(Type.Boolean()),
targetId: Type.Optional(Type.String()),
@@ -1572,7 +1547,7 @@ Usage rules:
const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
const base = `Typed ${params.text.length} character(s)${into}.`;
const text = summary ? `${base} (${summary})` : base;
return formatActionWithCapture(raw, params, text);
return formatActionResult(raw, params, text);
},
});
@@ -1580,7 +1555,7 @@ Usage rules:
name: "chrome_fill",
label: "Chrome Fill",
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.",
parameters: Type.Object({
text: Type.String(),
@@ -1588,7 +1563,7 @@ Usage rules:
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." })),
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." })),
targetId: Type.Optional(Type.String()),
urlIncludes: Type.Optional(Type.String()),
@@ -1606,7 +1581,7 @@ Usage rules:
const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
const base = `Filled ${params.text.length} character(s)${into}.`;
const text = summary ? `${base} (${summary})` : base;
return formatActionWithCapture(raw, params, text);
return formatActionResult(raw, params, text);
},
});
@@ -1614,7 +1589,7 @@ Usage rules:
name: "chrome_key",
label: "Chrome Key",
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.",
parameters: Type.Object({
key: Type.String(),
@@ -1624,7 +1599,7 @@ Usage rules:
altKey: Type.Optional(Type.Boolean()),
metaKey: Type.Optional(Type.Boolean()),
}, { 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." })),
targetId: Type.Optional(Type.String()),
urlIncludes: Type.Optional(Type.String()),
@@ -1641,7 +1616,7 @@ Usage rules:
const summary = summarizeActionResult(result);
const base = `Pressed ${params.key}.`;
const text = summary ? `${base} (${summary})` : base;
return formatActionWithCapture(raw, params, text);
return formatActionResult(raw, params, text);
},
});
@@ -1663,7 +1638,6 @@ Usage rules:
}),
async execute(_id, params, signal): Promise<ToolTextResult> {
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 } };
},
});
@@ -1807,7 +1781,6 @@ Usage rules:
}),
async execute(_id, params, signal): Promise<ToolTextResult> {
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 } };
},
});
@@ -1834,7 +1807,6 @@ Usage rules:
}),
async execute(_id, params, signal): Promise<ToolTextResult> {
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 } };
},
});
@@ -1857,7 +1829,6 @@ Usage rules:
}),
async execute(_id, params, signal): Promise<ToolTextResult> {
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}`;
return { content: [{ type: "text", text: `Tapped ${target} (touch)` }], details: { result: result as Json } };
},
@@ -1881,7 +1852,6 @@ Usage rules:
}),
async execute(_id, params, signal): Promise<ToolTextResult> {
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 } };
},
});
@@ -1904,7 +1874,6 @@ Usage rules:
const cwd = workspaceCwd(ctx);
const paths = params.paths.map((p) => resolve(cwd, p));
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 } };
},
});
+1 -2
View File
@@ -2,7 +2,7 @@
"name": "pi-chrome",
"version": "0.15.46",
"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",
"prepublishOnly": "node scripts/sync-manifest-version.js"
},
@@ -52,7 +52,6 @@
"type": "commonjs",
"files": [
"extensions",
"src",
"test",
"docs",
"test-suite",
-140
View File
@@ -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"));
});
+48
View File
@@ -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"));
});
+1 -1
View File
@@ -4,7 +4,7 @@
- 根包必须通过 `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 的历史投影,与 Context Mode 的输入隔离职责互补。首次 Session hydration 允许一次完整扫描;后续普通 `context` hot path 必须复用不可变 snapshot 或只处理 delta,不得调用 summarizer、写 Session 或重新构建完整 branch。
- `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 内部实现。
+1 -1
View File
@@ -16,7 +16,7 @@ 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.
Context Mode remains responsible for preventing large external data from entering the session. RTK remains responsible for ordinary tool-output cleanup.
pi-overflow bounds new tool results as they arrive. RTK remains responsible for ordinary tool-output cleanup.
## Configuration
+1 -1
View File
@@ -19,7 +19,7 @@
### 依赖
- `better-sqlite3@^13.0.3`:13.x 运行时实际 `dlopen` 的是包内 N-API 预编译二进制 `prebuilds/<platform>.node`,**不依赖 node-gyp 编译产物**。npm 会因该包存在 `binding.gyp` 而推断出隐式 `node-gyp rebuild`,但该步骤既无必要也不得批准。npm 会把它嵌套安装到本目录的 `node_modules/`,与 Context Mode 使用的根 `better-sqlite3@12.11.1` 并存;因此根 `allowScripts` 无需新增条目,也不得删除既有的 `better-sqlite3@12.11.1` 放行。
- `better-sqlite3@^13.0.3`:13.x 运行时实际 `dlopen` 的是包内 N-API 预编译二进制 `prebuilds/<platform>.node`,**不依赖 node-gyp 编译产物**。npm 会因该包存在 `binding.gyp` 而推断出隐式 `node-gyp rebuild`,但该步骤既无必要也不得批准。npm 会把它嵌套安装到本目录的 `node_modules/`,根 `allowScripts` 无需任何 `better-sqlite3` 放行条目(context-mode 移除后根依赖已不再包含 better-sqlite3@12)。
- `strip-ansi@7.2.0`:解析到根 `node_modules`。
### 本地改动
+1 -1
View File
@@ -8,7 +8,7 @@
- reviewer 默认跟随会话当前主模型(每次审查实时读取 `ctx.model`),经 `modelRegistry.streamSimple(...).result()` 调用;`autoReview.provider` / `autoReview.model` 必须同时设置(固定审查模型)或同时留空,只设其一视为配置错误并 `defer`。默认 `reasoning: "low"`(仅对支持推理的模型生效)、总超时 30 秒、最多 2 次尝试(仅 Provider 错误重试)、`maxOutputTokens` 4000。只把 active branch 中的直接用户消息与已识别、未取消的结构化问答作为授权证据,assistant/tool/compaction 内容不能自行授权;工具结果可替换为更短的 pi-compaction 摘要,但仍属不可信证据。同一条用户消息内完全相同的请求只缓存 `approve` 结论。
- `config/pi-permission-policy.jsonc` 必须显式配置 `authorizerChain: ["auto-review"]`,普通未匹配请求和需要自动复核的类别声明为 `ask`;普通 pattern 必须按具体度优先(精确匹配、字面字符更多、通配符更少、pattern 更长,最后才以声明顺序破同级);`autoReview` 段只保留 `enabled`,不设 provider/model 种子值。`config/pi-permission-hard-guards.jsonc` 独立保存可由用户调整但不能被普通策略、Session grant、per-agent 规则或 reviewer 覆盖的高危 `deny`,以及 `protectedWrites` 人工写保护清单。
- `protectedWrites` 是确定性的 human-only 写路径,不属于普通策略也不是第二个授权范围:命中目标的 `write`/`edit` 与本地 `bash` 写入(redirect-out、写操作程序参数、`bash -c`/`eval` 等包装载荷中的受保护路径)一律弹出人工确认,跳过 AutoReview、yolo 与既有 Session grant,弹窗不提供「本会话允许」选项;没有可用确认界面(无 UI、子代理转发终端)时直接拒绝。读取不受影响;`ssh_bash` 等远端 shell 工具不参与本机路径保护。
- 策略默认允许常规只读与低风险工具;`web_fetch`、`ctx_fetch_and_index`、`skill_manage` 保持 `ask`(模型自选的 URL 可能外传数据,skill 写入会进入所有后续会话);硬防御默认只保留确定性的破坏性 Shell 命令;Git 只读管道命令放行,其余非只读操作、包管理、文件/系统/网络高风险操作与 `mcp__<server>__<tool>` 调用按唯一全局 policy 判定。项目外路径不产生独立 `external_directory` 判断,只走配置中的工具、`path` 与 Bash pattern。
- 策略默认允许常规只读与低风险工具;`web_fetch`、`skill_manage` 保持 `ask`(模型自选的 URL 可能外传数据,skill 写入会进入所有后续会话);硬防御默认只保留确定性的破坏性 Shell 命令;Git 只读管道命令放行,其余非只读操作、包管理、文件/系统/网络高风险操作与 `mcp__<server>__<tool>` 调用按唯一全局 policy 判定。项目外路径不产生独立 `external_directory` 判断,只走配置中的工具、`path` 与 Bash pattern。
- `HardGuardPolicyLoader` 必须保持 global-only、无 SessionRules、无项目覆盖;唯一的组合包 hard-guards 文件不可用时 fail closed 到 deny-all,并报告配置问题。普通 policy 与 hard guard 配置不得合并成一份文件。
- 内置 AutoReview 实现位于 `src/auto-review/`;同步原 AutoReview 上游时必须从源码仓库明确 tag/commit 移植,保留 `LICENSES/pi-permission-auto-review-MIT.txt`、作者和 `docs/auto-review/UPSTREAM.md`,不得恢复顶层独立扩展、npm 预编译入口或平行 gate。
- reviewer 只能处理普通策略已判定为 `ask` 且未被 hard guard 拒绝的请求;不得绕过硬 `deny`。
+1 -1
View File
@@ -3,7 +3,7 @@
- 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `extensions/catalog.ts`,配置读取与路径解析位于 `extensions/config.ts`(直接读取组合包 `config/tool-search.json`;目录缓存仍在 `<agentDir>/my-pi/tool-search/catalog-v1.json`),测试保留在 `test/`。
- 同步上游必须记录明确 tag/commit,保留 README、CHANGELOG、文档、作者和许可证说明;不得导入上游 `.pi`、嵌套 `.git`、`node_modules` 或构建产物。
- 根包必须通过 `file:./pi-tool-search` 提供本地依赖并直接加载仓库源码,不得同时安装或加载 npm `pi-tool-search` 实现;同步上游时先核对本地增量动态工具改造,禁止用旧 npm 入口或上游旧宿主实现直接覆盖。
- 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute`、六个记忆工具与 `context_tree_query`,以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。配置从已安装组合包内的 `config/tool-search.json` 直接读取,文件缺失或无效时回退 `BUNDLE_TOOL_SEARCH_DEFAULTS`;扩展自身不播种、不迁移,也不再读写 `settings.json` 的 `toolSearch` 段。
- 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、六个记忆工具与 `context_tree_query`、`overflow_query`,以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。配置从已安装组合包内的 `config/tool-search.json` 直接读取,文件缺失或无效时回退 `BUNDLE_TOOL_SEARCH_DEFAULTS`;扩展自身不播种、不迁移,也不再读写 `settings.json` 的 `toolSearch` 段。
- 其余组合包工具必须优先使用 `extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。
- 标准组合包工具全部命中预置目录时不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
- 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。Tool Search 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决。
+1
View File
@@ -3,6 +3,7 @@
## Unreleased — my-pi maintained source
### Changed
- Removed the retired context-mode integration: the `ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` pins left `alwaysEnabled` and the four `context-*` bundle groups were dropped. Large tool results are now bounded by pi-overflow and recoverable through the pinned `overflow_query`.
- Imported upstream `v0.3.6` at `ddfb23646fd3957b791214de278e23aa393c9b13` for direct local maintenance.
- Migrated runtime imports to `@earendil-works/pi-coding-agent` and current `typebox`.
- Replaced the hidden steer/retry compatibility loop with current Pi dynamic-tool propagation.
+2 -9
View File
@@ -33,10 +33,6 @@ Run `/tool-search-status` to inspect the current catalog source, pinned count, d
| `ssh-remote-shell` | `ssh_cd`, `ssh_bash` |
| `code-intelligence` | CodeGraph and all LSP tools |
| `web-search` | `web_search`, `web_fetch` (pi-web-search unified tools) |
| `context-execution` | `ctx_execute`, `ctx_execute_file`, `ctx_batch_execute` |
| `context-knowledge` | Context indexing, searching, and web-doc ingestion |
| `context-observability` | Context stats, doctor, and Insight |
| `context-administration` | Context upgrade and destructive purge |
| `memory-recall` | Hermes memory/session search and condensed-output recovery |
| `memory-management` | Hermes memory writes and procedural skill management |
| `subagents` | Launch agents, collect background results, and steer running agents |
@@ -45,7 +41,7 @@ Run `/tool-search-status` to inspect the current catalog source, pinned count, d
| `chrome-interaction` | Chrome click/type/fill/key/hover/drag/tap/scroll input |
| `chrome-debugging` | Chrome page evaluation, console/network diagnostics, response retrieval, and file upload |
Pinned tools are removed from these dynamic groups at runtime. The defaults pin `codegraph_explore`, `lsp_diagnostics`, the three high-frequency Context Mode execution tools, and all six Hermes memory tools, so none of them consume dynamic group or tool capacity.
Pinned tools are removed from these dynamic groups at runtime. The defaults pin `codegraph_explore`, `lsp_diagnostics`, and all six Hermes memory tools, so none of them consume dynamic group or tool capacity.
## Loading and eviction
@@ -78,9 +74,6 @@ Pi reads this configuration directly from the installed bundle's `config/tool-se
"alwaysEnabled": [
"codegraph_explore",
"lsp_diagnostics",
"ctx_execute",
"ctx_execute_file",
"ctx_batch_execute",
"memory_search",
"session_search",
"memory_add",
@@ -100,7 +93,7 @@ Pi reads this configuration directly from the installed bundle's `config/tool-se
| Key | Bundle default | Description |
|---|---|---|
| `alwaysEnabled` | CodeGraph explore, LSP diagnostics, Context execution trio, all six Hermes memory tools, `context_tree_query`, `overflow_query` | Exact names pinned alongside Pi core tools |
| `alwaysEnabled` | CodeGraph explore, LSP diagnostics, all six Hermes memory tools, `context_tree_query`, `overflow_query` | Exact names pinned alongside Pi core tools |
| `showToolSearchFooterStatus` | `false` | Show active tool/group counts |
| `maxActiveGroups` | `5` | Maximum simultaneously active dynamic groups |
| `maxToolsPerGroup` | `8` | Maximum size accepted from model-generated groups |
+1 -1
View File
@@ -2,7 +2,7 @@
`pi-tool-search` keeps full low-frequency schemas hidden behind a compact, validated group catalog.
1. `session_start` activates `tool_search`, the six cross-platform Pi core tools, and configured `alwaysEnabled` names. The my-pi defaults also pin CodeGraph exploration, LSP diagnostics, the three high-frequency Context Mode execution tools, and the six Hermes memory tools. On Windows, `powershell` is pinned beside `bash`; on non-Windows hosts it is excluded. Pinned tools do not consume the dynamic-group or dynamic-tool budget.
1. `session_start` activates `tool_search`, the six cross-platform Pi core tools, and configured `alwaysEnabled` names. The my-pi defaults also pin CodeGraph exploration, LSP diagnostics, and the six Hermes memory tools. On Windows, `powershell` is pinned beside `bash`; on non-Windows hosts it is excluded. Pinned tools do not consume the dynamic-group or dynamic-tool budget.
2. Hidden definitions are hashed with grouping constraints and matched against `extensions/bundle-groups.ts`. Available standard tools receive checked-in groups; unavailable optional tools are omitted.
3. If every hidden tool is recognized, the bundle catalog is used directly. No model call or per-user cache is required.
4. Additional tools receive immediate deterministic prefix/source groups. A matching model-enriched cache is reused only if it preserves all checked-in bundle assignments.
@@ -85,42 +85,6 @@ export const BUNDLE_GROUP_DEFINITIONS: BundleGroupDefinition[] = [
keywords: ["web", "search", "fetch", "news", "docs", "网页", "搜索", "新闻", "文档"],
tools: ["web_search", "web_fetch"],
},
{
id: "context-execution",
title: "Context-isolated execution",
summary: "Run commands or analyze large files in a sandbox while returning only compact derived output.",
useWhen: ["Logs, tests, builds, generated data, commands, or workspace files may produce large output"],
avoidWhen: ["Exact source lines are needed for an anchored edit"],
keywords: ["execute", "large file", "logs", "tests", "build", "大文件", "日志", "测试", "构建"],
tools: ["ctx_execute", "ctx_execute_file", "ctx_batch_execute"],
},
{
id: "context-knowledge",
title: "Context knowledge base",
summary: "Index local or web documentation and retrieve focused passages from the persistent context-mode knowledge base.",
useWhen: ["Documentation or session knowledge should be stored and queried without rereading raw content"],
avoidWhen: ["A one-shot small source can be read directly"],
keywords: ["index", "search", "knowledge base", "fetch docs", "索引", "知识库", "文档检索"],
tools: ["ctx_index", "ctx_search", "ctx_fetch_and_index"],
},
{
id: "context-observability",
title: "Context observability",
summary: "Inspect context-mode health, savings statistics, and the hosted Insight dashboard.",
useWhen: ["Diagnosing context-mode or reviewing context savings and analytics"],
avoidWhen: ["The task is normal code or data processing"],
keywords: ["stats", "doctor", "insight", "health", "统计", "诊断", "上下文节省"],
tools: ["ctx_stats", "ctx_doctor", "ctx_insight"],
},
{
id: "context-administration",
title: "Context administration",
summary: "Upgrade context-mode or destructively purge indexed context data.",
useWhen: ["The user explicitly asks to upgrade context-mode or purge a named scope"],
avoidWhen: ["Routine searching, indexing, or performance inspection"],
keywords: ["upgrade", "purge", "delete", "升级", "清除", "删除知识库"],
tools: ["ctx_upgrade", "ctx_purge"],
},
{
id: "memory-recall",
title: "Persistent memory recall",
-1
View File
@@ -335,7 +335,6 @@ export function writeCachedCatalog(cachePath: string, catalog: ToolCatalog): voi
}
const GROUP_QUERY_ALIASES: Record<string, string[]> = {
"context-execution": ["context mode", "run command", "test build logs", "执行命令", "运行测试", "分析日志"],
"ssh-connection": ["ssh connect", "remote server connection", "连接服务器", "远程连接"],
"ssh-remote-shell": ["ssh shell", "remote command", "remote terminal", "远程命令", "远程终端"],
"ssh-remote-files": ["ssh file", "remote read write edit", "远程文件", "远程读写"],
-3
View File
@@ -25,9 +25,6 @@ export const BUNDLE_TOOL_SEARCH_DEFAULTS = {
alwaysEnabled: [
"codegraph_explore",
"lsp_diagnostics",
"ctx_execute",
"ctx_execute_file",
"ctx_batch_execute",
"memory_search",
"session_search",
"memory_add",
+1 -1
View File
@@ -25,7 +25,7 @@ test("bundle seed names are unique and cover the declared my-pi tools", () => {
assert.deepEqual(result.unknownTools, []);
assert.equal(result.coveredNames.size, names.length);
assert.equal(result.catalog.tools.length, names.length);
assert.equal(result.catalog.tools.find((card) => card.name === "ctx_purge")?.primaryGroup, "context-administration");
assert.equal(result.catalog.tools.find((card) => card.name === "overflow_query")?.primaryGroup, "memory-recall");
assert.equal(result.catalog.tools.find((card) => card.name === "web_fetch")?.primaryGroup, "web-search");
assert.equal(result.catalog.tools.find((card) => card.name === "memory_search")?.primaryGroup, "memory-recall");
assert.equal(result.catalog.tools.find((card) => card.name === "session_search")?.primaryGroup, "memory-recall");
-3
View File
@@ -33,9 +33,6 @@ test("bundle defaults keep the committed tool list", () => {
assert.deepEqual(BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled, [
"codegraph_explore",
"lsp_diagnostics",
"ctx_execute",
"ctx_execute_file",
"ctx_batch_execute",
"memory_search",
"session_search",
"memory_add",
+4 -4
View File
@@ -254,9 +254,9 @@ test("uses the precomputed bundle groups without calling a model", async () => {
sourceTool("read", "Read a file"),
sourceTool("web_search", "Search broadly for current web sources"),
sourceTool("web_fetch", "Fetch a selected web source"),
sourceTool("ctx_execute", "Run commands in a context sandbox"),
sourceTool("ctx_execute_file", "Analyze a file in a context sandbox"),
sourceTool("ctx_batch_execute", "Run a bounded command batch"),
sourceTool("context_tree_query", "Inspect the context tree ledger"),
sourceTool("overflow_query", "Inspect a stored oversized tool result"),
sourceTool("memory_search", "Search durable memories"),
];
const api = {
getAllTools: () => sourceTools,
@@ -280,7 +280,7 @@ test("uses the precomputed bundle groups without calling a model", async () => {
} as unknown as ExtensionContext["modelRegistry"],
});
handlers.get("session_start")?.({}, ctx);
assert.ok(["ctx_execute", "ctx_execute_file", "ctx_batch_execute"].every((name) => activeCalls.at(-1)?.includes(name)));
assert.ok(["context_tree_query", "overflow_query", "memory_search"].every((name) => activeCalls.at(-1)?.includes(name)));
const ambiguous = await registered.get("tool_search")?.execute(
"call-1",
+25 -20
View File
@@ -51,8 +51,8 @@ test("all package extensions load together without global registration conflicts
const packageJson = JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")) as {
dependencies: Record<string, string>;
files: string[];
pi: { extensions: string[] };
allowScripts: Record<string, boolean>;
pi: { extensions: string[]; skills?: string[] };
allowScripts?: Record<string, boolean>;
};
assert.ok(
packageJson.pi.extensions.includes("./extensions/codegraph.ts"),
@@ -213,14 +213,19 @@ test("all package extensions load together without global registration conflicts
"./pi-overflow/index.ts",
"pi-overflow bound stage must load after all other content extensions and before system-prompt",
);
assert.ok(
packageJson.pi.extensions.includes("./extensions/context-mode.ts"),
"Context Mode must load through the bundle's prompt-guarded wrapper",
assert.equal(
packageJson.dependencies["context-mode"],
undefined,
"context-mode was retired; new tool-result bounding is owned by pi-overflow",
);
assert.equal(
packageJson.pi.extensions.some((entry) => entry.includes("context-mode/build/")),
packageJson.pi.extensions.some((entry) => entry.includes("context-mode")),
false,
"the raw Context Mode adapter must not be loaded alongside the wrapper",
"no context-mode entry point may be loaded",
);
assert.ok(
!Array.isArray(packageJson.pi.skills) || packageJson.pi.skills.length === 0,
"pi.skills must be absent or empty now that the context-mode skills are gone",
);
assert.equal(
packageJson.pi.extensions.includes("./extensions/system-prompt-cover.ts"),
@@ -260,28 +265,28 @@ test("all package extensions load together without global registration conflicts
);
assert.equal(packageJson.dependencies["@the-forge-flow/hippo-memory-pi"], undefined);
assert.equal(packageJson.dependencies["better-sqlite3"], undefined);
// allowScripts must approve exactly the root lockfile's better-sqlite3
// build — and nothing else. pi-hermes-memory's own lockfile carries a
// nested major that ships N-API prebuilds and needs no approval entry.
// better-sqlite3 was only present as context-mode's transitive build
// dependency; with context-mode retired the root lockfile must not carry a
// root-level better-sqlite3 and allowScripts must not approve one.
// pi-hermes-memory's own lockfile carries a nested major that ships N-API
// prebuilds and needs no approval entry.
const rootLock = JSON.parse(await readFile(join(repositoryRoot, "package-lock.json"), "utf8")) as {
packages: Record<string, { version?: string }>;
};
const hermesLock = JSON.parse(await readFile(join(repositoryRoot, "pi-hermes-memory", "package-lock.json"), "utf8")) as {
packages: Record<string, { version?: string }>;
};
const rootSqliteVersion = rootLock.packages["node_modules/better-sqlite3"]?.version;
const hermesSqliteVersion = hermesLock.packages["node_modules/better-sqlite3"]?.version;
assert.match(rootSqliteVersion ?? "", /^\d+\.\d+\.\d+$/, "the root lockfile must pin a root-level better-sqlite3");
assert.equal(
rootLock.packages["node_modules/better-sqlite3"],
undefined,
"the root lockfile must not pin a root-level better-sqlite3 now that context-mode is gone",
);
assert.match(hermesSqliteVersion ?? "", /^\d+\.\d+\.\d+$/, "the Hermes lockfile must pin its own better-sqlite3");
assert.deepEqual(
Object.keys(packageJson.allowScripts).filter((key) => key.startsWith("better-sqlite3@")),
[`better-sqlite3@${rootSqliteVersion}`],
"allowScripts must approve exactly the root lockfile's better-sqlite3 build",
);
assert.equal(
packageJson.allowScripts[`better-sqlite3@${hermesSqliteVersion}`],
undefined,
`Hermes uses better-sqlite3 ${hermesSqliteVersion} N-API prebuilds, so no extra install-script approval is required`,
Object.keys(packageJson.allowScripts ?? {}).filter((key) => key.startsWith("better-sqlite3@")),
[],
"allowScripts must not approve a better-sqlite3 build without a root-level package",
);
assert.equal(packageJson.dependencies["@sinclair/typebox"], undefined);
const home = await mkdtemp(join(tmpdir(), "my-pi-extension-load-"));
+3 -69
View File
@@ -29,10 +29,6 @@ import systemPromptExtension, {
getSystemPromptDumpPath,
} from "../extensions/system-prompt.ts";
import { guardExtensionApi } from "../extensions/prompt-guard.ts";
import {
CONTEXT_MODE_ANCHOR_PREFIX,
filterContextModeMessages,
} from "../extensions/context-mode-filter.ts";
import { BUNDLE_GROUP_DEFINITIONS } from "../pi-tool-search/extensions/bundle-groups.ts";
import { BUNDLE_TOOL_SEARCH_DEFAULTS } from "../pi-tool-search/extensions/config.ts";
@@ -164,7 +160,6 @@ test("prompt config carries no retired guidance phrasing", () => {
"read/grep",
"replace_text",
"Read/edit files",
"ctx_fetch_and_index then ctx_search",
];
for (const file of ["preamble.md", "tools.jsonc", "routing.jsonc"]) {
const source = readFileSync(join(promptConfigDir, file), "utf8");
@@ -267,8 +262,8 @@ test("bundled routing rules select the same guidance the retired tool-routing ex
assert.match(ssh, /only when the user explicitly names an imported host/);
assert.match(ssh, /command-local directory change/);
const chrome = renderRouting(routing.rules, ["chrome_snapshot", "ctx_execute_file"]);
assert.match(chrome, /immediately analyze the returned workspace-relative capture path/);
const chrome = renderRouting(routing.rules, ["chrome_snapshot", "overflow_query"]);
assert.match(chrome, /return the page snapshot JSON directly/);
const minimal = renderRouting(routing.rules, ["read"]);
assert.doesNotMatch(minimal, /codegraph_explore first/);
@@ -646,7 +641,7 @@ test("prompt guard runs context results through the provided contextFilter", asy
test("prompt guard returns the handler result untouched when the filter changes nothing", async () => {
const { pi, handlers } = makeRecordingPi();
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI, {
contextFilter: filterContextModeMessages,
contextFilter: (_before, after) => after,
});
const expected = { messages: [{ role: "user", content: "plain" }] };
@@ -662,67 +657,6 @@ test("prompt guard passes unrelated API members through to the real pi object",
assert.equal((guarded as unknown as { someOtherApi: () => string }).someOtherApi(), "passthrough");
});
// ── context-mode filter ─────────────────────────────────────────────────────
test("context-mode filter drops only the routing-anchor part", () => {
const before = [{ role: "user", content: "real" }];
const anchor = `${CONTEXT_MODE_ANCHOR_PREFIX} ctx_batch_execute > ctx_execute`;
const after = [
...before,
{ role: "user", content: `${anchor}\n\n<active_memory>\nx\n</active_memory>` },
];
const filtered = filterContextModeMessages(before, after);
assert.equal(filtered.length, 2);
assert.equal(filtered[1].content, "<active_memory>\nx\n</active_memory>");
});
test("context-mode filter removes the appended message when only the anchor remains", () => {
const before = [{ role: "user", content: "real" }];
const after = [
...before,
{ role: "user", content: `${CONTEXT_MODE_ANCHOR_PREFIX} only anchor` },
];
assert.deepEqual(filterContextModeMessages(before, after), before);
});
test("context-mode filter returns the same reference when nothing changed", () => {
const before = [{ role: "user", content: "real" }];
const after = [...before, { role: "user", content: "no anchor here" }];
assert.equal(filterContextModeMessages(before, after), after);
assert.equal(filterContextModeMessages(after, after), after);
});
test("context-mode filter ignores appended non-user or non-string messages", () => {
const before = [{ role: "user", content: "real" }];
const after = [
...before,
{ role: "assistant", content: `${CONTEXT_MODE_ANCHOR_PREFIX} not ours` },
{ role: "user", content: [{ type: "text", text: "structured" }] },
];
assert.equal(filterContextModeMessages(before, after), after);
});
// ── Pinned upstream text ────────────────────────────────────────────────────
test("context-mode upstream still appends the single anchor-bearing user message", async () => {
const packageJson = JSON.parse(
await readFile(join(repositoryRoot, "package.json"), "utf8"),
) as { dependencies: Record<string, string> };
assert.match(
packageJson.dependencies["context-mode"] ?? "",
/^\d+\.\d+\.\d+$/,
"the context-filter pin requires an exact upstream version",
);
const upstream = await readFile(
join(repositoryRoot, "node_modules/context-mode/build/adapters/pi/extension.js"),
"utf8",
);
assert.ok(upstream.includes('parts.push("context-mode active. Hierarchy:'));
assert.ok(upstream.includes('_pendingContext = extraParts.join("\\n\\n")'));
assert.ok(upstream.includes("event.messages.push({"));
assert.ok(upstream.includes('role: "user"'));
});
// ── /dump-system-prompt ─────────────────────────────────────────────────────
test("dump-system-prompt writes the last composed prompt under .pi-debug", async () => {
+2 -2
View File
@@ -5,7 +5,7 @@ import test from "node:test";
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as {
dependencies: Record<string, string>;
files: string[];
pi: { extensions: string[]; skills: string[] };
pi: { extensions: string[]; skills?: string[] };
};
const filesIncludePackageDir = (files: string[], packageDir: string): boolean =>
@@ -45,7 +45,7 @@ test("no extension in the bundle loads the MCP adapter or upstream search extens
});
test("the keenable skill path is gone with the upstream package", () => {
for (const skillPath of packageJson.pi.skills) {
for (const skillPath of packageJson.pi.skills ?? []) {
assert.equal(skillPath.includes("@keenable"), false);
}
});