Compare commits

..
21 Commits
Author SHA1 Message Date
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 87940da51a feat(prompt): 重新注册 bash,把"不要用 bash 搜索"写进工具描述
真实会话里模型仍习惯性先调 bash grep,被拒后才改用 grep 工具;系统提示词里已有 4 处
提醒,但模型下调用决定时看的是工具 schema 描述,而 Pi 内置 bash 描述对此只字未提。

- 新增 extensions/bash-override.ts:用 Pi 的 createBashToolDefinition 重新注册 bash,
  执行、输出结构与 codemode 说明完全沿用上游;shellPath/commandPrefix 按 ctx.cwd
  经 SettingsManager 惰性解析,与内置一致
- tools.jsonc 新增 descriptionAppend 字段,由 prompt-guard 注册时拼接到上游描述后
- bash 增加命令→工具对照规则(grep -n / grep -rl / find -name),并提示
  文件清单用 $(git ls-files) 传参、不要接 xargs(会触发权限审核)

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 01:20:52 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2b21163a18 fix(pi-permission-system): bash 命令枚举下降进入控制流与赋值载荷
for/while/if/case/{ }/函数体此前只按整段文本匹配,
"for d in ...; do n=$(find ...); done" 中的 find 绕过了 find* deny(真实会话复现);
x=$(grep ...)、export X=$(find ...)、! grep ... 同样绕过。

控制流语句仍整段判定,并逐条枚举条件与体内命令;do/else/case 分支容器只下降不单独成单元;
其余语句兜底额外枚举其中的 $(…)。结果只会更严,hard guard 共享同一枚举。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 00:14:49 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> dae1a01f23 feat(prompt): 搜索引导改为意图→工具的正向指引,multi_grep 按上游保持关闭
真实会话中模型仍用 bash grep -o | sort -u 做抽取,被拒后直接放弃核对;
原提示词只说"不要",没告诉它抽取/计数、给程序传文件清单、过滤命令输出时该用什么。

- routing:按已知信息选 grep/find(多变体用 grep 正则 alternation),
  codemode + tools.grep 做聚合,bash 文件清单用 git ls-files,
  overflow_query 过滤输出,被拒后改用替代方案继续
- 权限策略:rg/grep/git grep/find/fd 的 deny 附带指向替代工具的 reason
- tools.jsonc:grep/find 新 snippet,补上 codegraph_explore snippet
- FFF 上游标注 multi_grep 有害:wrapper 删除 PI_FFF_MULTIGREP,清理全部引用
- JSONC 测试改用字符串感知的注释剥离(reason 中含 'src/**/*.ts')

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 00:14:49 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 833b9514d4 feat(update): update.sh 先同步 Pi 最新 release 再升级组合包
pi update <source> 只更新单个包,Pi 本体不会随之升级;改为先执行
pi update --self(版本比较由 Pi 自身完成,已是最新时跳过,失败记录后继续),
再升级组合包。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 23:26:43 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f9a7f743dd feat: 接入 Pi 内置 codemode,并修复全面审查发现的问题
- codemode:Tool Search 常驻(alwaysEnabled)、权限 allow(嵌套调用仍逐个过权限门)、
  tools.jsonc 登记使用规则;RTK 跳过 parentToolCallId 嵌套调用,避免丢失 bash
  structuredContent;Tool Search 目录过滤 hidden / codemode(script-only)exposure;
  扩展加载测试加载 builtin:codemode 并断言其常驻激活
- 搜索 key 不再经命令行参数传递:search-auth.mjs 改为 --set-stdin(先读校验再加锁),
  configure.sh 交互输入经管道、非交互用 --<provider> -
- CodeGraph 客户端监听子进程 stdin error,避免 EPIPE 未处理导致崩溃
- 移除 PI_TOOL_SEARCH/HERMES_MEMORY/RTK_CONFIG_PATH 环境变量入口,测试改用工厂 configPath 注入
- pi-subagents 固定 vitest / coverage-istanbul 4.1.10,修复 npm ls 版本冲突
- 补 pi-web-search / pi-worktree UPSTREAM.md 与 pi-minimal-footer AGENTS.md,
  files 白名单补齐并加测试;.codegraph/.gitignore 移出跟踪
- detect_kitty 优先识别官方安装目录

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 23:18:34 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 56cbc83296 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>
2026-10-21 20:45:33 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2a6bf8c929 feat(pi-overflow): 新增统一的新工具结果展示截断扩展
此前截断分散在多层且互相冲突:Pi bash 保留尾部 50KB,RTK 兜底截断又只留
开头 12000 字符(砍掉报错与完整输出路径),pi-chrome 截开头 30K,web_fetch
各 provider 不一致。新扩展统一负责"新结果给模型看多少":
- capture.ts 最先加载暂存原文,index.ts 最后加载(system-prompt 之前)
- 超过 24K 字符时原文落盘 <agentDir>/my-pi/pi-overflow/(700/600、原子写),
  优先取 bash fullOutputPath 完整文件;模型看到 头 + 中段信号行 + 尾 的有界
  视图,行号基于落盘原文、结尾永远可见
- overflow_query:按 id 正则搜索、行区间、jsonPath 只读查询,id 校验防穿越
- 收编:RTK truncate 默认关闭、删除 pi-chrome truncateText、web_fetch 默认
  每页 20000(上限 100000)、web_search max_results 限定 1–20
- pi-compaction 遮蔽带 details.overflow.id 的结果时在占位符提示 overflow_query
- 登记 tools.jsonc / routing / 权限 allow / tool-search 常驻与分组

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 20:25:44 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a6d8416c76 refactor(pi-compaction): 改为压力触发的观察遮蔽与空闲阈值整段摘要
原实现对所有工具结果(含 read)在下一次请求即替换为约 4K 的首尾存根,
不看上下文压力,导致模型看不到大文件中段;后台逐条语义摘要又反复改写
历史、打碎 prompt 缓存。参照 Anthropic 上下文编辑、Manus 与 JetBrains
观察遮蔽研究重构为:
- L2:上下文达到阈值时在 turn_end 批量提交 Pi 原生 context_edit,把最近
  keepRecentTurns 轮之外的旧 toolResult 换成确定性占位符;trigger/target
  回差与 minClearTokens 保证少触发、一次清够;原始条目保留,可经
  context_tree_query 取回
- L3:空闲 agent_settled 且超过绝对阈值时调用一次 ctx.compact
- 阈值按绝对 token 计并受窗口比例约束,默认针对 1M 窗口(DeepSeek V4.1
  Flash):遮蔽 200K→100K、整段摘要 400K
- 删除逐条存根、语义 worker、soft/hard/target 比例与自行取消 Pi 压缩的门控;
  Pi 原生阈值压缩保留为最终安全网

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 19:51:36 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a254d4fd68 fix: 根 files 改为子包运行时白名单,并删除过期提示词快照
整目录列入子包时,pi-subagents 上游 .npmignore 覆盖 .gitignore,导致
嵌套 node_modules(含第二套 Pi runtime)与 dist 被打包:64.9MB/19,570
文件降至 1.2MB/471 文件。新增 tests/package-files.test.ts 守护:无嵌套
node_modules/dist、扩展入口与 file: 依赖清单在包内、包内源码相对引用闭包。
同时删除无引用的 pi-default-system-prompt.md 旧快照(可用 /dump-system-prompt)。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 19:16:47 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> e996f59469 refactor: notify/footer 持久配置收编到 config/,chrome bridge 地址固定
仓库规则要求 config/ 为唯一配置来源、不以环境变量作持久默认值:
- pi-notify 改读 config/pi-notify.json(逐字段校验回退,复用原 parsers 钳制)
- pi-minimal-footer 改读 config/pi-minimal-footer.json
- pi-chrome 移除 PI_CHROME_BRIDGE_HOST/PORT,固定 127.0.0.1:17318
  (浏览器端 service_worker.js 硬编码同一地址,单侧可配只会断连)

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 19:16:47 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 0c45b1a438 docs: 拆分 AGENTS.md,根文件只保留全局规则
根 AGENTS.md 从约 78KB 压缩到约 10KB,避免作为常驻规则加载时被截断:
只保留仓库定位、扩展清单、跨扩展硬规则与通用验证命令。各扩展的维护
约定与验证命令移入对应目录的 AGENTS.md(新增 extensions/AGENTS.md 承载
系统提示词与根目录 TS/LSP 约定),上游来源记录统一放进各目录的中文
UPSTREAM.md,并补齐 pi-permission-system 与 pi-rtk-optimizer 的来源记录。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 19:43:22 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 360d8cffc8 feat(pi-subagents): 重新启用子 Agent 并使用独立的子 Agent 提示词
- 根包恢复 file:./pi-subagents 依赖与 extensions/subagents.ts 加载
  (排在 permission-system 之后、Tool Search 之前),并刷新根锁文件;
  Agent / get_subagent_result / steer_subagent 权限设为 allow,
  子 Agent 内的实际操作仍逐个经权限系统并转发主会话。
- 新增 config/prompt/subagent/:执行者角色 preamble 与组合包自带的
  general-purpose / Explore / Plan 定义(替换上游内置,去掉 bash find
  冲突规则与写死的 Haiku 模型)。
- pi-subagents 新增 basePrompt / bundledAgentsDir / excludedExtensionPaths
  选项:子 Agent 不再嵌入父会话提示词,身份标记置于开头;子会话排除
  pi-ask-user。未传选项时保持上游行为。
- system-prompt.ts:customPrompt 自带开场白时不再覆盖(修复子 Agent 角色
  与 <active_agent> 身份被抹掉);仅凭 customPrompt 上的 <active_agent>
  识别子会话,routing 规则支持 scope,sections 支持 subagentExclude
  (子会话去掉 memory_policy 与 chrome_profile_bridge)。
- pi-hermes-memory:子会话只读,记忆/skill 写入工具返回错误,后台整理、
  纠错检测、会话 flush 与索引写入全部跳过。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 19:43:22 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b817d66a11 chore: Pi 开发依赖跟随最新版并去掉测试里的固定版本号
- pi-hermes-memory / pi-notify / pi-permission-system / pi-subagents 的
  @earendil-works/* devDependencies 改为 latest,pi-rtk-optimizer 放开
  peer 范围;各子包锁文件刷新到 Pi 1.3.0,测试改为验证当前宿主 API。
- 新增 scripts/update-pi-devdeps.sh,统一刷新根与子包锁文件中的 Pi 版本。
- pi-subagents 适配 Pi 1.3:mention clone 改走 systemPromptOverride、
  BOM frontmatter、e2e 读取规范化后的 system 消息与工具列表。
- 根测试不再写死 ssh2 / jiti / better-sqlite3 / context-mode 版本号,
  改为精确版本格式与锁文件一致性断言;新增 latest 约束测试。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 19:38:54 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ba164896d2 feat: 系统提示词改为单一入口统一管理
多个扩展各自整段改写系统提示词并按加载顺序接力,任一扩展定死提示词后
其余只能做字符串手术,且各上游的工具说明与组合包实际工具格局冲突。

- 新增 extensions/system-prompt.ts 作为唯一出口(加载列表最后),按
  config/prompt/(preamble.md、tools.jsonc、routing.jsonc、sections.jsonc)
  组合开场白、工具简介/规则与 <tool_routing> 分区;替代原封面与
  tool-routing 扩展,/dump-system-prompt 输出最终提示词。
- pi-chrome、pi-ssh、pi-hermes-memory 改为写命名分区,pi-rtk-optimizer
  改写 promptGuidelines,pi-permission-system 直接过滤结构化 skills,
  均不再返回 systemPrompt;pi-chrome 未授权时不再每轮冻结提示词。
- 新增 prompt-guard 代理加载第三方扩展(Hashline、FFF、LSP、
  context-mode):拦截直接改写提示词并按目录覆盖工具说明;context-mode
  去掉与组合包路由冲突的锚点段,保留记忆与恢复快照。
- 消除冲突:bash 简介不再建议 grep/find;Hashline edit 不再称可用 grep
  锚点、不再介绍已关闭的 replace_text;context-mode 读写/网页路由提示。
- 守门测试:工具目录覆盖率、禁用用法检查、context-mode 上游原文锁定;
  AGENTS.md 新增「系统提示词单一所有权」。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 17:53:04 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> dc4fe92e27 feat(codegraph): codegraph_explore 改为自有 stdio JSON-RPC 客户端直连 codegraph serve
一次性 `codegraph explore` CLI 不会同步索引,新增符号在手动 sync 前
无法被搜到。改为按索引根惰性启动常驻 `codegraph serve --mcp` 子进程,
由组合包自有的逐行 JSON-RPC 客户端(extensions/codegraph-client.ts,
不依赖 MCP adapter/SDK)收发,serve 内置文件监听自动同步索引,删除
mtime 结果缓存。子进程闲置 10 分钟或会话结束关闭,子进程死亡时透明
重试一次;用户拒绝初始化在会话内记住;用户中止调用不关闭子进程;
所有失败以文本打回引导改用 find/grep/read/LSP。新增模拟服务与真实
CLI 集成测试。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 02:02:47 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> da9908fb6e fix(pi-ssh): ssh_grep 的 rg 后端在指定 include 时不再搜入隐藏文件
rg 中后出现的 glob 优先,include glob 排在隐藏文件排除规则之后会把
排除覆盖掉,导致 includeHidden=false 时仍返回隐藏文件;调整参数顺序,
使 rg、git grep 与 grep 三种后端行为一致。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 01:05:31 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2aa1be0b54 feat: 组合包配置以包内 config/ 为唯一来源,并新增受保护写入人工审批
配置统一:权限策略、硬防御、Tool Search、提示词封面以及新迁入的
RTK、pi-compaction、pi-worktree、pi-hermes-memory 配置全部由扩展
直接读取已安装组合包内的 config/,pi install / pi update 即完成部署;
删除 rebuild-bundle-config.mjs 与 bundle-manifest.json,my-pi/ 只保留
日志与缓存。update.sh 成功升级后将默认 git 来源的已安装副本
reset 回发布版本。AGENTS.md 新增「组合包配置原则」。

脚本整合:search_config.sh / ssh_config.sh 合并为 configure.sh
search|ssh;install.sh / update.sh 共享函数抽到 scripts/lib.sh,远程
安装器改为先下载到临时文件再执行,下载失败可被检测。

权限:hard-guards 新增 protectedWrites,写入 Pi 凭据/设置、已安装
组合包与扩展目录、pi-ssh 凭据库时一律人工确认(跳过 AutoReview、
yolo 与 Session grant,无 UI 时拒绝);放行 ls、LSP 查询、ctx 只读、
web_search、Chrome 只读与 git 只读命令,skill_manage 改为 ask。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 01:05:31 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a0ee9389c7 chore: 清理 pi-mcp-adapter 移除后的陈旧引用
Pi 0.99+ 已内置 MCP(builtin:mcp),组合包不再加载 adapter;
删除引用不存在 mcp 工具的 mcp-management 分组,并把权限文档
中的 MCP 表述澄清为内置 mcp__<server>__<tool> 命名。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 22:35:49 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 0c9823ef5c feat(pi-web-search): 搜索凭据迁移到 Pi auth.json
三个搜索 key 改为 auth.json 中的 tavily/exa/keenable api_key 凭据,
pi-web-search/auth.ts 复刻上游 resolve-config-value 语义(!command、
$VAR 插值、credential env 优先、$$/$! 转义)并在每次调用时读取,
进程环境变量与 search.env 不再被直接读取。search_config.sh 重写为
scripts/search-auth.mjs 的薄封装:加锁原子写、权限 600、保留其他
凭据条目,支持 --migrate 从旧 search.env 一次性导入。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 20:39:13 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ee1e6999d7 feat(pi-web-search): 统一 web_search/web_fetch 并由主模型路由搜索厂商
7 个按厂商命名的搜索工具收敛为 2 个统一工具,schema 与提示词不再暴露
provider 概念:web_search 由当前主模型按内置规则在 Tavily/Exa/Keenable
间路由(会话内 LRU 缓存,失败按可用性降级),web_fetch 固定走 Exa 并
静默降级。三家均改为纯 fetch 直连——Exa 直接对 mcp.exa.ai 发 JSON-RPC
并缓存会话,彻底移除 pi-mcp-adapter、@tavily/pi-extension 与
@keenable/pi-search 依赖。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 20:13:37 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 6fe6cb29ae feat: codegraph_explore 改为原生 CLI 工具并移除 /plugin_init
codegraph 有一等公民 CLI 且上游保证其输出与 codegraph_explore MCP
工具一致,走共享 MCP adapter 只是多余的传输层。改为原生 Pi 工具后
每次调用以短命 codegraph explore 进程完成,去掉常驻 server/daemon;
未初始化项目经 ctx.ui.confirm 确认后 init 并自动重试,拒绝或失败时
打回并引导模型回退 find/grep/read/LSP;结果按 codegraph.db mtime
做有界缓存,索引一变即失效。共享 MCP adapter 只保留 Exa;工具名
不变,权限策略、Tool Search 常驻表、路由规则与分组不受影响。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 19:42:11 -07:00
289 changed files with 20613 additions and 19189 deletions
-5
View File
@@ -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
+45 -293
View File
@@ -1,305 +1,57 @@
# 仓库说明 # 仓库说明
本仓库用于维护可通过一次 `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-system/`:统一提供工具、路径、MCP、Skill、子代理转发、独立 JSONC 硬防御、普通策略、内置 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底。 | `pi-tool-search/` | 校验过的工具目录 + 固定工具 + 有界动态组 LRU,按需加载原始 schema |
- 权限系统上游来源:<https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system>;初始导入快照:`ec4fdb11343dc94f7185b113e559a4cf9f8dc035`(`pi-permission-system-v26.2.1`)。 | `pi-ask-user/` | `ask_user_question`:模型主动发起的单题/多题/选择/文本提问(TUI + RPC) |
- AutoReview 上游来源:<https://github.com/mzwing/pi-packages/tree/main/packages/pi-permission-auto-review>;初始导入快照:`8d196e4ef0884cac8326c366191dad3f585d470a`(`@mzwing/pi-permission-auto-review@0.2.0`)。 | `pi-worktree/` | `/wt`(`/worktree`)git worktree 斜杠命令,不注册模型工具 |
- 原 AutoReview 源码已整合进 `pi-permission-system/src/auto-review/`,不再保留或加载第二个顶层扩展;其 MIT 许可与来源记录保留在 `pi-permission-system/LICENSES/` 和 `pi-permission-system/docs/auto-review/`。该目录从明确 tag/commit 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。 | `pi-hermes-memory/` | Hermes 风格持久记忆:policy-only 注入、六记忆工具、`skill_manage` |
- `pi-subagents/`:提供 Claude Code 风格的隔离子 Agent、后台执行、steering、resume、嵌套代理与自定义 Agent。 | `pi-minimal-footer/` | 紧凑 footer:上下文仪表、订阅用量条、扩展状态 |
- 上游来源:<https://github.com/tintinweb/pi-subagents> | `pi-notify/` | Kitty OSC 99 完成与权限确认通知,精确聚焦来源窗口 |
- 初始导入快照:`3f9d35cd078d18a141eb5a6d8f4fc5010d756280`(`v0.18.0`)。 | `pi-compaction/` | 压力触发的观察遮蔽(Pi context_edit)、阈值整段摘要、effective token、native compaction |
- 该目录从明确 tag 导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留上游 `.pi` 状态;源码、测试、wrapper 与权限集成实现继续保留,但当前不属于根组合包运行时依赖,`extensions/subagents.ts` 也不在默认扩展加载列表中。 | `pi-overflow/` | 新工具结果统一截断:超阈值原文落盘 + 有界视图 + `overflow_query` |
- `extensions/fff-override.ts`:强制使用 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 FFF 的 `@` 补全;显式 CLI flag 仍遵循 FFF 官方优先级。 | `extensions/fff-override.ts` | FFF `override` 模式接管 `find`/`grep` 与 `@` 补全,`multi_grep` 按上游保持关闭 |
- `extensions/tavily-override.ts`:加载 `@tavily/pi-extension`,把上游工具重命名为 `tavily_web_search` / `tavily_web_fetch`,并同步改写 label 与工具提示中的内部名称。 | `extensions/bash-override.ts` | 用 Pi 的 `createBashToolDefinition` 重新注册 `bash`,仅替换模型可见描述(追加 `tools.jsonc` `descriptionAppend`);执行逻辑、输出结构、codemode 说明不变 |
- `@keenable/pi-search`:加载 `keenable_search` / `keenable_fetch` 及其 skill,默认 keyless,`KEENABLE_API_KEY` 仅用于提高速率限制。 | `extensions/codegraph.ts` | 原生 `codegraph_explore`,自有 stdio JSON-RPC 直连 `codegraph serve`(`codegraph-client.ts`) |
- `search_config.sh` 与 `extensions/search-config.ts`:以交互式或参数方式把三家搜索 key 写入用户级 `search.env`(权限 `600`),并在搜索扩展初始化前加载;显式进程环境变量优先。 | `extensions/hashline.ts` | 部署 `config/pi-hashline-edit.json` 后加载 pi-hashline-edit 覆盖 `read`/`edit`(`hashline-config.ts`) |
- `extensions/mcp.ts`:通过单个共享 `pi-mcp-adapter` 实例同时连接 Exa 托管 MCP 与机器现有的 `codegraph serve --mcp`,避免重复注册 Pi 的全局 MCP flag 与命令;Exa key 只通过 `x-api-key` 请求头发送,原始工具映射为统一的来源前缀形式,CodeGraph 只暴露 `codegraph_explore`。 | `extensions/permission-system.ts` | 直接加载仓库内 `pi-permission-system/src/index.ts` |
- `extensions/plugin-init.ts`:注册用户显式触发的 `/plugin_init`,在确认当前项目后只初始化缺失的 CodeGraph 状态,并在全部成功后调用 Pi 官方热重载。 | `extensions/lsp.ts` | 物化包内 typescript-language-server、部署 LSP 配置并加载 pi-lsp(`lsp-config.ts`) |
- `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。 | `extensions/prompt-guard.ts` | 第三方扩展代理 API:丢弃 `systemPrompt` 返回、应用 `tools.jsonc` description 覆盖、context 过滤器 |
- `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。 | `extensions/subagents.ts` | pi-subagents 加载 wrapper,注入权威 `permission-system.ts` 为 mandatory extension |
- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,自身不部署、不播种、不迁移任何配置。`config/pi-permission-policy.jsonc` / `config/pi-permission-hard-guards.jsonc` 由 `install.sh` / `update.sh` 经 `scripts/rebuild-bundle-config.mjs` 部署到 `<agentDir>/my-pi/permission-system/`(policy.jsonc 与 hard-guards.jsonc 且权限 owner-only);旧 `extensions/pi-permission-system/` 位置的残留文件被忽略,不迁移也不清理。项目级 policy 不读取。 | `extensions/context-view.ts` | 只读 `/context` observer,消费 pi-compaction projection metrics |
- `extensions/lsp.ts`:把组合包内 TypeScript Language Server CLI 物化为绝对命令路径,部署 TypeScript/Kotlin/JDT LS 全局配置并加载 `pi-lsp`。 | `extensions/system-prompt.ts` | 唯一系统提示词出口(`system-prompt-config.ts` 为纯 helper),排在最后 |
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,在每轮开始前按激活工具追加简短路由规则,并提供 `/dump-system-prompt` 将扩展所见的有效提示词写入 `.pi-debug/effective-system-prompt.md`。 | `configure.sh` + `scripts/` | 交互式写入搜索 key(`auth.json`)与 SSH 主机导入 |
- `extensions/system-prompt-cover.ts`:把 Pi 默认系统提示词的开头替换为组合包权威 `config/system-prompt-cover.md`(部署到 `~/.pi/agent/my-pi/system-prompt-cover.md`),并用文本拼接保留其余全部分区及其原始顺序。必须注册在 `tool-routing.ts` **之前**:`tool-routing` 会以 `forceSystemPrompt` 压平提示词,在其后改 `systemPromptOptions` 将不再影响本轮请求。封面文件缺失或空白时不干预。`install.sh` / `update.sh` 经 `scripts/rebuild-bundle-config.mjs` 与全部组合包配置一起整体重建该文件,不保留备份。 | `install.sh` / `update.sh` / `uninstall.sh` | `pi install`/`update`/`remove` 包装 + 逐项询问的机器级依赖与终端配置 |
- `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-worktree/`:完全自维护(无上游)的斜杠命令 git worktree 管理扩展,提供 `/wt`(别名 `/worktree`)的 `new` / `merge` / `switch` / `list` / `remove` / `prune` 子命令,实现「一键拆分」与「一键合并」;不注册模型可调用工具。
- 状态登记位于 `<agentDir>/pi-worktree/state.json`(目录 `700`、文件 `600`、原子写入),不放 `<agentDir>/my-pi/` 下;Pi 不能修改进程 cwd,会话切换通过 `SessionManager.forkFrom`(或为空会话写入最小 header)生成 cwd 指向目标目录的新会话文件再 `ctx.switchSession` 重建运行时,切换后的收尾只能在新 `ctx` 的 `withSession` 回调中执行。
- 合并流程固定为先确认、再 rebase 到来源分支、最后默认 `--ff-only` 合并(`--squash` 可选压缩),冲突时可选择中止或保留后用 `/wt merge --continue` / `--abort` 处理,来源改动用 stash 兜底,永远不会 push;登记文件写入走 `state.json.lock` 锁文件串行化;worktree 根目录与分支前缀由 `settings.json.piWorktree` 的 `root` / `branchPrefix` 配置。
- 来源 checkout 的 `.pi/worktree.json`(`version: 1`)声明环境复刻:`copy` 只复制未跟踪文件、`link` 只软链 gitignore 目标、`env` 渲染为 `600` 权限环境文件、`postCreate` 仅在 `ctx.isProjectTrusted()` 时以 argv 形式 `execFile`(绝不走 shell),任一失败即停并可用 `/wt reopen` 增量重跑;未被 `.gitignore` 覆盖的软链/环境文件写入 `.git/info/exclude`(仓库各 worktree 共享),保证 `/wt merge` 不会提交它们。
- `pi-lsp@0.1.7`:提供声明式 LSP 接入;组合包内置 `typescript-language-server@5.3.0` + `typescript@6.0.3`,并配置机器级 `kotlin-lsp --stdio` 与 `jdtls`。
- `pi-hermes-memory/`:从官方 `pi-hermes-memory` 仓库导入并由本仓库直接维护的 Hermes 风格持久记忆扩展;提供全局/项目/用户/失败四类记忆、SQLite FTS5 trigram 会话与记忆检索、`SKILL.md` 过程记忆、correction 检测、自动 consolidation、`STANDING.md` 常驻指令和 6 个记忆工具。
- 上游来源:<https://github.com/chandra447/pi-hermes-memory>
- 初始导入快照:`df624056a2e8c68bc89ba0a81e1c120856ec9665`(`v0.9.10`)。
- 该目录从明确 tag 导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`、上游 `.github` 工作流或构建产物。
- `pi-minimal-footer/`:基于上游紧凑 footer,保留上下文仪表、订阅用量条和扩展状态。
- 上游来源:<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 后发送完成通知,并在 `permissions:ui_prompt` 表示即将请求人工权限确认时立即通知。
- 上游来源:<https://github.com/smoosex/pi-notify>
- 初始导入快照:`3a3691ab690b4bc37a4412ab0dcbd35ef14adcbf`(`@smoose/pi-notify@0.1.1`)。
- 该目录从 npm 对应明确 commit 的源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
- `pi-compaction/`:统一管理增量 Session segment、确定性工具结果压缩、后台语义摘要、effective token 口径、`context_tree_query`、portable checkpoint 与 Codex native compaction;普通 Provider、native compaction、footer 和本地 context observer 必须消费同一个不可变 projection snapshot。
- 上游参考一:<https://github.com/jjuraszek/pi-condense>,快照 `59c95110aacd8131db71e21296765dd80b72fae8`(`v2.9.1`)。
- 上游来源二:<https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-codex-compaction>,导入快照 `c4d6ca8fb484ba51684296ba8b4715f8c7b3ac91`(`0.1.4`)。
- `pi-compaction` 采用全新的 `pi-compaction` version 1 Session 记录和 `settings.json.piCompaction` 配置,不读取或迁移旧 `context-prune-*`、`contextPrune` 或 `openai-codex-native-compaction` 状态;切换后应使用新 Session。两份上游 MIT 许可证及来源差异保留在包内。
- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0` 和 `typescript@6.0.3`;`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`pi-ask-user`、`pi-chrome`、`pi-compaction`、`pi-hermes-memory`、`pi-minimal-footer`、`pi-ssh`、`pi-tool-search` 与 `pi-worktree` 分别指向仓库内对应目录的本地 `file:` 依赖;`pi-subagents/` 仅保留源码,不作为根运行时依赖。
- `install.sh`:先安装根组合包,再通过 `scripts/rebuild-bundle-config.mjs` 整体重建 `<agentDir>/my-pi/` 受管配置目录,然后交互检查 Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。
- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。不得在 `pi update` 前预清理安装缓存;`pi update` 失败时 fail-fast 停止——Pi 1.3.0 会在下一次 `pi update` 时依据其 `.pi-update-incomplete` 标记自行重跑 `git clean -fdx` 与依赖安装完成自愈,本脚本不做人工恢复。升级成功后调用可定位的更新后组合包 Node helper `scripts/rebuild-bundle-config.mjs` 整体重建 `<agentDir>/my-pi/`:先按 `config/bundle-manifest.json` 校验全部来源与目标,在 agent 目录内构建临时树,再删除旧 `my-pi/`(符号链接只删链接不跟随)并原子替换,不备份、不迁移、不合并不属于组合包的内容。无法从 `PI_PACKAGE_SOURCE` 定位更新后 helper 时必须报告失败,不能回退到可能陈旧的脚本目录 helper。其他组件升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
- `uninstall.sh`:先移除根组合包,其他共享终端环境和机器工具不卸载。不得删除 `.pi/agent/pi-hermes-memory/`、`projects-memory/`、`.hippo/` 或任何用户记忆数据。
## 当前职责与默认行为 ## 全局硬规则
- 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 与具备 Pi extension UI bridge 的 RPC 客户端、顺序执行和最多 8 题;RPC 必须使用标准 `select` / `input` 对话框逐题收集,JSON/print 等无 UI 模式保持拒绝。取消或 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`、六个记忆工具与 `context_tree_query`,以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。配置从 `<agentDir>/my-pi/tool-search/config.json` 读取(由 rebuild 脚本部署 `config/tool-search.json`),文件缺失或无效时回退 `BUNDLE_TOOL_SEARCH_DEFAULTS`;扩展自身不播种、不迁移,也不再读写 `settings.json` 的 `toolSearch` 段。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。
- `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。授权撤销或过期时必须停用全部 21 个工具,包括 `chrome_find` / `chrome_inspect`;Tool Search 重新激活 schema 不能替代每次执行时的授权校验。
- 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
- Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。Tool Search 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决,不能读写 active tool 列表。
- `pi-worktree` 只注册 `/wt` / `/worktree` 斜杠命令,不暴露任何模型可调用工具;其 git 操作由用户显式触发,经 `execFile` argv 直接调用(带超时、不经 shell),按设计绕过 Bash 工具权限策略,不新增权限审批层。
- `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
- CodeGraph MCP 扩展只配置 Pi 到外部 `codegraph` 命令的连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。安装、升级和普通启动流程不得执行 `codegraph init`;只有用户在目标项目显式调用 `/plugin_init` 并确认后,命令才可以 `.codegraph/codegraph.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。
- `extensions/context-view.ts` 是只读的本地 effective-context observer,只读取 `pi-compaction/src/service.ts` 发布的 projection metrics,不注册生产 `context` writer;`pi-minimal-footer` 优先显示同一 effective usage,snapshot 尚未建立时才回退宿主原始 usage。
- `pi-compaction` 负责已经进入 Session 的历史投影,与 Context Mode 的输入隔离职责互补。首次 Session hydration 允许一次完整扫描;后续普通 `context` hot path 必须复用不可变 snapshot 或只处理 delta,不得调用 summarizer、写 Session 或重新构建完整 branch。
- 已闭合工具结果先同步生成保留工具名、参数、状态、首尾证据和 source key 的有界确定性 stub;大于 `semantic.minInputChars` 的结果再进入单 Session 有界后台语义 worker。后台结果只在安全事件边界提交,陈旧 hash 必须丢弃,Provider 请求不得等待后台摘要。
- effective token 默认使用 Provider usage-backed baseline 加 projected delta;无可靠 baseline 时使用带上下界的估算。Pi Core raw threshold 只负责唤醒,只有 effective lower bound 达到 `hardRatio` 才执行 threshold compaction;manual 与 overflow 不得被阻断。
- Codex native compaction 必须直接消费当前 projection 生成的 ResponseItems,不得从 raw branch 重建输入;native checkpoint 同时保存有界 portable summary 供模型或 Provider 切换。配置只从 `settings.json.piCompaction` 读取且不写回,默认 soft/hard/target 比例为 72%/90%/55%。
- Kitty 完成通知只在 TUI 模式的 `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。权限通知只订阅 `permissions:ui_prompt`,在人类确认界面即将出现时立即发送,不得为策略或 AutoReview 已直接决定的请求通知,也不受完成通知时长门槛影响。默认 `o=always`、`a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。
- `/notify on|off` 只覆盖当前 Session;持久默认来自 `PI_NOTIFY_*` 环境变量。`PI_NOTIFY_MESSAGE_SOURCE=none` 必须继续提供不泄露回复正文、权限值或请求者细节的隐私模式,非 TUI 模式不得写入 OSC 序列。
- 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜,硬防御默认只保留确定性的破坏性 Shell 命令;Git 非只读操作、包管理、文件/系统/网络高风险操作与普通 MCP 调用按唯一全局 policy 判定。项目外路径不再产生独立 `external_directory` 判断,只走配置中的工具、`path` 与 Bash pattern。
- 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 的默认 `input.path` 归一化。
- `auto-review` 是 `pi-permission-system` 内置 authorizer,不是独立扩展或第二个 `tool_call` gate;只复核普通策略产生的 `ask`,不会重复处理普通策略已 `allow` / `deny` 或硬防御已 `deny` 的请求。
- reviewer 是对话式 Codex Guardian 风格审查:以独立请求(不写入主会话)把有界 active-branch 对话记录与权限请求交给模型,模型只能通过 `approve` / `reject` 两个工具给出带 `reason` 的结论,不强制 `toolChoice`。`approve` 自动批准(包括 `path`);`reject` 以及配置、模型、超时、Provider 错误、截断、未调用工具、多次调用或参数无效等一切异常都必须 `defer` 到人工提示,并在弹窗中显示原因(模型理由标注为模型生成)。正文文本即使是合法 JSON 也不得作为结论;只有用户按 Esc 中止当前操作时才直接拒绝。不得在 authorizer chain 外增加第二层内置授权范围限制,也不得恢复 annotator 机制。
- 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"]`,普通未匹配请求和需要自动复核的 Git 非只读、包管理等类别声明为 `ask`;普通 pattern 必须按具体度优先(精确匹配、字面字符更多、通配符更少、pattern 更长,最后才以声明顺序破同级);`autoReview` 段只保留 `enabled`,不设 provider/model 种子值,使审查默认跟随主模型;`config/pi-permission-hard-guards.jsonc` 独立保存可由用户调整但不能被普通策略、Session grant、per-agent 规则或 reviewer 覆盖的高危 `deny`。
- `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` 在安装流程中逐项询问后才安装或启用。
- 原生远程压缩限定为 `openai-codex` + `openai-codex-responses` 与 `sub-api` + `openai-responses`,默认在 turn boundary 达到 effective 下界 90% 时触发。`pi-compaction/src/native-provider.ts` 统一资格与传输选择:Codex 保留原 endpoint/JWT/account ID;SubAPI 从显式 `baseUrl` 解析公共 `/responses`,使用配置的 API key 和请求头,不解析 ChatGPT account ID、不硬编码网关域名,也不调用独立 `/responses/compact`。两者复用 projection、`compaction_trigger`、SSE 结果、native checkpoint 恢复和 portable 模型切换摘要;SubAPI 部署须支持原生 compaction v2。普通聊天支持不代表压缩支持,失败回退 Pi,不自动改写用户模型配置。
- SubAPI 原生压缩认证必须保留非空显式 `Authorization`(大小写不敏感)的优先级;没有该头时才使用非空 API key 生成 Bearer,均缺失时可接受非空 `cf-aig-authorization` 代理认证头。不得要求 header-only 配置额外提供假 API key,也不得生成 `Bearer undefined`;空白或缺失凭据在网络请求前拒绝并回退 Pi。Codex 仍必须提供有效 JWT,不能用请求头绕过。
- 记忆由 `pi-hermes-memory` 统一负责:默认 `policy-only` 模式只注入 `<memory-policy>` 指令而不注入记忆正文,因此它不改变 system prompt 前缀、对 DeepSeek 前缀缓存友好;模型需要历史上下文时主动调用 `memory_search` / `session_search`,写入用 `memory_add` / `memory_replace` / `memory_remove`,过程记忆用 `skill_manage`。
- 六个记忆工具与 `context_tree_query` 必须由 `pi-tool-search` 的 `alwaysEnabled` 常驻,不得放入动态组:注入的 policy 会在普通工作中指示模型调用记忆工具,而恢复被投影的工具证据也不能依赖 `tool_search` 激活;常驻同时保持请求前缀稳定。它们只写扩展自己的 agent 目录数据或只读本地索引,不触碰工作区,因此在权限策略中默认 `allow`,避免每轮 AutoReview 往返。
- `pi-hermes-memory` 必须注册在 `pi-compaction` 之前。`ExtensionRunner.emit()` 在第一个返回 `{ cancel: true }` 的 `session_before_compact` handler 处立即停止,而 `pi-compaction` 在判定无需压缩时正是这样返回;注册在其后的 handler 会被跳过,从而静默禁用 `flushOnCompact`。
- `pi-hermes-memory` 依赖 `better-sqlite3@13`,运行时实际 `dlopen` 的是包内 N-API 预编译二进制 `prebuilds/<platform>.node`,不依赖 node-gyp 编译产物。npm 会因该包存在 `binding.gyp` 而推断出隐式 `node-gyp rebuild`,该步骤既无必要也不得批准;根 `allowScripts` 必须保持不新增条目,同时保留既有的 `better-sqlite3@12.11.1` 放行。
- `skill_manage` 是 `pi-hermes-memory` 提供的能力,不是 Pi 宿主能力:Pi 只发现并加载 skill 目录(`~/.pi/agent/skills/` 加扩展通过 `resources_discover` 注册的目录),没有创建 skill 的工具。该扩展同时承担 skill 写入与目录注册;不加载它,`projects-memory/<项目>/skills/` 下的既有 skill 也不会被 Pi 发现。
- `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` 视为明确授权升级已安装组件并整体重建 `<agentDir>/my-pi/` 受管配置目录:重建按 `config/bundle-manifest.json` 全量替换,不逐字段迁移或合并用户改动;需要长期保留的自定义必须改仓库 `config/` 源文件。不得安装缺失组件或改写受管块之外的用户配置;卸载不得删除用户记忆数据,其他共享机器级工具保持不变。
- `<agentDir>/my-pi/` 只存放组合包拥有、可随时重建的配置/日志/缓存;`install.sh` / `update.sh` 每次运行都整体删除后按 `config/bundle-manifest.json` 重建且不备份。需要长期保留的配置修改必须改仓库 `config/` 中的源文件;用户数据(记忆、会话、`auth.json`、`models.json`、`settings.json`、`search.env` 等)不得放入该目录。外部 npm 扩展固定路径的 `hashline.json`、`lsp.json` 位于 agent 根目录,是唯一例外;旧位置(`extensions/pi-permission-system/`、`settings.json` 的 `toolSearch` 段、agent 根 `SYSTEM_COVER.md`)的残留不迁移也不清理。
## 修改边界
- 优先在目标扩展目录内完成改动;不要让一个扩展依赖另一个扩展的未公开内部实现。
- 保留原项目的 `LICENSE`、版权信息和必要的来源说明。
- agent 目录下扩展运行时产生的数据——`<agentDir>/my-pi/` 受管重建目录、旧 `extensions/pi-permission-system/` 残留、日志、构建产物、覆盖率目录和依赖目录——属于本地状态,不应提交;`config/bundle-manifest.json`、`config/pi-permission-policy.jsonc`、`config/pi-permission-hard-guards.jsonc`、`config/tool-search.json`、`config/system-prompt-cover.md`、`config/pi-hashline-edit.json` 与 `config/lsp.json` 是组合包权威源配置,必须提交并维护。
- 外部 Pi 扩展依赖必须在根 `package.json` 中使用精确版本,自维护扩展必须使用本地 `file:` 依赖,并更新根 `package-lock.json`;不要用仓库级 `.pi/settings.json` 代替组合包依赖。
- 需要原生构建的依赖只按锁定版本加入根 `allowScripts`;当前 `better-sqlite3@12.11.1` 仍由 Context Mode 使用并保持精确放行。`pi-hermes-memory` 的 `better-sqlite3@13` 运行时使用包内 N-API 预编译二进制,npm 因 `binding.gyp` 推断出的 `node-gyp rebuild` 无需执行,不得为此新增放行条目。
- Pi 核心包只作为宿主 peer dependencies,不得在组合包内再安装或打包一套 Pi runtime;保留根 `.npmrc` 的 peer 安装策略。
- `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 与版本。
- 每个自维护扩展目录必须保留 `UPSTREAM.md` 来源记录,并且**必须用中文维护**:记录官方仓库、tag/commit、导入版本、导入日期、许可证归属、本地改动清单,以及同步上游时的约束。`UPSTREAM.md` 是仓库自有的维护文档,不是上游产物;上游自带的源码、测试、文档、README、CHANGELOG 与 `LICENSE` 一律保持上游原文,不翻译、不改写。新增扩展或同步上游导致来源信息变化时,必须同步更新该文件。
- `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` 只授权升级已安装项和整体重建 `my-pi/` 受管配置目录;重建必须先完成全部校验并在临时目录构建成功后一次性替换,任何失败不得留下半重建状态。缺失项仍必须跳过。`.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` 本地状态。
- 配置在 `authorizerChain` 中显式命名的 authorizer 可对任一普通策略 `ask`(包括 `path`)返回最终 `allow` / `deny`;`defer` 才继续下一个 authorizer 或终端人工 authority。
- 修改 gate、authorizer chain、子代理转发或公共 service 类型时,必须同步更新包内 README/架构文档和对应测试。
- 根包必须通过 `file:./pi-permission-system` 提供运行时依赖,包装入口必须直接加载仓库源码;不得同时加载 npm 预编译入口或第二个 permission-system 实例。
- 内置 AutoReview 实现位于 `pi-permission-system/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`。
- `HardGuardPolicyLoader` 必须保持 global-only、无 SessionRules、无项目覆盖;用户文件和 bundled fallback 都不可用时 fail closed 到 deny-all,并报告配置问题。普通 policy 与 hard guard 配置不得合并成一份文件。
- 修改 reviewer 策略、可信证据边界、配置结构、hard-guard precedence、模型解析或 authorizer/lifecycle 注册时,必须同步更新 README、配置文档、架构文档、schema 和相应测试。
## `pi-hermes-memory` 开发约定
- 扩展入口是 `pi-hermes-memory/src/index.ts`,来源记录是 `pi-hermes-memory/UPSTREAM.md`;只从官方 `chandra447/pi-hermes-memory` 的明确 tag/commit 移植并保留官方 README、LICENSE 和版权。
- 根包必须通过 `file:./pi-hermes-memory` 提供依赖并直接加载仓库源码;不得同时安装或加载 npm 预编译入口,也不得保留第二个记忆扩展(`hippo-memory-pi` 已移除)。
- `package.json` 的 `pi.extensions` 必须把 `./pi-hermes-memory/src/index.ts` 排在 `./pi-compaction/index.ts` 之前,否则 `flushOnCompact` 会被 `pi-compaction` 的 `{ cancel: true }` 静默跳过;根 `tests/extension-load.test.ts` 必须断言该顺序。
- 六个记忆工具与 `context_tree_query` 必须在 `pi-tool-search/extensions/config.ts` 的 `BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled` 中常驻,并由 `pi-tool-search/extensions/bundle-groups.ts` 的 `memory-recall` / `memory-management` 分组提供目录元数据(`context_tree_query` 留在 `memory-recall`)。新增、删除或重命名这些工具时必须同步 `bundle-groups.ts`、`BUNDLE_TOOL_SEARCH_DEFAULTS`、`config/tool-search.json`(二者必须完全一致)、Tool Search 测试、权限策略与 `pi-tool-search` 的 README / CHANGELOG。
- 依赖必须保持 `better-sqlite3@^13.0.3`:13.x 运行时使用包内 N-API 预编译二进制 `prebuilds/<platform>.node`,不依赖 node-gyp 编译产物,必须能由 npm 嵌套安装并与根 `better-sqlite3@12.11.1` 并存。不得把它降级回 12.x(Node 24 下会在进程退出时 SIGABRT),也不得让根包提升为单一版本。
- `devDependencies` 中的 `@earendil-works/*` 必须与根包当前版本精确一致。若写成 `^0.80.2` 这类与根不兼容的范围,npm 会在本目录嵌套安装第二套 Pi runtime,违反"不得在组合包内再安装一套 Pi runtime"的纪律;`typebox`、`tsx`、`typescript`、`@types/better-sqlite3` 同理,必须钉到根已有版本。
- 全局记忆、项目记忆与 skill 位于 `~/.pi/agent/pi-hermes-memory/` 与 `~/.pi/agent/projects-memory/`,配置位于 `~/.pi/agent/hermes-memory-config.json`;这些属于用户数据,安装、升级与卸载脚本不得创建、迁移或删除。
- 修改工具注册、`before_agent_start` 注入策略、`resources_discover` 目录注册、SQLite 迁移或 consolidation 后,必须同步包内 README/CHANGELOG/docs,并运行根扩展联合加载与 Tool Search 测试。
## `pi-compaction` 开发约定
- 扩展入口是 `pi-compaction/index.ts`,增量索引、投影、后台摘要、服务与 Codex native 实现在 `pi-compaction/src/`,测试位于 `pi-compaction/test/`,双上游来源记录是 `pi-compaction/UPSTREAM.md`。
- 根包必须通过 `file:./pi-compaction` 提供依赖并直接加载源码;不得重新声明或加载 `pi-condense`、npm `@ogulcancelik/pi-codex-compaction` 或 npm `pi-context-view`。
- `pi-compaction` 只支持自己的 version 1 记录和 `piCompaction` 配置,不得恢复旧状态兼容、自动迁移或加载时写回 `settings.json`。
- Context Authority 是 compaction 唯一生产投影者。普通 Provider、Codex native、`/context`、`/compaction-status` 和 footer 必须消费同一 snapshot;background worker 不得在 Provider 请求中被 await。
- Segment custom entry 只能保存摘要、hash 和 source reference,不得复制完整原始工具输出。`context_tree_query` 恢复结果必须有界,native checkpoint 的 portable summary 也必须有界。
- 修改 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。
- 同步 Codex 协议代码必须从官方 `ogulcancelik/pi-extensions` 明确 commit 移植并保留对应 MIT 许可证;不得从 npm tarball、缓存、`node_modules` 或构建产物导入。
## `pi-subagents` 开发约定
- 扩展入口是 `pi-subagents/src/index.ts`,测试保留在该目录的 `test/`,来源记录是 `pi-subagents/UPSTREAM.md`。
- 同步上游必须从明确 tag/commit 移植并保留 README、CHANGELOG、SECURITY、作者和 LICENSE;不得导入嵌套 `.git`、上游 `.pi` 状态、`node_modules`、覆盖率或构建产物。
- 根包当前必须保持禁用 `pi-subagents`:不得声明 `file:./pi-subagents` 或 npm 运行时依赖,也不得在 `pi.extensions` 中加载 `extensions/subagents.ts` 或上游入口;源码、测试、来源记录和 wrapper 继续保留,且不得把 Pi host runtime 作为组合包开发依赖带入根安装。
- 子会话必须在 `bindExtensions()` 前注册到 `pi-permission-system` 的 process-global child registry,并在真实 dispose 时清理;resume、并发和嵌套 Agent 必须维持正确 parent/child session identity。
- `extensions/subagents.ts` 是当前禁用但保留的组合包 wrapper:若以后经明确决定恢复加载,它只能把组合包内权威 `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 明确 `allow` 时自动批准,包括路径请求;reviewer `defer` 或异常必须转发父会话人工 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` 后发送;权限通知只在 `permissions:ui_prompt` 后立即发送。非 TUI 输出完整性、策略/AutoReview 静默决定、隐私模式、自动 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` 的远端路径不能按本机路径执行默认 `input.path` gate;必须通过公共 `PermissionsService` 注册显式 extractor 关闭本机路径推断,并保持六个工具的默认策略为 `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` 必须在唯一全局 policy 的 `shellTools` 中映射 `commandArgument: "command"` 并设置 `decisionFloor: "ask"`,复用 Bash 命令拆分、硬拒绝、风险 `ask` 和 authorizer chain;不得新增第二个并行 `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-system` → `pi-ssh` → `pi-chrome` → `system-prompt-cover` → `tool-routing` → `pi-tool-search`,确保内置 authorizer 与权限服务由同一扩展先注册/发布、SSH 桥接随后安装、Chrome 工具可在授权后进入目录、系统提示词封面在 `tool-routing` 压平提示词之前改写开头,且 Tool Search 最后收集完整工具目录。
## `extensions/system-prompt-cover.ts` 开发约定
- 扩展入口是 `extensions/system-prompt-cover.ts`;纯 helper(`spliceCover` / `readCoverFile` / `resolveCoverPath` / `COVER_FILE_NAME`)位于 `extensions/system-prompt-cover-config.ts` 以便 `node --test` 单测;部署由 `scripts/rebuild-bundle-config.mjs` 按 `config/bundle-manifest.json` 统一完成;组合包权威源是 `config/system-prompt-cover.md`。
- 只能使用 Pi 的 `forceSystemPrompt` 投影路径(`before_agent_start` 返回 `{ systemPrompt }`)配合文本拼接来保持分区顺序;不得改用 `customPrompt` 选项。后者会抑制 `tools` / `rules` / `docs` 三个分区,而回填只能追加到末尾,无法恢复原顺序。
- 不得在 handler 中写 `customPrompt` 或依赖其生效:`forceSystemPrompt` 优先级更高,二者同时存在时 `customPrompt` 会被静默忽略而不报错。
- 分割边界必须是独占一行的裸标签(带 backreference 同名闭合),不能匹配带属性的标签:`<project_instructions path="...">` 嵌套在 `<project_context>` 内,只有裸标签才是分区边界。
- 封面文件是 `<agentDir>/my-pi/system-prompt-cover.md`,不得改名为 `SYSTEM.md`:后者会被 Pi 先当成 `customPrompt` 读入,封面扩展拿到的提示词会缺少可回填的分区。
- 文件缺失、不可读或为空白时必须静默 no-op,保持 Pi 默认封面;不得写入空 preamble,也不得因读取失败阻止其他扩展加载。
- `install.sh` / `update.sh` 必须从**当前升级后**组合包内的 helper 重建 `my-pi/`(`update.sh` 只定位 `updated_bundle_dir` 内的 `scripts/rebuild-bundle-config.mjs`,禁止回退到可能陈旧的脚本目录;`install.sh` 使用自身脚本目录副本),helper 缺失时报告失败但继续处理其他组件。重建不保留备份,与 `pi-hashline-edit.json` 的组合包权威配置语义一致。
- 修改 `spliceCover` 语义、注册顺序、部署路径或脚本钩子后,必须运行 `node --test tests/system-prompt-cover.test.ts`、`node --test tests/bundle-config-rebuild.test.ts`、`node --test tests/extension-load.test.ts`、`sh -n install.sh`、`sh -n update.sh`,并确认 `npm pack --dry-run` 包含 `config/system-prompt-cover.md`、`config/bundle-manifest.json` 与 `scripts/rebuild-bundle-config.mjs`。
## `pi-ask-user` 开发约定
- 扩展入口是 `pi-ask-user/index.ts`,schema、归一化、TUI、RPC extension UI bridge 与工具注册位于 `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;RPC 必须只依赖 Pi 标准 `select` / `input` extension UI、逐题执行、传递 abort signal,并把 dismiss/abort 归一化为取消。
- 成功结果的 `details` 必须保持 `version`、`cancelled: false` 与非空 `answers`,每个 answer 必须提供原始 `question` 和用户 `answer`;取消结果不得成为权限授权证据。修改结果结构时同步验证 `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 隔离安装验证。
## `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`(`<agentDir>/my-pi/tool-search/config.json`,由 rebuild 脚本部署),测试保留在 `pi-tool-search/test/`。
- 同步上游必须记录明确 tag/commit,保留 README、CHANGELOG、文档、作者和许可证说明;不得导入上游 `.pi`、嵌套 `.git`、`node_modules` 或构建产物。
- 修改 Tool Card 提示词、目录校验、分组、缓存、active-tool/LRU 生命周期、配置或工具结果时,必须同步更新包内 README、当前动态加载文档、CHANGELOG 和测试。
- 新增、删除或重命名组合包暴露工具时必须同步维护 `BUNDLE_GROUP_DEFINITIONS`;日常只读工具、provider 工具和高风险管理工具不得因名称相似而混入同一大组,根验证应确认标准组合包没有落入 unknown-tools 路径。
- 生成目录只能让模型提供摘要、使用边界、关键词与分组;工具名、参数和原始 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,不连真实服务器。
安装、升级、卸载、搜索或 SSH 配置脚本变化时,至少运行 `sh -n install.sh`、`sh -n update.sh`、`sh -n uninstall.sh`、`sh -n search_config.sh`、`sh -n ssh_config.sh` 和 ShellCheck,并核对脚本仍具有可执行权限、仍包含在根 `package.json` 的 `files` 中、README 描述与实际流程一致。组合包配置重建必须用隔离 agent dir 覆盖:`my-pi/` 全量替换且内容字节一致、权限 `600`/`700`、目录外文件不受影响、空或相对 agentDir 拒绝且不删除、manifest 目标越界拒绝且不删除、`my-pi/` 为符号链接时只替换链接且不触碰目标目录;`update.sh` 中 helper 无法定位时必须记录失败且不回退脚本目录。搜索配置测试只能使用虚拟 key 和隔离 HOME;SSH 配置测试必须使用隔离 HOME、虚拟凭据与 mock transport,不得连接真实服务器或把真实密码写入测试输出。涉及真实 `pi install`、`pi update`、`pi remove`、Homebrew、远程安装器、Git 克隆、真实 SSH 连接或真实用户终端配置的端到端验证属于外部写操作,未经明确要求不得执行;可以使用隔离的临时 HOME 和 mock 命令验证分支行为。 - 真实 `pi install`/`update`/`remove`、Homebrew、远程安装器、Git 克隆、真实 SSH、真实终端配置的端到端验证属外部写操作,未经要求不执行;用隔离 HOME + mock 验证分支行为。
- 根目录测试:`node --test 'tests/*.test.ts'`、`git diff --check`;各扩展目录的验证命令见其 `AGENTS.md`。未能运行的验证交付时明确说明,不以静态检查代替运行结果。
记忆扩展变化必须额外验证:`pi-hermes-memory` 在隔离临时 Pi agent 目录中可加载且注册 6 个工具、`pi.extensions` 中排在 `pi-compaction` 之前、Tool Search 目录把六个工具归入 `memory-recall` / `memory-management` 且全部常驻、权限策略默认 `allow`、`better-sqlite3@13` 嵌套安装与根 `12.11.1` 并存。不得在测试中连接真实数据库或修改真实 `~/.pi/agent/` 记忆数据。
在 `pi-permission-system/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`。涉及内置 AutoReview 或硬防御时,还要验证 authorizer 对普通工具与 `path` 的 `allow` / `deny` / `defer`、hard-guard precedence、未配置/未知 provider 或模型时的 `defer` 与一次性提示,以及 owner-only 持久化。
在 `pi-notify/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm pack --dry-run --json`;涉及加载入口或 lifecycle 时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
在 `pi-compaction/` 内至少运行 `npm test`、`npm run check` 和 `npm pack --dry-run --json`;涉及依赖、加载入口、context writer、footer 或 observer 时还要验证根 `npm install` 幂等、锁文件仅包含预期本地 link 变化、根扩展联合加载、性能回归,以及实际 packed 根 tarball 的隔离安装/加载。
在 `pi-tool-search/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`;涉及加载入口或 active-tool 行为时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
在 `pi-rtk-optimizer/` 内按改动范围选择最小充分验证:
- `npm run build`:TypeScript 转译检查。
- `npm run typecheck`:完整类型检查。
- `npm run test`:运行 Bun 测试。
- `npm run check`:类型、测试和打包检查的完整验证。
若环境缺少依赖或未执行某项验证,交付时明确说明,不以静态检查代替运行结果。
<!-- pi-hermes-memory:start --> <!-- pi-hermes-memory:start -->
## 项目记忆(pi-hermes-memory) ## 项目记忆(pi-hermes-memory)
+99 -89
View File
@@ -2,26 +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 可选。
- `pi-mcp-adapter@2.26.0`:通过单个共享 adapter 实例接入机器现有的 CodeGraph 和 Exa 托管 MCP,避免重复注册 Pi 的全局 MCP flag 与命令。
- `extensions/context-view.ts`:通过 `/context` 只读显示 `pi-compaction` 的 effective context,不建立第二套上下文口径。 - `extensions/context-view.ts`:通过 `/context` 只读显示 `pi-compaction` 的 effective context,不建立第二套上下文口径。
- `pi-lsp@0.1.7`:为 TypeScript/JavaScript、Kotlin 和 Java 提供 LSP 诊断、跳转与符号工具;TypeScript 后端由组合包内置。 - `pi-lsp@0.1.7`:为 TypeScript/JavaScript、Kotlin 和 Java 提供 LSP 诊断、跳转与符号工具;TypeScript 后端由组合包内置。
- `pi-hermes-memory/`:从官方 `pi-hermes-memory v0.9.10` 源码导入的持久记忆扩展,提供全局/项目/用户/失败四类记忆、SQLite FTS5 会话与记忆检索、`SKILL.md` 过程记忆、correction 检测与自动 consolidation。 - `pi-hermes-memory/`:从官方 `pi-hermes-memory v0.9.10` 源码导入的持久记忆扩展,提供全局/项目/用户/失败四类记忆、SQLite FTS5 会话与记忆检索、`SKILL.md` 过程记忆、correction 检测与自动 consolidation。
- 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer。 - 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer。
- 本仓库维护的 `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`:以增量投影统一确定性工具结果压缩、后台语义摘要、effective token 计量、`context_tree_query`、portable checkpoint 与 Codex native compaction。 - 本仓库维护的 `pi-compaction`:压力触发时以 Pi `context_edit` 批量遮蔽旧工具结果、空闲时按阈值触发整段摘要、effective token 计量、`context_tree_query`、portable checkpoint 与 Codex native compaction。
- 本仓库自维护的 `pi-overflow`:统一的新工具结果展示截断——超阈值原文落盘、向模型展示「头 + 信号行 + 尾」的有界视图,并提供 `overflow_query` 按 id 搜索、行区间或 JSON 路径取回原文。
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`;TUI 中保留单题、多题、选择、文本、自定义回答与提交前复核,Paseo 等兼容 RPC 客户端通过标准 Pi extension UI 逐题交互。 - 本仓库维护的 `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-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-system`:只读取一份全局普通策略与一份独立 Hard Guards,按 pattern 具体度裁决 allow/ask/deny,并整合 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底;自动评审模型完全由配置文件指定,未配置时提示并转人工审批;工具显隐仍只由 Tool Search 管理。 - 本仓库维护的 `pi-permission-system`:只读取一份全局普通策略与一份独立 Hard Guards,按 pattern 具体度裁决 allow/ask/deny,并整合 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底;自动评审模型完全由配置文件指定,未配置时提示并转人工审批;工具显隐仍只由 Tool Search 管理。
- 本仓库保留维护的 `pi-subagents` 源码及权限集成实现,但当前已从根组合包依赖和默认扩展加载列表中移除,不会随核心包启用。 - 本仓库维护的 `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 复核的连接。 - 本仓库维护的 `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` 导出当前有效提示词。
## 安装、升级与卸载 ## 安装、升级与卸载
@@ -52,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> >>>` 到
@@ -69,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
``` ```
脚本不会在升级前破坏性预清理安装缓存:`pi update` 失败时立即停止后续升级——Pi 1.3.0 会在下一次 `pi update` 时依据自己的 `.pi-update-incomplete` 标记自动重新清理并恢复未完成的升级,重新运行 `update.sh` 即可,脚本不做人工恢复。 `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,以及由
@@ -87,7 +87,7 @@ Git checkout 在 `fetch` 后比较本地与上游提交,Homebrew 包在刷新
日志中的“Zsh 插件未安装,升级流程跳过”不是升级失败:升级脚本不会新增用户此前未选择的 日志中的“Zsh 插件未安装,升级流程跳过”不是升级失败:升级脚本不会新增用户此前未选择的
插件。需要补装时重新运行 `./install.sh`,并在对应的逐项询问中明确选择 `Y`。 插件。需要补装时重新运行 `./install.sh`,并在对应的逐项询问中明确选择 `Y`。
升级成功后,脚本会使用**更新后**组合包中的 `scripts/rebuild-bundle-config.mjs` 整体重建 `<agentDir>/my-pi/` 受管配置目录:按 `config/bundle-manifest.json` 校验来源与目标、在临时目录完成构建后一次性替换,不备份也不保留目录内的本地改动。Powerlevel10k 受管副本只在内容不同时备份并原子同步;`.zshrc` 也只更新已有的 my-pi 受管块,不触碰块外配置。因此安装流程负责补齐缺失项,升级流程只升级已安装项并整体刷新组合包拥有的配置。 `pi update` 在有新版本时用更新后的组合包整体覆盖已安装副本;`update.sh` 在每次成功升级后还会把默认 git 来源的已安装 checkout `git reset --hard` 还原到已发布版本(本地路径来源不 reset,不做 `git clean` 以保留 `node_modules`),因此对已安装副本(含 `config/`)的本地修改不会保留。扩展运行时直接读取已安装组合包内的 `config/` 文件,脚本不再做任何配置部署。Powerlevel10k 受管副本只在内容不同时备份并原子同步;`.zshrc` 也只更新已有的 my-pi 受管块,不触碰块外配置。因此安装流程负责补齐缺失项,升级流程只升级已安装项。
卸载组合包: 卸载组合包:
@@ -117,18 +117,18 @@ prompt 前缀、对前缀缓存友好。模型需要历史上下文时主动调
Tool Search 常驻,保证 policy 指示与模型可见工具一致。 Tool Search 常驻,保证 policy 指示与模型可见工具一致。
记忆与 skill 数据位于 `~/.pi/agent/pi-hermes-memory/`(全局)与 `~/.pi/agent/projects-memory/<项目>/` 记忆与 skill 数据位于 `~/.pi/agent/pi-hermes-memory/`(全局)与 `~/.pi/agent/projects-memory/<项目>/`
(项目级),可选配置为 `~/.pi/agent/hermes-memory-config.json`。这些是用户数据,安装、升级与卸载脚本 (项目级)。这些是用户数据,安装、升级与卸载脚本不会创建、迁移或删除;扩展配置来自已安装组合包的
不会创建、迁移或删除。 `config/pi-hermes-memory.json`。
进入目标项目后可在 Pi 中执行 `/plugin_init`:命令会显示当前目录并要求确认,以 CodeGraph 采用惰性初始化:`codegraph_explore` 在未建立索引的项目上被调用时会先向用户确认,
`.codegraph/codegraph.db` 为初始化标志,只为缺失状态运行 `codegraph init`;全部成功后通过 Pi 官方热重载 确认后才运行 `codegraph init` 并自动重试本次探索,拒绝一次后本会话不再询问;不需要手动执行任何初始化命令。
重新连接 CodeGraph MCP。也可以继续在终端手动执行 `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 的更新或遥测设置。
## 组合行为 ## 组合行为
@@ -138,25 +138,21 @@ CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自
### 工具与搜索路由 ### 工具与搜索路由
`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-cover.ts` 把 Pi 默认系统提示词的**开头**替换为组合包权威的 `config/system-prompt-cover.md`(部署到 `~/.pi/agent/my-pi/system-prompt-cover.md`),同时**保留其余全部分区及其原始顺序**:`<tools>`、`<rules>`、`<docs>`、`<skills>`、`<project_context>`、`<cwd>` 都不会被删除或重新排序。 `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 的 `forceSystemPrompt` 投影路径(每轮把渲染结果作为强制定本发送)而不是 `customPrompt` 选项:后者会连带抑制 `tools`/`rules`/`docs` 三个分区,且回填只能追加到末尾,无法保持顺序。封面只在**请求**时应用,不修改 transcript 中已持久化的结构化分区。 其他扩展不直接改写提示词:pi-chrome、pi-ssh、pi-hermes-memory 等通过 `systemPromptOptions.sections.<name>` 贡献结构化分区,pi-rtk-optimizer 通过 `promptGuidelines` 补充规则;第三方扩展(FFF、Hashline、LSP)经 `extensions/` 下的包装文件与 `prompt-guard.ts` 代理加载,拦截直接的提示词改写并按 `tools.jsonc` 应用工具说明覆盖。
封面每轮重新读取,因此修改 `~/.pi/agent/my-pi/system-prompt-cover.md` 后在**下一轮**即生效,不需要重开会话;已经结束的历史对话不受影响。文件缺失或为空白时不干预,Pi 继续使用自身默认封面。 `config/prompt/` 每轮重新读取(按 mtime 缓存),修改已安装组合包内的文件后下一轮即生效;配置解析失败时警告一次并保持 Pi 默认提示词,不阻止会话。该目录由组合包直接维护并随组合包发布;对已安装副本的本地修改不做保护,`pi update` 会直接覆盖;要长期自定义请直接编辑仓库中的 `config/prompt/`。
该文件由组合包直接维护,`install.sh` 与 `update.sh` 会在组合包安装/升级后连同整个 `my-pi/` 目录一起重建,**不保留备份并且会覆盖**本地修改;要自定义请直接编辑仓库中的 `config/system-prompt-cover.md`。
`system-prompt-cover.md` 必须使用这个名字:不能改名为 `SYSTEM.md`,否则 Pi 会先把它当作 `customPrompt` 读入,封面扩展拿到的提示词就已经缺少可供回填的分区了。
### 接管当前 Chrome ### 接管当前 Chrome
@@ -164,7 +160,7 @@ CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自
授权后注册的 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
@@ -172,29 +168,28 @@ 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 目录的 `my-pi/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。
Tool Search 的用户级配置文件是 `~/.pi/agent/my-pi/tool-search/config.json`(准确位置为 Pi agent dir 下的 `my-pi/tool-search/config.json`),由 `install.sh` / `update.sh` 从仓库的 `config/tool-search.json` 部署: Tool Search 的配置文件是已安装组合包内的 `config/tool-search.json`(即本仓库的 `config/tool-search.json`,随 `pi install` / `pi update` 就位,扩展运行时直接读取):
```json ```json
{ {
"alwaysEnabled": [ "alwaysEnabled": [
"codegraph_explore", "codegraph_explore",
"lsp_diagnostics", "lsp_diagnostics",
"ctx_execute",
"ctx_execute_file",
"ctx_batch_execute",
"memory_search", "memory_search",
"session_search", "session_search",
"memory_add", "memory_add",
"memory_replace", "memory_replace",
"memory_remove", "memory_remove",
"skill_manage", "skill_manage",
"context_tree_query" "context_tree_query",
"overflow_query",
"codemode"
], ],
"showToolSearchFooterStatus": false, "showToolSearchFooterStatus": false,
"maxActiveGroups": 5, "maxActiveGroups": 5,
@@ -204,108 +199,114 @@ Tool Search 的用户级配置文件是 `~/.pi/agent/my-pi/tool-search/config.js
} }
``` ```
扩展只读取该文件,从不写入:文件缺失、不可读或结构无效时回退内置的 `BUNDLE_TOOL_SEARCH_DEFAULTS`;每次运行 `install.sh` / `update.sh` 都会随整个 `my-pi/` 目录重建该文件,本地手改会被覆盖,长期自定义应改仓库 `config/tool-search.json`。`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 时,可直接执行:
```text
/plugin_init
```
该命令拒绝文件系统根目录和用户主目录,先确认 CLI 可用,再初始化缺失数据库状态。失败时不会热重载,也不会回滚此前已成功完成的初始化;已初始化时仍可用该命令快速热重载扩展。
### 统一权限系统、硬防御与 AutoReview ### 统一权限系统、硬防御与 AutoReview
`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 扩展。 `pi-permission-system/` 从上游 `pi-permission-system-v26.2.1` tag 导入,初始源码快照为 `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`。原 `pi-permission-auto-review` 的 reviewer、可信证据边界和 Codex Guardian 风格策略已整合进同一个扩展;其 MIT 许可和来源记录保留在 `pi-permission-system/LICENSES/` 与 `docs/auto-review/`。根组合包不再加载第二个 AutoReview 扩展。
`extensions/permission-system.ts` 只加载扩展本身,不做任何配置播种或迁移;两份用户可编辑 JSONC 由 `install.sh` / `update.sh` 经 `scripts/rebuild-bundle-config.mjs` 以 `0600` 部署: `extensions/permission-system.ts` 只加载扩展本身,不做任何配置播种或迁移;扩展运行时直接读取已安装组合包内的两份 JSONC:
- `~/.pi/agent/my-pi/permission-system/policy.jsonc`:唯一的全局普通 allow/ask/deny 策略,同时保存运行参数与 AutoReview 配置。项目级 `.pi/extensions/pi-permission-system/policy.jsonc` 不再读取。 - `config/pi-permission-policy.jsonc`:唯一的全局普通 allow/ask/deny 策略,同时保存运行参数与 AutoReview 配置。项目级 `.pi/extensions/pi-permission-system/policy.jsonc` 不再读取。
- `~/.pi/agent/my-pi/permission-system/hard-guards.jsonc`:仅全局生效的硬防御。硬 `deny` 先于 Session grant、per-agent 规则和 AutoReview,不能被后续层放宽。 - `config/pi-permission-hard-guards.jsonc`:仅全局生效的硬防御。硬 `deny` 先于 Session grant、per-agent 规则和 AutoReview,不能被后续层放宽。
- 同一文件内的 `protectedWrites`:Pi 凭据/全局设置/模型配置、已安装组合包与扩展目录、pi-ssh 凭据库等受保护文件的写入永远走人工确认——不经 AutoReview、yolo 或 Session grant,弹窗没有「本会话允许」选项,没有可用确认界面时直接拒绝。读取不受影响,`ssh_bash` 等远端 shell 工具不参与本机路径保护。
普通 pattern 不再按“最后一条命中”覆盖,而按具体度选择:精确匹配优先,其次字面字符更多、通配符更少、pattern 更长,只有完全同级时才以后声明者为准。因此具体 deny 会稳定压过宽泛的 `"*": "allow"`。 普通 pattern 不再按“最后一条命中”覆盖,而按具体度选择:精确匹配优先,其次字面字符更多、通配符更少、pattern 更长,只有完全同级时才以后声明者为准。因此具体 deny 会稳定压过宽泛的 `"*": "allow"`。
系统不再产生 `external_directory` 判断:项目外路径与项目内路径一样,只走用户配置的工具、`path` 和 Bash 规则。Tool Search 是唯一的工具显隐/激活控制者;权限系统只在工具被调用时做裁决。 系统不再产生 `external_directory` 判断:项目外路径与项目内路径一样,只走用户配置的工具、`path` 和 Bash 规则。Tool Search 是唯一的工具显隐/激活控制者;权限系统只在工具被调用时做裁决。
`my-pi/` 目录由组合包整体拥有:每次 `install.sh` / `update.sh` 都会按 `config/bundle-manifest.json` 删除后重建,不备份也不迁移目录内的改动;要长期保留的自定义必须改仓库 `config/` 下的权威源。旧位置 `~/.pi/agent/extensions/pi-permission-system/` 的残留文件被忽略,不迁移也不清理。 `<agentDir>/my-pi/` 只保留可随时丢弃的运行时状态(如权限日志、Tool Search 目录缓存);组合包配置一律位于仓库 `config/` 并随组合包发布,对已安装副本的本地改动由 `pi update` 覆盖,要长期保留的自定义必须改仓库 `config/` 下的权威源。旧位置 `~/.pi/agent/extensions/pi-permission-system/` 的残留文件被忽略,不迁移也不清理。
默认普通策略对未匹配请求返回 `ask`,并通过 `authorizerChain: ["auto-review"]` 先交给内置 reviewer。高危 Hard Guard 直接终止;AutoReview 的 `allow` / `deny` 对任一普通策略 `ask`(包括 `path`)直接结束当前审批;模型、配置、认证、超时或输出异常统一 `defer` 到人工界面。 默认普通策略对未匹配请求返回 `ask`,并通过 `authorizerChain: ["auto-review"]` 先交给内置 reviewer。高危 Hard Guard 直接终止;AutoReview 的 `allow` / `deny` 对任一普通策略 `ask`(包括 `path`)直接结束当前审批;模型、配置、认证、超时或输出异常统一 `defer` 到人工界面。
@@ -357,8 +358,9 @@ JDT LS 需要 Java 21 或更高版本;可使用带 `jdtls` wrapper 的官方
skill 也不会出现在 Pi 的 skill 索引中。 skill 也不会出现在 Pi 的 skill 索引中。
记忆与 skill 位于 `~/.pi/agent/pi-hermes-memory/`(全局)和 `~/.pi/agent/projects-memory/<项目>/` 记忆与 skill 位于 `~/.pi/agent/pi-hermes-memory/`(全局)和 `~/.pi/agent/projects-memory/<项目>/`
(项目级),配置位于 `~/.pi/agent/hermes-memory-config.json`。这些是用户数据:安装、升级和卸载脚本都 (项目级)。这些是用户数据:安装、升级和卸载脚本都
不会创建、迁移或删除它们,也不会自动转换旧 Hippo `.hippo/` 数据。 不会创建、迁移或删除它们,也不会自动转换旧 Hippo `.hippo/` 数据;扩展配置来自已安装组合包的
`config/pi-hermes-memory.json`。
### Kitty 完成与权限确认通知 ### Kitty 完成与权限确认通知
@@ -367,23 +369,31 @@ compaction 和排队 follow-up 完成前不会提前通知;同时监听 `pi-pe
`permissions:ui_prompt`,仅在权限系统即将显示人工确认界面时立即通知,策略或 AutoReview 已直接完成的 `permissions:ui_prompt`,仅在权限系统即将显示人工确认界面时立即通知,策略或 AutoReview 已直接完成的
决定不会通知。Kitty 后端使用 Base64 OSC 99、每个 Pi Session 独立的稳定通知 ID、显式 `a=focus`, 决定不会通知。Kitty 后端使用 Base64 OSC 99、每个 Pi Session 独立的稳定通知 ID、显式 `a=focus`,
并默认设置 `o=always`:即使来源 Kitty window/pane 正在接收键盘输入也会显示通知,点击通知由 Kitty 并默认设置 `o=always`:即使来源 Kitty window/pane 正在接收键盘输入也会显示通知,点击通知由 Kitty
返回原始来源;仍可通过环境变量改用 `unfocused` 或 `invisible`。 返回原始来源;仍可通过 `visibility` 改用 `unfocused` 或 `invisible`。
默认忽略不足 3 秒的短任务,但权限确认通知不受该时长门槛影响。`/notify on|off|test|status` 可控制 默认忽略不足 3 秒的短任务,但权限确认通知不受该时长门槛影响。`/notify on|off|test|status` 可控制
当前 Session 的两类通知;持久默认通过 `PI_NOTIFY_ENABLED`、`PI_NOTIFY_MIN_SECONDS`、 当前 Session 的两类通知;持久默认通过组合包 `config/pi-notify.json` 的 `enabled`、`minSeconds`、
`PI_NOTIFY_VISIBILITY`、`PI_NOTIFY_ACTION`、`PI_NOTIFY_MESSAGE_SOURCE` 等环境变量配置。 `visibility`、`action`、`messageSource` 等字段配置。`messageSource: "none"`
`PI_NOTIFY_MESSAGE_SOURCE=none` 会同时隐藏回复摘要以及权限值和请求者细节。Kitty 位于 tmux 内时需要 会同时隐藏回复摘要以及权限值和请求者细节。Kitty 位于 tmux 内时需要
`set -g allow-passthrough all` 并重启 tmux server。完整配置和上游差异见 `set -g allow-passthrough all` 并重启 tmux server。完整配置和上游差异见
[`pi-notify/README.md`](pi-notify/README.md)。 [`pi-notify/README.md`](pi-notify/README.md)。
### Pi Compaction ### Pi Compaction
组合包只加载仓库内维护的 `pi-compaction/`。它以增量 Segment Index 管理已经进入 Session 的历史:已闭合工具结果先同步替换为有界的确定性证据 stub,大于阈值的结果再由单 Session 有界后台 worker 生成语义摘要;Provider 请求不会等待后台摘要。原始工具证据不重复写入 custom entry,可通过 `context_tree_query` 使用 source key 或 tool call ID 有界恢复。 组合包只加载仓库内维护的 `pi-compaction/`。它管理已经进入 Session 的历史投影:上下文压力达到阈值时,在 `turn_end` 批量提交 Pi 原生 `context_edit`,把最近 `keepRecentTurns` 轮之外的大型工具结果遮蔽为指向 `context_tree_query` 的单行占位符;遮蔽成批发生、受 trigger/target 回差与 `minClearTokens` 约束。原始工具结果保留在 Session 中(UI、导出不变),可通过 `context_tree_query` 按 tool call ID 有界恢复。
普通 Provider、阈值判断、Codex native compaction、`/context`、`/compaction-status` 和 minimal footer 都读取同一个 effective projection snapshot。Pi Core 的 raw threshold 只负责唤醒:只有 effective token 下界达到默认 90% 才执行 threshold compaction;manual 与 overflow 始终允许继续并在 native 失败时回退 Pi 默认 compaction。Codex native API 直接消费 effective ResponseItems,同时保存有界 portable summary 供模型或 Provider 切换。 普通 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 切换。
唯一配置位于 Pi agent `settings.json` 的 `piCompaction` 字段,默认 soft/hard/target 比例为 72%/90%/55%,扩展只在内存中归一化且不写回配置。`/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)。 唯一配置位于已安装组合包的 `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)。
原生远程压缩同时支持 `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。 原生远程压缩同时支持 `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
组合包自维护的 `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。
-10
View File
@@ -1,10 +0,0 @@
{
"version": 1,
"directory": "my-pi",
"files": [
{ "source": "config/pi-permission-policy.jsonc", "target": "permission-system/policy.jsonc" },
{ "source": "config/pi-permission-hard-guards.jsonc", "target": "permission-system/hard-guards.jsonc" },
{ "source": "config/tool-search.json", "target": "tool-search/config.json" },
{ "source": "config/system-prompt-cover.md", "target": "system-prompt-cover.md" }
]
}
+22
View File
@@ -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
}
}
+35
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
{
"showCwd": true,
"showBranch": true
}
+11
View File
@@ -0,0 +1,11 @@
{
"enabled": true,
"title": "π - {project}",
"body": "{message}",
"messageSource": "assistant",
"messageMax": 80,
"minSeconds": 3,
"visibility": "always",
"action": "focus",
"soundCommand": ""
}
+13
View File
@@ -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
}
+21 -3
View File
@@ -1,6 +1,7 @@
{ {
// Bundle default. The installed user-level copy is seeded once and may then // Bundle-owned hard guards, read directly from the installed bundle's
// be edited by the user; project configuration cannot override it. // config/ directory. pi update overwrites this file; project configuration
// cannot override it.
"version": 1, "version": 1,
"permission": { "permission": {
"bash": { "bash": {
@@ -13,5 +14,22 @@
"mkfs*": "deny", "mkfs*": "deny",
"dd * of=/dev/*": "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 配置" }
]
} }
+51 -15
View File
@@ -37,14 +37,30 @@
"edit": "allow", "edit": "allow",
"grep": "allow", "grep": "allow",
"find": "allow", "find": "allow",
"multi_grep": "allow", "ls": "allow",
"codegraph_explore": "allow", "codegraph_explore": "allow",
"lsp_diagnostics": "allow", "lsp_diagnostics": "allow",
"ctx_execute": "allow", "lsp_definition": "allow",
"ctx_execute_file": "allow", "lsp_references": "allow",
"ctx_batch_execute": "allow", "lsp_hover": "allow",
"lsp_symbols": "allow",
"tool_search": "allow", "tool_search": "allow",
"ask_user_question": "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 // Hermes memory tools write only to the extension's own agent-directory
// stores and never touch the workspace, so they stay allowed. memory_search // stores and never touch the workspace, so they stay allowed. memory_search
@@ -55,11 +71,22 @@
"memory_add": "allow", "memory_add": "allow",
"memory_replace": "allow", "memory_replace": "allow",
"memory_remove": "allow", "memory_remove": "allow",
"skill_manage": "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 // 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 // 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. // alwaysEnabled set, so it must not pay an AutoReview round trip per turn.
"context_tree_query": "allow", "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_connect": "ask",
"ssh_cd": "ask", "ssh_cd": "ask",
@@ -75,10 +102,10 @@
}, },
"bash": { "bash": {
"*": "allow", "*": "allow",
"rg*": "deny", "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*": "deny", "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*": "deny", "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*": "deny", "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 *": "ask",
"git status*": "allow", "git status*": "allow",
"git diff*": "allow", "git diff*": "allow",
@@ -86,11 +113,25 @@
"git show*": "allow", "git show*": "allow",
"git rev-parse*": "allow", "git rev-parse*": "allow",
"git ls-files*": "allow", "git ls-files*": "allow",
"git grep*": "deny", "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": "allow",
"git branch --show-current*": "allow", "git branch --show-current*": "allow",
"git remote -v": "allow", "git remote -v": "allow",
"git remote get-url*": "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 install *": "ask",
"npm add *": "ask", "npm add *": "ask",
"npm uninstall *": "ask", "npm uninstall *": "ask",
@@ -136,11 +177,6 @@
"wget *": "ask", "wget *": "ask",
"printenv*": "ask" "printenv*": "ask"
}, },
"mcp": {
"*": "ask",
"mcp_status": "allow",
"mcp_list": "allow"
},
"skill": { "skill": {
"*": "allow" "*": "allow"
} }
+4
View File
@@ -0,0 +1,4 @@
{
"root": "~/.pi/worktrees",
"branchPrefix": "wt/"
}
+84
View File
@@ -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."
}
]
}
+12
View File
@@ -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"]
}
+27
View File
@@ -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.
+31
View File
@@ -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
---
+8
View File
@@ -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.
+107
View File
@@ -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 }
}
}
+30
View File
@@ -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
}
}
+3 -4
View File
@@ -2,16 +2,15 @@
"alwaysEnabled": [ "alwaysEnabled": [
"codegraph_explore", "codegraph_explore",
"lsp_diagnostics", "lsp_diagnostics",
"ctx_execute",
"ctx_execute_file",
"ctx_batch_execute",
"memory_search", "memory_search",
"session_search", "session_search",
"memory_add", "memory_add",
"memory_replace", "memory_replace",
"memory_remove", "memory_remove",
"skill_manage", "skill_manage",
"context_tree_query" "context_tree_query",
"overflow_query",
"codemode"
], ],
"showToolSearchFooterStatus": false, "showToolSearchFooterStatus": false,
"maxActiveGroups": 5, "maxActiveGroups": 5,
Executable
+153
View File
@@ -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
+47
View File
@@ -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 后端启动失败。
+38
View File
@@ -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,
),
});
}
+380
View File
@@ -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);
}
}
+311
View File
@@ -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 -1
View File
@@ -17,7 +17,7 @@ export default function contextView(pi: ExtensionAPI): void {
`Raw: ${formatNumber(metrics.rawTokens)} / ${formatNumber(metrics.contextWindow)}`, `Raw: ${formatNumber(metrics.rawTokens)} / ${formatNumber(metrics.contextWindow)}`,
`Reclaimed: ${formatNumber(metrics.reclaimedTokens)}`, `Reclaimed: ${formatNumber(metrics.reclaimedTokens)}`,
`Segments: ${formatNumber(metrics.segmentCount)}`, `Segments: ${formatNumber(metrics.segmentCount)}`,
`Semantic jobs: ${formatNumber(metrics.semanticJobs)}`, `Masked tool results: ${formatNumber(metrics.maskedResults)}`,
`Generation: ${formatNumber(metrics.generation)}`, `Generation: ${formatNumber(metrics.generation)}`,
].join("\n") ].join("\n")
: "No pi-compaction projection is available yet. Send one message to build the first snapshot."; : "No pi-compaction projection is available yet. Send one message to build the first snapshot.";
-57
View File
@@ -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;
}
+3 -1
View File
@@ -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));
} }
+7 -2
View File
@@ -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
View File
@@ -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));
} }
-36
View File
@@ -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,
},
},
};
}
-12
View File
@@ -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));
}
+3 -3
View File
@@ -4,9 +4,9 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
/** /**
* Load the bundled permission system directly. * Load the bundled permission system directly.
* *
* Its bundle-owned config (policy.jsonc, hard-guards.jsonc) is deployed by * Its bundle-owned config (pi-permission-policy.jsonc, pi-permission-hard-guards.jsonc)
* install.sh / update.sh into `<agentDir>/my-pi/permission-system/`; the * is read directly from the installed bundle's `config/` directory; the
* extension itself never seeds or migrates configuration. * extension itself never seeds, deploys, or migrates configuration.
*/ */
export default function bundledPermissionSystemExtension(pi: ExtensionAPI): void { export default function bundledPermissionSystemExtension(pi: ExtensionAPI): void {
permissionSystemExtension(pi); permissionSystemExtension(pi);
-158
View File
@@ -1,158 +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";
command: "codegraph";
database: string;
initArgs(cwd: string): string[];
}
const INIT_TARGETS: InitTarget[] = [
{
name: "CodeGraph",
command: "codegraph",
database: join(".codegraph", "codegraph.db"),
initArgs: (cwd) => ["init", cwd],
},
];
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 every CLI before creating project 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 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");
}
},
});
}
+120
View File
@@ -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;
},
});
}
-57
View File
@@ -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();
}
+17 -2
View File
@@ -1,13 +1,28 @@
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
// binds the maintained permission-system source. // binds the maintained permission-system source.
const BUNDLED_PERMISSION_EXTENSION = fileURLToPath( 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],
}); });
+430
View File
@@ -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);
}
-57
View File
@@ -1,57 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
/** File name of the bundle-managed system prompt cover inside `<agentDir>/my-pi/`. */
export const COVER_FILE_NAME = "system-prompt-cover.md";
/**
* Matches the newline-delimited boundary before the first top-level prompt section.
*
* Pi renders the system prompt as `<preamble>` followed by sections wrapped in tags of
* the same name (for example `<tools>`). Nested markup such as
* `<project_instructions path="...">` carries attributes and therefore never matches a
* bare tag line, so the first match is the end of the preamble.
*/
const FIRST_SECTION_BOUNDARY = /\n<[a-z][a-z0-9_-]*>\n/;
/** Resolve the cover path inside a given agent directory's bundle-owned `my-pi/` tree. */
export function resolveCoverPath(agentDir: string): string {
return join(agentDir, "my-pi", COVER_FILE_NAME);
}
/**
* Read the bundle-managed cover file.
*
* Returns `undefined` when the file is absent, unreadable, or blank so the caller can
* leave Pi's own default prompt untouched instead of forcing an empty preamble.
*/
export function readCoverFile(coverPath: string): string | undefined {
let raw: string;
try {
raw = readFileSync(coverPath, "utf8");
} catch {
return undefined;
}
const trimmed = raw.replace(/\s+$/, "");
return trimmed.length > 0 ? trimmed : undefined;
}
/**
* Replace the rendered prompt's preamble with `cover`, keeping everything after the first
* top-level section byte-for-byte identical.
*
* Pi builds the preamble at the top of the rendered prompt, so swapping that prefix is
* what preserves section order: options-based replacement (`customPrompt`) would drop
* `tools`/`rules`/`docs` and rebuild them at the end, reordering the prompt. Splicing the
* rendered text keeps the existing order untouched.
*
* A rendered prompt without any top-level section degenerates to the cover alone.
*/
export function spliceCover(rendered: string, cover: string): string {
const trimmed = cover.replace(/\s+$/, "");
const boundary = rendered.search(FIRST_SECTION_BOUNDARY);
if (boundary < 0) {
return trimmed;
}
return trimmed + rendered.slice(boundary);
}
-28
View File
@@ -1,28 +0,0 @@
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { readCoverFile, resolveCoverPath, spliceCover } from "./system-prompt-cover-config.ts";
export { COVER_FILE_NAME, readCoverFile, resolveCoverPath, spliceCover } from "./system-prompt-cover-config.ts";
/**
* Replaces Pi's default system prompt preamble with the bundle-managed cover while
* preserving every other prompt section in its original position.
*
* The cover is read on each `before_agent_start`, so edits to `system-prompt-cover.md` take
* effect on the next turn of an already-running session rather than requiring a restart.
*
* Returning `systemPrompt` sets Pi's `forceSystemPrompt`, which is projected onto the
* request at send time. That is deliberate: it is the only path that keeps the rendered
* section order intact. The options-based alternative (`customPrompt`) would suppress
* `tools`, `rules`, and `docs`, and any later re-injection cannot restore their original
* positions. Pi still diffs and persists the structured sections in the transcript.
*/
export default function systemPromptCoverExtension(pi: ExtensionAPI): void {
pi.on("before_agent_start", (event) => {
const cover = readCoverFile(resolveCoverPath(getAgentDir()));
if (cover === undefined) {
return;
}
return { systemPrompt: spliceCover(event.systemPrompt, cover) };
});
}
+181
View File
@@ -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");
},
});
}
-21
View File
@@ -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);
}
-25
View File
@@ -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;
}
-157
View File
@@ -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");
},
});
}
+22 -97
View File
@@ -3,13 +3,13 @@
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
@@ -21,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"
@@ -64,42 +56,6 @@ record_install_failure() {
install_failed=1 install_failed=1
} }
resolve_agent_dir() {
if [ -n "${PI_CODING_AGENT_DIR:-}" ]; then
case $PI_CODING_AGENT_DIR in
~/*) printf '%s\n' "$HOME/${PI_CODING_AGENT_DIR#~/}" ;;
*) printf '%s\n' "$PI_CODING_AGENT_DIR" ;;
esac
return 0
fi
printf '%s\n' "$HOME/.pi/agent"
}
# 整体删除并重建组合包拥有的 <agentDir>/my-pi/ 配置目录;脚本目录内的组合包副本即为来源。
rebuild_bundle_config() {
rebuild_helper=$SCRIPT_DIR/scripts/rebuild-bundle-config.mjs
if [ ! -f "$rebuild_helper" ]; then
warn "找不到组合包配置重建辅助脚本,已跳过配置重建。"
return 1
fi
if ! command -v node >/dev/null 2>&1; then
warn "找不到 node,无法重建组合包配置目录。"
return 1
fi
rebuild_agent_dir=$(resolve_agent_dir) || return 1
rebuild_status=$(node "$rebuild_helper" "$rebuild_agent_dir" 2>&1) || {
warn "组合包配置目录重建失败:$rebuild_status"
return 1
}
case $rebuild_status in
rebuilt:*) say "✓ 已重建组合包配置目录:${rebuild_status#rebuilt:}" ;;
*)
warn "组合包配置重建返回未知状态:$rebuild_status"
return 1
;;
esac
}
java_major_version() { java_major_version() {
java_version=$(java -version 2>&1 | awk -F '"' 'NR == 1 { print $2 }') java_version=$(java -version 2>&1 | awk -F '"' 'NR == 1 { print $2 }')
case $java_version in case $java_version in
@@ -117,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
@@ -152,21 +114,6 @@ install_jdtls() {
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)
@@ -178,16 +125,18 @@ install_kitty() {
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
@@ -201,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" ]
@@ -238,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
@@ -270,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"'
} }
@@ -327,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)"
} }
@@ -369,9 +296,7 @@ if ! pi install "$PACKAGE_SOURCE"; then
exit 1 exit 1
fi fi
say "Pi 组合包安装完成,开始部署受管配置。" say "Pi 组合包安装完成。"
rebuild_bundle_config || record_install_failure "组合包配置重建"
say "开始检查终端环境和机器级 CLI 依赖。" say "开始检查终端环境和机器级 CLI 依赖。"
+1378 -4557
View File
File diff suppressed because it is too large Load Diff
+97 -33
View File
@@ -10,15 +10,76 @@
], ],
"files": [ "files": [
"extensions", "extensions",
"pi-ask-user", "pi-ask-user/index.ts",
"pi-compaction", "pi-ask-user/src",
"pi-chrome", "pi-ask-user/package.json",
"pi-hermes-memory", "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,37 +87,47 @@
"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-worktree", "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/LICENSES",
"pi-permission-system/docs/auto-review", "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",
"./pi-hermes-memory/src/index.ts", "./pi-hermes-memory/src/index.ts",
"./pi-compaction/index.ts", "./pi-compaction/index.ts",
@@ -64,37 +135,33 @@
"./pi-minimal-footer/index.ts", "./pi-minimal-footer/index.ts",
"./pi-notify/src/index.ts", "./pi-notify/src/index.ts",
"./extensions/permission-system.ts", "./extensions/permission-system.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-worktree/index.ts",
"./pi-chrome/extensions/chrome-profile-bridge/index.ts", "./pi-chrome/extensions/chrome-profile-bridge/index.ts",
"./extensions/system-prompt-cover.ts", "./pi-tool-search/extensions/index.ts",
"./extensions/tool-routing.ts", "./pi-overflow/index.ts",
"./pi-tool-search/extensions/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",
"@gotgenes/pi-permission-system": "file:./pi-permission-system", "@gotgenes/pi-permission-system": "file:./pi-permission-system",
"@keenable/pi-search": "0.1.2",
"@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",
"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-compaction": "file:./pi-compaction", "pi-compaction": "file:./pi-compaction",
"pi-hashline-edit": "0.8.3", "pi-hashline-edit": "0.8.3",
"pi-hermes-memory": "file:./pi-hermes-memory", "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", "pi-worktree": "file:./pi-worktree",
"ssh2": "1.17.0", "ssh2": "1.17.0",
"typescript": "6.0.3", "typescript": "6.0.3",
@@ -123,8 +190,5 @@
}, },
"engines": { "engines": {
"node": ">=22.19.0" "node": ">=22.19.0"
},
"allowScripts": {
"better-sqlite3@12.11.1": true
} }
} }
+9
View File
@@ -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 隔离安装验证。
+8
View File
@@ -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
View File
@@ -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.
--- ---
+5 -5
View File
@@ -15,9 +15,9 @@
导入后的本地改动: 导入后的本地改动:
- Chrome 快照以原子方式写入当前项目 `.pi/chrome-context/` 目录下的 owner-only、Session 临时文件,而不是把原始页面结构返回给模型上下文。 - Chrome 快照与 `includeSnapshot=true` 的动作结果把快照 JSON 直接放入工具文本返回,`details` 只保留元素数/匹配数等小型元数据;展示截断由组合包 pi-overflow 统一负责(原文落盘 + `overflow_query` 查询)。`chrome_find` 只返回紧凑排序匹配,不保存文件。早期版本曾把快照原子写入 `.pi/chrome-context/` 临时文件并引导 `ctx_execute_file` 分析;该 Session 临时文件机制已随 context-mode 一起移除,各项目中残留的 `.pi/chrome-context/` 目录不再创建,可手动删除。
- 每个 Session/目标只保留最新快照;浏览器交互会使陈旧捕获失效,Session 关闭时移除该 Session 目录。 - 授权后的 `before_agent_start` 不再返回 `systemPrompt`:Chrome 使用提示写入 `event.systemPromptOptions.sections.chrome_profile_bridge` 结构化分区,由组合包单一提示词出口 `extensions/system-prompt.ts` 统一组装,顺序由 `config/prompt/sections.jsonc` 控制;未授权时不贡献任何提示词内容。
- `chrome_find` 返回紧凑的排序匹配,同时把完整结构化快照保留在 Session 本地捕获中,供可选的 `ctx_execute_file` 分析使用。 - 移除 `PI_CHROME_BRIDGE_HOST`/`PI_CHROME_BRIDGE_PORT` 环境变量,host/port 固定为 `127.0.0.1:17318`(Companion 扩展 `service_worker.js` 同样硬编码该地址,单侧可配只会断连且非回环 host 有安全风险)。
- click/type/fill/key 上的 `includeSnapshot=true` 会保存动作后的快照并返回其捕获路径,而不是把快照嵌入工具结果。 - 移除 `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 { 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 } };
}, },
}); });
+1 -2
View File
@@ -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",
-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"));
});
+23
View File
@@ -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。
+43 -40
View File
@@ -1,56 +1,59 @@
# pi-compaction # pi-compaction
High-performance unified context projection and compaction for Pi. Pressure-aware context masking and threshold compaction for Pi.
## Responsibilities ## Responsibilities
- incrementally index completed tool calls; Pi context management is layered; this extension owns the middle two layers:
- replace large tool results with deterministic bounded evidence stubs;
- pass small tool results through unchanged whenever a stub would not be shorter;
- refine large stubs with non-blocking semantic summaries;
- expose one effective context projection to normal provider requests and Codex/SubAPI native compaction;
- cancel Pi's raw threshold compaction until the effective lower-bound reaches the configured threshold;
- retain original tool evidence through `context_tree_query`;
- publish read-only effective metrics for the footer and context observer.
Context Mode remains responsible for preventing large external data from entering the session. RTK remains responsible for ordinary tool-output cleanup. - **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
Set `piCompaction` in the Pi agent `settings.json`: 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 ```json
{ {
"piCompaction": { "enabled": true,
"masking": {
"enabled": true, "enabled": true,
"softRatio": 0.72, "triggerTokens": 200000,
"hardRatio": 0.9, "triggerRatio": 0.6,
"targetRatio": 0.55, "targetTokens": 100000,
"deterministic": { "targetRatio": 0.35,
"enabled": true, "minClearTokens": 50000,
"maxStubTokens": 256 "keepRecentTurns": 10,
}, "minEntryTokens": 256,
"semantic": { "excludeTools": ["ask_user_question"]
"enabled": true, },
"model": "default", "summary": {
"thinking": "low", "triggerTokens": 400000,
"concurrency": 1, "triggerRatio": 0.9,
"minInputChars": 4000, "thinking": "low"
"maxInputChars": 24000, },
"maxRetries": 2 "native": {
}, "enabled": true
"native": {
"enabled": true
}
} }
} }
``` ```
The extension never writes settings. Invalid or missing fields are normalized in memory. 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 providers
Native compaction is enabled by `piCompaction.native.enabled` and uses the model selected in the session when compaction starts. It does not select a separate summarization model. 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 | | Provider | API | Native transport |
| --- | --- | --- | | --- | --- | --- |
@@ -69,17 +72,17 @@ The gateway must have a compatible deployed version, model and available upstrea
## Session format ## Session format
The only custom entry type is `pi-compaction`, version 1. Segment summary entries contain hashes, summaries, and source references; they never duplicate complete raw tool output. Native checkpoints use Pi compaction-entry details with the same kind/version. 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 `.
Projection replaces a tool result only when the resulting summary plus its `Source:` recovery line is strictly shorter than the original text content. Small tool results are therefore passed through byte-for-byte and keep their original content parts, including any non-text parts. Because a stub is only larger than the text it would replace when the original is short, this comparison is the sole gate; no separate size option exists. Every indexed result remains recoverable through `context_tree_query` whether or not the message was replaced. 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.
This implementation intentionally does not read legacy `context-prune-*` or `openai-codex-native-compaction` entries. Start a new session after switching to this extension.
## Interfaces ## Interfaces
- `context_tree_query`: recover original bounded tool evidence by source key or tool call ID. - `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.
- `/compaction-status`: show raw/effective tokens, reclaimed tokens, confidence, segments, and pending semantic jobs. - 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 ## 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 and no session write. Semantic summaries run in a bounded background worker and become visible only at later safe event boundaries. 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.
+5
View File
@@ -29,3 +29,8 @@
- 协议证据:`backend/internal/service/openai_compact_body_signal.go` 指出原生远端压缩 v2 为 Responses 加 `stream: true` 与 `compaction_trigger`,并处理 Codex beta 特性。 - 协议证据:`backend/internal/service/openai_compact_body_signal.go` 指出原生远端压缩 v2 为 Responses 加 `stream: true` 与 `compaction_trigger`,并处理 Codex beta 特性。
- 本地 `src/native-provider.ts` 适配器是针对该协议独立实现的。没有导入任何 Sub2API 源码;复用现有 Codex 序列化器、流解析器和 checkpoint 格式。 - 本地 `src/native-provider.ts` 适配器是针对该协议独立实现的。没有导入任何 Sub2API 源码;复用现有 Codex 序列化器、流解析器和 checkpoint 格式。
- 该参考描述的是上游能力,不构成对任何特定部署、模型或账号的保证。本适配器不使用单独的 Responses compact 端点。 - 该参考描述的是上游能力,不构成对任何特定部署、模型或账号的保证。本适配器不使用单独的 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":"…"}` 恢复提示。
+174 -76
View File
@@ -14,6 +14,12 @@ import {
type SessionEntry, type SessionEntry,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import { DEFAULT_CONFIG, loadConfig } from "./src/config.js"; 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 { buildNativeCompactionTransport, isNativeCompactionModel } from "./src/native-provider.js";
import { import {
buildCompactionRequestBody, buildCompactionRequestBody,
@@ -32,27 +38,28 @@ import {
type NativeCompactionDetails, type NativeCompactionDetails,
type ResponseItem, type ResponseItem,
} from "./src/native-compaction.js"; } from "./src/native-compaction.js";
import { SegmentIndex } from "./src/segment-index.js"; import { contentText, SegmentIndex } from "./src/segment-index.js";
import { SemanticWorker } from "./src/semantic-worker.js";
import { shouldCompact } from "./src/token-accounting.js";
import { import {
CompactionServiceRegistry, CompactionServiceRegistry,
publishCompactionService, publishCompactionService,
unpublishCompactionService, unpublishCompactionService,
} from "./src/service.js"; } from "./src/service.js";
import { effectiveThreshold } from "./src/token-accounting.js";
import { import {
COMPACTION_CUSTOM_TYPE, COMPACTION_CUSTOM_TYPE,
COMPACTION_VERSION, COMPACTION_VERSION,
QUERY_TOOL_NAME, QUERY_TOOL_NAME,
type MaskedResultRecord,
type PiCompactionConfig, type PiCompactionConfig,
type ProjectionSnapshot, type ProjectionSnapshot,
type ToolHistoryRecord,
} from "./src/types.js"; } from "./src/types.js";
interface SessionRuntime { interface SessionRuntime {
index: SegmentIndex; index: SegmentIndex;
config: PiCompactionConfig; config: PiCompactionConfig;
codexCache?: { fingerprint: string; modelKey: string; toolsHash: string; items: ResponseItem[] }; codexCache?: { fingerprint: string; modelKey: string; toolsHash: string; items: ResponseItem[] };
masked: Map<string, MaskedLedgerEntry>;
lastAutoCompactLeafId: string | null;
} }
interface CachedPayload { interface CachedPayload {
@@ -64,10 +71,6 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
function hash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 20);
}
interface EnvironmentValue { interface EnvironmentValue {
hash: string; hash: string;
tokens: number; tokens: number;
@@ -81,6 +84,10 @@ interface EnvironmentCacheEntry extends EnvironmentValue {
tools: unknown[]; tools: unknown[];
} }
function hash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 20);
}
function environment( function environment(
pi: ExtensionAPI, pi: ExtensionAPI,
ctx: ExtensionContext, ctx: ExtensionContext,
@@ -182,34 +189,46 @@ export default function piCompactionExtension(pi: ExtensionAPI): void {
const runtimes = new Map<string, SessionRuntime>(); const runtimes = new Map<string, SessionRuntime>();
const payloads = new Map<string, CachedPayload>(); const payloads = new Map<string, CachedPayload>();
const environments = new Map<string, EnvironmentCacheEntry>(); const environments = new Map<string, EnvironmentCacheEntry>();
const worker = new SemanticWorker();
const service = new CompactionServiceRegistry(); const service = new CompactionServiceRegistry();
publishCompactionService(service); 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 runtimeFor = (ctx: ExtensionContext): SessionRuntime => {
const sessionId = ctx.sessionManager.getSessionId(); const sessionId = ctx.sessionManager.getSessionId();
let runtime = runtimes.get(sessionId); let runtime = runtimes.get(sessionId);
if (!runtime) { if (!runtime) {
const index = new SegmentIndex(sessionId); runtime = {
index.hydrate(ctx.sessionManager.getBranch() as SessionEntry[], DEFAULT_CONFIG); index: new SegmentIndex(sessionId),
runtime = { index, config: structuredClone(DEFAULT_CONFIG) }; config: structuredClone(DEFAULT_CONFIG),
masked: rebuildLedger(ctx),
lastAutoCompactLeafId: null,
};
runtimes.set(sessionId, runtime); runtimes.set(sessionId, runtime);
service.setSession(sessionId, (ids) => index.query(ids)); service.setSession(sessionId, (ids) => maskedRecords(sessionId, ids));
} }
return runtime; return runtime;
}; };
const persist = (record: unknown): void => { const ledgerStats = (runtime: SessionRuntime): { maskedResults: number; reclaimedTokens: number } => {
pi.appendEntry(COMPACTION_CUSTOM_TYPE, record); let reclaimedTokens = 0;
}; for (const entry of runtime.masked.values()) {
reclaimedTokens += Math.max(0, entry.originalTokens - entry.placeholderTokens);
const drainSemantic = (ctx: ExtensionContext, runtime: SessionRuntime): void => {
const sessionId = ctx.sessionManager.getSessionId();
for (const result of worker.drain(sessionId)) {
const record = runtime.index.applySemantic(result.sourceKey, result.sourceHash, result.summary);
if (record) persist(record);
} }
runtime.index.setSemanticJobs(worker.count(sessionId)); return { maskedResults: runtime.masked.size, reclaimedTokens };
}; };
const projectMessages = ( const projectMessages = (
@@ -218,16 +237,32 @@ export default function piCompactionExtension(pi: ExtensionAPI): void {
messages: readonly any[], messages: readonly any[],
): ProjectionSnapshot => { ): ProjectionSnapshot => {
const env = environment(pi, ctx, environments); const env = environment(pi, ctx, environments);
runtime.index.setSemanticJobs(worker.count(ctx.sessionManager.getSessionId())); const stats = ledgerStats(runtime);
const snapshot = runtime.index.project(messages, { const snapshot = runtime.index.project(messages, {
environmentHash: env.hash, environmentHash: env.hash,
environmentTokens: env.tokens, environmentTokens: env.tokens,
contextWindow: env.contextWindow, contextWindow: env.contextWindow,
maskedResults: stats.maskedResults,
reclaimedTokens: stats.reclaimedTokens,
}); });
service.setSnapshot(snapshot); service.setSnapshot(snapshot);
return 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 = ( const branchMessages = (
ctx: ExtensionContext, ctx: ExtensionContext,
runtime: SessionRuntime, runtime: SessionRuntime,
@@ -237,9 +272,10 @@ export default function piCompactionExtension(pi: ExtensionAPI): void {
const checkpoint = findNativeCheckpoint(branch); const checkpoint = findNativeCheckpoint(branch);
let messages: any[]; let messages: any[];
if (checkpoint.status === "valid") { if (checkpoint.status === "valid") {
messages = branch // sessionEntryToContextMessages sees only raw entries; applyContextEdits
.slice(checkpoint.checkpoint.entryIndex + 1) // additionally replays the latest context_edit per target (omissions and
.flatMap((entry) => sessionEntryToContextMessages(entry)); // placeholder replacements included).
messages = applyContextEdits(branch.slice(checkpoint.checkpoint.entryIndex + 1));
if (checkpoint.checkpoint.details.modelKey !== (ctx.model ? modelKey(ctx.model) : "")) { if (checkpoint.checkpoint.details.modelKey !== (ctx.model ? modelKey(ctx.model) : "")) {
messages = [asPortableMessage(checkpoint.checkpoint.details.portableSummary), ...messages]; messages = [asPortableMessage(checkpoint.checkpoint.details.portableSummary), ...messages];
} }
@@ -278,15 +314,22 @@ export default function piCompactionExtension(pi: ExtensionAPI): void {
publishCompactionService(service); publishCompactionService(service);
const sessionId = ctx.sessionManager.getSessionId(); const sessionId = ctx.sessionManager.getSessionId();
const config = await loadConfig(); const config = await loadConfig();
const index = new SegmentIndex(sessionId); const runtime = runtimes.get(sessionId) ?? {
index.hydrate(ctx.sessionManager.getBranch() as SessionEntry[], config); index: new SegmentIndex(sessionId),
runtimes.set(sessionId, { index, config }); config,
service.setSession(sessionId, (ids) => index.query(ids)); 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) => { pi.on("session_shutdown", (_event, ctx) => {
const sessionId = ctx.sessionManager.getSessionId(); const sessionId = ctx.sessionManager.getSessionId();
worker.cancelSession(sessionId);
runtimes.delete(sessionId); runtimes.delete(sessionId);
payloads.delete(sessionId); payloads.delete(sessionId);
environments.delete(sessionId); environments.delete(sessionId);
@@ -296,15 +339,13 @@ export default function piCompactionExtension(pi: ExtensionAPI): void {
pi.on("session_tree", (_event, ctx) => { pi.on("session_tree", (_event, ctx) => {
const runtime = runtimeFor(ctx); const runtime = runtimeFor(ctx);
runtime.index.hydrate(ctx.sessionManager.getBranch() as SessionEntry[], runtime.config); hydrateRuntime(ctx, runtime);
runtime.codexCache = undefined;
service.clearSnapshot(ctx.sessionManager.getSessionId()); service.clearSnapshot(ctx.sessionManager.getSessionId());
}); });
pi.on("session_compact", (_event, ctx) => { pi.on("session_compact", (_event, ctx) => {
const runtime = runtimeFor(ctx); const runtime = runtimeFor(ctx);
runtime.index.hydrate(ctx.sessionManager.getBranch() as SessionEntry[], runtime.config); hydrateRuntime(ctx, runtime);
runtime.codexCache = undefined;
service.clearSnapshot(ctx.sessionManager.getSessionId()); service.clearSnapshot(ctx.sessionManager.getSessionId());
payloads.delete(ctx.sessionManager.getSessionId()); payloads.delete(ctx.sessionManager.getSessionId());
}); });
@@ -319,21 +360,9 @@ export default function piCompactionExtension(pi: ExtensionAPI): void {
pi.on("turn_end", (event, ctx) => { pi.on("turn_end", (event, ctx) => {
const runtime = runtimeFor(ctx); const runtime = runtimeFor(ctx);
drainSemantic(ctx, runtime); if (!runtime.config.enabled) return undefined;
const previous = runtime.index.getSnapshot();
const captured = runtime.index.captureTurn(event.message, event.toolResults, runtime.config);
for (const record of captured) persist(runtime.index.toPersisted(record));
const pressure = previous && previous.contextWindow > 0
? previous.upperBound / previous.contextWindow >= runtime.config.softRatio
: false;
const semanticCandidates = pressure
? runtime.index.allRecords()
: captured.filter((record) => record.rawText.length >= runtime.config.semantic.maxInputChars);
for (const record of semanticCandidates) {
worker.enqueue(ctx.sessionManager.getSessionId(), record, ctx, runtime.config);
}
runtime.index.setSemanticJobs(worker.count(ctx.sessionManager.getSessionId()));
const previous = runtime.index.getSnapshot();
const usage = ctx.getContextUsage(); const usage = ctx.getContextUsage();
if (usage?.tokens && previous) { if (usage?.tokens && previous) {
const env = environment(pi, ctx, environments); const env = environment(pi, ctx, environments);
@@ -343,6 +372,50 @@ export default function piCompactionExtension(pi: ExtensionAPI): void {
env.hash, 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) => { pi.on("context", (event, ctx) => {
@@ -358,7 +431,10 @@ export default function piCompactionExtension(pi: ExtensionAPI): void {
const projected = branchMessages(ctx, runtime, branch); const projected = branchMessages(ctx, runtime, branch);
return { messages: projected.snapshot.messages as any }; return { messages: projected.snapshot.messages as any };
} }
return { messages: projectMessages(ctx, runtime, event.messages).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) => { pi.on("before_provider_headers", (event, ctx) => {
@@ -387,7 +463,6 @@ export default function piCompactionExtension(pi: ExtensionAPI): void {
pi.on("session_before_compact", async (event, ctx) => { pi.on("session_before_compact", async (event, ctx) => {
const runtime = runtimeFor(ctx); const runtime = runtimeFor(ctx);
if (!runtime.config.enabled) return undefined; if (!runtime.config.enabled) return undefined;
drainSemantic(ctx, runtime);
const { snapshot, checkpoint } = branchMessages( const { snapshot, checkpoint } = branchMessages(
ctx, ctx,
runtime, runtime,
@@ -395,8 +470,6 @@ export default function piCompactionExtension(pi: ExtensionAPI): void {
event.reason === "overflow" && event.willRetry, event.reason === "overflow" && event.willRetry,
); );
if (!shouldCompact(event.reason, snapshot, runtime.config)) return { cancel: true };
const model = ctx.model; const model = ctx.model;
if (!model) return undefined; if (!model) return undefined;
if (!runtime.config.native.enabled || !isNativeCompactionModel(model)) { if (!runtime.config.native.enabled || !isNativeCompactionModel(model)) {
@@ -412,7 +485,7 @@ export default function piCompactionExtension(pi: ExtensionAPI): void {
event.signal, event.signal,
event.customInstructions, event.customInstructions,
undefined, undefined,
runtime.config.semantic.thinking, runtime.config.summary.thinking,
); );
return { return {
compaction: { compaction: {
@@ -463,7 +536,7 @@ export default function piCompactionExtension(pi: ExtensionAPI): void {
replacementHistory: buildReplacementHistory( replacementHistory: buildReplacementHistory(
input, input,
remote.compactionItem, remote.compactionItem,
Math.min(64_000, Math.max(4_096, Math.floor(snapshot.contextWindow * Math.max(0.05, runtime.config.targetRatio - 0.3)))), Math.min(64_000, Math.max(4_096, Math.floor(snapshot.contextWindow * 0.25))),
), ),
portableSummary: portableSummary(snapshot.messages), portableSummary: portableSummary(snapshot.messages),
sourceFingerprint: snapshot.fingerprint, sourceFingerprint: snapshot.fingerprint,
@@ -504,7 +577,7 @@ export default function piCompactionExtension(pi: ExtensionAPI): void {
event.signal, event.signal,
event.preparation.customInstructions, event.preparation.customInstructions,
undefined, undefined,
runtime.config.semantic.thinking, runtime.config.summary.thinking,
undefined, undefined,
undefined, undefined,
undefined, undefined,
@@ -531,38 +604,64 @@ export default function piCompactionExtension(pi: ExtensionAPI): void {
pi.registerTool({ pi.registerTool({
name: QUERY_TOOL_NAME, name: QUERY_TOOL_NAME,
label: "Query Compacted Tool History", label: "Query Compacted Tool History",
description: "Recover original tool outputs by source key or tool call id after pi-compaction replaced them with bounded summaries.", description: "Recover the original output of a tool result that pi-compaction cleared from context, by tool call id.",
promptSnippet: "Recover original compacted tool evidence", promptSnippet: "Recover original tool output cleared from context",
promptGuidelines: [ promptGuidelines: [
"Use context_tree_query only when a pi-compaction summary lacks evidence needed for the current task.", "Use context_tree_query when you see a \"[pi-compaction] Earlier\" placeholder and still need the original tool output.",
], ],
parameters: Type.Object({ parameters: Type.Object({
toolCallIds: Type.Array(Type.String(), { minItems: 1, maxItems: 20 }), toolCallIds: Type.Array(Type.String(), { minItems: 1, maxItems: 20 }),
}), }),
async execute(_id, params, _signal, _update, ctx) { async execute(_id, params, _signal, _update, ctx) {
const records = service.query(ctx.sessionManager.getSessionId(), params.toolCallIds); const branch = ctx.sessionManager.getBranch() as SessionEntry[];
const blocks = records.map((record: ToolHistoryRecord) => { const calls = new Map<string, { toolName: string; args: unknown }>();
const perResultBytes = Math.max(1_024, Math.floor(DEFAULT_MAX_BYTES / Math.max(1, records.length))); const results = new Map<string, any>();
const args = truncateHead(JSON.stringify(record.args), { maxLines: 20, maxBytes: 2_048 }).content; for (const entry of branch) {
const truncated = truncateHead(record.rawText, { const message = (entry as any)?.message;
maxLines: Math.max(20, Math.floor(DEFAULT_MAX_LINES / Math.max(1, records.length))), 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, maxBytes: perResultBytes,
}); });
return [ blocks.push([
`## ${record.sourceKey}`, `## ${id}`,
`Tool: ${record.toolName}`, `Tool: ${call.toolName}`,
`Args: ${args}`, `Args: ${args}`,
`Status: ${record.isError ? "ERROR" : "OK"}`, `Status: ${result.isError ? "ERROR" : "OK"}`,
"", "",
truncated.content, truncated.content,
...(truncated.truncated ? [`[truncated: ${truncated.outputLines}/${truncated.totalLines} lines]`] : []), ...(truncated.truncated ? [`[truncated: ${truncated.outputLines}/${truncated.totalLines} lines]`] : []),
].join("\n"); ].join("\n"));
}); }
const missing = params.toolCallIds.filter((id: string) => !records.some((record) => record.sourceKey === id || record.toolCallId === id)); const missing = params.toolCallIds.filter((id: string) => !results.has(id));
for (const id of missing) blocks.push(`## ${id}\n(not found)`); for (const id of missing) blocks.push(`## ${id}\n(not found)`);
return { return {
content: [{ type: "text", text: blocks.join("\n\n---\n\n") }], content: [{ type: "text", text: blocks.join("\n\n---\n\n") }],
details: { sourceKeys: records.map((record) => record.sourceKey), missing }, details: { toolCallIds: found, missing },
}; };
}, },
}); });
@@ -576,12 +675,11 @@ export default function piCompactionExtension(pi: ExtensionAPI): void {
`Effective: ${metrics.effectiveTokens}/${metrics.contextWindow} (${metrics.percent.toFixed(1)}%, ${metrics.confidence})`, `Effective: ${metrics.effectiveTokens}/${metrics.contextWindow} (${metrics.percent.toFixed(1)}%, ${metrics.confidence})`,
`Raw: ${metrics.rawTokens}/${metrics.contextWindow}`, `Raw: ${metrics.rawTokens}/${metrics.contextWindow}`,
`Reclaimed: ${metrics.reclaimedTokens}`, `Reclaimed: ${metrics.reclaimedTokens}`,
`Segments: ${metrics.segmentCount}; semantic jobs: ${metrics.semanticJobs}; generation: ${metrics.generation}`, `Segments: ${metrics.segmentCount}; masked tool results: ${metrics.maskedResults}; generation: ${metrics.generation}`,
].join("\n") ].join("\n")
: "pi-compaction has no projection snapshot yet."; : "pi-compaction has no projection snapshot yet.";
if (ctx.hasUI) ctx.ui.notify(text, "info"); if (ctx.hasUI) ctx.ui.notify(text, "info");
else process.stderr.write(`${text}\n`); else process.stderr.write(`${text}\n`);
}, },
}); });
} }
+71 -41
View File
@@ -1,33 +1,40 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { join } from "node:path"; import { fileURLToPath } from "node:url";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import type { PiCompactionConfig } from "./types.js"; import type { PiCompactionConfig } from "./types.js";
export const SETTINGS_KEY = "piCompaction" as const; // 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 = { export const DEFAULT_CONFIG: PiCompactionConfig = {
enabled: true, enabled: true,
softRatio: 0.72, masking: {
hardRatio: 0.9,
targetRatio: 0.55,
deterministic: {
enabled: true, enabled: true,
maxStubTokens: 1_024, triggerTokens: 200_000,
triggerRatio: 0.6,
targetTokens: 100_000,
targetRatio: 0.35,
minClearTokens: 50_000,
keepRecentTurns: 10,
minEntryTokens: 256,
excludeTools: ["ask_user_question"],
}, },
semantic: { summary: {
enabled: true, triggerTokens: 400_000,
model: "default", triggerRatio: 0.9,
thinking: "low", thinking: "low",
concurrency: 1,
minInputChars: 4_000,
maxInputChars: 24_000,
maxRetries: 2,
}, },
native: { native: {
enabled: true, enabled: true,
}, },
}; };
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
function ratio(value: unknown, fallback: number): number { function ratio(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 && value < 1 ? value : fallback; return typeof value === "number" && Number.isFinite(value) && value > 0 && value < 1 ? value : fallback;
} }
@@ -36,52 +43,75 @@ function integer(value: unknown, fallback: number, min = 0, max = Number.MAX_SAF
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max ? value : fallback; 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> { function object(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value) return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> ? 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 { export function normalizeConfig(value: unknown): PiCompactionConfig {
const input = object(value); const input = object(value);
const deterministic = object(input.deterministic); const masking = object(input.masking);
const semantic = object(input.semantic); const summary = object(input.summary);
const native = object(input.native); const native = object(input.native);
const softRatio = ratio(input.softRatio, DEFAULT_CONFIG.softRatio);
const hardRatio = Math.max(softRatio + 0.01, ratio(input.hardRatio, DEFAULT_CONFIG.hardRatio));
const targetRatio = Math.min(softRatio - 0.01, ratio(input.targetRatio, DEFAULT_CONFIG.targetRatio));
const thinking = ["off", "minimal", "low", "medium", "high", "xhigh"].includes(String(semantic.thinking))
? semantic.thinking as PiCompactionConfig["semantic"]["thinking"]
: DEFAULT_CONFIG.semantic.thinking;
return { const normalized: PiCompactionConfig = {
enabled: typeof input.enabled === "boolean" ? input.enabled : DEFAULT_CONFIG.enabled, enabled: typeof input.enabled === "boolean" ? input.enabled : DEFAULT_CONFIG.enabled,
softRatio, masking: {
hardRatio: Math.min(0.99, hardRatio), enabled: typeof masking.enabled === "boolean" ? masking.enabled : DEFAULT_CONFIG.masking.enabled,
targetRatio: Math.max(0.1, targetRatio), triggerTokens: positiveInteger(masking.triggerTokens, DEFAULT_CONFIG.masking.triggerTokens),
deterministic: { triggerRatio: ratio(masking.triggerRatio, DEFAULT_CONFIG.masking.triggerRatio),
enabled: typeof deterministic.enabled === "boolean" ? deterministic.enabled : DEFAULT_CONFIG.deterministic.enabled, targetTokens: positiveInteger(masking.targetTokens, DEFAULT_CONFIG.masking.targetTokens),
maxStubTokens: integer(deterministic.maxStubTokens, DEFAULT_CONFIG.deterministic.maxStubTokens, 32, 4_096), 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),
}, },
semantic: { summary: {
enabled: typeof semantic.enabled === "boolean" ? semantic.enabled : DEFAULT_CONFIG.semantic.enabled, triggerTokens: positiveInteger(summary.triggerTokens, DEFAULT_CONFIG.summary.triggerTokens),
model: typeof semantic.model === "string" && semantic.model.trim() ? semantic.model.trim() : DEFAULT_CONFIG.semantic.model, triggerRatio: ratio(summary.triggerRatio, DEFAULT_CONFIG.summary.triggerRatio),
thinking, thinking: THINKING_LEVELS.includes(String(summary.thinking))
concurrency: integer(semantic.concurrency, DEFAULT_CONFIG.semantic.concurrency, 1, 4), ? summary.thinking as PiCompactionConfig["summary"]["thinking"]
minInputChars: integer(semantic.minInputChars, DEFAULT_CONFIG.semantic.minInputChars, 0, 1_000_000), : DEFAULT_CONFIG.summary.thinking,
maxInputChars: integer(semantic.maxInputChars, DEFAULT_CONFIG.semantic.maxInputChars, 1_000, 1_000_000),
maxRetries: integer(semantic.maxRetries, DEFAULT_CONFIG.semantic.maxRetries, 0, 5),
}, },
native: { native: {
enabled: typeof native.enabled === "boolean" ? native.enabled : DEFAULT_CONFIG.native.enabled, 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(): Promise<PiCompactionConfig> { export async function loadConfig(
configPath: string = CONFIG_PATH,
): Promise<PiCompactionConfig> {
try { try {
const parsed = JSON.parse(await readFile(join(getAgentDir(), "settings.json"), "utf8")); const parsed = JSON.parse(await readFile(configPath, "utf8"));
return normalizeConfig(object(parsed)[SETTINGS_KEY]); return normalizeConfig(parsed);
} catch { } catch {
return structuredClone(DEFAULT_CONFIG); return structuredClone(DEFAULT_CONFIG);
} }
+257
View File
@@ -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;
}
+31 -246
View File
@@ -1,16 +1,8 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { estimateTokens } from "@earendil-works/pi-coding-agent"; import { estimateTokens } from "@earendil-works/pi-coding-agent";
import { import type { ProjectionSnapshot, UsageBaseline } from "./types.js";
COMPACTION_CUSTOM_TYPE,
COMPACTION_VERSION,
type PiCompactionConfig,
type ProjectionSnapshot,
type SegmentSummaryRecord,
type ToolHistoryRecord,
type UsageBaseline,
} from "./types.js";
function timestampOf(value: unknown): number { export function timestampOf(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") { if (typeof value === "string") {
const parsed = Date.parse(value); const parsed = Date.parse(value);
@@ -19,10 +11,6 @@ function timestampOf(value: unknown): number {
return 0; return 0;
} }
export function sourceKey(toolCallId: string, timestamp: unknown): string {
return `${toolCallId}@${timestampOf(timestamp)}`;
}
export function contentText(content: unknown): string { export function contentText(content: unknown): string {
if (typeof content === "string") return content; if (typeof content === "string") return content;
if (!Array.isArray(content)) return ""; if (!Array.isArray(content)) return "";
@@ -37,42 +25,6 @@ function hash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 20); return createHash("sha256").update(value).digest("hex").slice(0, 20);
} }
function json(value: unknown): string {
try {
return JSON.stringify(value) ?? "undefined";
} catch {
return "[unserializable]";
}
}
function boundedEvidence(text: string, maxChars: number): string {
const normalized = text.replace(/\u001b\[[0-9;]*m/g, "").trim();
if (normalized.length <= maxChars) return normalized;
const marker = `\n… ${normalized.length - maxChars} chars omitted …\n`;
const available = Math.max(0, maxChars - marker.length);
const head = Math.ceil(available * 0.7);
const tail = Math.max(0, available - head);
return `${normalized.slice(0, head)}${marker}${tail ? normalized.slice(-tail) : ""}`;
}
export function deterministicSummary(params: {
toolName: string;
args: unknown;
rawText: string;
isError: boolean;
maxStubTokens: number;
}): string {
const maxChars = params.maxStubTokens * 4;
const argsText = boundedEvidence(json(params.args), Math.min(600, Math.floor(maxChars * 0.25)));
const evidenceBudget = Math.max(128, maxChars - argsText.length - 120);
const evidence = boundedEvidence(params.rawText || "(no output)", evidenceBudget);
return [
`[pi-compaction tool=${params.toolName} status=${params.isError ? "error" : "ok"}]`,
`Args: ${argsText}`,
`Evidence:\n${evidence}`,
].join("\n");
}
function messageKey(message: any): string { function messageKey(message: any): string {
const role = String(message?.role ?? "unknown"); const role = String(message?.role ?? "unknown");
const timestamp = timestampOf(message?.timestamp); const timestamp = timestampOf(message?.timestamp);
@@ -85,24 +37,14 @@ function messageKey(message: any): string {
return `${role}:${timestamp}:${typeof message?.content === "string" ? message.content.length : (message?.content?.length ?? 0)}`; return `${role}:${timestamp}:${typeof message?.content === "string" ? message.content.length : (message?.content?.length ?? 0)}`;
} }
function isSummaryRecord(value: unknown): value is SegmentSummaryRecord { /**
if (!value || typeof value !== "object") return false; * Incremental context projection: tracks the branch's message list, estimates
const record = value as Partial<SegmentSummaryRecord>; * tokens, and maintains the usage-backed effective-token baseline. Projection
return record.version === COMPACTION_VERSION * is identity — pi-compaction never rewrites messages on this path; context
&& record.kind === "segment-summary" * pressure reduction happens through append-only context_edit entries.
&& typeof record.sourceKey === "string" */
&& typeof record.toolCallId === "string"
&& typeof record.resultTimestamp === "number"
&& typeof record.toolName === "string"
&& typeof record.sourceHash === "string"
&& typeof record.deterministicSummary === "string";
}
export class SegmentIndex { export class SegmentIndex {
private readonly records = new Map<string, ToolHistoryRecord>();
private readonly ids = new Map<string, string[]>();
private generation = 0; private generation = 0;
private semanticJobs = 0;
private usageBaseline?: UsageBaseline; private usageBaseline?: UsageBaseline;
private sourceMessages: any[] = []; private sourceMessages: any[] = [];
private sourceKeys: string[] = []; private sourceKeys: string[] = [];
@@ -117,67 +59,13 @@ export class SegmentIndex {
this.sessionId = sessionId; this.sessionId = sessionId;
} }
hydrate(branch: readonly any[], config: PiCompactionConfig): void { hydrate(): void {
this.reset(); this.reset();
if (!config.enabled) return;
const summaries = new Map<string, SegmentSummaryRecord>();
for (const entry of branch) {
if (entry?.type === "custom" && entry.customType === COMPACTION_CUSTOM_TYPE && isSummaryRecord(entry.data)) {
summaries.set(entry.data.sourceKey, entry.data);
}
}
const pendingCalls = new Map<string, { toolName: string; args: unknown }>();
for (const entry of branch) {
if (entry?.type !== "message") continue;
const message = entry.message;
if (message?.role === "assistant" && Array.isArray(message.content)) {
for (const block of message.content) {
if (block?.type === "toolCall" && typeof block.id === "string") {
pendingCalls.set(block.id, {
toolName: String(block.name ?? "unknown"),
args: block.input ?? block.arguments ?? block.args ?? {},
});
}
}
} else if (message?.role === "toolResult" && typeof message.toolCallId === "string") {
const call = pendingCalls.get(message.toolCallId) ?? {
toolName: String(message.toolName ?? "unknown"),
args: {},
};
const key = sourceKey(message.toolCallId, message.timestamp);
const rawText = contentText(message.content);
const persisted = summaries.get(key);
const record: ToolHistoryRecord = {
sourceKey: key,
toolCallId: message.toolCallId,
resultTimestamp: timestampOf(message.timestamp),
toolName: call.toolName,
args: call.args,
isError: Boolean(message.isError),
rawText,
sourceHash: hash(`${call.toolName}\0${json(call.args)}\0${rawText}`),
deterministicSummary: persisted?.deterministicSummary ?? deterministicSummary({
toolName: call.toolName,
args: call.args,
rawText,
isError: Boolean(message.isError),
maxStubTokens: config.deterministic.maxStubTokens,
}),
semanticSummary: persisted?.semanticSummary,
};
this.put(record);
pendingCalls.delete(message.toolCallId);
}
}
this.generation++; this.generation++;
} }
reset(): void { reset(): void {
this.records.clear();
this.ids.clear();
this.generation = 0; this.generation = 0;
this.semanticJobs = 0;
this.usageBaseline = undefined; this.usageBaseline = undefined;
this.sourceMessages = []; this.sourceMessages = [];
this.sourceKeys = []; this.sourceKeys = [];
@@ -187,86 +75,6 @@ export class SegmentIndex {
this.cached = undefined; this.cached = undefined;
} }
private put(record: ToolHistoryRecord): void {
this.records.set(record.sourceKey, record);
const keys = this.ids.get(record.toolCallId) ?? [];
if (!keys.includes(record.sourceKey)) keys.push(record.sourceKey);
this.ids.set(record.toolCallId, keys);
}
captureTurn(message: any, toolResults: any[], config: PiCompactionConfig): ToolHistoryRecord[] {
if (!config.enabled || !config.deterministic.enabled || !Array.isArray(message?.content)) return [];
const calls = new Map<string, any>();
for (const block of message.content) {
if (block?.type === "toolCall" && typeof block.id === "string") calls.set(block.id, block);
}
const captured: ToolHistoryRecord[] = [];
for (const result of toolResults ?? []) {
if (!result || typeof result.toolCallId !== "string") continue;
const call = calls.get(result.toolCallId);
if (!call) continue;
const rawText = contentText(result.content);
const toolName = String(call.name ?? result.toolName ?? "unknown");
const args = call.input ?? call.arguments ?? call.args ?? {};
const key = sourceKey(result.toolCallId, result.timestamp);
const record: ToolHistoryRecord = {
sourceKey: key,
toolCallId: result.toolCallId,
resultTimestamp: timestampOf(result.timestamp),
toolName,
args,
isError: Boolean(result.isError),
rawText,
sourceHash: hash(`${toolName}\0${json(args)}\0${rawText}`),
deterministicSummary: deterministicSummary({
toolName,
args,
rawText,
isError: Boolean(result.isError),
maxStubTokens: config.deterministic.maxStubTokens,
}),
};
this.put(record);
captured.push(record);
}
if (captured.length) {
this.generation++;
this.cached = undefined;
}
return captured;
}
applySemantic(sourceKeyValue: string, sourceHash: string, summary: string): SegmentSummaryRecord | undefined {
const record = this.records.get(sourceKeyValue);
if (!record || record.sourceHash !== sourceHash || !summary.trim()) return undefined;
record.semanticSummary = summary.trim();
this.generation++;
this.cached = undefined;
return this.toPersisted(record);
}
toPersisted(record: ToolHistoryRecord): SegmentSummaryRecord {
const effective = record.semanticSummary ?? record.deterministicSummary;
return {
version: COMPACTION_VERSION,
kind: "segment-summary",
sourceKey: record.sourceKey,
toolCallId: record.toolCallId,
resultTimestamp: record.resultTimestamp,
toolName: record.toolName,
sourceHash: record.sourceHash,
deterministicSummary: record.deterministicSummary,
...(record.semanticSummary ? { semanticSummary: record.semanticSummary } : {}),
rawChars: record.rawText.length,
effectiveChars: effective.length,
};
}
setSemanticJobs(count: number): void {
this.semanticJobs = Math.max(0, count);
if (this.cached) this.cached = { ...this.cached, semanticJobs: this.semanticJobs };
}
recordUsage(tokens: number, messageEstimate: number, environmentHash: string): void { recordUsage(tokens: number, messageEstimate: number, environmentHash: string): void {
if (!Number.isFinite(tokens) || tokens <= 0) return; if (!Number.isFinite(tokens) || tokens <= 0) return;
this.usageBaseline = { tokens, messageEstimate, environmentHash }; this.usageBaseline = { tokens, messageEstimate, environmentHash };
@@ -277,11 +85,27 @@ export class SegmentIndex {
environmentHash: string; environmentHash: string;
environmentTokens: number; environmentTokens: number;
contextWindow: number; contextWindow: number;
maskedResults: number;
reclaimedTokens: number;
}): ProjectionSnapshot { }): ProjectionSnapshot {
const sameTail = messages.length === this.sourceMessages.length const sameTail = messages.length === this.sourceMessages.length
&& messages.length > 0 && messages.length > 0
&& messageKey(messages[messages.length - 1]) === this.sourceKeys[this.sourceKeys.length - 1]; && messageKey(messages[messages.length - 1]) === this.sourceKeys[this.sourceKeys.length - 1];
if (this.cached && sameTail && this.cached.environmentHash === params.environmentHash) return this.cached; 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 const appendOnly = this.sourceMessages.length > 0
&& messages.length >= this.sourceMessages.length && messages.length >= this.sourceMessages.length
@@ -301,17 +125,14 @@ export class SegmentIndex {
for (let index = start; index < messages.length; index++) { for (let index = start; index < messages.length; index++) {
const message = messages[index]; const message = messages[index];
const projected = this.projectMessage(message);
this.sourceMessages.push(message); this.sourceMessages.push(message);
this.sourceKeys.push(messageKey(message)); this.sourceKeys.push(messageKey(message));
this.projectedMessages.push(projected); this.projectedMessages.push(message);
this.rawEstimates.push(estimateTokens(message as any)); this.rawEstimates.push(estimateTokens(message as any));
this.effectiveEstimates.push(estimateTokens(projected as any)); this.effectiveEstimates.push(estimateTokens(message as any));
} }
const rawMessageTokens = this.rawEstimates.reduce((sum, value) => sum + value, 0);
const effectiveMessageTokens = this.effectiveEstimates.reduce((sum, value) => sum + value, 0); const effectiveMessageTokens = this.effectiveEstimates.reduce((sum, value) => sum + value, 0);
const rawTokens = rawMessageTokens + params.environmentTokens;
const baseline = this.usageBaseline; const baseline = this.usageBaseline;
const usageBacked = baseline?.environmentHash === params.environmentHash; const usageBacked = baseline?.environmentHash === params.environmentHash;
const effectiveTokens = usageBacked const effectiveTokens = usageBacked
@@ -329,55 +150,19 @@ export class SegmentIndex {
environmentHash: params.environmentHash, environmentHash: params.environmentHash,
messages: this.projectedMessages, messages: this.projectedMessages,
messageEstimate: effectiveMessageTokens, messageEstimate: effectiveMessageTokens,
rawTokens, rawTokens: effectiveTokens + params.reclaimedTokens,
effectiveTokens, effectiveTokens,
reclaimedTokens: Math.max(0, rawTokens - effectiveTokens), reclaimedTokens: params.reclaimedTokens,
contextWindow: params.contextWindow, contextWindow: params.contextWindow,
confidence, confidence,
lowerBound, lowerBound,
upperBound, upperBound,
segmentCount: this.sourceMessages.length, segmentCount: this.sourceMessages.length,
semanticJobs: this.semanticJobs, maskedResults: params.maskedResults,
}; };
return this.cached; return this.cached;
} }
private projectMessage(message: any): any {
if (message?.role !== "toolResult" || typeof message.toolCallId !== "string") return message;
const record = this.records.get(sourceKey(message.toolCallId, message.timestamp));
if (!record) return message;
const original = contentText(message.content);
const summary = record.semanticSummary ?? record.deterministicSummary;
const projected = `${summary}\nSource: ${record.sourceKey}; recover with context_tree_query.`;
if (projected.length >= original.length) return message;
return {
...message,
content: [{ type: "text", text: projected }],
};
}
query(ids: string[]): ToolHistoryRecord[] {
const found: ToolHistoryRecord[] = [];
const seen = new Set<string>();
for (const id of ids) {
const direct = this.records.get(id);
const keys = direct ? [id] : this.ids.get(id) ?? [];
for (const key of keys) {
if (seen.has(key)) continue;
const record = this.records.get(key);
if (record) {
found.push(record);
seen.add(key);
}
}
}
return found;
}
allRecords(): ToolHistoryRecord[] {
return [...this.records.values()];
}
getSnapshot(): ProjectionSnapshot | undefined { getSnapshot(): ProjectionSnapshot | undefined {
return this.cached; return this.cached;
} }
-189
View File
@@ -1,189 +0,0 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { PiCompactionConfig, ToolHistoryRecord } from "./types.js";
interface Job {
sessionId: string;
sourceKey: string;
sourceHash: string;
toolName: string;
args: unknown;
isError: boolean;
rawText: string;
ctx: ExtensionContext;
config: PiCompactionConfig;
controller: AbortController;
}
export interface SemanticResult {
sessionId: string;
sourceKey: string;
sourceHash: string;
summary: string;
}
function modelFor(config: PiCompactionConfig, ctx: ExtensionContext): any {
if (config.semantic.model === "default") return ctx.model;
const slash = config.semantic.model.indexOf("/");
if (slash <= 0) return ctx.model;
return ctx.modelRegistry.find(
config.semantic.model.slice(0, slash),
config.semantic.model.slice(slash + 1),
) ?? ctx.model;
}
function serialize(job: Job): string {
const args = (() => {
try { return JSON.stringify(job.args); } catch { return "[unserializable]"; }
})();
const max = job.config.semantic.maxInputChars;
const raw = job.rawText.length <= max
? job.rawText
: `${job.rawText.slice(0, Math.floor(max * 0.7))}\n… omitted …\n${job.rawText.slice(-Math.floor(max * 0.3))}`;
return `Summarize this completed coding-agent tool call for future context.\nKeep concrete outcomes, paths, identifiers, values, and exact errors.\nUse at most 6 concise bullets. Do not give advice and do not call tools.\n\nTool: ${job.toolName}\nStatus: ${job.isError ? "ERROR" : "OK"}\nArgs: ${args}\nOutput:\n${raw}`;
}
async function run(job: Job): Promise<string> {
const model = modelFor(job.config, job.ctx);
if (!model) throw new Error("No summarizer model selected");
const message = await job.ctx.modelRegistry.complete(model, {
messages: [{
role: "user",
content: [{ type: "text", text: serialize(job) }],
timestamp: Date.now(),
}],
} as any, {
signal: job.controller.signal,
reasoningEffort: job.config.semantic.thinking === "off" ? undefined : job.config.semantic.thinking,
} as any);
if (message.stopReason === "length" || message.stopReason === "error" || message.stopReason === "aborted") {
throw new Error(message.errorMessage ?? `Semantic compaction stopped: ${message.stopReason}`);
}
const text = message.content
.flatMap((part: any) => part.type === "text" && typeof part.text === "string" ? [part.text] : [])
.join("\n")
.trim();
if (!text) throw new Error("Semantic compaction returned an empty summary");
return text;
}
function abortableDelay(ms: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.reject(new Error("Semantic compaction aborted"));
return new Promise((resolve, reject) => {
const timer = setTimeout(done, ms);
function done(): void {
signal.removeEventListener("abort", aborted);
resolve();
}
function aborted(): void {
clearTimeout(timer);
signal.removeEventListener("abort", aborted);
reject(new Error("Semantic compaction aborted"));
}
signal.addEventListener("abort", aborted, { once: true });
});
}
export class SemanticWorker {
private readonly queue: Job[] = [];
private readonly active = new Map<string, Job>();
private readonly ready: SemanticResult[] = [];
private readonly queuedKeys = new Set<string>();
enqueue(sessionId: string, record: ToolHistoryRecord, ctx: ExtensionContext, config: PiCompactionConfig): void {
if (!config.semantic.enabled || record.semanticSummary || record.rawText.length < config.semantic.minInputChars) return;
const key = `${sessionId}:${record.sourceKey}:${record.sourceHash}`;
if (this.queuedKeys.has(key)) return;
this.queuedKeys.add(key);
this.queue.push({
sessionId,
sourceKey: record.sourceKey,
sourceHash: record.sourceHash,
toolName: record.toolName,
args: record.args,
isError: record.isError,
rawText: record.rawText,
ctx,
config,
controller: new AbortController(),
});
this.pump(config.semantic.concurrency);
}
private pump(limit: number): void {
while (this.active.size < limit && this.queue.length > 0) {
const job = this.queue.shift()!;
const key = `${job.sessionId}:${job.sourceKey}:${job.sourceHash}`;
this.active.set(key, job);
void this.execute(key, job, limit);
}
}
private async execute(key: string, job: Job, limit: number): Promise<void> {
let lastError: unknown;
try {
for (let attempt = 0; attempt <= job.config.semantic.maxRetries; attempt++) {
try {
const summary = await run(job);
if (!job.controller.signal.aborted) {
this.ready.push({
sessionId: job.sessionId,
sourceKey: job.sourceKey,
sourceHash: job.sourceHash,
summary,
});
}
return;
} catch (error) {
lastError = error;
if (job.controller.signal.aborted || attempt === job.config.semantic.maxRetries) break;
await abortableDelay(Math.min(8_000, 1_000 * 2 ** attempt), job.controller.signal);
}
}
if (!job.controller.signal.aborted) {
console.error(`pi-compaction: semantic summary failed for ${job.sourceKey}:`, lastError);
}
} finally {
this.active.delete(key);
this.queuedKeys.delete(key);
this.pump(limit);
}
}
drain(sessionId: string): SemanticResult[] {
const drained: SemanticResult[] = [];
for (let index = this.ready.length - 1; index >= 0; index--) {
if (this.ready[index]!.sessionId !== sessionId) continue;
drained.push(this.ready[index]!);
this.ready.splice(index, 1);
}
return drained.reverse();
}
count(sessionId: string): number {
return this.queue.filter((job) => job.sessionId === sessionId).length
+ [...this.active.values()].filter((job) => job.sessionId === sessionId).length;
}
cancelSession(sessionId: string): void {
for (const job of this.active.values()) {
if (job.sessionId === sessionId) job.controller.abort();
}
for (let index = this.queue.length - 1; index >= 0; index--) {
if (this.queue[index]!.sessionId === sessionId) {
const job = this.queue[index]!;
this.queuedKeys.delete(`${job.sessionId}:${job.sourceKey}:${job.sourceHash}`);
this.queue.splice(index, 1);
}
}
for (let index = this.ready.length - 1; index >= 0; index--) {
if (this.ready[index]!.sessionId === sessionId) this.ready.splice(index, 1);
}
}
cancelAll(): void {
for (const job of this.active.values()) job.controller.abort();
this.queue.length = 0;
this.ready.length = 0;
this.queuedKeys.clear();
}
}
+5 -5
View File
@@ -2,8 +2,8 @@ import {
SERVICE_SYMBOL, SERVICE_SYMBOL,
type CompactionMetrics, type CompactionMetrics,
type CompactionService, type CompactionService,
type MaskedResultRecord,
type ProjectionSnapshot, type ProjectionSnapshot,
type ToolHistoryRecord,
} from "./types.js"; } from "./types.js";
interface GlobalWithCompactionService { interface GlobalWithCompactionService {
@@ -14,11 +14,11 @@ const root = globalThis as GlobalWithCompactionService;
export class CompactionServiceRegistry implements CompactionService { export class CompactionServiceRegistry implements CompactionService {
private readonly snapshots = new Map<string, ProjectionSnapshot>(); private readonly snapshots = new Map<string, ProjectionSnapshot>();
private readonly queryHandlers = new Map<string, (ids: string[]) => ToolHistoryRecord[]>(); private readonly queryHandlers = new Map<string, (ids: string[]) => MaskedResultRecord[]>();
setSession( setSession(
sessionId: string, sessionId: string,
query: (ids: string[]) => ToolHistoryRecord[], query: (ids: string[]) => MaskedResultRecord[],
): void { ): void {
this.queryHandlers.set(sessionId, query); this.queryHandlers.set(sessionId, query);
} }
@@ -58,11 +58,11 @@ export class CompactionServiceRegistry implements CompactionService {
percent: snapshot.contextWindow > 0 ? 100 * snapshot.effectiveTokens / snapshot.contextWindow : 0, percent: snapshot.contextWindow > 0 ? 100 * snapshot.effectiveTokens / snapshot.contextWindow : 0,
confidence: snapshot.confidence, confidence: snapshot.confidence,
segmentCount: snapshot.segmentCount, segmentCount: snapshot.segmentCount,
semanticJobs: snapshot.semanticJobs, maskedResults: snapshot.maskedResults,
}; };
} }
query(sessionId: string, ids: string[]): ToolHistoryRecord[] { query(sessionId: string, ids: string[]): MaskedResultRecord[] {
return this.queryHandlers.get(sessionId)?.(ids) ?? []; return this.queryHandlers.get(sessionId)?.(ids) ?? [];
} }
} }
+4 -12
View File
@@ -1,13 +1,5 @@
import type { PiCompactionConfig, ProjectionSnapshot } from "./types.js"; // Effective trigger for one configured (tokens, ratio) pair: the absolute
// token figure wins on large windows, the ratio caps it on small ones.
export type CompactionReason = "manual" | "threshold" | "overflow"; export function effectiveThreshold(tokens: number, ratio: number, contextWindow: number): number {
return contextWindow > 0 ? Math.min(tokens, Math.floor(ratio * contextWindow)) : tokens;
export function shouldCompact(
reason: CompactionReason,
snapshot: Pick<ProjectionSnapshot, "lowerBound" | "contextWindow">,
config: Pick<PiCompactionConfig, "hardRatio">,
): boolean {
if (reason === "manual" || reason === "overflow") return true;
if (snapshot.contextWindow <= 0) return true;
return snapshot.lowerBound / snapshot.contextWindow >= config.hardRatio;
} }
+30 -48
View File
@@ -5,58 +5,33 @@ export const COMPACTION_VERSION = 1 as const;
export const QUERY_TOOL_NAME = "context_tree_query"; export const QUERY_TOOL_NAME = "context_tree_query";
export const SERVICE_SYMBOL = Symbol.for("my-pi.pi-compaction.service.v1"); 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 { export interface PiCompactionConfig {
enabled: boolean; enabled: boolean;
softRatio: number; masking: MaskingConfig;
hardRatio: number; summary: SummaryConfig;
targetRatio: number;
deterministic: {
enabled: boolean;
maxStubTokens: number;
};
semantic: {
enabled: boolean;
model: string;
thinking: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
concurrency: number;
minInputChars: number;
maxInputChars: number;
maxRetries: number;
};
native: { native: {
enabled: boolean; enabled: boolean;
}; };
} }
export interface SegmentSummaryRecord {
version: typeof COMPACTION_VERSION;
kind: "segment-summary";
sourceKey: string;
toolCallId: string;
resultTimestamp: number;
toolName: string;
sourceHash: string;
deterministicSummary: string;
semanticSummary?: string;
rawChars: number;
effectiveChars: number;
}
export type PiCompactionRecord = SegmentSummaryRecord;
export interface ToolHistoryRecord {
sourceKey: string;
toolCallId: string;
resultTimestamp: number;
toolName: string;
args: unknown;
isError: boolean;
rawText: string;
sourceHash: string;
deterministicSummary: string;
semanticSummary?: string;
}
export interface UsageBaseline { export interface UsageBaseline {
tokens: number; tokens: number;
messageEstimate: number; messageEstimate: number;
@@ -78,7 +53,7 @@ export interface ProjectionSnapshot {
lowerBound: number; lowerBound: number;
upperBound: number; upperBound: number;
segmentCount: number; segmentCount: number;
semanticJobs: number; maskedResults: number;
codexItems?: ResponseItem[]; codexItems?: ResponseItem[];
} }
@@ -99,6 +74,13 @@ export interface PortableCheckpointDetails {
sourceFingerprint: string; 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 { export interface CompactionMetrics {
sessionId: string; sessionId: string;
generation: number; generation: number;
@@ -109,11 +91,11 @@ export interface CompactionMetrics {
percent: number; percent: number;
confidence: ProjectionSnapshot["confidence"]; confidence: ProjectionSnapshot["confidence"];
segmentCount: number; segmentCount: number;
semanticJobs: number; maskedResults: number;
} }
export interface CompactionService { export interface CompactionService {
getSnapshot(sessionId: string): ProjectionSnapshot | undefined; getSnapshot(sessionId: string): ProjectionSnapshot | undefined;
getMetrics(sessionId: string): CompactionMetrics | undefined; getMetrics(sessionId: string): CompactionMetrics | undefined;
query(sessionId: string, ids: string[]): ToolHistoryRecord[]; query(sessionId: string, ids: string[]): MaskedResultRecord[];
} }
+68 -20
View File
@@ -2,34 +2,82 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { DEFAULT_CONFIG, normalizeConfig } from "../src/config.ts"; import { DEFAULT_CONFIG, normalizeConfig } from "../src/config.ts";
test("normalizes one greenfield piCompaction config without legacy aliases", () => { test("normalizes a full pi-compaction config", () => {
const config = normalizeConfig({ const config = normalizeConfig({
enabled: false, enabled: false,
softRatio: 0.7, masking: {
hardRatio: 0.91, enabled: false,
targetRatio: 0.5, triggerTokens: 150_000,
deterministic: { enabled: false, maxStubTokens: 512 }, triggerRatio: 0.5,
semantic: { enabled: false, concurrency: 2, maxRetries: 0 }, 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 }, native: { enabled: false },
}); });
assert.equal(config.enabled, false); assert.equal(config.enabled, false);
assert.equal(config.deterministic.enabled, false); assert.deepEqual(config.masking, {
assert.equal(config.deterministic.maxStubTokens, 512); enabled: false,
assert.equal(config.semantic.enabled, false); triggerTokens: 150_000,
assert.equal(config.semantic.maxRetries, 0); 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); assert.equal(config.native.enabled, false);
}); });
test("invalid ratios and bounds fail safe to bounded defaults", () => { test("invalid fields fail safe to bounded defaults", () => {
const config = normalizeConfig({ const config = normalizeConfig({
softRatio: 2, enabled: "yes",
hardRatio: -1, masking: {
targetRatio: 0.99, triggerTokens: -1,
semantic: { concurrency: 50, maxRetries: -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.softRatio, DEFAULT_CONFIG.softRatio); assert.equal(config.enabled, DEFAULT_CONFIG.enabled);
assert.ok(config.targetRatio < config.softRatio); assert.deepEqual(config.masking, DEFAULT_CONFIG.masking);
assert.ok(config.hardRatio > config.softRatio); assert.equal(config.summary.triggerTokens, DEFAULT_CONFIG.summary.triggerTokens);
assert.equal(config.semantic.concurrency, DEFAULT_CONFIG.semantic.concurrency); assert.equal(config.summary.triggerRatio, DEFAULT_CONFIG.summary.triggerRatio);
assert.equal(config.semantic.maxRetries, DEFAULT_CONFIG.semantic.maxRetries); 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, []);
}); });
+225 -61
View File
@@ -1,7 +1,8 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { performance } from "node:perf_hooks";
import piCompaction from "../index.ts"; import piCompaction from "../index.ts";
import { getCompactionService } from "../src/service.ts";
import { MASK_PREFIX } from "../src/masking.ts";
function harness() { function harness() {
const handlers = new Map<string, Function[]>(); const handlers = new Map<string, Function[]>();
@@ -24,90 +25,253 @@ function harness() {
return { pi, handlers, tools, commands, entries }; return { pi, handlers, tools, commands, entries };
} }
function context(branch: any[] = []) { function context(branch: any[] = [], tokens = 1_000) {
return { const compactCalls: any[] = [];
const sessionId = `session-${Math.random().toString(36).slice(2)}`;
const ctx = {
sessionManager: { sessionManager: {
getSessionId: () => "session-test", getSessionId: () => sessionId,
getBranch: () => branch, getBranch: () => branch,
getLeafId: () => "leaf-1",
}, },
model: { provider: "test", api: "test", id: "model", contextWindow: 100_000 }, model: { provider: "test", api: "test", id: "model", contextWindow: 100_000 },
modelRegistry: {}, modelRegistry: {},
getSystemPrompt: () => "system", getSystemPrompt: () => "system",
getContextUsage: () => ({ tokens: 1_000, contextWindow: 100_000, percent: 1 }), getContextUsage: () => ({ tokens, contextWindow: 100_000, percent: 1 }),
compact(options: unknown) { compactCalls.push(options); },
isIdle: () => true,
hasUI: false, hasUI: false,
abort() {}, abort() {},
} as any; } as any;
return { ctx, compactCalls };
} }
test("one runtime projects tool evidence and recovers it through context_tree_query", async () => { 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(); const h = harness();
piCompaction(h.pi); piCompaction(h.pi);
const ctx = context(); const { ctx } = context([], 60_000);
await h.handlers.get("session_start")![0]!({}, ctx); await h.handlers.get("session_start")![0]!({}, ctx);
const assistant = { const prior = { type: "custom", customType: "other-extension", data: {} };
role: "assistant", const result = await h.handlers.get("turn_end")![0]!({
content: [{ type: "toolCall", id: "call-1", name: "bash", arguments: { command: "npm test" } }], entries: [prior],
timestamp: 10, message: assistantMessage("a1", []),
}; toolResults: [],
const result = { context: { contextEntries: pressurizedContextEntries(), contextMessages: [] },
role: "toolResult", }, ctx);
toolCallId: "call-1",
toolName: "bash",
content: [{ type: "text", text: `failure evidence\n${"x".repeat(12_000)}\nstack tail` }],
isError: true,
timestamp: 11,
};
await h.handlers.get("turn_end")![0]!({ message: assistant, toolResults: [result] }, ctx);
const projected = await h.handlers.get("context")![0]!({ messages: [assistant, result] }, ctx);
assert.match(projected.messages[1].content[0].text, /pi-compaction tool=bash/); assert.ok(result?.entries);
const cacheStart = performance.now(); assert.strictEqual(result.entries[0], prior);
for (let iteration = 0; iteration < 1_000; iteration++) { const edits = result.entries.slice(1);
await h.handlers.get("context")![0]!({ messages: [assistant, result] }, ctx); assert.equal(edits.length, 1);
} assert.equal(edits[0]!.type, "context_edit");
const cacheAverageMs = (performance.now() - cacheStart) / 1_000; assert.equal(edits[0]!.targetId, "r1");
assert.ok(cacheAverageMs < 2, `full context handler cache-hit average ${cacheAverageMs.toFixed(3)}ms exceeded 2ms`); assert.ok(edits[0]!.replacement.content.startsWith(MASK_PREFIX));
assert.equal(h.entries.length, 1); // Masking is append-only context_edit; nothing is written as custom entries.
assert.equal(h.entries[0]!.customType, "pi-compaction"); assert.equal(h.entries.length, 0);
const query = h.tools.get("context_tree_query");
const recovered = await query.execute("q", { toolCallIds: ["call-1"] }, new AbortController().signal, undefined, ctx);
assert.match(recovered.content[0].text, /failure evidence/);
await h.handlers.get("session_shutdown")![0]!({}, ctx); await h.handlers.get("session_shutdown")![0]!({}, ctx);
}); });
test("small tool results are passed through without a stub wrapper", async () => { test("turn_end below the masking trigger returns undefined", async () => {
const h = harness(); const h = harness();
piCompaction(h.pi); piCompaction(h.pi);
const ctx = context(); const { ctx } = context([], 40_000);
await h.handlers.get("session_start")![0]!({}, ctx); await h.handlers.get("session_start")![0]!({}, ctx);
const assistant = { const result = await h.handlers.get("turn_end")![0]!({
role: "assistant", entries: [],
content: [{ type: "toolCall", id: "call-small", name: "bash", arguments: { command: "ls" } }], message: assistantMessage("a1", []),
timestamp: 20, toolResults: [],
}; context: { contextEntries: pressurizedContextEntries(), contextMessages: [] },
const result = { }, ctx);
role: "toolResult", assert.equal(result, undefined);
toolCallId: "call-small", await h.handlers.get("session_shutdown")![0]!({}, ctx);
toolName: "bash", });
content: [{ type: "text", text: "ok\nfile-a.ts\nfile-b.ts" }],
timestamp: 21,
};
await h.handlers.get("turn_end")![0]!({ message: assistant, toolResults: [result] }, ctx); test("context hook returns messages untouched on the non-checkpoint path", async () => {
const projected = await h.handlers.get("context")![0]!({ messages: [assistant, result] }, ctx); const h = harness();
piCompaction(h.pi);
const { ctx } = context();
await h.handlers.get("session_start")![0]!({}, ctx);
assert.strictEqual(projected.messages[1].content[0].text, "ok\nfile-a.ts\nfile-b.ts"); const messages = [assistantMessage("a1", []), toolResultMessage("r1", "c1", "visible\nfile-a.ts")];
assert.doesNotMatch(projected.messages[1].content[0].text, /pi-compaction tool=/); 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);
// Original evidence stays recoverable even when the message is passed through.
const query = h.tools.get("context_tree_query")!; const query = h.tools.get("context_tree_query")!;
const recovered = await query.execute("q", { toolCallIds: ["call-small"] }, new AbortController().signal, undefined, ctx); const recovered = await query.execute("q", { toolCallIds: ["c1"] }, new AbortController().signal, undefined, ctx);
assert.match(recovered.content[0].text, /file-a\.ts/); 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); await h.handlers.get("session_shutdown")![0]!({}, ctx);
}); });
@@ -127,7 +291,7 @@ test("model switching after a native checkpoint projects the portable checkpoint
{ type: "compaction", id: "c1", parentId: null, timestamp: new Date().toISOString(), summary: "marker", firstKeptEntryId: "m0", tokensBefore: 1_000, details }, { 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 } }, { type: "message", id: "m1", parentId: "c1", timestamp: new Date().toISOString(), message: { role: "user", content: "continue", timestamp: 20 } },
] as any[]; ] as any[];
const ctx = context(branch); const { ctx } = context(branch);
await h.handlers.get("session_start")![0]!({}, ctx); await h.handlers.get("session_start")![0]!({}, ctx);
const projected = await h.handlers.get("context")![0]!({ messages: [] }, ctx); const projected = await h.handlers.get("context")![0]!({ messages: [] }, ctx);
assert.match(projected.messages[0].content[0].text, /decisions and files/); assert.match(projected.messages[0].content[0].text, /decisions and files/);
@@ -143,7 +307,7 @@ for (const provider of ["sub-api", "openai-codex"]) {
type: "message", id: "u1", parentId: null, type: "message", id: "u1", parentId: null,
message: { role: "user", content: "Remember the test decision.", timestamp: 1 }, message: { role: "user", content: "Remember the test decision.", timestamp: 1 },
}]; }];
const ctx = context(branch); const { ctx } = context(branch);
ctx.model = { ctx.model = {
provider, api: provider === "sub-api" ? "openai-responses" : "openai-codex-responses", provider, api: provider === "sub-api" ? "openai-responses" : "openai-codex-responses",
id: "selected-model", input: ["text"], contextWindow: 100_000, id: "selected-model", input: ["text"], contextWindow: 100_000,
@@ -231,7 +395,7 @@ test("SubAPI native failures and cancellation return control to Pi without a che
type: "message", id: "u1", parentId: null, type: "message", id: "u1", parentId: null,
message: { role: "user", content: "Test only.", timestamp: 1 }, message: { role: "user", content: "Test only.", timestamp: 1 },
}]; }];
const ctx = context(branch); const { ctx } = context(branch);
ctx.model = { provider: "sub-api", api: "openai-responses", id: "test-model", input: ["text"], baseUrl: "https://gateway.example/v1" }; 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" }); ctx.modelRegistry.getApiKeyAndHeaders = async () => ({ ok: true, apiKey: "test-key" });
const responses = [ const responses = [
@@ -266,7 +430,7 @@ test("SubAPI native failures and cancellation return control to Pi without a che
test("other providers and SubAPI Chat Completions keep their provider requests unchanged", async () => { test("other providers and SubAPI Chat Completions keep their provider requests unchanged", async () => {
const h = harness(); const h = harness();
piCompaction(h.pi); piCompaction(h.pi);
const ctx = context(); const { ctx } = context();
try { try {
for (const model of [ for (const model of [
{ provider: "other", api: "openai-responses", id: "test" }, { provider: "other", api: "openai-responses", id: "test" },
@@ -302,7 +466,7 @@ test("native runtime delegates authentication to each provider without blocking
type: "message", id: "u1", parentId: null, type: "message", id: "u1", parentId: null,
message: { role: "user", content: "Synthetic authentication test.", timestamp: 1 }, message: { role: "user", content: "Synthetic authentication test.", timestamp: 1 },
}]; }];
const ctx = context(branch); const { ctx } = context(branch);
const provider = scenario.provider ?? "sub-api"; const provider = scenario.provider ?? "sub-api";
ctx.model = { ctx.model = {
provider, api: provider === "sub-api" ? "openai-responses" : "openai-codex-responses", provider, api: provider === "sub-api" ? "openai-responses" : "openai-codex-responses",
+209
View File
@@ -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);
});
+3 -2
View File
@@ -10,9 +10,9 @@ function message(index: number) {
test("cached projection and append-only delta stay inside the hot-path budget", () => { test("cached projection and append-only delta stay inside the hot-path budget", () => {
const index = new SegmentIndex("perf"); const index = new SegmentIndex("perf");
index.hydrate([], DEFAULT_CONFIG); index.hydrate();
const messages = Array.from({ length: 10_000 }, (_, value) => message(value)); const messages = Array.from({ length: 10_000 }, (_, value) => message(value));
const params = { environmentHash: "env", environmentTokens: 2_000, contextWindow: 300_000 }; const params = { environmentHash: "env", environmentTokens: 2_000, contextWindow: 300_000, maskedResults: 0, reclaimedTokens: 0 };
index.project(messages, params); index.project(messages, params);
const cacheStart = performance.now(); const cacheStart = performance.now();
@@ -24,6 +24,7 @@ test("cached projection and append-only delta stay inside the hot-path budget",
index.project(appended, params); index.project(appended, params);
const deltaMs = performance.now() - deltaStart; 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(cacheAverageMs < 2, `cache-hit average ${cacheAverageMs.toFixed(3)}ms exceeded 2ms`);
assert.ok(deltaMs < 10, `delta projection ${deltaMs.toFixed(3)}ms exceeded 10ms`); assert.ok(deltaMs < 10, `delta projection ${deltaMs.toFixed(3)}ms exceeded 10ms`);
}); });
+69 -40
View File
@@ -1,7 +1,6 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { DEFAULT_CONFIG } from "../src/config.ts"; import { SegmentIndex } from "../src/segment-index.ts";
import { SegmentIndex, deterministicSummary } from "../src/segment-index.ts";
const assistant = { const assistant = {
role: "assistant", role: "assistant",
@@ -17,66 +16,96 @@ const result = {
timestamp: 11, timestamp: 11,
}; };
test("deterministic summary is bounded and preserves head/tail evidence", () => { // Regression: a ~14.5K-char read result projected under a 1M window must reach
const summary = deterministicSummary({ // the model byte-for-byte unchanged — masking only happens via context_edit
toolName: "bash", // drafts under real pressure, never on the hot projection path.
args: { command: "npm test" }, test("projection is byte-for-byte identity for a 300-line read result under a 1M window", () => {
rawText: result.content[0]!.text, const readBody = Array.from({ length: 300 }, (_unused, index) => `line ${index + 1}: ${"y".repeat(38)}`).join("\n");
isError: true, const readResult = {
maxStubTokens: 256, 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.match(summary, /tool=bash status=error/);
assert.match(summary, /failure/); assert.strictEqual(snapshot.messages[1], readResult);
assert.match(summary, /stack tail/); assert.strictEqual(snapshot.messages[1]!.content[0].text, readBody);
assert.ok(summary.length <= 1_100); assert.ok(snapshot.messages[1]!.content[0].text.includes("line 150:"));
assert.equal(snapshot.reclaimedTokens, 0);
assert.equal(snapshot.maskedResults, 0);
}); });
test("projection incrementally replaces tool results and caches identical input", () => { test("projection caches identical input and handles append-only deltas", () => {
const index = new SegmentIndex("session-1"); const index = new SegmentIndex("session-2");
index.hydrate([], DEFAULT_CONFIG); index.hydrate();
const records = index.captureTurn(assistant, [result], DEFAULT_CONFIG);
assert.equal(records.length, 1);
const messages = [assistant, result]; const messages = [assistant, result];
const first = index.project(messages, { const params = { environmentHash: "env", environmentTokens: 100, contextWindow: 300_000, maskedResults: 0, reclaimedTokens: 0 };
environmentHash: "env", const first = index.project(messages, params);
environmentTokens: 100, const second = index.project(messages, params);
contextWindow: 300_000,
});
const second = index.project(messages, {
environmentHash: "env",
environmentTokens: 100,
contextWindow: 300_000,
});
assert.strictEqual(first, second); assert.strictEqual(first, second);
assert.ok(first.effectiveTokens < first.rawTokens); assert.ok(first.rawTokens > 0);
assert.match(first.messages[1]!.content[0].text, /context_tree_query/);
const record = records[0]!; const appended = index.project([...messages, { role: "user", content: "next", timestamp: 13 }], params);
assert.ok(index.applySemantic(record.sourceKey, record.sourceHash, "- npm test failed\n- stack tail")); assert.notStrictEqual(appended, first);
const semantic = index.project(messages, { 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", environmentHash: "env",
environmentTokens: 100, environmentTokens: 100,
contextWindow: 300_000, contextWindow: 300_000,
}); maskedResults: 0,
assert.notStrictEqual(semantic, first); reclaimedTokens: 0,
assert.match(semantic.messages[1]!.content[0].text, /npm test failed/); };
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", () => { test("usage-backed baseline is adjusted only by projected delta", () => {
const index = new SegmentIndex("session-2"); const index = new SegmentIndex("session-3");
index.hydrate([], DEFAULT_CONFIG); index.hydrate();
const initial = index.project([assistant], { const initial = index.project([assistant], {
environmentHash: "env", environmentHash: "env",
environmentTokens: 50, environmentTokens: 50,
contextWindow: 100_000, contextWindow: 100_000,
maskedResults: 0,
reclaimedTokens: 0,
}); });
index.recordUsage(1_000, initial.messageEstimate, "env"); index.recordUsage(1_000, initial.messageEstimate, "env");
const next = index.project([assistant, { role: "user", content: "next", timestamp: 12 }], { const next = index.project([assistant, { role: "user", content: "next", timestamp: 12 }], {
environmentHash: "env", environmentHash: "env",
environmentTokens: 50, environmentTokens: 50,
contextWindow: 100_000, contextWindow: 100_000,
maskedResults: 0,
reclaimedTokens: 0,
}); });
assert.equal(next.confidence, "usage-backed"); assert.equal(next.confidence, "usage-backed");
assert.ok(next.effectiveTokens >= 1_000); assert.ok(next.effectiveTokens >= 1_000);
+11 -13
View File
@@ -1,19 +1,17 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { shouldCompact } from "../src/token-accounting.ts"; import { effectiveThreshold } from "../src/token-accounting.ts";
test("raw pressure cannot trigger compaction while effective lower-bound stays below 90%", () => { test("absolute token threshold wins on large windows", () => {
const config = { hardRatio: 0.9 }; assert.equal(effectiveThreshold(200_000, 0.6, 1_000_000), 200_000);
const snapshot = { lowerBound: 231_000, contextWindow: 300_000 };
assert.equal(shouldCompact("threshold", snapshot, config), false);
assert.equal(shouldCompact("manual", snapshot, config), true);
assert.equal(shouldCompact("overflow", snapshot, config), true);
}); });
test("effective lower-bound at the configured threshold compacts", () => { test("window ratio caps the threshold on small windows", () => {
assert.equal(shouldCompact( assert.equal(effectiveThreshold(200_000, 0.6, 272_000), 163_200);
"threshold", assert.equal(effectiveThreshold(100_000, 0.35, 272_000), 95_200);
{ lowerBound: 270_000, contextWindow: 300_000 }, });
{ hardRatio: 0.9 },
), true); 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);
}); });
-31
View File
@@ -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
+18
View File
@@ -1,3 +1,21 @@
# my-pi 维护约定(优先于下文上游约定)
- 扩展入口是 `pi-hermes-memory/src/index.ts`,来源记录是 `UPSTREAM.md`;只从官方 `chandra447/pi-hermes-memory` 的明确 tag/commit 移植并保留官方 README、LICENSE 和版权。
- 根包必须通过 `file:./pi-hermes-memory` 提供依赖并直接加载仓库源码;不得同时安装或加载 npm 预编译入口,也不得保留第二个记忆扩展(`hippo-memory-pi` 已移除)。
- `package.json` 的 `pi.extensions` 必须把 `./pi-hermes-memory/src/index.ts` 排在 `./pi-compaction/index.ts` 之前:`ExtensionRunner.emit()` 在第一个返回 `{ cancel: true }` 的 `session_before_compact` handler 处立即停止,而 `pi-compaction` 在判定无需压缩时正是这样返回,注册在其后的 handler 会被跳过从而静默禁用 `flushOnCompact`;根 `tests/extension-load.test.ts` 必须断言该顺序。
- 记忆默认 `policy-only` 模式:只注入 `<memory-policy>` 指令而不注入记忆正文,不改变 system prompt 前缀、对 DeepSeek 前缀缓存友好;模型需要历史上下文时主动调用 `memory_search` / `session_search`,写入用 `memory_add` / `memory_replace` / `memory_remove`,过程记忆用 `skill_manage`。
- 六个记忆工具与 `context_tree_query` 必须在 `pi-tool-search/extensions/config.ts` 的 `BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled` 中常驻(不放入动态组——注入的 policy 指示模型在普通工作中调用记忆工具,恢复被投影的工具证据也不能依赖 `tool_search` 激活,常驻同时保持请求前缀稳定),并由 `pi-tool-search/extensions/bundle-groups.ts` 的 `memory-recall` / `memory-management` 分组提供目录元数据(`context_tree_query` 留在 `memory-recall`)。新增、删除或重命名这些工具时必须同步 `bundle-groups.ts`、`BUNDLE_TOOL_SEARCH_DEFAULTS`、`config/tool-search.json`(二者必须完全一致)、Tool Search 测试、权限策略与 `pi-tool-search` 的 README / CHANGELOG。它们只写扩展自己的 agent 目录数据或只读本地索引,不触碰工作区,因此在权限策略中默认 `allow`,避免每轮 AutoReview 往返。
- 依赖必须保持 `better-sqlite3@^13.0.3`:13.x 运行时使用包内 N-API 预编译二进制 `prebuilds/<platform>.node`,不依赖 node-gyp 编译产物,必须能由 npm 嵌套安装并与根 `better-sqlite3@12.11.1` 并存。不得把它降级回 12.x(Node 24 下会在进程退出时 SIGABRT),也不得让根包提升为单一版本;npm 因 `binding.gyp` 推断出的 `node-gyp rebuild` 不得批准,根 `allowScripts` 不新增条目。
- `devDependencies` 中的 `@earendil-works/*` 必须与根包当前版本精确一致。若写成 `^0.80.2` 这类与根不兼容的范围,npm 会在本目录嵌套安装第二套 Pi runtime,违反"不得在组合包内再安装一套 Pi runtime"的纪律;`typebox`、`tsx`、`typescript`、`@types/better-sqlite3` 同理,必须钉到根已有版本。
- 全局记忆、项目记忆与 skill 位于 `~/.pi/agent/pi-hermes-memory/` 与 `~/.pi/agent/projects-memory/`,属于用户数据,安装、升级与卸载脚本不得创建、迁移或删除;扩展配置位于已安装组合包的 `config/pi-hermes-memory.json`(其中路径类默认值由代码运行时计算),旧 `~/.pi/agent/hermes-memory-config.json` 被忽略。
- 子会话(pi-subagents child)只读:`src/subagent-mode.ts` 用与 pi-subagents 相同的 `<active_agent name="..."/>` 契约检测——权威判定在 `before_agent_start` 的 `systemPromptOptions.customPrompt`(绝不检测渲染后提示词,主会话文档里也可能含该字面量),`session_start` 仅以"渲染提示词以该 tag 开头"作提前预判以跳过 session_start 写入;按 `sessionManager` 逐会话标记(同一进程内父子会话并发,不能用全局开关)。子会话中 `memory_add`/`memory_replace`/`memory_remove`/`skill_manage` 返回固定错误且零写入,后台 review、correction detection、compact/shutdown flush、live/shutdown session 索引与初始化迁移/同步全部跳过;`memory_search`/`session_search` 保持只读可用。
- `skill_manage` 是本扩展提供的能力,不是 Pi 宿主能力:Pi 只发现并加载 skill 目录(`~/.pi/agent/skills/` 加扩展通过 `resources_discover` 注册的目录),没有创建 skill 的工具。该扩展同时承担 skill 写入与目录注册;不加载它,`projects-memory/<项目>/skills/` 下的既有 skill 也不会被 Pi 发现。
- 修改工具注册、`before_agent_start` 注入策略、`resources_discover` 目录注册、SQLite 迁移或 consolidation 后,必须同步包内 README/CHANGELOG/docs,并运行根扩展联合加载与 Tool Search 测试。
## 验证
- 在本目录内运行包内测试与类型检查。记忆扩展变化必须额外验证:在隔离临时 Pi agent 目录中可加载且注册 6 个工具、`pi.extensions` 中排在 `pi-compaction` 之前、Tool Search 目录把六个工具归入 `memory-recall` / `memory-management` 且全部常驻、权限策略默认 `allow`、`better-sqlite3@13` 嵌套安装与根 `12.11.1` 并存。不得在测试中连接真实数据库或修改真实 `~/.pi/agent/` 记忆数据。
# Pi Hermes Memory Extension # Pi Hermes Memory Extension
## Project Overview ## Project Overview
+2
View File
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- **Subagent (pi-subagents child) sessions are read-only**: sessions whose `systemPromptOptions.customPrompt` carries the `<active_agent name="..."/>` identity tag (detected in `before_agent_start`, tracked per `sessionManager`) get a fixed error result from `memory_add`, `memory_replace`, `memory_remove`, and `skill_manage` — no writes — and skip background review, correction detection, compact/shutdown flush, live and shutdown session indexing, and the write side of initialization (markdown↔SQLite sync, legacy migration, pruning, backfill). `memory_search` and `session_search` stay fully usable. Sessions started from a tag-leading rendered prompt are detected as early as `session_start`; otherwise the authoritative `customPrompt` check at `before_agent_start` applies before any turn-level work.
- **Opt-in chunked subprocess consolidation** (`consolidationChunking`, default `false`): stores above `consolidationChunkChars` (new, default `4000`) consolidate in bounded rounds — a head slice of entries per child run, reloading from disk between rounds, so a killed run resumes from partial progress instead of restarting. The rounds share one overall time budget (`consolidationTimeoutMs`, same worst case as the single call this replaces), the loop stops at the target's capacity goal (failure memory is a 2× tier and is no longer over-merged down to the slice size), a slice that cannot shrink is skipped rather than blocking later slices, A store that fits one prompt runs a single unscoped decisive round with the whole remaining store; a child exiting 0 without shrinking is reported as an honest partial instead of silent success, and an over-chunk store already within its capacity goal is a clean no-op. Partial runs report `partial: true` with the reason and are surfaced by the warning path, the store's add-retry error, and `/memory-consolidate`; disappearances outside a round's slice (rogue child deletions, legitimate cross-slice dedup, or concurrent writers — indistinguishable to the parent) are detected and reported rather than resurrected, because re-adding through the cap-enforcing add path cannot converge and resurrection ping-pongs across triggers. An over-chunk store already within its capacity goal is a clean no-op. Single-shot behavior below the threshold is unchanged. - **Opt-in chunked subprocess consolidation** (`consolidationChunking`, default `false`): stores above `consolidationChunkChars` (new, default `4000`) consolidate in bounded rounds — a head slice of entries per child run, reloading from disk between rounds, so a killed run resumes from partial progress instead of restarting. The rounds share one overall time budget (`consolidationTimeoutMs`, same worst case as the single call this replaces), the loop stops at the target's capacity goal (failure memory is a 2× tier and is no longer over-merged down to the slice size), a slice that cannot shrink is skipped rather than blocking later slices, A store that fits one prompt runs a single unscoped decisive round with the whole remaining store; a child exiting 0 without shrinking is reported as an honest partial instead of silent success, and an over-chunk store already within its capacity goal is a clean no-op. Partial runs report `partial: true` with the reason and are surfaced by the warning path, the store's add-retry error, and `/memory-consolidate`; disappearances outside a round's slice (rogue child deletions, legitimate cross-slice dedup, or concurrent writers — indistinguishable to the parent) are detected and reported rather than resurrected, because re-adding through the cap-enforcing add path cannot converge and resurrection ping-pongs across triggers. An over-chunk store already within its capacity goal is a clean no-op. Single-shot behavior below the threshold is unchanged.
### Added ### Added
+1 -2
View File
@@ -511,7 +511,7 @@ Move behavior:
## Configuration ## Configuration
Create `~/.pi/agent/hermes-memory-config.json`: Configuration is read from the installed bundle's `config/pi-hermes-memory.json` — the bundle repository's `config/` directory is the single source of truth, refreshed by `pi update`. The shipped file only contains machine-independent values; `memoryDir`, `projectsMemoryDir`, and `childExtensionPaths` are machine-dependent and omitted so the code computes them at runtime. A leftover `~/.pi/agent/hermes-memory-config.json` is ignored. All supported keys:
```json ```json
{ {
@@ -708,7 +708,6 @@ native install scripts are disabled because the fixture writes no memories.
│ │ └── SKILL.md │ │ └── SKILL.md
│ └── another-project/ │ └── another-project/
│ └── MEMORY.md │ └── MEMORY.md
├── hermes-memory-config.json
└── ... └── ...
``` ```
+3 -4
View File
@@ -19,12 +19,13 @@
### 依赖 ### 依赖
- `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`。 - `strip-ansi@7.2.0`:解析到根 `node_modules`。
### 本地改动 ### 本地改动
- `devDependencies` 中的 `@earendil-works/pi-ai`、`@earendil-works/pi-coding-agent`、`@earendil-works/pi-tui`、`typebox`、`tsx`、`typescript`、`@types/better-sqlite3` 已从上游的 `^0.80.2` 等范围改为与根包一致的精确版本。上游的宽松范围与根包的 `0.84.2` 不兼容,会让 npm 在本目录嵌套安装第二套 Pi runtime(实测 229 MB),违反组合包"不得再安装一套 Pi runtime"的纪律;`pi-notify/package.json` 是同样的先例。运行时依赖(`better-sqlite3`、`strip-ansi`)与 `peerDependencies` 保持上游原样。 - `devDependencies` 中的 `@earendil-works/pi-ai`、`@earendil-works/pi-coding-agent`、`@earendil-works/pi-tui`、`typebox`、`tsx`、`typescript`、`@types/better-sqlite3` 已从上游的 `^0.80.2` 等范围改为与根包一致的精确版本。上游的宽松范围与根包的 `0.84.2` 不兼容,会让 npm 在本目录嵌套安装第二套 Pi runtime(实测 229 MB),违反组合包"不得再安装一套 Pi runtime"的纪律;`pi-notify/package.json` 是同样的先例。运行时依赖(`better-sqlite3`、`strip-ansi`)与 `peerDependencies` 保持上游原样。
- `before_agent_start` 在结构化 `systemPromptOptions` 下把记忆上下文写入 `sections.memory_policy` 命名分区(而不是上游的 `appendSystemPrompt` 追加),分区顺序由组合包 `config/prompt/sections.jsonc` 统一控制;无结构化 options 的旧宿主分支保持原有的 `systemPrompt` 文本拼接行为。
### 工具与常驻 ### 工具与常驻
@@ -36,8 +37,6 @@
- 全局记忆与 skill:`~/.pi/agent/pi-hermes-memory/` - 全局记忆与 skill:`~/.pi/agent/pi-hermes-memory/`
- 项目记忆与 skill:`~/.pi/agent/projects-memory/<项目>/` - 项目记忆与 skill:`~/.pi/agent/projects-memory/<项目>/`
- 配置:`~/.pi/agent/hermes-memory-config.json`(可选,缺省时使用内置默认值) 以上路径是用户数据,不属于组合包,也不应提交或自动清理。组合包配置位于已安装组合包的 `config/pi-hermes-memory.json`,随 `pi update` 刷新;旧 `~/.pi/agent/hermes-memory-config.json` 被忽略。
这些路径是用户数据,不属于组合包,也不应提交或自动清理。
同步上游时,请从明确 tag/commit 移植改动,并与本地改动对照审查,不得整体覆盖本目录。 同步上游时,请从明确 tag/commit 移植改动,并与本地改动对照审查,不得整体覆盖本目录。
+621 -2269
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -53,9 +53,9 @@
"@earendil-works/pi-tui": "*" "@earendil-works/pi-tui": "*"
}, },
"devDependencies": { "devDependencies": {
"@earendil-works/pi-ai": "0.84.2", "@earendil-works/pi-ai": "latest",
"@earendil-works/pi-coding-agent": "0.84.2", "@earendil-works/pi-coding-agent": "latest",
"@earendil-works/pi-tui": "0.84.2", "@earendil-works/pi-tui": "latest",
"@types/better-sqlite3": "7.6.13", "@types/better-sqlite3": "7.6.13",
"tsx": "4.23.15", "tsx": "4.23.15",
"typebox": "1.3.7", "typebox": "1.3.7",
@@ -24,9 +24,10 @@ const emit = async (event, data = {}) => {
try { try {
process.env.PI_CODING_AGENT_DIR = root; process.env.PI_CODING_AGENT_DIR = root;
const configPath = join(root, "hermes-memory-config.json");
await mkdir(globalDir); await mkdir(globalDir);
await mkdir(ctx.cwd); await mkdir(ctx.cwd);
await writeFile(join(root, "hermes-memory-config.json"), JSON.stringify({ await writeFile(configPath, JSON.stringify({
lazyInitialization: lazy, memoryMode: "policy-only", lazyInitialization: lazy, memoryMode: "policy-only",
reviewEnabled: false, correctionDetection: false, reviewEnabled: false, correctionDetection: false,
flushOnShutdown: false, flushOnCompact: false, flushOnShutdown: false, flushOnCompact: false,
@@ -39,7 +40,10 @@ try {
} }
const started = performance.now(); const started = performance.now();
const { default: register } = await import(pathToFileURL(resolve(entry)).href); const module = await import(pathToFileURL(resolve(entry)).href);
const register = module.createHermesMemoryExtension
? module.createHermesMemoryExtension({ configPath })
: module.default;
const imported = performance.now(); const imported = performance.now();
register({ register({
on(event, handler) { on(event, handler) {
+8 -5
View File
@@ -1,5 +1,5 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import { fileURLToPath } from "node:url";
import type { MemoryConfig, MemoryOverflowStrategy, ReviewTransport, SessionSearchVariant, ThinkingLevel } from "./types.js"; import type { MemoryConfig, MemoryOverflowStrategy, ReviewTransport, SessionSearchVariant, ThinkingLevel } from "./types.js";
import { import {
DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MEMORY_CHAR_LIMIT,
@@ -21,7 +21,7 @@ import {
DEFAULT_FAILURE_INJECTION_MAX_ENTRIES, DEFAULT_FAILURE_INJECTION_MAX_ENTRIES,
DEFAULT_SESSION_RETENTION_DAYS, DEFAULT_SESSION_RETENTION_DAYS,
} from "./constants.js"; } from "./constants.js";
import { AGENT_ROOT, normalizeConfiguredMemoryDir, normalizeProjectsMemoryDir } from "./paths.js"; import { normalizeConfiguredMemoryDir, normalizeProjectsMemoryDir } from "./paths.js";
const MEMORY_OVERFLOW_STRATEGIES: readonly MemoryOverflowStrategy[] = ["auto-consolidate", "reject", "fifo-evict"]; const MEMORY_OVERFLOW_STRATEGIES: readonly MemoryOverflowStrategy[] = ["auto-consolidate", "reject", "fifo-evict"];
const SESSION_SEARCH_VARIANTS: readonly SessionSearchVariant[] = ["legacy", "anchors"]; const SESSION_SEARCH_VARIANTS: readonly SessionSearchVariant[] = ["legacy", "anchors"];
@@ -81,9 +81,12 @@ const DEFAULT_CONFIG: MemoryConfig = {
sessionRetentionDays: DEFAULT_SESSION_RETENTION_DAYS, sessionRetentionDays: DEFAULT_SESSION_RETENTION_DAYS,
}; };
export const DEFAULT_CONFIG_PATH = path.join( // Bundle-level `config/` is the single source of truth: the extension reads
AGENT_ROOT, // `config/pi-hermes-memory.json` from inside the installed bundle (resolved
"hermes-memory-config.json", // relative to this source file). Memory, sessions, and credentials stay under
// the agent dir — they are user data, not bundle config.
export const DEFAULT_CONFIG_PATH = fileURLToPath(
new URL("../../config/pi-hermes-memory.json", import.meta.url),
); );
export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig { export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
@@ -17,6 +17,7 @@ import { MemoryStore } from "../store/memory-store.js";
import { DatabaseManager } from "../store/db.js"; import { DatabaseManager } from "../store/db.js";
import type { MemoryConfig } from "../types.js"; import type { MemoryConfig } from "../types.js";
import type { EnsureMemoryReady } from "../memory-initialization.js"; import type { EnsureMemoryReady } from "../memory-initialization.js";
import { isSubagentSession } from "../subagent-mode.js";
import { applyRecentMessageLimit, collectMessageParts } from "./message-parts.js"; import { applyRecentMessageLimit, collectMessageParts } from "./message-parts.js";
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js"; import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
import { REVIEW_COMPLETION_TIMEOUT_MS, runDirectMemoryCompletion, usesDirectTransport, type DirectReviewResult } from "./review-memory-ops.js"; import { REVIEW_COMPLETION_TIMEOUT_MS, runDirectMemoryCompletion, usesDirectTransport, type DirectReviewResult } from "./review-memory-ops.js";
@@ -196,6 +197,7 @@ export function setupBackgroundReview(
}); });
pi.on("turn_end", async (event, ctx) => { pi.on("turn_end", async (event, ctx) => {
if (isSubagentSession(ctx)) return;
turnsSinceReview++; turnsSinceReview++;
if (!config.reviewEnabled) return; if (!config.reviewEnabled) return;
@@ -23,6 +23,7 @@ import {
} from "../constants.js"; } from "../constants.js";
import type { MemoryConfig } from "../types.js"; import type { MemoryConfig } from "../types.js";
import type { EnsureMemoryReady } from "../memory-initialization.js"; import type { EnsureMemoryReady } from "../memory-initialization.js";
import { isSubagentSession } from "../subagent-mode.js";
import { getMessageText } from "../types.js"; import { getMessageText } from "../types.js";
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js"; import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
import { resolveProjectName, resolveProjectStore, type ProjectNameRef, type ProjectStoreRef } from "../project-context.js"; import { resolveProjectName, resolveProjectStore, type ProjectNameRef, type ProjectStoreRef } from "../project-context.js";
@@ -151,6 +152,7 @@ export function setupCorrectionDetector(
// Trigger on turn_end (we need full context: user correction + what agent said) // Trigger on turn_end (we need full context: user correction + what agent said)
pi.on("turn_end", async (event, ctx) => { pi.on("turn_end", async (event, ctx) => {
if (isSubagentSession(ctx)) return;
if (!pendingCorrection) { if (!pendingCorrection) {
turnsSinceLastCorrection++; turnsSinceLastCorrection++;
return; return;
@@ -37,6 +37,7 @@ import {
import type { MemoryConfig } from "../types.js"; import type { MemoryConfig } from "../types.js";
import type { EnsureMemoryReady } from "../memory-initialization.js"; import type { EnsureMemoryReady } from "../memory-initialization.js";
import { measureLifecycle } from "../lifecycle-timing.js"; import { measureLifecycle } from "../lifecycle-timing.js";
import { isSubagentSession } from "../subagent-mode.js";
import { collectMessageParts } from "./message-parts.js"; import { collectMessageParts } from "./message-parts.js";
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js"; import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
import { runDirectMemoryCompletion, usesDirectTransport } from "./review-memory-ops.js"; import { runDirectMemoryCompletion, usesDirectTransport } from "./review-memory-ops.js";
@@ -167,6 +168,7 @@ export function setupSessionFlush(
kind: FlushKind, kind: FlushKind,
): Promise<void> { ): Promise<void> {
try { try {
if (isSubagentSession(ctx)) return;
if (userTurnCount < config.flushMinTurns) return; if (userTurnCount < config.flushMinTurns) return;
let entries; let entries;
+40 -13
View File
@@ -52,7 +52,7 @@ import { registerPreviewContextCommand } from "./handlers/preview-context.js";
import { registerStandingPinCommand } from "./handlers/standing-pin.js"; import { registerStandingPinCommand } from "./handlers/standing-pin.js";
import { StandingInstructions } from "./store/standing-instructions.js"; import { StandingInstructions } from "./store/standing-instructions.js";
import { STANDING_FILE } from "./constants.js"; import { STANDING_FILE } from "./constants.js";
import { loadConfig } from "./config.js"; import { DEFAULT_CONFIG_PATH, loadConfig } from "./config.js";
import { shouldWarnAutoConsolidationFailure } from "./auto-consolidation-warning.js"; import { shouldWarnAutoConsolidationFailure } from "./auto-consolidation-warning.js";
import { detectProject, detectProjectSkills } from "./project.js"; import { detectProject, detectProjectSkills } from "./project.js";
import { buildPromptContext } from "./prompt-context.js"; import { buildPromptContext } from "./prompt-context.js";
@@ -61,6 +61,7 @@ import { AGENT_ROOT } from "./paths.js";
import { isDatabaseMigrationPending } from "./extension-root-migration.js"; import { isDatabaseMigrationPending } from "./extension-root-migration.js";
import { measureLifecycle, measureLifecycleSync } from "./lifecycle-timing.js"; import { measureLifecycle, measureLifecycleSync } from "./lifecycle-timing.js";
import { createMemoryInitializer, withMemoryInitialization, type EnsureMemoryReady } from "./memory-initialization.js"; import { createMemoryInitializer, withMemoryInitialization, type EnsureMemoryReady } from "./memory-initialization.js";
import { isSubagentSession, markSubagentSession, markSubagentSessionFromPrompt } from "./subagent-mode.js";
export function resolveProjectSkillDiscovery( export function resolveProjectSkillDiscovery(
skillStore: SkillStore, skillStore: SkillStore,
@@ -89,8 +90,12 @@ export function registerProjectSkillDiscoveryHandler(
}); });
} }
export default function (pi: ExtensionAPI) { export interface HermesMemoryExtensionOptions {
const config = loadConfig(); configPath?: string;
}
function hermesMemoryExtension(pi: ExtensionAPI, configPath: string) {
const config = loadConfig(configPath);
const lazy = config.lazyInitialization === true && config.memoryMode === "policy-only"; const lazy = config.lazyInitialization === true && config.memoryMode === "policy-only";
let sessionContext: ExtensionContext | undefined; let sessionContext: ExtensionContext | undefined;
@@ -195,8 +200,12 @@ export default function (pi: ExtensionAPI) {
const initialization = createMemoryInitializer(async (ctx) => { const initialization = createMemoryInitializer(async (ctx) => {
const timingPrefix = lazy ? "memory-init" : "session-start"; const timingPrefix = lazy ? "memory-init" : "session-start";
if (lazy) migrateLegacyProjectMemoryDirs(agentRoot, config.projectsMemoryDir); // Subagent sessions are read-only: skip the write side of initialization
if (!persistenceInitialized) { // (legacy-dir migration, markdown↔SQLite sync, pruning, backfill) while
// still loading the store so memory_search/session_search keep working.
const subagent = isSubagentSession(ctx);
if (lazy && !subagent) migrateLegacyProjectMemoryDirs(agentRoot, config.projectsMemoryDir);
if (!persistenceInitialized && !subagent) {
try { try {
await measureLifecycle(`${timingPrefix}.persistence-sync`, async () => { await measureLifecycle(`${timingPrefix}.persistence-sync`, async () => {
// Startup reconciles the global files plus the current project only: // Startup reconciles the global files plus the current project only:
@@ -290,26 +299,35 @@ export default function (pi: ExtensionAPI) {
// Skills and pinned instructions must be available even without a lookup. // Skills and pinned instructions must be available even without a lookup.
pi.on("session_start", async (_event, ctx) => { pi.on("session_start", async (_event, ctx) => {
sessionContext = ctx; sessionContext = ctx;
// session_start runs before before_agent_start; a child's rendered prompt
// leads with the <active_agent> tag, so detect it here early enough to
// skip the session_start writes below.
markSubagentSessionFromPrompt(ctx.getSystemPrompt?.() ?? "", ctx);
// Pinned directives must not depend on migration/SQLite being healthy. // Pinned directives must not depend on migration/SQLite being healthy.
if (standingStore) await standingStore.load(); if (standingStore) await standingStore.load();
if (!lazy) await ensureMemoryReady(ctx); if (!lazy) await ensureMemoryReady(ctx);
refreshSkillProjectContext(ctx.cwd); refreshSkillProjectContext(ctx.cwd);
await skillStore.migrateLegacySkills(); if (!isSubagentSession(ctx)) {
await skillStore.ensureDiscoveredRoots(); await skillStore.migrateLegacySkills();
await skillStore.ensureDiscoveredRoots();
}
}); });
registerProjectSkillDiscoveryHandler(pi, skillStore, config.projectsMemoryDir); registerProjectSkillDiscoveryHandler(pi, skillStore, config.projectsMemoryDir);
// ── 2. Inject memory policy by default; legacy mode keeps full frozen memory blocks ── // ── 2. Inject memory policy by default; legacy mode keeps full frozen memory blocks ──
pi.on("before_agent_start", async (event, _ctx) => { pi.on("before_agent_start", async (event, ctx) => {
markSubagentSession(event, ctx);
const promptContext = await buildPromptContext(config, store, projectStoreRef(), projectNameRef(), standingStore); const promptContext = await buildPromptContext(config, store, projectStoreRef(), projectNameRef(), standingStore);
if (!promptContext) return; if (!promptContext) return;
// pi 0.86+ hands handlers a normalized clone of these options and re-renders // pi 0.86+ hands handlers a normalized clone of these options and re-renders
// the prompt from it, so appending through `appendSystemPrompt` keeps the // the prompt from it, so writing a named `memory_policy` section keeps the
// prompt a set of named sections; returning `systemPrompt` instead forces the // prompt a set of named sections; returning `systemPrompt` instead forces the
// rendered text and flattens the sections for every later handler and for // rendered text and flattens the sections for every later handler and for
// section-aware providers (#251). // section-aware providers (#251). The bundle's single system-prompt owner
// (extensions/system-prompt.ts) orders the section via
// config/prompt/sections.jsonc.
// //
// Branch on that mechanism, not on the field: `systemPromptOptions` is on the // Branch on that mechanism, not on the field: `systemPromptOptions` is on the
// event at the 0.80.6 floor too, but 0.80.x-0.85.x pass the raw long-lived // event at the 0.80.6 floor too, but 0.80.x-0.85.x pass the raw long-lived
@@ -319,9 +337,7 @@ export default function (pi: ExtensionAPI) {
// 0.86+ runner normalizes `sections` onto the clone it passes. // 0.86+ runner normalizes `sections` onto the clone it passes.
const promptOptions = event.systemPromptOptions; const promptOptions = event.systemPromptOptions;
if (promptOptions && "sections" in promptOptions) { if (promptOptions && "sections" in promptOptions) {
promptOptions.appendSystemPrompt = [promptOptions.appendSystemPrompt, promptContext] (promptOptions.sections as Record<string, string>).memory_policy = promptContext;
.filter(Boolean)
.join("\n\n");
return; return;
} }
@@ -400,6 +416,7 @@ export default function (pi: ExtensionAPI) {
// ── 10. Live session indexing ── // ── 10. Live session indexing ──
pi.on("message_end", async (_event, ctx) => { pi.on("message_end", async (_event, ctx) => {
if (isSubagentSession(ctx)) return;
// Cold lazy sessions remain in Pi's JSONL files and are backfilled on use. // Cold lazy sessions remain in Pi's JSONL files and are backfilled on use.
if (lazy && !initialization.isReady()) return; if (lazy && !initialization.isReady()) return;
scheduleLiveSessionIndex(dbManager, ctx.sessionManager, { scheduleLiveSessionIndex(dbManager, ctx.sessionManager, {
@@ -432,6 +449,7 @@ export default function (pi: ExtensionAPI) {
return; return;
} }
try { try {
if (isSubagentSession(ctx)) return;
measureLifecycleSync("shutdown.active-index", () => { measureLifecycleSync("shutdown.active-index", () => {
const sessionFile = ctx.sessionManager.getSessionFile(); const sessionFile = ctx.sessionManager.getSessionFile();
if (sessionFile && fs.existsSync(sessionFile)) { if (sessionFile && fs.existsSync(sessionFile)) {
@@ -466,3 +484,12 @@ export default function (pi: ExtensionAPI) {
} }
}); });
} }
export function createHermesMemoryExtension(
options?: HermesMemoryExtensionOptions,
): (pi: ExtensionAPI) => void {
const configPath = options?.configPath ?? DEFAULT_CONFIG_PATH;
return (pi) => hermesMemoryExtension(pi, configPath);
}
export default createHermesMemoryExtension();
+58
View File
@@ -0,0 +1,58 @@
/**
* subagent-mode.ts — subagent sessions are read-only for memory and skill
* writes. All automatic write-related work (background review, correction
* detection, session flushes, session indexing) is skipped as well.
*
* A child session is identified by the `<active_agent name="..."/>` identity
* tag that pi-subagents stamps into every child prompt
* (permission-system-bridge.ts): the tag arrives via
* `systemPromptOptions.customPrompt`. The tag is only trusted on that field —
* never in the rendered system prompt, which a main session can legitimately
* contain through project docs.
*
* Membership is tracked per session by its sessionManager identity: the same
* process hosts the parent and its children concurrently, so a global boolean
* would let a child's turn flip the parent's session into read-only mid-turn.
*/
import type { BeforeAgentStartEvent, ExtensionContext } from "@earendil-works/pi-coding-agent";
/** pi-subagents `<active_agent name="X"/>` identity tag contract. */
const ACTIVE_AGENT_TAG = /<active_agent\s+name=["'][^"']+["'][^>]*>/i;
const LEADING_ACTIVE_AGENT_TAG = /^\s*<active_agent\s+name=["'][^"']+["'][^>]*>/i;
export const SUBAGENT_WRITE_DISABLED_MESSAGE =
"Memory and skill writes are disabled in subagent sessions; include this in your final report so the delegating agent can decide.";
const subagentSessionManagers = new WeakSet<object>();
/** Same regex as the pi-subagents `<active_agent>` contract, applied only to customPrompt. */
export function isSubagentCustomPrompt(customPrompt: string | undefined | null): boolean {
return typeof customPrompt === "string" && ACTIVE_AGENT_TAG.test(customPrompt);
}
/**
* before_agent_start hook: authoritative detection via the event's
* systemPromptOptions.customPrompt.
*/
export function markSubagentSession(event: BeforeAgentStartEvent, ctx: ExtensionContext): void {
if (isSubagentCustomPrompt(event.systemPromptOptions?.customPrompt)) {
subagentSessionManagers.add(ctx.sessionManager);
}
}
/**
* session_start runs before before_agent_start; detect tag-leading prompts
* early so session_start writes (skill migration, root setup) can be skipped.
* A main prompt cannot lead with the tag — without customPrompt the rendered
* prompt always opens with the base preamble.
*/
export function markSubagentSessionFromPrompt(systemPrompt: string, ctx: ExtensionContext): void {
if (LEADING_ACTIVE_AGENT_TAG.test(systemPrompt)) {
subagentSessionManagers.add(ctx.sessionManager);
}
}
export function isSubagentSession(ctx: unknown): boolean {
const sm = (ctx as { sessionManager?: unknown } | undefined | null)?.sessionManager;
return typeof sm === "object" && sm !== null && subagentSessionManagers.has(sm);
}
+8 -1
View File
@@ -25,6 +25,7 @@ import type { MemoryCategory, MemoryResult } from "../types.js";
import { normalizeMemoryLookupText } from "../store/memory-lookup.js"; import { normalizeMemoryLookupText } from "../store/memory-lookup.js";
import { createSharedToolResultRenderer } from "./shared-output-view.js"; import { createSharedToolResultRenderer } from "./shared-output-view.js";
import { memoryResultView } from "./tool-result-views.js"; import { memoryResultView } from "./tool-result-views.js";
import { isSubagentSession, SUBAGENT_WRITE_DISABLED_MESSAGE } from "../subagent-mode.js";
function appendSyncWarning(result: MemoryResult, warning: string): MemoryResult { function appendSyncWarning(result: MemoryResult, warning: string): MemoryResult {
const warnings = [...(((result as any).warnings ?? []) as string[]), warning]; const warnings = [...(((result as any).warnings ?? []) as string[]), warning];
@@ -425,7 +426,13 @@ This action-specific tool accepts only the parameters listed in its schema.`;
], ],
renderResult: createSharedToolResultRenderer(memoryResultView), renderResult: createSharedToolResultRenderer(memoryResultView),
parameters, parameters,
async execute(_toolCallId, params, signal, _onUpdate, ctx?: { cwd?: string }) { async execute(_toolCallId, params, signal, _onUpdate, ctx?: { cwd?: string; sessionManager?: object }) {
if (isSubagentSession(ctx)) {
return {
content: [{ type: "text" as const, text: SUBAGENT_WRITE_DISABLED_MESSAGE }],
details: { success: false, error: SUBAGENT_WRITE_DISABLED_MESSAGE },
};
}
if (bindProjectFromCwd && ctx?.cwd) { if (bindProjectFromCwd && ctx?.cwd) {
await bindProjectFromCwd(ctx.cwd); await bindProjectFromCwd(ctx.cwd);
} }
+14
View File
@@ -10,6 +10,7 @@ import { SkillStore } from "../store/skill-store.js";
import { SKILL_TOOL_DESCRIPTION } from "../constants.js"; import { SKILL_TOOL_DESCRIPTION } from "../constants.js";
import { createSharedToolResultRenderer } from "./shared-output-view.js"; import { createSharedToolResultRenderer } from "./shared-output-view.js";
import { skillResultView } from "./tool-result-views.js"; import { skillResultView } from "./tool-result-views.js";
import { isSubagentSession, SUBAGENT_WRITE_DISABLED_MESSAGE } from "../subagent-mode.js";
function normalizeTextList(value: unknown): string[] { function normalizeTextList(value: unknown): string[] {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
@@ -90,6 +91,13 @@ const SKILL_TOOL_PARAMETERS = Type.Object({
export const SKILL_MANAGE_TOOL_NAME = "skill_manage"; export const SKILL_MANAGE_TOOL_NAME = "skill_manage";
export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void { export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
if (typeof pi.on === "function") {
pi.on("tool_result", (event) => {
if (event.toolName !== SKILL_MANAGE_TOOL_NAME) return;
const details = event.details as { success?: unknown } | undefined;
if (details?.success === false) return { isError: true };
});
}
pi.registerTool({ pi.registerTool({
name: SKILL_MANAGE_TOOL_NAME, name: SKILL_MANAGE_TOOL_NAME,
label: "Skill Manager", label: "Skill Manager",
@@ -108,6 +116,12 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
renderResult: createSharedToolResultRenderer(skillResultView), renderResult: createSharedToolResultRenderer(skillResultView),
parameters: SKILL_TOOL_PARAMETERS, parameters: SKILL_TOOL_PARAMETERS,
async execute(toolCallId, params, signal, onUpdate, ctx) { async execute(toolCallId, params, signal, onUpdate, ctx) {
if (isSubagentSession(ctx)) {
return {
content: [{ type: "text", text: SUBAGENT_WRITE_DISABLED_MESSAGE }],
details: { success: false, error: SUBAGENT_WRITE_DISABLED_MESSAGE },
};
}
const skillParams = params as { const skillParams = params as {
action: "create" | "view" | "patch" | "update" | "edit" | "delete"; action: "create" | "view" | "patch" | "update" | "edit" | "delete";
name?: string; name?: string;
+4 -2
View File
@@ -9,7 +9,9 @@ import { after, beforeEach, afterEach, describe, it } from "node:test";
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-lazy-startup-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-lazy-startup-"));
const previousRoot = process.env.PI_CODING_AGENT_DIR; const previousRoot = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = root; process.env.PI_CODING_AGENT_DIR = root;
const { default: registerExtension } = await import("../src/index.js"); const configPath = path.join(root, "hermes-memory-config.json");
const { createHermesMemoryExtension } = await import("../src/index.js");
const registerExtension = createHermesMemoryExtension({ configPath });
const { MemoryStore } = await import("../src/store/memory-store.js"); const { MemoryStore } = await import("../src/store/memory-store.js");
const { DatabaseManager } = await import("../src/store/db.js"); const { DatabaseManager } = await import("../src/store/db.js");
const globalDir = path.join(root, "pi-hermes-memory"); const globalDir = path.join(root, "pi-hermes-memory");
@@ -22,7 +24,7 @@ let notifications: string[];
let ctx: any; let ctx: any;
async function configure(overrides: Record<string, unknown> = {}) { async function configure(overrides: Record<string, unknown> = {}) {
await fs.writeFile(path.join(root, "hermes-memory-config.json"), JSON.stringify({ await fs.writeFile(configPath, JSON.stringify({
lazyInitialization: true, lazyInitialization: true,
reviewEnabled: false, correctionDetection: false, reviewEnabled: false, correctionDetection: false,
flushOnCompact: false, flushOnShutdown: false, flushOnCompact: false, flushOnShutdown: false,
@@ -18,10 +18,11 @@ describe("session project memory rebinding", () => {
const launchDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-launch-")); const launchDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-launch-"));
const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-target-")); const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-target-"));
const previousAgentRoot = process.env.PI_CODING_AGENT_DIR; const previousAgentRoot = process.env.PI_CODING_AGENT_DIR;
const configPath = path.join(agentRoot, "hermes-memory-config.json");
const previousCwd = process.cwd(); const previousCwd = process.cwd();
try { try {
await fs.writeFile( await fs.writeFile(
path.join(agentRoot, "hermes-memory-config.json"), configPath,
JSON.stringify({ JSON.stringify({
memoryMode: "legacy-inject", memoryMode: "legacy-inject",
reviewEnabled: false, reviewEnabled: false,
@@ -48,7 +49,8 @@ describe("session project memory rebinding", () => {
process.env.PI_CODING_AGENT_DIR = agentRoot; process.env.PI_CODING_AGENT_DIR = agentRoot;
process.chdir(launchDir); process.chdir(launchDir);
// Import after setting PI_CODING_AGENT_DIR so AGENT_ROOT is test-local. // Import after setting PI_CODING_AGENT_DIR so AGENT_ROOT is test-local.
const { default: registerExtension } = await import("../src/index.js"); const { createHermesMemoryExtension } = await import("../src/index.js");
const registerExtension = createHermesMemoryExtension({ configPath });
const mockPi: MockPi = { const mockPi: MockPi = {
handlers: {}, handlers: {},
tools: {}, tools: {},
+25 -14
View File
@@ -2,9 +2,11 @@
* `before_agent_start` prompt injection (#251). * `before_agent_start` prompt injection (#251).
* *
* pi 0.86+ passes a normalized *clone* of the prompt options and re-renders the * pi 0.86+ passes a normalized *clone* of the prompt options and re-renders the
* prompt from it, so appending the memory context through `appendSystemPrompt` * prompt from it, so writing the memory context into `sections.memory_policy`
* keeps the prompt a set of named sections for later handlers and section-aware * keeps the prompt a set of named sections for later handlers and section-aware
* providers; returning `systemPrompt` forces the rendered text instead. * providers; returning `systemPrompt` forces the rendered text instead. The
* bundle's single system-prompt owner (extensions/system-prompt.ts) orders the
* section via `config/prompt/sections.jsonc`.
* *
* 0.80.6-0.85.x put `systemPromptOptions` on the event too, but they pass the raw * 0.80.6-0.85.x put `systemPromptOptions` on the event too, but they pass the raw
* long-lived base options and only apply a returned `systemPrompt` — a mutation * long-lived base options and only apply a returned `systemPrompt` — a mutation
@@ -23,7 +25,9 @@ import { after, beforeEach, describe, it } from "node:test";
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-prompt-injection-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-prompt-injection-"));
const previousRoot = process.env.PI_CODING_AGENT_DIR; const previousRoot = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = root; process.env.PI_CODING_AGENT_DIR = root;
const { default: registerExtension } = await import("../src/index.js"); const configPath = path.join(root, "hermes-memory-config.json");
const { createHermesMemoryExtension } = await import("../src/index.js");
const registerExtension = createHermesMemoryExtension({ configPath });
const globalDir = path.join(root, "pi-hermes-memory"); const globalDir = path.join(root, "pi-hermes-memory");
const cwd = path.join(root, "workspace"); const cwd = path.join(root, "workspace");
@@ -64,7 +68,7 @@ let handlers: Record<string, Array<(event: any, ctx: any) => any>>;
let ctx: any; let ctx: any;
async function configure(overrides: Record<string, unknown> = {}) { async function configure(overrides: Record<string, unknown> = {}) {
await fs.writeFile(path.join(root, "hermes-memory-config.json"), JSON.stringify({ await fs.writeFile(configPath, JSON.stringify({
memoryMode: "policy-only", memoryMode: "policy-only",
memoryPolicyStyle: "full", memoryPolicyStyle: "full",
lazyInitialization: true, lazyInitialization: true,
@@ -119,31 +123,38 @@ after(async () => {
}); });
describe("before_agent_start prompt injection", () => { describe("before_agent_start prompt injection", () => {
it("appends through structured prompt options instead of forcing the prompt", async () => { it("contributes a named section through structured prompt options instead of forcing the prompt", async () => {
await registerWith(); await registerWith();
const event = { systemPrompt: "base prompt", systemPromptOptions: structuredOptions() }; const event = { systemPrompt: "base prompt", systemPromptOptions: structuredOptions() };
const result = await emit("before_agent_start", event); const result = await emit("before_agent_start", event);
assert.equal(result, undefined, "structured events must not force a full prompt replacement"); assert.equal(result, undefined, "structured events must not force a full prompt replacement");
assert.match(event.systemPromptOptions.appendSystemPrompt, /memory-policy/); assert.match(event.systemPromptOptions.sections.memory_policy, /memory-policy/);
assert.equal(
event.systemPromptOptions.appendSystemPrompt,
"",
"the policy is a named section, not appended text",
);
}); });
it("appends after an earlier handler's appendSystemPrompt", async () => { it("keeps earlier sections while adding memory_policy", async () => {
await registerWith(); await registerWith();
const event = { const event = {
systemPrompt: "base prompt", systemPrompt: "base prompt",
systemPromptOptions: structuredOptions({ appendSystemPrompt: "earlier handler text" }), systemPromptOptions: structuredOptions({
appendSystemPrompt: "earlier handler text",
sections: { other_section: "earlier content" },
}),
}; };
await emit("before_agent_start", event); await emit("before_agent_start", event);
const appended = event.systemPromptOptions.appendSystemPrompt; assert.equal(event.systemPromptOptions.sections.other_section, "earlier content");
assert.match(appended, /earlier handler text/); assert.match(event.systemPromptOptions.sections.memory_policy, /memory-policy/);
assert.match(appended, /memory-policy/); assert.equal(
assert.ok( event.systemPromptOptions.appendSystemPrompt,
appended.indexOf("earlier handler text") < appended.indexOf("memory-policy"), "earlier handler text",
"the policy goes after earlier contributions",
); );
}); });
@@ -0,0 +1,228 @@
/**
* Subagent (pi-subagents child) sessions are read-only: memory/skill write
* tools return a fixed error and all automatic writes are skipped.
*
* Child detection keys on the `<active_agent name="X"/>` tag pi-subagents
* stamps into `systemPromptOptions.customPrompt`; membership is tracked per
* sessionManager so a child in the same process never flips the parent.
*/
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
isSubagentCustomPrompt,
isSubagentSession,
markSubagentSession,
markSubagentSessionFromPrompt,
SUBAGENT_WRITE_DISABLED_MESSAGE,
} from "../src/subagent-mode.js";
import { registerMemoryTool } from "../src/tools/memory-tool.js";
import { registerSkillTool } from "../src/tools/skill-tool.js";
import { setupSessionFlush } from "../src/handlers/session-flush.js";
import { MemoryStore } from "../src/store/memory-store.js";
import { SkillStore } from "../src/store/skill-store.js";
import type { MemoryConfig } from "../src/types.js";
const CHILD_PROMPT = 'You are the Explore agent.\n<active_agent name="Explore"/>';
function beforeAgentStartEvent(customPrompt?: string) {
return { systemPromptOptions: customPrompt === undefined ? {} : { customPrompt } } as any;
}
describe("subagent detection", () => {
it("matches the pi-subagents <active_agent> contract on customPrompt only", () => {
assert.ok(isSubagentCustomPrompt(CHILD_PROMPT));
assert.ok(isSubagentCustomPrompt("intro <active_agent name=\"General Purpose\" extra=\"1\"> tail"));
assert.ok(!isSubagentCustomPrompt("a normal custom prompt"));
assert.ok(!isSubagentCustomPrompt(""));
assert.ok(!isSubagentCustomPrompt(undefined));
assert.ok(!isSubagentCustomPrompt(null));
});
it("marks only sessions whose before_agent_start customPrompt carries the tag", () => {
const childManager = {};
const mainManager = {};
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager: childManager } as any);
markSubagentSession(beforeAgentStartEvent(), { sessionManager: mainManager } as any);
markSubagentSession(beforeAgentStartEvent("no tag here"), { sessionManager: {} } as any);
assert.ok(isSubagentSession({ sessionManager: childManager }));
assert.ok(!isSubagentSession({ sessionManager: mainManager }));
assert.ok(!isSubagentSession(undefined));
assert.ok(!isSubagentSession({}));
});
it("detects tag-leading rendered prompts early (session_start), not mid-prompt tags", () => {
const childManager = {};
const mainManager = {};
markSubagentSessionFromPrompt(
`<active_agent name="Explore"/>\n\nYou are the Explore agent.\n\nrest`,
{ sessionManager: childManager } as any,
);
markSubagentSessionFromPrompt(
`preamble\n\nliteral <active_agent name="X"/> inside project docs`,
{ sessionManager: mainManager } as any,
);
assert.ok(isSubagentSession({ sessionManager: childManager }));
assert.ok(!isSubagentSession({ sessionManager: mainManager }));
});
});
describe("subagent write gating", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subagent-mode-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function makeMemoryStore(): MemoryStore {
return new MemoryStore({
memoryMode: "policy-only",
memoryCharLimit: 5000,
userCharLimit: 5000,
projectCharLimit: 5000,
nudgeInterval: 10,
reviewEnabled: false,
flushOnCompact: false,
flushOnShutdown: false,
flushMinTurns: 6,
autoConsolidate: false,
correctionDetection: false,
failureInjectionEnabled: false,
failureInjectionMaxAgeDays: 7,
failureInjectionMaxEntries: 5,
nudgeToolCalls: 15,
consolidationTimeoutMs: 60000,
memoryDir: tmpDir,
});
}
it("memory_add/replace/remove return the disabled error and perform no writes", async () => {
const tools = new Map<string, any>();
const mockPi = { registerTool: (def: any) => tools.set(def.name, def) } as any;
const store = makeMemoryStore();
await store.loadFromDisk();
let bindCalls = 0;
registerMemoryTool(mockPi, store, null, null, null, () => { bindCalls++; });
const sessionManager = {};
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager } as any);
const ctx = { cwd: tmpDir, sessionManager };
for (const name of ["memory_add", "memory_replace", "memory_remove"]) {
const result = await tools.get(name).execute("tc", { target: "memory", content: "x", old_text: "x" }, undefined, undefined, ctx);
assert.strictEqual(result.content[0].text, SUBAGENT_WRITE_DISABLED_MESSAGE, name);
assert.strictEqual(result.details.success, false, name);
assert.strictEqual(result.details.error, SUBAGENT_WRITE_DISABLED_MESSAGE, name);
}
assert.strictEqual(bindCalls, 0, "blocked writes must not bind projects");
assert.deepStrictEqual(store.getMemoryEntries(), [], "blocked writes must not mutate the store");
assert.ok(!fs.existsSync(path.join(tmpDir, "MEMORY.md")), "blocked writes must not write files");
});
it("memory tools still write in an unmarked (main) session", async () => {
const tools = new Map<string, any>();
const mockPi = { registerTool: (def: any) => tools.set(def.name, def) } as any;
const store = makeMemoryStore();
await store.loadFromDisk();
registerMemoryTool(mockPi, store, null);
const result = await tools.get("memory_add").execute(
"tc",
{ target: "memory", content: "main session entry" },
undefined,
undefined,
{ cwd: tmpDir, sessionManager: {} },
);
assert.strictEqual(result.details.success, true);
assert.deepStrictEqual(store.getMemoryEntries(), ["main session entry"]);
});
it("skill_manage returns the disabled error in a subagent session", async () => {
let tool: any;
const mockPi = { registerTool: (def: any) => { tool = def; } } as any;
const store = new SkillStore({
globalSkillsDir: path.join(tmpDir, "skills"),
piGlobalSkillsDir: path.join(tmpDir, "pi-skills"),
projectSkillsDir: null,
projectName: null,
legacySkillsDir: path.join(tmpDir, "legacy-skills"),
migrationSentinelPath: path.join(tmpDir, ".migrated"),
});
registerSkillTool(mockPi, store);
const sessionManager = {};
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager } as any);
const result = await tool.execute(
"tc",
{ action: "create", name: "demo", description: "d", scope: "global", content: "body" },
undefined,
undefined,
{ sessionManager },
);
assert.strictEqual(result.content[0].text, SUBAGENT_WRITE_DISABLED_MESSAGE);
assert.strictEqual(result.details.success, false);
assert.ok(!fs.existsSync(path.join(tmpDir, "skills")), "blocked skill writes must not create the skills dir");
});
it("session flush skips compact and shutdown flushes in a subagent session", async () => {
const handlers: Record<string, Function[]> = {};
const mockPi = {
on(event: string, handler: Function) {
(handlers[event] ??= []).push(handler);
},
async exec() {
throw new Error("subagent flush must not spawn a child pi");
},
registerTool() {},
registerCommand() {},
} as any;
let directCalls = 0;
const config = {
flushOnCompact: true,
flushOnShutdown: true,
flushMinTurns: 1,
flushCompactTimeoutMs: 60000,
reviewTransport: "direct",
} as MemoryConfig;
const sessionManager = { getBranch: () => [] };
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager } as any);
setupSessionFlush(mockPi, { getMemoryEntries: () => [], getUserEntries: () => [] } as any, null, config, null, null, {
runDirectMemoryCompletion: async () => {
directCalls++;
return { ok: true } as any;
},
});
const ctx = { sessionManager, cwd: tmpDir };
for (const handler of handlers["message_end"] ?? []) {
await handler({ message: { role: "user", content: "hi" } }, ctx);
}
for (const handler of handlers["session_before_compact"] ?? []) {
await handler({ signal: undefined }, ctx);
}
for (const handler of handlers["session_shutdown"] ?? []) {
await handler({ reason: "exit" }, ctx);
}
assert.strictEqual(directCalls, 0, "subagent sessions must not flush");
// Control: the same handlers must still flush an unmarked (main) session.
const mainCtx = { sessionManager: { getBranch: () => [] }, cwd: tmpDir };
for (const handler of handlers["message_end"] ?? []) {
await handler({ message: { role: "user", content: "hi" } }, mainCtx);
}
for (const handler of handlers["session_before_compact"] ?? []) {
await handler({ signal: undefined }, mainCtx);
}
assert.ok(directCalls > 0, "main sessions must still flush");
});
});
+10
View File
@@ -0,0 +1,10 @@
# my-pi 维护约定(pi-minimal-footer)
- 扩展入口是 `pi-minimal-footer/index.ts`(`package.json` 的 `pi.extensions` 指向 `./index.ts`);`status.ts` 收集 `ctx.ui.setStatus()` 发布的非空扩展状态并渲染为 footer 片段,`settings.ts` 读取 footer 配置,`assets/` 为 provider 图标,测试在 `test/`。
- 持久配置只读组合包 `config/pi-minimal-footer.json`(`settings.ts` 的 `readFooterSettings` 解析 `showCwd` / `showBranch`,缺失或非法时回退默认 `true`);不读写 `settings.json`,不以环境变量作持久默认。
- 上下文仪表优先显示 pi-compaction 发布的 effective usage 快照(`globalThis[Symbol.for("my-pi.pi-compaction.service.v1")].getMetrics(sessionId)` 的 `percent` / `effectiveTokens`);无快照时回退宿主用量(`buildSessionContext` 取最后一条非 aborted assistant 消息的 `input + output + cacheRead + cacheWrite`)。
- 来源记录在 `UPSTREAM.md`:从 `ogulcancelik/pi-extensions` 的 `pi-minimal-footer@0.1.10`(commit `77bd1e1`)导入,保留上游 LICENSE 与作者;本地改动为扩展状态渲染与 bundle 配置文件读取。
## 验证
- 在本目录内运行 `npm test`(`node --experimental-strip-types --test test/*.test.ts`)与 `npm run check`(`node --experimental-strip-types --check index.ts`)。
+6 -6
View File
@@ -34,14 +34,14 @@ pi install npm:@ogulcancelik/pi-minimal-footer
## Configuration ## Configuration
Environment variables (all optional): Persistent defaults are read from `config/pi-minimal-footer.json` inside the installed bundle; a missing or invalid file falls back to the defaults shown:
| Variable | Description | Default | | Field | Description | Default |
| ------------------------------- | -------------------------------------------------------- | ------- | | ------------ | -------------------------------------------------------- | ------- |
| `PI_MINIMAL_FOOTER_SHOW_CWD` | Show current working directory in footer status line | `1` | | `showCwd` | Show current working directory in footer status line | `true` |
| `PI_MINIMAL_FOOTER_SHOW_BRANCH` | Show git branch/dirty/ahead/behind in footer status line | `1` | | `showBranch` | Show git branch/dirty/ahead/behind in footer status line | `true` |
Accepted false values: `0`, `false`, `no`, `off` (case-insensitive). Only `true`/`false` values are honoured; anything else falls back to the default for that field.
## How it works ## How it works
+1 -1
View File
@@ -5,4 +5,4 @@
- 导入包版本:`@ogulcancelik/pi-minimal-footer@0.1.10` - 导入包版本:`@ogulcancelik/pi-minimal-footer@0.1.10`
- 导入日期:2026-08-19 - 导入日期:2026-08-19
本目录由 `my-pi` 直接维护;它不是 submodule,也不保留上游 Git 元数据。本地改动新增了 Pi 扩展状态渲染(把 `ctx.ui.setStatus()` 发布的每个非空状态渲染为普通 footer 片段)。 本目录由 `my-pi` 直接维护;它不是 submodule,也不保留上游 Git 元数据。本地改动新增了 Pi 扩展状态渲染(把 `ctx.ui.setStatus()` 发布的每个非空状态渲染为普通 footer 片段),并把 showCwd/showBranch 持久配置由 `PI_MINIMAL_FOOTER_*` 环境变量改为组合包 `config/pi-minimal-footer.json`。
+3 -16
View File
@@ -15,6 +15,7 @@ import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { collectFooterStatusItems } from "./status.ts"; import { collectFooterStatusItems } from "./status.ts";
import { readFooterSettings } from "./settings.ts";
// ============ Types ============ // ============ Types ============
@@ -43,19 +44,6 @@ interface GitCache {
const USAGE_REFRESH_INTERVAL = 5 * 60_000; // 5 minutes const USAGE_REFRESH_INTERVAL = 5 * 60_000; // 5 minutes
const usageCache = new Map<string, UsageSnapshot>(); // keyed by provider const usageCache = new Map<string, UsageSnapshot>(); // keyed by provider
// ============ Env Flags ============
function parseBooleanEnv(value: string | undefined, fallback: boolean): boolean {
if (value === undefined) return fallback;
const normalized = value.trim().toLowerCase();
if (!normalized) return fallback;
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
return fallback;
}
// ============ Identity Cache ============ // ============ Identity Cache ============
let codexEmailPrefix: string | null | undefined; // undefined = not yet resolved let codexEmailPrefix: string | null | undefined; // undefined = not yet resolved
@@ -754,9 +742,8 @@ export default function (pi: ExtensionAPI) {
const BAR_FILLED = "━"; const BAR_FILLED = "━";
const BAR_EMPTY = "─"; const BAR_EMPTY = "─";
// Optional visibility toggles (default: enabled) // Optional visibility toggles from bundle config (default: enabled)
const showCwd = parseBooleanEnv(process.env.PI_MINIMAL_FOOTER_SHOW_CWD, true); const { showCwd, showBranch } = readFooterSettings();
const showBranch = parseBooleanEnv(process.env.PI_MINIMAL_FOOTER_SHOW_BRANCH, true);
function formatTokenCount(tokens: number): string { function formatTokenCount(tokens: number): string {
if (tokens >= 1_000_000) { if (tokens >= 1_000_000) {
+30
View File
@@ -0,0 +1,30 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
export interface FooterSettings {
showCwd: boolean;
showBranch: boolean;
}
// Bundle-level `config/` is the single source of truth: the extension reads
// `config/pi-minimal-footer.json` from inside the installed bundle (resolved
// relative to this source file), never environment variables.
export const FOOTER_CONFIG_PATH = fileURLToPath(
new URL("../config/pi-minimal-footer.json", import.meta.url),
);
export function readFooterSettings(configPath: string = FOOTER_CONFIG_PATH): FooterSettings {
try {
const parsed = JSON.parse(readFileSync(configPath, "utf8")) as unknown;
const raw =
parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
return {
showCwd: typeof raw.showCwd === "boolean" ? raw.showCwd : true,
showBranch: typeof raw.showBranch === "boolean" ? raw.showBranch : true,
};
} catch {
return { showCwd: true, showBranch: true };
}
}

Some files were not shown because too many files have changed in this diff Show More