mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
Compare commits
5
Commits
56cbc83296
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87940da51a | ||
|
|
2b21163a18 | ||
|
|
dae1a01f23 | ||
|
|
833b9514d4 | ||
|
|
f9a7f743dd |
@@ -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
|
||||
@@ -20,7 +20,8 @@
|
||||
| `pi-notify/` | Kitty OSC 99 完成与权限确认通知,精确聚焦来源窗口 |
|
||||
| `pi-compaction/` | 压力触发的观察遮蔽(Pi context_edit)、阈值整段摘要、effective token、native compaction |
|
||||
| `pi-overflow/` | 新工具结果统一截断:超阈值原文落盘 + 有界视图 + `overflow_query` |
|
||||
| `extensions/fff-override.ts` | FFF `override` 模式接管 `find`/`grep`/`multi_grep` 与 `@` 补全 |
|
||||
| `extensions/fff-override.ts` | FFF `override` 模式接管 `find`/`grep` 与 `@` 补全,`multi_grep` 按上游保持关闭 |
|
||||
| `extensions/bash-override.ts` | 用 Pi 的 `createBashToolDefinition` 重新注册 `bash`,仅替换模型可见描述(追加 `tools.jsonc` `descriptionAppend`);执行逻辑、输出结构、codemode 说明不变 |
|
||||
| `extensions/codegraph.ts` | 原生 `codegraph_explore`,自有 stdio JSON-RPC 直连 `codegraph serve`(`codegraph-client.ts`) |
|
||||
| `extensions/hashline.ts` | 部署 `config/pi-hashline-edit.json` 后加载 pi-hashline-edit 覆盖 `read`/`edit`(`hashline-config.ts`) |
|
||||
| `extensions/permission-system.ts` | 直接加载仓库内 `pi-permission-system/src/index.ts` |
|
||||
@@ -41,7 +42,7 @@
|
||||
- 组合包不加载任何 MCP adapter/SDK:Exa 与 CodeGraph 均用自有直连客户端。
|
||||
- 导入/同步上游:只从官方 Git 仓库的明确 tag/commit 取源码快照,禁止 npm tarball/缓存/`node_modules`/`dist/` 等预编译产物作导入源;保留上游 LICENSE、作者、测试与来源说明,移除嵌套 `.git`、`.pi` 状态、`node_modules` 与构建产物;同步前先核对本地改动再按明确范围移植,不得整体覆盖。各目录的维护约定见 `AGENTS.md`,每个自维护目录必须有中文 `UPSTREAM.md` 记录来源,上游原文文件不翻译不改写。
|
||||
- 脚本:`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 同规则);组合包安装失败立即停止,机器级依赖失败继续检查其余项并最终返回非零。
|
||||
- 安装/升级纪律:机器级依赖与终端配置逐项询问、默认拒绝;`update.sh` 只升级已安装项、缺失项跳过,组件升级先比较本地与远端版本、一致不重装,成功升级后把 git 来源的受管 checkout `git reset --hard` 还原到已发布版本(本地路径来源不 reset、不 `git clean`);`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/`)或共享机器级工具。
|
||||
- 安装/升级纪律:机器级依赖与终端配置逐项询问、默认拒绝;`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 目录等外部写操作;凭据不进入命令参数、日志、会话或仓库(`auth.json` 权限 `600`、不提交)。
|
||||
- 优先在目标扩展目录内完成改动;保留原项目 LICENSE 与版权;不让一个扩展依赖另一个扩展的未公开内部实现。
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
个人维护的 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-web-search`:对模型只暴露 `web_search` / `web_fetch` 两个工具;`web_search` 由当前主模型在 Tavily / Exa / Keenable 间路由,`web_fetch` 固定走 Exa,三家均以纯 `fetch` 直连(Exa 为 `mcp.exa.ai` 的 JSON-RPC,不经 MCP adapter)。
|
||||
- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性启动常驻 `codegraph serve --mcp` 子进程,由组合包自有 stdio JSON-RPC 客户端直连(无 MCP adapter/SDK);serve 的文件监听自动同步索引,无结果缓存;未初始化项目经确认后才 `codegraph init`,拒绝记入会话不再重复询问。
|
||||
@@ -68,13 +68,14 @@ Powerlevel10k 主题/配置和各 Zsh 插件使用 `# >>> my-pi:<id> >>>` 到
|
||||
./update.sh
|
||||
```
|
||||
|
||||
升级脚本先执行:
|
||||
升级脚本先把 Pi 本体同步到最新发布版本,再升级组合包:
|
||||
|
||||
```bash
|
||||
pi update --self
|
||||
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 及已启用的
|
||||
Solarized Dark、Oh My Zsh、Powerlevel10k、两个第三方 Zsh 插件、CodeGraph,以及由
|
||||
@@ -137,7 +138,7 @@ CodeGraph 的更新或遥测设置。
|
||||
|
||||
### 工具与搜索路由
|
||||
|
||||
`config/prompt/routing.jsonc` 定义跨工具路由规则,由 `extensions/system-prompt.ts` 在每轮开始前按当前激活工具渲染为 `<tool_routing>` 分区。代码结构、调用关系和待修改 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。只有来源质量或不确定性确实需要时才跨服务复核,避免默认把三套结果全部塞进上下文。
|
||||
|
||||
@@ -167,7 +168,7 @@ CodeGraph 的更新或遥测设置。
|
||||
|
||||
本地源码直接使用 `@earendil-works/pi-coding-agent`、当前 `typebox`、`ModelRegistry.complete()` 与 `setActiveTools()`,不安装、别名映射或加载旧 `@mariozechner` runtime,也不使用 provider payload 改写、代理执行或隐藏 `sendMessage` 循环。
|
||||
|
||||
新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics`、六个记忆工具、`context_tree_query`、`overflow_query` 和 `tool_search`;Windows 上额外把内置 `powershell` 与 `bash` 作为同类本地 Shell 核心工具固定启用,非 Windows 平台则从目录中过滤它。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、Chrome 导航/交互/调试、用户交互、CodeGraph/LSP、Tavily、Exa、Keenable、Memory 查询/维护与 Skill 管理。不可用、已固定或尚未授权而未注册的可选工具会自动从动态组中滤除。
|
||||
新 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 顺序和最近淘汰原因。
|
||||
|
||||
@@ -187,7 +188,8 @@ Tool Search 的配置文件是已安装组合包内的 `config/tool-search.json`
|
||||
"memory_remove",
|
||||
"skill_manage",
|
||||
"context_tree_query",
|
||||
"overflow_query"
|
||||
"overflow_query",
|
||||
"codemode"
|
||||
],
|
||||
"showToolSearchFooterStatus": false,
|
||||
"maxActiveGroups": 5,
|
||||
@@ -209,9 +211,14 @@ Hashline 不负责压缩大文件;每行锚点本身还会增加少量 token
|
||||
|
||||
`extensions/fff-override.ts` 强制设置 FFF 官方的 `PI_FFF_MODE=override`(显式传入
|
||||
`--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。
|
||||
|
||||
`extensions/bash-override.ts` 用 Pi 的 `createBashToolDefinition` 重新注册内置 `bash`,
|
||||
把「不要用 bash 搜索」的警告经 `tools.jsonc` 的 `descriptionAppend` 写进工具 schema,
|
||||
让模型在调用时就看到替代工具;执行逻辑、输出结构和 codemode 的 `tools.bash` 说明完全沿用上游定义。
|
||||
|
||||
### 输出压缩归 RTK
|
||||
|
||||
RTK 保留 Bash、read、build、test、lint 和 Git 输出压缩。默认 `read` 的有损压缩
|
||||
@@ -241,10 +248,10 @@ RTK 保留 Bash、read、build、test、lint 和 Git 输出压缩。默认 `read
|
||||
|
||||
### 在线搜索服务
|
||||
|
||||
运行 `./configure.sh search` 可用隐藏输入依次配置 Tavily、Exa 和 Keenable;也可一次无交互配置:
|
||||
运行 `./configure.sh search` 可用隐藏输入依次配置 Tavily、Exa 和 Keenable;key 不得出现在命令行参数中,非交互方式从标准输入读取(每次调用最多一个 provider):
|
||||
|
||||
```bash
|
||||
./configure.sh search --tavily '...' --exa '...' --keenable '...'
|
||||
printf '%s' "$TAVILY_API_KEY" | ./configure.sh search --tavily -
|
||||
```
|
||||
|
||||
只传部分参数时保留其他服务的已有值。凭据写入 Pi 自己的
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
"edit": "allow",
|
||||
"grep": "allow",
|
||||
"find": "allow",
|
||||
"multi_grep": "allow",
|
||||
"ls": "allow",
|
||||
"codegraph_explore": "allow",
|
||||
"lsp_diagnostics": "allow",
|
||||
@@ -82,6 +81,9 @@
|
||||
// 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",
|
||||
@@ -100,10 +102,10 @@
|
||||
},
|
||||
"bash": {
|
||||
"*": "allow",
|
||||
"rg*": "deny",
|
||||
"grep*": "deny",
|
||||
"find*": "deny",
|
||||
"fd*": "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*": { "action": "deny", "reason": "Bash content search is disabled. Locally use the grep tool (pattern + path/exclude; regex alternation for several names); to extract/dedupe/count matches, call tools.grep in codemode and process in JS; to filter command output, use overflow_query on the stored result. Over SSH use ssh_grep." },
|
||||
"find*": { "action": "deny", "reason": "Bash file search is disabled. Locally use the find tool (fuzzy path, or a glob in path such as 'src/**/*.ts'); to pass a file list to a program use $(git ls-files '<glob>'). Over SSH use ssh_find." },
|
||||
"fd*": { "action": "deny", "reason": "Bash file search is disabled. Locally use the find tool (fuzzy path, or a glob in path such as 'src/**/*.ts'); to pass a file list to a program use $(git ls-files '<glob>'). Over SSH use ssh_find." },
|
||||
"git *": "ask",
|
||||
"git status*": "allow",
|
||||
"git diff*": "allow",
|
||||
@@ -111,7 +113,7 @@
|
||||
"git show*": "allow",
|
||||
"git rev-parse*": "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 --show-current*": "allow",
|
||||
"git remote -v": "allow",
|
||||
|
||||
@@ -16,16 +16,25 @@
|
||||
"text": "Use LSP for symbol definitions, references, hover/type information, and post-edit diagnostics when the language server supports the file."
|
||||
},
|
||||
{
|
||||
"whenAny": ["find", "grep", "multi_grep"],
|
||||
"text": "For literal search, narrow in stages instead of requesting a repository-wide dump:\n 1. Use find to identify likely files or directories. Constrain path/glob and keep the result limit small.\n 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.\n 3. Use read with offset/limit only for the exact matching region when full source is still needed."
|
||||
"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", "multi_grep"],
|
||||
"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 search files by running grep, find, rg, or fd through bash; use the find/grep tools."
|
||||
"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"],
|
||||
@@ -59,12 +68,15 @@
|
||||
},
|
||||
{
|
||||
"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 file merely to copy, compare, hash, count, explore, or summarize it."
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ You locate code and answer "where is X / what references Y" questions for the de
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
@@ -14,7 +14,7 @@ You explore the codebase and return an implementation plan to the delegating age
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
@@ -4,21 +4,30 @@
|
||||
// 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; only
|
||||
// applied to third-party tools loaded through a bundle
|
||||
// adapter (in-repo tools edit their own source instead)
|
||||
// "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"
|
||||
"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/multi_grep results carry plain line numbers, not LINE#HASH anchors; read the exact target range before editing.",
|
||||
"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."
|
||||
@@ -28,12 +37,18 @@
|
||||
"write": { "inherit": true },
|
||||
"ls": { "inherit": true },
|
||||
"powershell": { "inherit": true },
|
||||
"grep": { "inherit": true },
|
||||
"find": { "inherit": true },
|
||||
"multi_grep": { "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": { "inherit": true },
|
||||
"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 },
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"memory_remove",
|
||||
"skill_manage",
|
||||
"context_tree_query",
|
||||
"overflow_query"
|
||||
"overflow_query",
|
||||
"codemode"
|
||||
],
|
||||
"showToolSearchFooterStatus": false,
|
||||
"maxActiveGroups": 5,
|
||||
|
||||
+21
-13
@@ -24,24 +24,25 @@ search_usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./configure.sh search
|
||||
./configure.sh search --tavily KEY --exa KEY --keenable KEY
|
||||
./configure.sh search --tavily - 从标准输入读取 key
|
||||
./configure.sh search --migrate [search.env 路径]
|
||||
./configure.sh search --list
|
||||
|
||||
选项:
|
||||
--tavily KEY 配置 tavily api_key 凭据
|
||||
--exa KEY 配置 exa api_key 凭据
|
||||
--keenable KEY 配置 keenable api_key 凭据
|
||||
--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 参数时进入隐藏输入的交互模式;传入任意 key 参数时不交互,
|
||||
未指定的 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")。
|
||||
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
|
||||
}
|
||||
|
||||
@@ -52,6 +53,7 @@ configure_search() {
|
||||
SEARCH_AUTH_FILE=
|
||||
search_mode=interactive
|
||||
search_did_work=0
|
||||
search_stdin_provider=
|
||||
|
||||
run_search_auth() {
|
||||
if [ -n "$SEARCH_AUTH_FILE" ]; then
|
||||
@@ -69,7 +71,7 @@ configure_search() {
|
||||
prompt_label=$1
|
||||
prompt_configured=$2
|
||||
if [ ! -t 0 ]; then
|
||||
warn "交互模式需要终端;请改用 --tavily/--exa/--keenable 参数。"
|
||||
warn "交互模式需要终端;请改用 '--<provider> -' 从标准输入提供 key。"
|
||||
return 2
|
||||
fi
|
||||
if [ "$prompt_configured" -eq 1 ]; then
|
||||
@@ -88,9 +90,15 @@ configure_search() {
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case $1 in
|
||||
--tavily|--exa|--keenable)
|
||||
[ "$#" -ge 2 ] || { warn "$1 缺少 KEY"; exit 2; }
|
||||
[ "$#" -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 "${1#--}=$2" || exit $?
|
||||
run_search_auth --set-stdin "$search_stdin_provider" || exit $?
|
||||
shift 2 ;;
|
||||
--migrate)
|
||||
search_mode=noninteractive; search_did_work=1
|
||||
@@ -121,7 +129,7 @@ configure_search() {
|
||||
if search_has_key "$provider"; then configured=1; else configured=0; fi
|
||||
value=$(search_prompt_key "$label" "$configured") || exit $?
|
||||
if [ -n "$value" ]; then
|
||||
run_search_auth --set "$provider=$value" || exit $?
|
||||
printf '%s' "$value" | run_search_auth --set-stdin "$provider" || exit $?
|
||||
search_did_work=1
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
|
||||
## 各 wrapper 约定
|
||||
|
||||
- `fff-override.ts`:强制 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 `@` 补全;FFF 独占本地字面搜索,远端服务器搜索由 `ssh_find` / `ssh_grep` 负责,RTK 不得接管搜索工具(见 `pi-rtk-optimizer/AGENTS.md`)。显式 CLI flag 仍遵循 FFF 官方优先级。
|
||||
- `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` 读取。
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -222,6 +222,8 @@ export class CodegraphRpcClient {
|
||||
// ── 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;
|
||||
|
||||
@@ -5,5 +5,6 @@ import { guardExtensionApi } from "./prompt-guard.ts";
|
||||
/** Load FFF as the bundle's canonical `find` and `grep` implementation. */
|
||||
export default function fffOverrideExtension(pi: ExtensionAPI): void {
|
||||
process.env.PI_FFF_MODE = "override";
|
||||
delete process.env.PI_FFF_MULTIGREP;
|
||||
fffExtension(guardExtensionApi(pi));
|
||||
}
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
import { loadToolCatalog, type ToolCatalog } from "./system-prompt-config.ts";
|
||||
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.
|
||||
@@ -19,9 +36,11 @@ export interface PromptGuardOptions {
|
||||
* 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 from
|
||||
* `config/prompt/tools.jsonc` when the catalog carries one (lazily loaded;
|
||||
* a catalog error keeps the upstream description).
|
||||
* - `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`.
|
||||
@@ -42,10 +61,12 @@ export function guardExtensionApi(pi: ExtensionAPI, opts: PromptGuardOptions = {
|
||||
|
||||
const registerTool: ExtensionAPI["registerTool"] = (tool) => {
|
||||
const entry = catalog()?.tools[tool.name];
|
||||
if (entry !== undefined && !("inherit" in entry) && typeof entry.description === "string") {
|
||||
pi.registerTool({ ...tool, description: entry.description });
|
||||
} else {
|
||||
const description =
|
||||
entry === undefined ? tool.description : catalogDescription(tool.description, entry);
|
||||
if (description === tool.description) {
|
||||
pi.registerTool(tool);
|
||||
} else {
|
||||
pi.registerTool({ ...tool, description });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,12 @@ import { fileURLToPath } from "node:url";
|
||||
/** Entry in `config/prompt/tools.jsonc`. */
|
||||
export type ToolCatalogEntry =
|
||||
| { inherit: true }
|
||||
| { snippet?: string | null; guidelines?: string[]; description?: string };
|
||||
| {
|
||||
snippet?: string | null;
|
||||
guidelines?: string[];
|
||||
description?: string;
|
||||
descriptionAppend?: string;
|
||||
};
|
||||
|
||||
export interface ToolCatalog {
|
||||
version: 1;
|
||||
@@ -152,7 +157,7 @@ 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"]);
|
||||
const TOOL_ENTRY_KEYS = new Set(["inherit", "snippet", "guidelines", "description", "descriptionAppend"]);
|
||||
|
||||
function validateToolEntry(name: string, raw: unknown): ToolCatalogEntry {
|
||||
if (!isPlainObject(raw)) {
|
||||
@@ -170,9 +175,9 @@ function validateToolEntry(name: string, raw: unknown): ToolCatalogEntry {
|
||||
return { inherit: true };
|
||||
}
|
||||
const hasField =
|
||||
"snippet" in raw || "guidelines" in raw || "description" in raw;
|
||||
"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`);
|
||||
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`);
|
||||
@@ -183,10 +188,13 @@ function validateToolEntry(name: string, raw: unknown): ToolCatalogEntry {
|
||||
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;
|
||||
}
|
||||
|
||||
function validateToolCatalog(raw: unknown): ToolCatalog {
|
||||
export function validateToolCatalog(raw: unknown): ToolCatalog {
|
||||
if (!isPlainObject(raw) || raw.version !== 1 || !isPlainObject(raw.tools)) {
|
||||
fail("tools.jsonc", 'expected { "version": 1, "tools": { ... } }');
|
||||
}
|
||||
@@ -321,8 +329,8 @@ export function isSubagentCustomPrompt(customPrompt: unknown): boolean {
|
||||
* 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` is not applied here — it is a registration-time concern handled
|
||||
* by `extensions/prompt-guard.ts`.
|
||||
* `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.
|
||||
*/
|
||||
|
||||
Generated
+27
-27
@@ -2763,31 +2763,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/coverage-istanbul": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.12.tgz",
|
||||
"integrity": "sha512-f98TiCqF7IriBQEpm6usi56wyD4G1cRYabI15f3Jm7R355q9tlwqJ0j1oPf+tWZ7D0LPnCh3dU70duRt10erHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"@jridgewell/gen-mapping": "^0.3.13",
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"magicast": "^0.5.2",
|
||||
"obug": "^2.1.1",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "4.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
@@ -5672,9 +5647,9 @@
|
||||
"@earendil-works/pi-coding-agent": "latest",
|
||||
"@earendil-works/pi-tui": "latest",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitest/coverage-istanbul": "^4.1.10",
|
||||
"@vitest/coverage-istanbul": "4.1.10",
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-ai": ">=0.81.0",
|
||||
@@ -5703,6 +5678,31 @@
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"pi-subagents/node_modules/@vitest/coverage-istanbul": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.10.tgz",
|
||||
"integrity": "sha512-AyNJ5pQRFqCX7pwB9PSTmoVKPaZ4H5IEVJfJsT+q1DYkXvZMEFYgJlyk5sfStmt9rVYRyYYRRsuBeImCOc39ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"@jridgewell/gen-mapping": "^0.3.13",
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"magicast": "^0.5.2",
|
||||
"obug": "^2.1.1",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
},
|
||||
"pi-subagents/node_modules/nanoid": {
|
||||
"version": "5.1.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
|
||||
|
||||
@@ -94,11 +94,13 @@
|
||||
"pi-web-search/package.json",
|
||||
"pi-web-search/README.md",
|
||||
"pi-web-search/LICENSE",
|
||||
"pi-web-search/UPSTREAM.md",
|
||||
"pi-worktree/index.ts",
|
||||
"pi-worktree/src",
|
||||
"pi-worktree/package.json",
|
||||
"pi-worktree/README.md",
|
||||
"pi-worktree/LICENSE",
|
||||
"pi-worktree/UPSTREAM.md",
|
||||
"pi-permission-system/LICENSES",
|
||||
"pi-permission-system/docs/auto-review",
|
||||
"pi-permission-system/src",
|
||||
@@ -107,6 +109,7 @@
|
||||
"pi-permission-system/README.md",
|
||||
"pi-permission-system/CHANGELOG.md",
|
||||
"pi-permission-system/LICENSE",
|
||||
"pi-permission-system/UPSTREAM.md",
|
||||
"config",
|
||||
"scripts",
|
||||
"install.sh",
|
||||
@@ -120,6 +123,7 @@
|
||||
"extensions": [
|
||||
"./pi-overflow/capture.ts",
|
||||
"./extensions/fff-override.ts",
|
||||
"./extensions/bash-override.ts",
|
||||
"./pi-web-search/index.ts",
|
||||
"./extensions/codegraph.ts",
|
||||
"./pi-rtk-optimizer/index.ts",
|
||||
|
||||
@@ -24,10 +24,10 @@ const emit = async (event, data = {}) => {
|
||||
|
||||
try {
|
||||
process.env.PI_CODING_AGENT_DIR = root;
|
||||
process.env.PI_HERMES_MEMORY_CONFIG_PATH = join(root, "hermes-memory-config.json");
|
||||
const configPath = join(root, "hermes-memory-config.json");
|
||||
await mkdir(globalDir);
|
||||
await mkdir(ctx.cwd);
|
||||
await writeFile(process.env.PI_HERMES_MEMORY_CONFIG_PATH, JSON.stringify({
|
||||
await writeFile(configPath, JSON.stringify({
|
||||
lazyInitialization: lazy, memoryMode: "policy-only",
|
||||
reviewEnabled: false, correctionDetection: false,
|
||||
flushOnShutdown: false, flushOnCompact: false,
|
||||
@@ -40,7 +40,10 @@ try {
|
||||
}
|
||||
|
||||
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();
|
||||
register({
|
||||
on(event, handler) {
|
||||
|
||||
@@ -89,16 +89,7 @@ export const DEFAULT_CONFIG_PATH = fileURLToPath(
|
||||
new URL("../../config/pi-hermes-memory.json", import.meta.url),
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolve the effective config path. `PI_HERMES_MEMORY_CONFIG_PATH` relocates
|
||||
* the file (tests point it at a temp file; it is never a persisted config
|
||||
* value).
|
||||
*/
|
||||
export function resolveConfigPath(): string {
|
||||
return process.env.PI_HERMES_MEMORY_CONFIG_PATH ?? DEFAULT_CONFIG_PATH;
|
||||
}
|
||||
|
||||
export function loadConfig(configPath = resolveConfigPath()): MemoryConfig {
|
||||
export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const raw = fs.readFileSync(configPath, "utf-8");
|
||||
|
||||
@@ -52,7 +52,7 @@ import { registerPreviewContextCommand } from "./handlers/preview-context.js";
|
||||
import { registerStandingPinCommand } from "./handlers/standing-pin.js";
|
||||
import { StandingInstructions } from "./store/standing-instructions.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 { detectProject, detectProjectSkills } from "./project.js";
|
||||
import { buildPromptContext } from "./prompt-context.js";
|
||||
@@ -90,8 +90,12 @@ export function registerProjectSkillDiscoveryHandler(
|
||||
});
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
const config = loadConfig();
|
||||
export interface HermesMemoryExtensionOptions {
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
function hermesMemoryExtension(pi: ExtensionAPI, configPath: string) {
|
||||
const config = loadConfig(configPath);
|
||||
const lazy = config.lazyInitialization === true && config.memoryMode === "policy-only";
|
||||
let sessionContext: ExtensionContext | undefined;
|
||||
|
||||
@@ -480,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();
|
||||
|
||||
@@ -8,10 +8,10 @@ import { after, beforeEach, afterEach, describe, it } from "node:test";
|
||||
// Bind the entry point to a disposable agent root, never the user's memory.
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-lazy-startup-"));
|
||||
const previousRoot = process.env.PI_CODING_AGENT_DIR;
|
||||
const previousConfigPath = process.env.PI_HERMES_MEMORY_CONFIG_PATH;
|
||||
process.env.PI_CODING_AGENT_DIR = root;
|
||||
process.env.PI_HERMES_MEMORY_CONFIG_PATH = path.join(root, "hermes-memory-config.json");
|
||||
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 { DatabaseManager } = await import("../src/store/db.js");
|
||||
const globalDir = path.join(root, "pi-hermes-memory");
|
||||
@@ -24,7 +24,7 @@ let notifications: string[];
|
||||
let ctx: any;
|
||||
|
||||
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,
|
||||
reviewEnabled: false, correctionDetection: false,
|
||||
flushOnCompact: false, flushOnShutdown: false,
|
||||
@@ -87,8 +87,6 @@ afterEach(async () => {
|
||||
after(async () => {
|
||||
if (previousRoot === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousRoot;
|
||||
if (previousConfigPath === undefined) delete process.env.PI_HERMES_MEMORY_CONFIG_PATH;
|
||||
else process.env.PI_HERMES_MEMORY_CONFIG_PATH = previousConfigPath;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -18,11 +18,11 @@ describe("session project memory rebinding", () => {
|
||||
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 previousAgentRoot = process.env.PI_CODING_AGENT_DIR;
|
||||
const previousConfigPath = process.env.PI_HERMES_MEMORY_CONFIG_PATH;
|
||||
const configPath = path.join(agentRoot, "hermes-memory-config.json");
|
||||
const previousCwd = process.cwd();
|
||||
try {
|
||||
await fs.writeFile(
|
||||
path.join(agentRoot, "hermes-memory-config.json"),
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
memoryMode: "legacy-inject",
|
||||
reviewEnabled: false,
|
||||
@@ -47,10 +47,10 @@ describe("session project memory rebinding", () => {
|
||||
);
|
||||
|
||||
process.env.PI_CODING_AGENT_DIR = agentRoot;
|
||||
process.env.PI_HERMES_MEMORY_CONFIG_PATH = path.join(agentRoot, "hermes-memory-config.json");
|
||||
process.chdir(launchDir);
|
||||
// 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 = {
|
||||
handlers: {},
|
||||
tools: {},
|
||||
@@ -103,8 +103,6 @@ describe("session project memory rebinding", () => {
|
||||
process.chdir(previousCwd);
|
||||
if (previousAgentRoot === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousAgentRoot;
|
||||
if (previousConfigPath === undefined) delete process.env.PI_HERMES_MEMORY_CONFIG_PATH;
|
||||
else process.env.PI_HERMES_MEMORY_CONFIG_PATH = previousConfigPath;
|
||||
await fs.rm(agentRoot, { recursive: true, force: true });
|
||||
await fs.rm(launchDir, { recursive: true, force: true });
|
||||
await fs.rm(targetDir, { recursive: true, force: true });
|
||||
|
||||
@@ -24,10 +24,10 @@ import { after, beforeEach, describe, it } from "node:test";
|
||||
// Bind the entry point to a disposable agent root, never the user's memory.
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-prompt-injection-"));
|
||||
const previousRoot = process.env.PI_CODING_AGENT_DIR;
|
||||
const previousConfigPath = process.env.PI_HERMES_MEMORY_CONFIG_PATH;
|
||||
process.env.PI_CODING_AGENT_DIR = root;
|
||||
process.env.PI_HERMES_MEMORY_CONFIG_PATH = path.join(root, "hermes-memory-config.json");
|
||||
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 cwd = path.join(root, "workspace");
|
||||
|
||||
@@ -68,7 +68,7 @@ let handlers: Record<string, Array<(event: any, ctx: any) => any>>;
|
||||
let ctx: any;
|
||||
|
||||
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",
|
||||
memoryPolicyStyle: "full",
|
||||
lazyInitialization: true,
|
||||
@@ -115,8 +115,6 @@ beforeEach(async () => {
|
||||
after(async () => {
|
||||
if (previousRoot === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousRoot;
|
||||
if (previousConfigPath === undefined) delete process.env.PI_HERMES_MEMORY_CONFIG_PATH;
|
||||
else process.env.PI_HERMES_MEMORY_CONFIG_PATH = previousConfigPath;
|
||||
try {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
} catch {
|
||||
|
||||
@@ -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`)。
|
||||
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- **Descend into bash control-flow bodies when enumerating command units.** `for`/`while`/`until` loops, `if`/`elif`/`else` branches, `case` items, `{ … }` brace groups, function definitions, and `!`-negated commands were previously matched only as whole-statement text, so a denied command hidden in a loop body or behind `!` (e.g. `for d in */; do find "$d" …; done`, `! rm -rf x`) bypassed every bash pattern — as did a substitution inside a value (`x=$(grep foo f)`, `export X=$(find .)`). The enumerator still emits each enclosing statement as a unit and now also emits every condition, body, and negated command — plus substitutions hosted in value lists, case subjects, assignments, declarations, and other non-command positions — so the most restrictive decision wins; pure body containers (`do …; done`, `elif`/`else` clauses, `case` items) no longer produce their own units. Hard guards share the same enumeration and now reach those nested commands too.
|
||||
- **Replace the AutoReview text-JSON chat reviewer with a conversational tool-call reviewer.** The reviewer makes one independent model call per ask — a fixed review prompt, the rendered transcript, and a bounded JSON description of the planned action — and reaches its verdict only by calling one of two tools, `approve` or `reject`, each carrying a `reason`. A reply with no tool call counts as no verdict; multiple calls, unknown tools, and missing reasons count as invalid responses; neither is retried. An experimental TypeSafe Jev classifier rewrite explored in the working tree was dropped before release and never shipped.
|
||||
- **The review model follows the session by default.** With `autoReview.provider` and `autoReview.model` both unset, the reviewer calls the session's current model (`ctx.model`), resolved live per ask so a mid-session switch applies to the next request. Setting both pins a fixed reviewer; setting exactly one is a misconfiguration that warns once at `session_start` and defers every ask.
|
||||
- **A rejection defers instead of denying.** `reject` maps to `defer` and the model's `reason` is shown in the human permission prompt, labelled as model-generated. Every review failure — timeout, provider error, malformed or truncated reply, unresolved model — also defers with a fixed system-authored note. Pressing Esc during a review aborts it and denies outright, so no human dialog appears after the user already refused.
|
||||
|
||||
@@ -21,3 +21,7 @@ AutoReview 源码已整合进 `src/auto-review/`,不再保留或加载第二
|
||||
## 本地维护
|
||||
|
||||
本目录不是 submodule,不保留嵌套 `.git`、上游 `.pi` 本地状态、`dist/` 或构建产物。同步任一上游时必须从其源码仓库的明确 tag/commit 移植并与本地改动对照审查,不得整体覆盖,也不得使用 npm 预编译产物作为导入源。
|
||||
|
||||
## 本地改动
|
||||
|
||||
- Bash 命令枚举在保留整条控制流语句为独立 command unit 的基础上递归下降:`for`/`while`/`until`、`if`/`elif`/`else`、`case` 分支、`{ … }` 组合语句、函数定义与 `!` 取反命令体内的命令也按 bash pattern 逐条判定;赋值与声明值(`x=$(…)`、`export X=$(…)`)、值列表、case 词与 `[[ … ]]` 测试中的 `$(…)` 替换同样枚举,而 `do …; done`、`elif`/`else`、`case` 分支等纯容器不再单独产生 unit。藏在循环体、取反前缀或赋值载荷里的 `find`/`grep`/`rm` 等不再绕过 deny 或 hard guard。
|
||||
|
||||
@@ -370,7 +370,10 @@ This holds wherever the substitution appears, not only in argument position.
|
||||
A substitution in a **redirect target** (`echo hi > $(rm *.txt)`, `cat < <(rm c)`, ``echo hi 2> `rm d` ``) and one in an **interpolating heredoc body** (`cat <<EOF` with `$(rm e)` in the body) are evaluated the same way.
|
||||
A quoted heredoc delimiter (`<<'EOF'` or `<<"EOF"`) does not interpolate, so its body is literal text and nothing in it is evaluated as a command.
|
||||
The enclosing command is still matched without its redirect, so a rule like `npm install` keeps matching `npm install > out.txt`.
|
||||
Control-flow bodies (`if`/`while`/`for`/`case`) and `{ … }` brace groups are not descended into; their contents are matched as part of the enclosing statement's text.
|
||||
Control-flow statements (`for`/`while`/`until` loops, `if`/`elif`/`else` branches, `case` items, `{ … }` brace groups, and function definitions) are evaluated as a whole unit *and* descended into: every command in their conditions and bodies is matched against the bash patterns in addition to the enclosing statement's text.
|
||||
So `for d in src docs; do find "$d" -name '*.ts'; done` evaluates both the whole loop and `find "$d" -name '*.ts'`; if `find *` is denied, the whole invocation is denied.
|
||||
Non-command parts of a control-flow statement — the `for` value list, the `case` subject and patterns, `[[ … ]]` tests — are not matched as commands themselves, but substitutions nested inside them still are, so `for f in $(find …)` and `if [[ "$(rm e)" = x ]]` gate the inner command.
|
||||
Negation and shell bookkeeping get the same treatment: `! grep -q x f` evaluates both `! grep -q x f` and the inner `grep -q x f`, and a substitution inside a value — `x=$(grep foo f)`, `export X=$(find .)`, `local y=$(rg z)` — is gated as a command substitution even though the assignment or declaration itself runs no command.
|
||||
|
||||
A leading environment-variable assignment prefix is stripped before matching, so the rule gates the underlying command rather than the prefix.
|
||||
So `AWS_PROFILE=prod aws ec2 …` is matched as `aws ec2 …` — a `aws *` rule applies even though the invocation begins with `AWS_PROFILE=`.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
EXECUTION_HOST_TYPES,
|
||||
forEachNestedExecution,
|
||||
NESTED_EXECUTION_CONTEXTS,
|
||||
} from "#src/access-intent/bash/nested-execution";
|
||||
import type { TSNode } from "#src/access-intent/bash/parser";
|
||||
import {
|
||||
@@ -72,6 +73,62 @@ const COMMAND_ENUM_DESCEND = new Set([
|
||||
*/
|
||||
const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
|
||||
|
||||
/**
|
||||
* Compound statement node types whose bodies really run commands:
|
||||
* `for`/`while`/`until` loops (tree-sitter-bash folds `until` into
|
||||
* `while_statement`), `if`/`case` statements, `{ … }` brace groups
|
||||
* (`compound_statement`), function definitions, and `!`-negated commands.
|
||||
*
|
||||
* These were once emitted whole without descending (#306 deferred that), which
|
||||
* let a gated command hide inside a loop body — a `for …; do find …; done`
|
||||
* matched only patterns starting with `for`. Each is now emitted whole AND
|
||||
* enumerated child-by-child; a `negated_command` emits `! grep …` whole and
|
||||
* then `grep …` so the `!` prefix cannot defeat a command pattern either.
|
||||
*/
|
||||
const CONTROL_FLOW_TYPES = new Set([
|
||||
"for_statement",
|
||||
"c_style_for_statement",
|
||||
"while_statement",
|
||||
"if_statement",
|
||||
"case_statement",
|
||||
"compound_statement",
|
||||
"function_definition",
|
||||
"negated_command",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Control-flow nodes that are pure body containers rather than statements in
|
||||
* their own right: `do …; done`, `elif`/`else` clauses, and `case` items.
|
||||
* They are descended exactly like {@link CONTROL_FLOW_TYPES} members but are
|
||||
* not emitted as whole units — nobody writes a command pattern against
|
||||
* `do …; done` or `a) find . ;;`, so emitting them only adds noise to prompts
|
||||
* and approval suggestions.
|
||||
*/
|
||||
const CONTROL_FLOW_CONTAINERS = new Set([
|
||||
"do_group",
|
||||
"elif_clause",
|
||||
"else_clause",
|
||||
"case_item",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Named children of a control-flow node that are statements in their own
|
||||
* right and get full command enumeration (whole-emit rules, wrapper
|
||||
* classification, substitution descent). Any other named child — words,
|
||||
* `variable_name`, a for-loop's value list, a case subject or pattern, a
|
||||
* `test_command` `[[ … ]]`, a comment — contributes only the nested
|
||||
* `$(…)`/`<(…)` executions it hosts.
|
||||
*/
|
||||
const CONTROL_FLOW_STATEMENT_TYPES = new Set([
|
||||
"command",
|
||||
"list",
|
||||
"pipeline",
|
||||
"redirected_statement",
|
||||
"subshell",
|
||||
...CONTROL_FLOW_TYPES,
|
||||
...CONTROL_FLOW_CONTAINERS,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Enumerate the command units of a bash program, in source order.
|
||||
*
|
||||
@@ -82,8 +139,19 @@ const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
|
||||
* subshells (`( … )`) — emitting each inner command as its own unit *in
|
||||
* addition to* the enclosing command, since those inner commands really execute
|
||||
* (#306).
|
||||
* Control-flow bodies and `{ … }` brace groups are emitted whole without
|
||||
* descending (deferred).
|
||||
* Control-flow statements (loops, `if`/`elif`/`else`, `case`, `{ … }` groups,
|
||||
* function definitions, `!` negation) are likewise emitted whole and then
|
||||
* descended child-by-child: statement children (including nested control flow
|
||||
* and conditions like `if grep -q …; then`) are enumerated as commands, while
|
||||
* non-statement children (the for-loop value list, case subject/patterns,
|
||||
* `[[ … ]]` tests, names) contribute only the nested executions they host.
|
||||
* Pure body containers (`do …; done`, `elif`/`else` clauses, `case` items)
|
||||
* are descended without a whole-unit emit of their own.
|
||||
* Body commands keep the enclosing execution context — a loop inside `$(…)`
|
||||
* runs there, a top-level loop's body runs in the current shell.
|
||||
* Remaining named statements (`variable_assignment`, `declaration_command`,
|
||||
* `test_command`, …) emit whole and still report the nested executions they
|
||||
* host, so `x=$(grep foo f)` and `export X=$(find .)` gate the inner command.
|
||||
*
|
||||
* The enclosing command/subshell is always still emitted whole, so adding the
|
||||
* nested units can only ever produce a more-restrictive decision, never weaker.
|
||||
@@ -135,9 +203,57 @@ function collectCommandsInto(
|
||||
return;
|
||||
}
|
||||
|
||||
// Any other named statement (compound_statement `{ … }`, if/while/for/case,
|
||||
// function_definition): emit whole, do not descend — deferred (#306).
|
||||
if (CONTROL_FLOW_TYPES.has(node.type)) {
|
||||
out.push(makeUnit(node.text, context)); // never-weaker whole emit
|
||||
descendControlFlowChildren(node, context, out);
|
||||
return;
|
||||
}
|
||||
|
||||
if (CONTROL_FLOW_CONTAINERS.has(node.type)) {
|
||||
// Pure body container — enumerate its contents, emit no unit of its own.
|
||||
descendControlFlowChildren(node, context, out);
|
||||
return;
|
||||
}
|
||||
|
||||
// Any other named statement (variable_assignment, declaration_command,
|
||||
// test_command, …) keeps the whole-emit fallback, but a substitution in its
|
||||
// payload still really runs — `x=$(grep foo f)` or `export X=$(find .)` —
|
||||
// so hosted executions are enumerated in addition to the whole text.
|
||||
out.push(makeUnit(node.text, context));
|
||||
collectHostedCommands(node, out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate inside a control-flow statement, keeping the enclosing context.
|
||||
*
|
||||
* Statement children (`command`, `list`, `pipeline`, `redirected_statement`,
|
||||
* `subshell`, or nested control flow such as a `do_group`)
|
||||
* get full enumeration. Other named children are scanned only for nested
|
||||
* executions: a `command_substitution` child directly (the `$(…)` in
|
||||
* `for f in $(…)` or `case $(…) in`) is a context node in its own right — the
|
||||
* strict-subtree {@link forEachNestedExecution} walk would not see it — so it
|
||||
* is descended with its own context, while words, `variable_name`, test and
|
||||
* expression subtrees go through the hosted-execution search.
|
||||
*/
|
||||
function descendControlFlowChildren(
|
||||
node: TSNode,
|
||||
context: BashCommandContext | undefined,
|
||||
out: BashCommand[],
|
||||
): void {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child?.isNamed) continue;
|
||||
if (CONTROL_FLOW_STATEMENT_TYPES.has(child.type)) {
|
||||
collectCommandsInto(child, context, out);
|
||||
continue;
|
||||
}
|
||||
const nestedContext = NESTED_EXECUTION_CONTEXTS.get(child.type);
|
||||
if (nestedContext) {
|
||||
descendCommandChildren(child, nestedContext, out);
|
||||
} else {
|
||||
collectHostedCommands(child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeUnit(
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
resetWarmBashParser,
|
||||
warmBashParser,
|
||||
} from "#src/access-intent/bash/parser";
|
||||
import { parseBashCommandsSync } from "#src/access-intent/bash/sync-commands";
|
||||
|
||||
describe("collectCommands — control-flow descent", () => {
|
||||
beforeEach(async () => {
|
||||
resetWarmBashParser();
|
||||
await warmBashParser();
|
||||
});
|
||||
afterEach(() => {
|
||||
resetWarmBashParser();
|
||||
});
|
||||
|
||||
it("emits a for-loop's body commands in addition to the whole statement", () => {
|
||||
expect(
|
||||
parseBashCommandsSync('for d in a b; do find "$d" -name x; done'),
|
||||
).toEqual([
|
||||
{ text: 'for d in a b; do find "$d" -name x; done' },
|
||||
{ text: 'find "$d" -name x' },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits a command substitution in the for value list as a nested context", () => {
|
||||
expect(
|
||||
parseBashCommandsSync('for f in $(find . -name x); do echo "$f"; done'),
|
||||
).toEqual([
|
||||
{ text: 'for f in $(find . -name x); do echo "$f"; done' },
|
||||
{ text: "find . -name x", context: "command_substitution" },
|
||||
{ text: 'echo "$f"' },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits the condition and body commands of a redirected while loop", () => {
|
||||
expect(
|
||||
parseBashCommandsSync('while read l; do grep x "$l"; done < list'),
|
||||
).toEqual([
|
||||
{ text: 'while read l; do grep x "$l"; done' },
|
||||
{ text: "read l" },
|
||||
{ text: 'grep x "$l"' },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits an if's condition, branch, and else commands", () => {
|
||||
expect(
|
||||
parseBashCommandsSync("if grep -q x f; then echo y; else rm z; fi"),
|
||||
).toEqual([
|
||||
{ text: "if grep -q x f; then echo y; else rm z; fi" },
|
||||
{ text: "grep -q x f" },
|
||||
{ text: "echo y" },
|
||||
{ text: "rm z" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits a case item's commands", () => {
|
||||
expect(parseBashCommandsSync("case $x in a) find . ;; esac")).toEqual([
|
||||
{ text: "case $x in a) find . ;; esac" },
|
||||
{ text: "find ." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits a command substitution in the case subject as a nested context", () => {
|
||||
expect(
|
||||
parseBashCommandsSync("case $(rm x) in a) find . ;; esac"),
|
||||
).toEqual([
|
||||
{ text: "case $(rm x) in a) find . ;; esac" },
|
||||
{ text: "rm x", context: "command_substitution" },
|
||||
{ text: "find ." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("descends a brace group", () => {
|
||||
expect(parseBashCommandsSync("{ grep x f; }")).toEqual([
|
||||
{ text: "{ grep x f; }" },
|
||||
{ text: "grep x f" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("descends a function definition's body", () => {
|
||||
expect(parseBashCommandsSync("f() { grep x f; }")).toEqual([
|
||||
{ text: "f() { grep x f; }" },
|
||||
{ text: "{ grep x f; }" },
|
||||
{ text: "grep x f" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("descends nested loops", () => {
|
||||
expect(
|
||||
parseBashCommandsSync("for a in 1; do for b in 2; do find .; done; done"),
|
||||
).toEqual([
|
||||
{ text: "for a in 1; do for b in 2; do find .; done; done" },
|
||||
{ text: "for b in 2; do find .; done" },
|
||||
{ text: "find ." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("descends a c-style for loop", () => {
|
||||
expect(
|
||||
parseBashCommandsSync("for ((i=0;i<3;i++)); do echo i; done"),
|
||||
).toEqual([
|
||||
{ text: "for ((i=0;i<3;i++)); do echo i; done" },
|
||||
{ text: "echo i" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits no container units for a clean loop body", () => {
|
||||
expect(parseBashCommandsSync('for d in a b; do echo "$d"; done')).toEqual([
|
||||
{ text: 'for d in a b; do echo "$d"; done' },
|
||||
{ text: 'echo "$d"' },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not emit a [[ ]] test as a separate command unit", () => {
|
||||
expect(parseBashCommandsSync("if [[ -f x ]]; then echo y; fi")).toEqual([
|
||||
{ text: "if [[ -f x ]]; then echo y; fi" },
|
||||
{ text: "echo y" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("catches a substitution hosted inside a [[ ]] test", () => {
|
||||
expect(
|
||||
parseBashCommandsSync('if [[ "$(rm x)" = y ]]; then echo y; fi'),
|
||||
).toEqual([
|
||||
{ text: 'if [[ "$(rm x)" = y ]]; then echo y; fi' },
|
||||
{ text: "rm x", context: "command_substitution" },
|
||||
{ text: "echo y" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectCommands — assignment, declaration, and negation", () => {
|
||||
beforeEach(async () => {
|
||||
resetWarmBashParser();
|
||||
await warmBashParser();
|
||||
});
|
||||
afterEach(() => {
|
||||
resetWarmBashParser();
|
||||
});
|
||||
|
||||
it("emits a substitution in a plain assignment value as a nested context", () => {
|
||||
expect(parseBashCommandsSync("x=$(grep foo f)")).toEqual([
|
||||
{ text: "x=$(grep foo f)" },
|
||||
{ text: "grep foo f", context: "command_substitution" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits a substitution in an export declaration value", () => {
|
||||
expect(parseBashCommandsSync("export X=$(find .)")).toEqual([
|
||||
{ text: "export X=$(find .)" },
|
||||
{ text: "find .", context: "command_substitution" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits a substitution in a local declaration inside a function body", () => {
|
||||
expect(parseBashCommandsSync("f() { local y=$(rg z); }")).toEqual([
|
||||
{ text: "f() { local y=$(rg z); }" },
|
||||
{ text: "{ local y=$(rg z); }" },
|
||||
{ text: "rg z", context: "command_substitution" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits a negated command's inner command as a normal unit", () => {
|
||||
expect(parseBashCommandsSync("! grep -q x f")).toEqual([
|
||||
{ text: "! grep -q x f" },
|
||||
{ text: "grep -q x f" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits a negated condition's inner command inside an if", () => {
|
||||
expect(parseBashCommandsSync("if ! grep -q x f; then :; fi")).toEqual([
|
||||
{ text: "if ! grep -q x f; then :; fi" },
|
||||
{ text: "! grep -q x f" },
|
||||
{ text: "grep -q x f" },
|
||||
{ text: ":" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -14,10 +14,13 @@ import { BashProgram } from "#src/access-intent/bash/program";
|
||||
import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
|
||||
import { pathFlavorForPlatform } from "#src/path/path-flavor";
|
||||
import { PathNormalizer } from "#src/path-normalizer";
|
||||
import { PermissionResolver } from "#src/permission-resolver";
|
||||
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
||||
import type { PermissionState } from "#src/types";
|
||||
import { SessionRules } from "#src/session-rules";
|
||||
import type { DenyWithReason, PermissionState } from "#src/types";
|
||||
|
||||
import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
||||
import { createInMemoryManager } from "#test/helpers/manager-harness";
|
||||
|
||||
/** Decision strength ordering: deny (2) > ask (1) > allow (0). */
|
||||
const STRENGTH: Record<PermissionState, number> = {
|
||||
@@ -146,3 +149,103 @@ describe("bash command gate — nested execution hosts do not weaken", () => {
|
||||
expect(await decide("cat <<'EOF'\n$(rm x)\nEOF", resolver)).toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The same totality property for control-flow bodies.
|
||||
*
|
||||
* A `for`/`while`/`if`/`case`/function was once emitted whole without
|
||||
* descending, so `for d in …; do find …; done` only ever matched patterns
|
||||
* starting with `for` — a `find*: deny` rule was bypassed. The enumerator now
|
||||
* emits the whole statement AND its body commands, so hosting a gated command
|
||||
* inside control flow cannot weaken its decision.
|
||||
*/
|
||||
describe("bash command gate — control-flow bodies do not weaken", () => {
|
||||
/** Real pattern matching over a bash map: `*` allow, the rest deny-with-reason. */
|
||||
function makePolicyResolver(
|
||||
bash: Record<string, PermissionState | DenyWithReason>,
|
||||
): ScopedPermissionResolver {
|
||||
const manager = createInMemoryManager({
|
||||
global: { permission: { "*": "allow", bash } },
|
||||
});
|
||||
return new PermissionResolver(manager, new SessionRules());
|
||||
}
|
||||
|
||||
async function checkWith(
|
||||
command: string,
|
||||
resolver: ScopedPermissionResolver,
|
||||
) {
|
||||
const program = await BashProgram.parse(
|
||||
command,
|
||||
new PathNormalizer(pathFlavorForPlatform(process.platform), "/cwd"),
|
||||
);
|
||||
return resolveBashCommandCheck(command, program.commands(), undefined, resolver);
|
||||
}
|
||||
|
||||
it("denies the reported repro: find inside a for-loop command substitution", async () => {
|
||||
const resolver = makePolicyResolver({
|
||||
"*": "allow",
|
||||
"find*": { action: "deny", reason: "use the find tool" },
|
||||
"grep*": { action: "deny", reason: "use the grep tool" },
|
||||
});
|
||||
|
||||
const result = await checkWith(
|
||||
'cd /Users/sia/code/my-pi && ls -1 extensions/ && echo "=== LOC by ext ===" && for d in pi-*/; do n=$(find "$d" -name \'*.ts\' -not -path \'*/node_modules/*\' -not -path \'*/dist/*\' -not -path \'*/test*\' | xargs wc -l 2>/dev/null | tail -1 | awk \'{print $1}\'); echo "$d $n"; done && echo "=== extensions ts ===" && wc -l extensions/*.ts | tail -3',
|
||||
resolver,
|
||||
);
|
||||
|
||||
expect(result.state).toBe("deny");
|
||||
expect(result.command).toBe(
|
||||
'find "$d" -name \'*.ts\' -not -path \'*/node_modules/*\' -not -path \'*/dist/*\' -not -path \'*/test*\'',
|
||||
);
|
||||
expect(result.matchedPattern).toBe("find*");
|
||||
expect(result.reason).toBe("use the find tool");
|
||||
expect(result.commandContext).toBe("command_substitution");
|
||||
});
|
||||
|
||||
it.each([
|
||||
'for d in a b; do find "$d" -name x; done',
|
||||
'while read l; do grep x "$l"; done < list',
|
||||
"if grep -q x f; then echo y; else echo n; fi",
|
||||
"case $x in a) find . ;; esac",
|
||||
"{ grep x f; }",
|
||||
"f() { grep x f; }",
|
||||
"for a in 1; do for b in 2; do find .; done; done",
|
||||
"x=$(grep foo f)",
|
||||
"export X=$(find .)",
|
||||
"f() { local y=$(rg z); }",
|
||||
"! grep -q x f",
|
||||
"if ! grep -q x f; then :; fi",
|
||||
])("denies a gated command inside `%s`", async (command) => {
|
||||
const resolver = makePolicyResolver({
|
||||
"*": "allow",
|
||||
"find*": "deny",
|
||||
"grep*": "deny",
|
||||
"rg*": "deny",
|
||||
});
|
||||
|
||||
expect((await checkWith(command, resolver)).state).toBe("deny");
|
||||
});
|
||||
|
||||
it("denies a hard-guard payload hidden in a loop body", async () => {
|
||||
const resolver = makePolicyResolver({
|
||||
"*": "allow",
|
||||
"rm -rf*": "deny",
|
||||
});
|
||||
|
||||
const result = await checkWith("for f in a; do rm -rf /; done", resolver);
|
||||
expect(result.state).toBe("deny");
|
||||
expect(result.matchedPattern).toBe("rm -rf*");
|
||||
});
|
||||
|
||||
it("allows a control-flow statement whose body commands are allowed", async () => {
|
||||
const resolver = makePolicyResolver({
|
||||
"*": "allow",
|
||||
"find*": "deny",
|
||||
"grep*": "deny",
|
||||
});
|
||||
|
||||
expect(
|
||||
(await checkWith('for d in a b; do echo "$d"; done', resolver)).state,
|
||||
).toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
autoReviewConfigFileSchema,
|
||||
normalizeAutoReviewConfig,
|
||||
} from "#src/auto-review/config";
|
||||
import { stripJsonComments } from "#src/config-loader";
|
||||
import { unifiedConfigSchema } from "#src/config-schema";
|
||||
|
||||
/** The bundle's authoritative policy seed, as shipped. */
|
||||
@@ -16,10 +17,10 @@ const SEED_PATH = fileURLToPath(
|
||||
|
||||
function readSeed(): Record<string, unknown> {
|
||||
const raw = readFileSync(SEED_PATH, "utf8");
|
||||
const stripped = raw
|
||||
.replace(/^\s*\/\/.*$/gm, "")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
return JSON.parse(stripped) as Record<string, unknown>;
|
||||
// Parse with the same string-aware stripper the loader uses: a naive regex
|
||||
// strips `/*`-looking runs inside string values (e.g. a `'src/**/*.ts'`
|
||||
// glob in a deny reason) and corrupts the JSON.
|
||||
return JSON.parse(stripJsonComments(raw)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("bundled policy seed", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
- RTK 默认只压缩非搜索输出,包括本地 `bash` 与远端 `ssh_bash` 的 ANSI 清理、测试聚合、构建过滤、Git 压缩、Lint 聚合;兜底截断默认关闭,新结果的展示截断由 pi-overflow 统一负责;`ssh_bash` 只复用输出处理,远端命令不得进入 RTK rewrite。
|
||||
- RTK 命令改写默认关闭(`commandRewritingEnabled` 默认 `false`);默认安装不依赖系统 `rtk` CLI,只有用户主动开启命令改写时才需要 `rtk rewrite`,其命令支持策略由已安装的 `rtk rewrite` 决定,不在扩展内重复维护规则。
|
||||
- FFF override 独占搜索工具与搜索输出;RTK 不得处理本地或远端搜索工具的调用/结果(含 `grep` 工具结果),也不得通过默认命令改写接管 `rg`、`grep`、`find`、`fd`。
|
||||
- RTK 不处理带 `parentToolCallId` 的嵌套调用(codemode 脚本内调用),否则会丢失 bash 的 structuredContent。
|
||||
- `readCompaction.enabled`、`sourceCodeFilteringEnabled` 和 `smartTruncate.enabled` 当前均默认 `false`,源码读取保持原样;未经用户明确决定,不因节省上下文而改变这些默认值。若以后开启 read 压缩,优先考虑 `readCompaction + smartTruncate`,源码过滤仍独立评估;必须保留精确 `offset/limit` 读取、短文件和行锚点的完整性。
|
||||
- read 压缩属于有损能力。任何默认值调整都必须同时说明哪些内容可能被省略、精确读取如何恢复原文,以及对 `edit` 文本匹配和排障证据的影响。
|
||||
- 工具输出压缩可能损失证据。排障和审计相关改动应优先保证原始输出可恢复,并覆盖锚点完整性和截断边界。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { resolveConfigPath } from "./constants.js";
|
||||
import { CONFIG_PATH } from "./constants.js";
|
||||
import { toRecord } from "./record-utils.js";
|
||||
import {
|
||||
DEFAULT_RTK_INTEGRATION_CONFIG,
|
||||
@@ -151,7 +151,7 @@ export function normalizeRtkIntegrationConfig(raw: unknown): RtkIntegrationConfi
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureConfigExists(configPath = resolveConfigPath()): EnsureConfigResult {
|
||||
export function ensureConfigExists(configPath = CONFIG_PATH): EnsureConfigResult {
|
||||
if (existsSync(configPath)) {
|
||||
return { created: false };
|
||||
}
|
||||
@@ -169,7 +169,7 @@ export function ensureConfigExists(configPath = resolveConfigPath()): EnsureConf
|
||||
}
|
||||
}
|
||||
|
||||
export function loadRtkIntegrationConfig(configPath = resolveConfigPath()): ConfigLoadResult {
|
||||
export function loadRtkIntegrationConfig(configPath = CONFIG_PATH): ConfigLoadResult {
|
||||
if (!existsSync(configPath)) {
|
||||
return { config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG) };
|
||||
}
|
||||
@@ -189,7 +189,7 @@ export function loadRtkIntegrationConfig(configPath = resolveConfigPath()): Conf
|
||||
|
||||
export function saveRtkIntegrationConfig(
|
||||
config: RtkIntegrationConfig,
|
||||
configPath = resolveConfigPath(),
|
||||
configPath = CONFIG_PATH,
|
||||
): ConfigSaveResult {
|
||||
const normalized = normalizeRtkIntegrationConfig(config);
|
||||
const tmpPath = `${configPath}.tmp`;
|
||||
@@ -218,6 +218,6 @@ export function saveRtkIntegrationConfig(
|
||||
}
|
||||
}
|
||||
|
||||
export function getRtkIntegrationConfigPath(configPath = resolveConfigPath()): string {
|
||||
export function getRtkIntegrationConfigPath(configPath = CONFIG_PATH): string {
|
||||
return configPath;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,3 @@ export const EXTENSION_NAME = "pi-rtk-optimizer";
|
||||
export const CONFIG_PATH = fileURLToPath(
|
||||
new URL("../../config/rtk-optimizer.json", import.meta.url),
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolve the effective config path. `PI_RTK_CONFIG_PATH` relocates the file
|
||||
* (tests point it at a temp file; it is never a persisted config value).
|
||||
*/
|
||||
export function resolveConfigPath(): string {
|
||||
return process.env.PI_RTK_CONFIG_PATH ?? CONFIG_PATH;
|
||||
}
|
||||
|
||||
@@ -36,16 +36,15 @@ mock.module("@earendil-works/pi-tui", {
|
||||
|
||||
const indexModule = await import("./index.ts");
|
||||
const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote } = indexModule;
|
||||
const rtkIntegrationExtension = indexModule.default;
|
||||
const { DEFAULT_RTK_INTEGRATION_CONFIG } = await import("./types.ts");
|
||||
|
||||
// Tests must never write the bundle's config/ file: relocate the config to a
|
||||
// throwaway temp file before any config-store call resolves its path.
|
||||
// throwaway temp file via the extension factory's configPath option.
|
||||
const TEST_CONFIG_PATH = join(
|
||||
mkdtempSync(join(tmpdir(), "my-pi-rtk-test-")),
|
||||
"rtk-optimizer.json",
|
||||
);
|
||||
process.env.PI_RTK_CONFIG_PATH = TEST_CONFIG_PATH;
|
||||
const rtkIntegrationExtension = indexModule.createRtkOptimizerExtension({ configPath: TEST_CONFIG_PATH });
|
||||
|
||||
function writeTestConfig(commandRewritingEnabled: boolean): void {
|
||||
writeFileSync(
|
||||
@@ -401,6 +400,69 @@ await runTest("tool_result lifecycle merges compaction metadata with existing de
|
||||
assert.equal(notifications.length, 0);
|
||||
});
|
||||
|
||||
await runTest("nested tool calls and results (parentToolCallId) are left untouched", async () => {
|
||||
writeTestConfig(true);
|
||||
const handlers: Record<string, ExtensionHandler> = {};
|
||||
const notifications: Notification[] = [];
|
||||
let rewriteCalls = 0;
|
||||
|
||||
rtkIntegrationExtension({
|
||||
exec: async (_command: string, args: string[]) => {
|
||||
if (args[0] === "--version") {
|
||||
return { code: 0, stdout: "rtk 1.0.0", stderr: "" };
|
||||
}
|
||||
if (args[0] === "rewrite") {
|
||||
rewriteCalls += 1;
|
||||
return { code: 3, stdout: "rtk git status", stderr: "" };
|
||||
}
|
||||
return { code: 1, stdout: "", stderr: "unexpected" };
|
||||
},
|
||||
on(eventName: string, handler: ExtensionHandler) {
|
||||
handlers[eventName] = handler;
|
||||
},
|
||||
registerCommand() {},
|
||||
} as never);
|
||||
|
||||
const toolCallHandler = handlers.tool_call;
|
||||
const toolResultHandler = handlers.tool_result;
|
||||
assert.ok(toolCallHandler);
|
||||
assert.ok(toolResultHandler);
|
||||
|
||||
// A codemode script's nested bash result must survive intact: rewriting
|
||||
// content without structuredContent would drop bash's outputSchema payload.
|
||||
const nestedResult = await toolResultHandler(
|
||||
{
|
||||
toolName: "bash",
|
||||
parentToolCallId: "codemode-1",
|
||||
input: { command: "printf TODO" },
|
||||
content: [{ type: "text", text: "\x1B[31msrc/a.ts\n 1: TODO\x1B[0m\n" }],
|
||||
},
|
||||
createNotificationContext(notifications),
|
||||
);
|
||||
assert.ok(!nestedResult || !("content" in nestedResult));
|
||||
|
||||
const nestedCall = {
|
||||
toolName: "bash",
|
||||
parentToolCallId: "codemode-1",
|
||||
input: { command: "git status" },
|
||||
};
|
||||
await toolCallHandler(nestedCall, createNotificationContext(notifications));
|
||||
assert.equal(nestedCall.input.command, "git status");
|
||||
assert.equal(rewriteCalls, 0);
|
||||
|
||||
const directResult = await toolResultHandler(
|
||||
{
|
||||
toolName: "bash",
|
||||
input: { command: "printf TODO" },
|
||||
content: [{ type: "text", text: "\x1B[31msrc/a.ts\n 1: TODO\x1B[0m\n" }],
|
||||
},
|
||||
createNotificationContext(notifications),
|
||||
);
|
||||
assert.ok(directResult && "content" in directResult);
|
||||
assert.equal(firstText(directResult.content), "src/a.ts\n 1: TODO\n");
|
||||
writeTestConfig(false);
|
||||
});
|
||||
|
||||
await runTest("tool_call surfaces RTK rewrite errors through existing UI warning path", async () => {
|
||||
writeTestConfig(true);
|
||||
const handlers: Record<string, (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>> = {};
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./config-store.js";
|
||||
import { computeRewriteDecision } from "./command-rewriter.js";
|
||||
import { registerRtkIntegrationCommand } from "./command-register.js";
|
||||
import { EXTENSION_NAME } from "./constants.js";
|
||||
import { CONFIG_PATH, EXTENSION_NAME } from "./constants.js";
|
||||
import { createLazyModuleLoader } from "./lazy-module-loader.js";
|
||||
import { clearOutputMetrics, getOutputMetricsSummary } from "./output-metrics.js";
|
||||
import type { ToolResultCompactionMetadata } from "./output-compactor.js";
|
||||
@@ -103,8 +103,12 @@ export function createBoundedNoticeTracker(maxEntries: number): BoundedNoticeTra
|
||||
};
|
||||
}
|
||||
|
||||
export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
||||
const initialLoad = loadRtkIntegrationConfig();
|
||||
export interface RtkOptimizerExtensionOptions {
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
function rtkIntegrationExtension(pi: ExtensionAPI, configPath: string): void {
|
||||
const initialLoad = loadRtkIntegrationConfig(configPath);
|
||||
let config: RtkIntegrationConfig = initialLoad.config;
|
||||
if (!config.enabled) {
|
||||
return;
|
||||
@@ -200,12 +204,12 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
||||
};
|
||||
|
||||
const refreshConfig = async (ctx?: ExtensionContext | ExtensionCommandContext): Promise<void> => {
|
||||
const ensured = ensureConfigExists();
|
||||
const ensured = ensureConfigExists(configPath);
|
||||
if (ensured.error && ctx) {
|
||||
warnOnce(ctx, ensured.error);
|
||||
}
|
||||
|
||||
const loaded = loadRtkIntegrationConfig();
|
||||
const loaded = loadRtkIntegrationConfig(configPath);
|
||||
config = loaded.config;
|
||||
pendingLoadWarning = loaded.warning;
|
||||
await refreshRuntimeStatus();
|
||||
@@ -218,7 +222,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
||||
|
||||
const setConfig = (next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void => {
|
||||
config = normalizeRtkIntegrationConfig(next);
|
||||
const saved = saveRtkIntegrationConfig(config);
|
||||
const saved = saveRtkIntegrationConfig(config, configPath);
|
||||
if (!saved.success && saved.error) {
|
||||
ctx.ui.notify(saved.error, "error");
|
||||
}
|
||||
@@ -310,7 +314,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
||||
const controller = {
|
||||
getConfig: () => config,
|
||||
setConfig,
|
||||
getConfigPath: getRtkIntegrationConfigPath,
|
||||
getConfigPath: () => getRtkIntegrationConfigPath(configPath),
|
||||
getRuntimeStatus: () => runtimeStatus,
|
||||
refreshRuntimeStatus,
|
||||
getMetricsSummary: getOutputMetricsSummary,
|
||||
@@ -396,6 +400,10 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (event.parentToolCallId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!config.enabled || !config.commandRewritingEnabled) {
|
||||
return {};
|
||||
}
|
||||
@@ -455,6 +463,10 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
||||
});
|
||||
|
||||
pi.on("tool_result", async (event, ctx) => {
|
||||
if (event.parentToolCallId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!config.enabled || !config.outputCompaction.enabled) {
|
||||
return {};
|
||||
}
|
||||
@@ -485,3 +497,12 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createRtkOptimizerExtension(
|
||||
options?: RtkOptimizerExtensionOptions,
|
||||
): (pi: ExtensionAPI) => void {
|
||||
const configPath = options?.configPath ?? CONFIG_PATH;
|
||||
return (pi) => rtkIntegrationExtension(pi, configPath);
|
||||
}
|
||||
|
||||
export default createRtkOptimizerExtension();
|
||||
|
||||
+1
@@ -76,6 +76,7 @@ declare module "@earendil-works/pi-coding-agent" {
|
||||
input: Record<string, unknown>;
|
||||
content: Array<Record<string, unknown>>;
|
||||
details?: unknown;
|
||||
parentToolCallId?: string;
|
||||
}
|
||||
|
||||
export interface BashToolCallEvent {
|
||||
|
||||
Generated
+2
-2
@@ -19,9 +19,9 @@
|
||||
"@earendil-works/pi-coding-agent": "latest",
|
||||
"@earendil-works/pi-tui": "latest",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitest/coverage-istanbul": "^4.1.10",
|
||||
"@vitest/coverage-istanbul": "4.1.10",
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-ai": ">=0.81.0",
|
||||
|
||||
@@ -61,9 +61,9 @@
|
||||
"@earendil-works/pi-coding-agent": "latest",
|
||||
"@earendil-works/pi-tui": "latest",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitest/coverage-istanbul": "^4.1.10",
|
||||
"@vitest/coverage-istanbul": "4.1.10",
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
- 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `extensions/catalog.ts`,配置读取与路径解析位于 `extensions/config.ts`(直接读取组合包 `config/tool-search.json`;目录缓存仍在 `<agentDir>/my-pi/tool-search/catalog-v1.json`),测试保留在 `test/`。
|
||||
- 同步上游必须记录明确 tag/commit,保留 README、CHANGELOG、文档、作者和许可证说明;不得导入上游 `.pi`、嵌套 `.git`、`node_modules` 或构建产物。
|
||||
- 根包必须通过 `file:./pi-tool-search` 提供本地依赖并直接加载仓库源码,不得同时安装或加载 npm `pi-tool-search` 实现;同步上游时先核对本地增量动态工具改造,禁止用旧 npm 入口或上游旧宿主实现直接覆盖。
|
||||
- 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、六个记忆工具与 `context_tree_query`、`overflow_query`,以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。配置从已安装组合包内的 `config/tool-search.json` 直接读取,文件缺失或无效时回退 `BUNDLE_TOOL_SEARCH_DEFAULTS`;扩展自身不播种、不迁移,也不再读写 `settings.json` 的 `toolSearch` 段。
|
||||
- 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、六个记忆工具与 `context_tree_query`、`overflow_query`、内置 `codemode`(exposure `model-only`),以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。exposure 为 `hidden` 或 `codemode`(script-only、模型不可直达)的工具一律不得进入目录或常驻集合。配置从已安装组合包内的 `config/tool-search.json` 直接读取,文件缺失或无效时回退 `BUNDLE_TOOL_SEARCH_DEFAULTS`;扩展自身不播种、不迁移,也不再读写 `settings.json` 的 `toolSearch` 段。
|
||||
- 其余组合包工具必须优先使用 `extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。
|
||||
- 标准组合包工具全部命中预置目录时不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
|
||||
- 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。Tool Search 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决。
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
## Unreleased — my-pi maintained source
|
||||
|
||||
### Changed
|
||||
- Removed `multi_grep` from the `filesystem-navigation` group: the bundle wrapper keeps FFF's upstream `PI_FFF_MULTIGREP` opt-in disabled, so the tool is never registered and the group now covers only `ls`.
|
||||
- Pinned Pi's built-in `codemode` (exposure `model-only`) in `alwaysEnabled` so batching several tool calls through one script never depends on a `tool_search` activation; `platformToolPolicy` now also drops tools with `hidden` or `codemode` (script-only) exposure so they can never enter the catalog or the unknown-tools path.
|
||||
- Removed the `PI_TOOL_SEARCH_CONFIG_PATH` environment override; the extension reads the bundle `config/tool-search.json`, and tests inject a path through the `createToolSearchExtension({ configPath })` factory.
|
||||
- Removed the retired context-mode integration: the `ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` pins left `alwaysEnabled` and the four `context-*` bundle groups were dropped. Large tool results are now bounded by pi-overflow and recoverable through the pinned `overflow_query`.
|
||||
- Imported upstream `v0.3.6` at `ddfb23646fd3957b791214de278e23aa393c9b13` for direct local maintenance.
|
||||
- Migrated runtime imports to `@earendil-works/pi-coding-agent` and current `typebox`.
|
||||
|
||||
@@ -10,7 +10,7 @@ Generated metadata never replaces executable schemas. Exact names, parameter typ
|
||||
|
||||
## Catalog lifecycle
|
||||
|
||||
1. `session_start` keeps `tool_search`, Pi core tools, and configured `alwaysEnabled` names active. PowerShell is filtered out on non-Windows hosts; on Windows it is pinned beside Bash as the same local-shell capability class.
|
||||
1. `session_start` keeps `tool_search`, Pi core tools, and configured `alwaysEnabled` names active. PowerShell is filtered out on non-Windows hosts; on Windows it is pinned beside Bash as the same local-shell capability class. Tools whose exposure is `hidden` or `codemode` (script-only, unreachable by the model directly) are excluded entirely; `model-only` tools such as the built-in `codemode` stay reachable and can be pinned.
|
||||
2. Hidden tool definitions are hashed with the grouping constraints.
|
||||
3. Every tool exposed by the standard `my-pi` bundle is matched against the checked-in catalog in `extensions/bundle-groups.ts`. If all tools are recognized, no model call and no user cache are needed.
|
||||
4. User `groupOverrides` take priority over checked-in assignments. Optional or unavailable bundle tools are simply filtered out of their predefined groups.
|
||||
@@ -26,7 +26,7 @@ Run `/tool-search-status` to inspect the current catalog source, pinned count, d
|
||||
|
||||
| Group | Bundle tools |
|
||||
|---|---|
|
||||
| `filesystem-navigation` | `ls`, `multi_grep` |
|
||||
| `filesystem-navigation` | `ls` |
|
||||
| `ssh-connection` | `ssh_connect` |
|
||||
| `ssh-remote-files` | `ssh_read`, `ssh_write`, `ssh_edit` |
|
||||
| `ssh-remote-search` | `ssh_find`, `ssh_grep` |
|
||||
@@ -41,7 +41,7 @@ Run `/tool-search-status` to inspect the current catalog source, pinned count, d
|
||||
| `chrome-interaction` | Chrome click/type/fill/key/hover/drag/tap/scroll input |
|
||||
| `chrome-debugging` | Chrome page evaluation, console/network diagnostics, response retrieval, and file upload |
|
||||
|
||||
Pinned tools are removed from these dynamic groups at runtime. The defaults pin `codegraph_explore`, `lsp_diagnostics`, and all six Hermes memory tools, so none of them consume dynamic group or tool capacity.
|
||||
Pinned tools are removed from these dynamic groups at runtime. The defaults pin `codegraph_explore`, `lsp_diagnostics`, all six Hermes memory tools, `context_tree_query`, `overflow_query`, and `codemode`, so none of them consume dynamic group or tool capacity.
|
||||
|
||||
## Loading and eviction
|
||||
|
||||
@@ -81,7 +81,8 @@ Pi reads this configuration directly from the installed bundle's `config/tool-se
|
||||
"memory_remove",
|
||||
"skill_manage",
|
||||
"context_tree_query",
|
||||
"overflow_query"
|
||||
"overflow_query",
|
||||
"codemode"
|
||||
],
|
||||
"showToolSearchFooterStatus": false,
|
||||
"maxActiveGroups": 5,
|
||||
@@ -93,7 +94,7 @@ Pi reads this configuration directly from the installed bundle's `config/tool-se
|
||||
|
||||
| Key | Bundle default | Description |
|
||||
|---|---|---|
|
||||
| `alwaysEnabled` | CodeGraph explore, LSP diagnostics, all six Hermes memory tools, `context_tree_query`, `overflow_query` | Exact names pinned alongside Pi core tools |
|
||||
| `alwaysEnabled` | CodeGraph explore, LSP diagnostics, all six Hermes memory tools, `context_tree_query`, `overflow_query`, `codemode` | Exact names pinned alongside Pi core tools |
|
||||
| `showToolSearchFooterStatus` | `false` | Show active tool/group counts |
|
||||
| `maxActiveGroups` | `5` | Maximum simultaneously active dynamic groups |
|
||||
| `maxToolsPerGroup` | `8` | Maximum size accepted from model-generated groups |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
`pi-tool-search` keeps full low-frequency schemas hidden behind a compact, validated group catalog.
|
||||
|
||||
1. `session_start` activates `tool_search`, the six cross-platform Pi core tools, and configured `alwaysEnabled` names. The my-pi defaults also pin CodeGraph exploration, LSP diagnostics, and the six Hermes memory tools. On Windows, `powershell` is pinned beside `bash`; on non-Windows hosts it is excluded. Pinned tools do not consume the dynamic-group or dynamic-tool budget.
|
||||
1. `session_start` activates `tool_search`, the six cross-platform Pi core tools, and configured `alwaysEnabled` names. The my-pi defaults also pin CodeGraph exploration, LSP diagnostics, the six Hermes memory tools, and Pi's built-in `codemode` (exposure `model-only`). On Windows, `powershell` is pinned beside `bash`; on non-Windows hosts it is excluded. Tools with `hidden` or `codemode` (script-only) exposure are filtered out before catalog and pin processing. Pinned tools do not consume the dynamic-group or dynamic-tool budget.
|
||||
2. Hidden definitions are hashed with grouping constraints and matched against `extensions/bundle-groups.ts`. Available standard tools receive checked-in groups; unavailable optional tools are omitted.
|
||||
3. If every hidden tool is recognized, the bundle catalog is used directly. No model call or per-user cache is required.
|
||||
4. Additional tools receive immediate deterministic prefix/source groups. A matching model-enriched cache is reused only if it preserves all checked-in bundle assignments.
|
||||
|
||||
@@ -25,11 +25,11 @@ export const BUNDLE_GROUP_DEFINITIONS: BundleGroupDefinition[] = [
|
||||
{
|
||||
id: "filesystem-navigation",
|
||||
title: "Filesystem navigation",
|
||||
summary: "Inspect directory layouts and run bounded multi-pattern literal searches.",
|
||||
useWhen: ["You need directory entries or several literal searches after narrowing a path"],
|
||||
summary: "Inspect directory layouts.",
|
||||
useWhen: ["You need the alphabetical directory entries of an unknown path"],
|
||||
avoidWhen: ["Code relationships or types are the real question"],
|
||||
keywords: ["files", "directory", "list", "multi grep", "目录", "文件", "多模式搜索"],
|
||||
tools: ["ls", "multi_grep"],
|
||||
keywords: ["files", "directory", "list", "目录", "文件"],
|
||||
tools: ["ls"],
|
||||
},
|
||||
{
|
||||
id: "ssh-connection",
|
||||
|
||||
@@ -11,8 +11,6 @@ const TOOL_SEARCH_DIR = join("my-pi", "tool-search");
|
||||
* agent directory.
|
||||
*/
|
||||
export function getToolSearchConfigPath(): string {
|
||||
const override = process.env.PI_TOOL_SEARCH_CONFIG_PATH;
|
||||
if (override) return override;
|
||||
return fileURLToPath(new URL("../../config/tool-search.json", import.meta.url));
|
||||
}
|
||||
|
||||
@@ -33,6 +31,7 @@ export const BUNDLE_TOOL_SEARCH_DEFAULTS = {
|
||||
"skill_manage",
|
||||
"context_tree_query",
|
||||
"overflow_query",
|
||||
"codemode",
|
||||
],
|
||||
showToolSearchFooterStatus: false,
|
||||
maxActiveGroups: 5,
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
createBundleCatalog,
|
||||
type BundleCatalogResult,
|
||||
} from "./bundle-groups.ts";
|
||||
import { getToolSearchCatalogPath, readToolSearchConfig } from "./config.ts";
|
||||
import { getToolSearchCatalogPath, getToolSearchConfigPath, readToolSearchConfig } from "./config.ts";
|
||||
|
||||
const TOOL_SEARCH_NAME = "tool_search";
|
||||
const POWERSHELL_TOOL_NAME = "powershell";
|
||||
@@ -39,13 +39,19 @@ type CatalogSource = "bundle" | "cache" | "fallback" | "hybrid" | "model";
|
||||
type ModelUsage = Awaited<ReturnType<ExtensionContext["modelRegistry"]["complete"]>>["usage"];
|
||||
type CatalogGenerationResult = { notice?: string; usage?: ModelUsage };
|
||||
|
||||
export function platformToolPolicy<T extends { name: string }>(
|
||||
export function platformToolPolicy<T extends { name: string; exposure?: string }>(
|
||||
definitions: readonly T[],
|
||||
platform: NodeJS.Platform,
|
||||
): { tools: T[]; coreTools: string[] } {
|
||||
// Script-only and hidden tools are unreachable by the model directly and
|
||||
// must never enter the catalog (or the pinned set); "model-only" tools like
|
||||
// codemode stay reachable and can be pinned.
|
||||
const reachable = definitions.filter(
|
||||
(tool) => tool.exposure !== "hidden" && tool.exposure !== "codemode",
|
||||
);
|
||||
const tools = platform === "win32"
|
||||
? [...definitions]
|
||||
: definitions.filter((tool) => tool.name !== POWERSHELL_TOOL_NAME);
|
||||
? [...reachable]
|
||||
: reachable.filter((tool) => tool.name !== POWERSHELL_TOOL_NAME);
|
||||
const coreTools = platform === "win32"
|
||||
? [...CORE_TOOLS, POWERSHELL_TOOL_NAME]
|
||||
: [...CORE_TOOLS];
|
||||
@@ -110,11 +116,11 @@ function groupDescription(catalog: ToolCatalog, source: CatalogSource): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export default function toolSearchExtension(pi: ExtensionAPI): void {
|
||||
function toolSearchExtension(pi: ExtensionAPI, configPath: string): void {
|
||||
const agentDir = getAgentDir();
|
||||
const cachePath = getToolSearchCatalogPath(agentDir);
|
||||
|
||||
let config = readToolSearchConfig();
|
||||
let config = readToolSearchConfig(configPath);
|
||||
let tools: ToolInfo[] = [];
|
||||
let catalog: ToolCatalog = createFallbackCatalog([], "", {
|
||||
maxToolsPerGroup: Math.min(config.maxToolsPerGroup, config.maxDynamicTools),
|
||||
@@ -440,7 +446,7 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
|
||||
function refreshState(ctx: Pick<ExtensionContext, "ui">, forceReset: boolean): void {
|
||||
const nextConfig = readToolSearchConfig();
|
||||
const nextConfig = readToolSearchConfig(configPath);
|
||||
const registeredTools = pi.getAllTools().filter((tool) => tool.name !== TOOL_SEARCH_NAME);
|
||||
const platformPolicy = platformToolPolicy(registeredTools, process.platform);
|
||||
const allTools = platformPolicy.tools;
|
||||
@@ -542,3 +548,16 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
||||
if (group && activeGroups.has(group)) activeGroups.set(group, ++clock);
|
||||
});
|
||||
}
|
||||
|
||||
export interface ToolSearchExtensionOptions {
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
export function createToolSearchExtension(
|
||||
options?: ToolSearchExtensionOptions,
|
||||
): (pi: ExtensionAPI) => void {
|
||||
const configPath = options?.configPath ?? getToolSearchConfigPath();
|
||||
return (pi) => toolSearchExtension(pi, configPath);
|
||||
}
|
||||
|
||||
export default createToolSearchExtension();
|
||||
|
||||
@@ -10,6 +10,7 @@ function tool(name: string): ToolInfo {
|
||||
name,
|
||||
description: `Full description for ${name}`,
|
||||
parameters: { type: "object", properties: {} } as ToolInfo["parameters"],
|
||||
exposure: "direct",
|
||||
sourceInfo: { source: "extension", scope: "user", path: `/test/${name}.ts` } as unknown as ToolInfo["sourceInfo"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ function tool(name: string, description: string): ToolInfo {
|
||||
name,
|
||||
description,
|
||||
parameters: { type: "object", properties: { query: { type: "string" } } } as ToolInfo["parameters"],
|
||||
exposure: "direct",
|
||||
sourceInfo: { source: "extension", scope: "user", path: `/test/${name}.ts` } as unknown as ToolInfo["sourceInfo"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ test("bundle defaults keep the committed tool list", () => {
|
||||
"skill_manage",
|
||||
"context_tree_query",
|
||||
"overflow_query",
|
||||
"codemode",
|
||||
]);
|
||||
assert.deepEqual(bundleFallbackConfig(), {
|
||||
alwaysEnabled: [...BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled],
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import toolSearchExtension, { platformToolPolicy } from "../extensions/index.ts";
|
||||
import toolSearchExtension, { createToolSearchExtension, platformToolPolicy } from "../extensions/index.ts";
|
||||
|
||||
interface ToolSearchResult {
|
||||
details: {
|
||||
@@ -49,13 +49,12 @@ function testContext(overrides: Partial<ExtensionContext> = {}): ExtensionContex
|
||||
test("loads whole groups and evicts the least-recently-used group", async () => {
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-extension-"));
|
||||
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const previousConfigPath = process.env.PI_TOOL_SEARCH_CONFIG_PATH;
|
||||
const configPath = join(agentDir, "tool-search-config.json");
|
||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||
process.env.PI_TOOL_SEARCH_CONFIG_PATH = join(agentDir, "tool-search-config.json");
|
||||
|
||||
try {
|
||||
await writeFile(
|
||||
process.env.PI_TOOL_SEARCH_CONFIG_PATH,
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"],
|
||||
showToolSearchFooterStatus: false,
|
||||
@@ -97,7 +96,7 @@ test("loads whole groups and evicts the least-recently-used group", async () =>
|
||||
on: (event: string, handler: (...args: any[]) => unknown) => handlers.set(event, handler),
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
toolSearchExtension(api);
|
||||
createToolSearchExtension({ configPath })(api);
|
||||
const ctx = testContext({
|
||||
hasUI: true,
|
||||
ui: {
|
||||
@@ -159,8 +158,6 @@ test("loads whole groups and evicts the least-recently-used group", async () =>
|
||||
} finally {
|
||||
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
||||
if (previousConfigPath === undefined) delete process.env.PI_TOOL_SEARCH_CONFIG_PATH;
|
||||
else process.env.PI_TOOL_SEARCH_CONFIG_PATH = previousConfigPath;
|
||||
await rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -328,6 +325,57 @@ test("keeps PowerShell with Bash only on Windows", () => {
|
||||
assert.ok(windows.coreTools.includes("powershell"));
|
||||
});
|
||||
|
||||
test("drops hidden and script-only tools but keeps reachable exposures", () => {
|
||||
const definitions = [
|
||||
sourceTool("read", "Read a file"),
|
||||
{ ...sourceTool("hidden_helper", "Internal helper"), exposure: "hidden" },
|
||||
{ ...sourceTool("script_only", "Only callable from scripts"), exposure: "codemode" },
|
||||
{ ...sourceTool("codemode", "Batch several tool calls in one script"), exposure: "model-only" },
|
||||
{ ...sourceTool("lazy_tool", "Activated on demand"), exposure: "deferred" },
|
||||
];
|
||||
|
||||
const { tools } = platformToolPolicy(definitions, "darwin");
|
||||
assert.deepEqual(tools.map((tool) => tool.name), ["read", "codemode", "lazy_tool"]);
|
||||
});
|
||||
|
||||
test("pins a model-only codemode tool instead of hiding it", async () => {
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-codemode-"));
|
||||
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||
|
||||
try {
|
||||
const handlers = new Map<string, (...args: any[]) => unknown>();
|
||||
const registered = new Map<string, RegisteredTool>();
|
||||
const activeCalls: string[][] = [];
|
||||
const sourceTools = [
|
||||
sourceTool("read", "Read a file"),
|
||||
{ ...sourceTool("codemode", "Batch several tool calls in one script"), exposure: "model-only" },
|
||||
];
|
||||
const api = {
|
||||
getAllTools: () => sourceTools,
|
||||
registerTool: (definition: unknown) => {
|
||||
const tool = definition as RegisteredTool & { name: string };
|
||||
registered.set(tool.name, tool);
|
||||
},
|
||||
registerCommand: () => {},
|
||||
setActiveTools: (names: string[]) => activeCalls.push([...names]),
|
||||
on: (event: string, handler: (...args: any[]) => unknown) => handlers.set(event, handler),
|
||||
} as unknown as ExtensionAPI;
|
||||
// The bundle config pins "codemode" in alwaysEnabled; exposure "model-only"
|
||||
// stays reachable, so it must land in the active set, not a dynamic group.
|
||||
toolSearchExtension(api);
|
||||
handlers.get("session_start")?.({}, testContext());
|
||||
|
||||
assert.ok(activeCalls.at(-1)?.includes("codemode"));
|
||||
assert.ok(activeCalls.at(-1)?.includes("tool_search"));
|
||||
assert.ok(activeCalls.at(-1)?.includes("read"));
|
||||
} finally {
|
||||
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
||||
await rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("loads an exact known group without waiting for unknown-tool model enrichment", async () => {
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-fast-path-"));
|
||||
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
- `web_fetch` 固定走 Exa `web_fetch_exa`,失败时静默降级 Keenable → Tavily;Exa 搜索统一走 `web_search_advanced_exa` 并显式约束 `numResults` 与 `textMaxCharacters`;Tavily 默认 `search_depth: basic` 且必须在 `auth.json` 存在 `tavily` api_key 凭据才参与路由;Keenable keyless 可用,`keenable` 凭据仅提高速率限制。
|
||||
- `clients/` 为纯 `fetch` 直连,不使用 `pi-mcp-adapter` 或任何 MCP SDK:Tavily 走 `api.tavily.com`(必须有 key);Keenable 复刻上游 client,保留 keyless `/public` 端点与 SSRF 防护;Exa 直接对 `https://mcp.exa.ai/mcp` 发 JSON-RPC——`initialize` 一次并缓存 `mcp-session-id`,随后 `tools/call`,HTTP 404 视为会话失效自动重建并重试一次;`exa` 凭据通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。
|
||||
- 搜索 key 统一保存在 Pi 的 `auth.json`(`tavily`/`exa`/`keenable` 各一条 `api_key` 凭据,文件权限 `600` 且不得提交);key 支持 `!command` 执行、`$VAR`/`${VAR}` 插值间接寻址,`$$`/`$!` 转义。credential `env` 优先于进程 env;显式进程环境变量不再直接读取,env 注入通过 `"key": "$VAR"` 表达。
|
||||
- `configure.sh search` 与 `scripts/search-auth.mjs` 以交互或参数方式写入凭据:加锁后原子写、权限 `600`、保留全部其他条目,支持 `--migrate` 从旧 `search.env` 一次性导入;只输出配置状态,禁止回显 key。`auth.ts` 每次调用读取 `auth.json`。
|
||||
- `configure.sh search` 与 `scripts/search-auth.mjs` 以交互或标准输入方式写入凭据,key 不得出现在命令参数中:加锁后原子写、权限 `600`、保留全部其他条目,支持 `--migrate` 从旧 `search.env` 一次性导入;只输出配置状态,禁止回显 key。`auth.ts` 每次调用读取 `auth.json`。
|
||||
|
||||
@@ -30,7 +30,10 @@ Keys live in Pi's `auth.json` as one `api_key` credential per provider
|
||||
(`tavily`, `exa`, `keenable`), read by `auth.ts` with upstream resolution
|
||||
semantics — literal values, `$VAR`/`${VAR}` interpolation, and `!command`
|
||||
indirection (e.g. `"!op read op://Vault/Tavily/key"`). `configure.sh search`
|
||||
writes them (atomically, mode 600, preserving all other entries); process
|
||||
writes them via its hidden interactive prompt or `scripts/search-auth.mjs
|
||||
--set-stdin` reading the key from stdin — a key is never accepted as a
|
||||
command-line argument (atomically, mode 600, preserving all other entries);
|
||||
process
|
||||
environment variables are not read directly — use `"key": "$TAVILY_API_KEY"`
|
||||
to indirect through env. `KEENABLE_API_URL` stays env-only, or can be stored
|
||||
in the keenable credential's `env` map.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 上游来源
|
||||
|
||||
- 本项目完全自维护,无上游;未从任何外部仓库或 npm 包导入源码。
|
||||
- `clients/` 下 Tavily、Exa、Keenable client 是按上游语义复刻的纯 `fetch` 直连实现,遵循各自 upstream 的接口语义,未导入上游源码。
|
||||
- 实现仅依赖 Pi 扩展宿主 API(`@earendil-works/pi-coding-agent`)与 `typebox`,两者均为组合包 peer 依赖。
|
||||
|
||||
## 本地改动记录
|
||||
|
||||
- 初始实现:`index.ts`(`web_search` / `web_fetch` 统一入口与工具注册)、`router.ts`(当前主模型按内置规则选择 provider)、`clients/`(Tavily / Exa / Keenable 直连)、`auth.ts`(auth.json 凭据解析)。
|
||||
@@ -0,0 +1,8 @@
|
||||
# 上游来源
|
||||
|
||||
- 本项目完全自维护,无上游;未从任何外部仓库或 npm 包导入源码。
|
||||
- 实现仅依赖 Pi 扩展宿主 API(`@earendil-works/pi-coding-agent`、`@earendil-works/pi-tui`)与 `typebox`,均为组合包 peer 依赖;git 操作经 `execFile` argv 直接调用系统 `git`。
|
||||
|
||||
## 本地改动记录
|
||||
|
||||
- 初始实现:`index.ts`(`/wt` 斜杠命令入口)、`src/`(worktree 生命周期、状态登记、会话切换与环境复刻)。
|
||||
+4
-4
@@ -17,16 +17,16 @@ warn() {
|
||||
|
||||
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
|
||||
if command -v kitty >/dev/null 2>&1; then
|
||||
kitty_bin=$(command -v kitty)
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
+15
-14
@@ -5,7 +5,7 @@
|
||||
* writes atomically with a lock directory, and keeps the file mode 600.
|
||||
*
|
||||
* Usage:
|
||||
* node search-auth.mjs --set tavily=KEY [--set exa=KEY] [--set keenable=KEY]
|
||||
* node search-auth.mjs --set-stdin tavily (reads the key from stdin)
|
||||
* node search-auth.mjs --unset tavily [--unset exa]
|
||||
* node search-auth.mjs --migrate [search.env path]
|
||||
* node search-auth.mjs --list
|
||||
@@ -47,7 +47,7 @@ function die(message) {
|
||||
|
||||
function usage() {
|
||||
console.log(`用法:
|
||||
node search-auth.mjs --set <provider>=<key> [--set ...]
|
||||
node search-auth.mjs --set-stdin <provider> (从标准输入读取 key,每次只能配置一个 provider)
|
||||
node search-auth.mjs --unset <provider> [--unset ...]
|
||||
node search-auth.mjs --migrate [search.env 路径]
|
||||
node search-auth.mjs --list
|
||||
@@ -69,17 +69,16 @@ function defaultSearchEnvPath(env = process.env) {
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = { sets: {}, unsets: new Set(), migrate: undefined, list: false, has: undefined, authPath: undefined };
|
||||
const result = { setStdin: undefined, unsets: new Set(), migrate: undefined, list: false, has: undefined, authPath: undefined };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--set") {
|
||||
const spec = argv[++i];
|
||||
const separator = spec ? spec.indexOf("=") : -1;
|
||||
if (separator < 1) die("--set 需要 <provider>=<key>");
|
||||
const provider = spec.slice(0, separator);
|
||||
const value = spec.slice(separator + 1);
|
||||
die("--set 已移除:key 不得出现在命令行参数中;请改用 --set-stdin 从标准输入提供。");
|
||||
} else if (arg === "--set-stdin") {
|
||||
const provider = argv[++i];
|
||||
if (!PROVIDERS.includes(provider)) die(`未知 provider:${provider}(支持 ${PROVIDERS.join("/")})`);
|
||||
result.sets[provider] = value;
|
||||
if (result.setStdin) die("每次调用最多一个 --set-stdin。");
|
||||
result.setStdin = provider;
|
||||
} else if (arg === "--unset") {
|
||||
const provider = argv[++i];
|
||||
if (!PROVIDERS.includes(provider)) die(`未知 provider:${provider}(支持 ${PROVIDERS.join("/")})`);
|
||||
@@ -208,12 +207,15 @@ if (args.has) {
|
||||
process.exit(hasKey(readAuthData(authPath), args.has) ? 0 : 1);
|
||||
}
|
||||
|
||||
const mutations = Object.keys(args.sets).length + args.unsets.size + (args.migrate ? 1 : 0);
|
||||
const mutations = (args.setStdin ? 1 : 0) + args.unsets.size + (args.migrate ? 1 : 0);
|
||||
if (mutations === 0) {
|
||||
usage();
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const stdinKey = args.setStdin ? readFileSync(0, "utf8").trim() : undefined;
|
||||
if (args.setStdin) validateKey(args.setStdin, stdinKey);
|
||||
|
||||
const release = acquireLock(authPath);
|
||||
try {
|
||||
const data = readAuthData(authPath);
|
||||
@@ -225,10 +227,9 @@ try {
|
||||
changed.push(`已移除 ${provider}`);
|
||||
}
|
||||
}
|
||||
for (const [provider, key] of Object.entries(args.sets)) {
|
||||
validateKey(provider, key);
|
||||
setKey(data, provider, key);
|
||||
changed.push(`已写入 ${provider}`);
|
||||
if (args.setStdin) {
|
||||
setKey(data, args.setStdin, stdinKey);
|
||||
changed.push(`已写入 ${args.setStdin}`);
|
||||
}
|
||||
if (args.migrate) {
|
||||
const source = args.migrate === true ? defaultSearchEnvPath() : args.migrate;
|
||||
|
||||
@@ -13,13 +13,13 @@ import {
|
||||
getToolSearchCatalogPath,
|
||||
getToolSearchConfigPath,
|
||||
} from "../pi-tool-search/extensions/config.ts";
|
||||
import {
|
||||
CONFIG_PATH as RTK_CONFIG_PATH,
|
||||
resolveConfigPath as resolveRtkConfigPath,
|
||||
} from "../pi-rtk-optimizer/src/constants.ts";
|
||||
import { CONFIG_PATH as RTK_CONFIG_PATH } from "../pi-rtk-optimizer/src/constants.ts";
|
||||
import { CONFIG_PATH as COMPACTION_CONFIG_PATH } from "../pi-compaction/src/config.ts";
|
||||
import { WORKTREE_CONFIG_PATH } from "../pi-worktree/src/settings.ts";
|
||||
import { resolvePreamblePath } from "../extensions/system-prompt-config.ts";
|
||||
import {
|
||||
resolvePreamblePath,
|
||||
stripJsoncComments,
|
||||
} from "../extensions/system-prompt-config.ts";
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const bundleConfigDir = join(repositoryRoot, "config");
|
||||
@@ -51,9 +51,9 @@ const BUNDLE_CONFIG_FILES = [
|
||||
"p10k.zsh",
|
||||
];
|
||||
|
||||
/** Strip JSONC comments (line and block) — same approach as the seed test. */
|
||||
/** Strip JSONC comments (line and block) without touching string literals. */
|
||||
function stripJsonc(raw: string): string {
|
||||
return raw.replace(/^\s*\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
return stripJsoncComments(raw);
|
||||
}
|
||||
|
||||
test("every bundle config file exists and is non-empty", async () => {
|
||||
@@ -75,11 +75,6 @@ test("every bundle JSON/JSONC config file parses", async () => {
|
||||
});
|
||||
|
||||
test("each extension resolves its config path inside the bundle config/ dir", () => {
|
||||
const savedToolSearch = process.env.PI_TOOL_SEARCH_CONFIG_PATH;
|
||||
const savedRtk = process.env.PI_RTK_CONFIG_PATH;
|
||||
delete process.env.PI_TOOL_SEARCH_CONFIG_PATH;
|
||||
delete process.env.PI_RTK_CONFIG_PATH;
|
||||
try {
|
||||
const inBundleConfig = (resolved: string, file: string) =>
|
||||
assert.equal(
|
||||
resolved,
|
||||
@@ -91,17 +86,10 @@ test("each extension resolves its config path inside the bundle config/ dir", ()
|
||||
inBundleConfig(getHardGuardConfigPath(), "pi-permission-hard-guards.jsonc");
|
||||
inBundleConfig(getToolSearchConfigPath(), "tool-search.json");
|
||||
inBundleConfig(RTK_CONFIG_PATH, "rtk-optimizer.json");
|
||||
inBundleConfig(resolveRtkConfigPath(), "rtk-optimizer.json");
|
||||
inBundleConfig(COMPACTION_CONFIG_PATH, "pi-compaction.json");
|
||||
inBundleConfig(WORKTREE_CONFIG_PATH, "pi-worktree.json");
|
||||
inBundleConfig(HERMES_CONFIG_PATH, "pi-hermes-memory.json");
|
||||
inBundleConfig(resolvePreamblePath(), "prompt/preamble.md");
|
||||
} finally {
|
||||
if (savedToolSearch === undefined) delete process.env.PI_TOOL_SEARCH_CONFIG_PATH;
|
||||
else process.env.PI_TOOL_SEARCH_CONFIG_PATH = savedToolSearch;
|
||||
if (savedRtk === undefined) delete process.env.PI_RTK_CONFIG_PATH;
|
||||
else process.env.PI_RTK_CONFIG_PATH = savedRtk;
|
||||
}
|
||||
});
|
||||
|
||||
test("disposable runtime state stays under the agent-dir my-pi tree", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import {
|
||||
execFileSync,
|
||||
spawn,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
@@ -278,6 +280,38 @@ test("client close() terminates the child process", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a stdin EPIPE after child death rejects pending requests without throwing", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-"));
|
||||
const child = new EventEmitter() as ChildProcess;
|
||||
child.stdin = new PassThrough();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.exitCode = null;
|
||||
child.signalCode = null;
|
||||
const client = new CodegraphRpcClient({
|
||||
root: dir,
|
||||
spawn: () => child,
|
||||
requestTimeoutMs: 5_000,
|
||||
});
|
||||
try {
|
||||
const start = client.start();
|
||||
await sleep(20);
|
||||
// Without the stdin "error" listener this emit throws uncaught.
|
||||
child.stdin.emit(
|
||||
"error",
|
||||
Object.assign(new Error("write EPIPE"), { code: "EPIPE" }),
|
||||
);
|
||||
child.exitCode = 1;
|
||||
child.emit("exit", 1, null);
|
||||
child.emit("close", 1, null);
|
||||
await assert.rejects(start, /子进程退出/);
|
||||
assert.equal(client.dead, true);
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── handler ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test("indexed project explores through the serve child", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -58,6 +58,11 @@ test("all package extensions load together without global registration conflicts
|
||||
packageJson.pi.extensions.includes("./extensions/codegraph.ts"),
|
||||
"the bundle must expose the native codegraph_explore tool",
|
||||
);
|
||||
assert.equal(
|
||||
packageJson.pi.extensions.indexOf("./extensions/bash-override.ts"),
|
||||
packageJson.pi.extensions.indexOf("./extensions/fff-override.ts") + 1,
|
||||
"the bash description override must register right after fff-override",
|
||||
);
|
||||
assert.ok(
|
||||
packageJson.pi.extensions.includes("./pi-web-search/index.ts"),
|
||||
"the bundle must load the unified pi-web-search tools",
|
||||
@@ -292,9 +297,61 @@ test("all package extensions load together without global registration conflicts
|
||||
const home = await mkdtemp(join(tmpdir(), "my-pi-extension-load-"));
|
||||
const mockBin = join(home, "bin");
|
||||
await mkdir(mockBin, { recursive: true });
|
||||
const args = ["--no-extensions"];
|
||||
// Probe loaded LAST: records every registered tool (name, exposure,
|
||||
// description, source) and the final active set so the test can assert the
|
||||
// pinned built-in codemode. Its async setup also runs the bash override's
|
||||
// registered definition end to end — executing `echo hi` through the same
|
||||
// delegation path a model call would use.
|
||||
const probeOutput = join(home, "probe-tools.json");
|
||||
const probePath = join(home, "probe.ts");
|
||||
await writeFile(
|
||||
probePath,
|
||||
[
|
||||
'import { writeFileSync } from "node:fs";',
|
||||
`import bashOverrideExtension from ${JSON.stringify(join(repositoryRoot, "extensions", "bash-override.ts"))};`,
|
||||
"export default async function (pi: any): Promise<void> {",
|
||||
"\tlet executed: unknown;",
|
||||
"\ttry {",
|
||||
"\t\tlet registered: any;",
|
||||
"\t\tbashOverrideExtension({ registerTool: (tool: any) => { registered = tool; } } as any);",
|
||||
"\t\tconst result = await registered.execute(",
|
||||
'\t\t\t"probe-1",',
|
||||
'\t\t\t{ command: "echo hi" },',
|
||||
"\t\t\tundefined,",
|
||||
"\t\t\tundefined,",
|
||||
`\t\t\t{ cwd: ${JSON.stringify(home)}, sessionManager: { getSessionId: () => "probe-session", getSessionFile: () => undefined } } as any,`,
|
||||
"\t\t);",
|
||||
"\t\texecuted = {",
|
||||
"\t\t\ttext: result?.content?.[0]?.text,",
|
||||
"\t\t\texitCode: result?.structuredContent?.exit_code,",
|
||||
"\t\t\thasOutputSchema: result?.structuredContent !== undefined,",
|
||||
"\t\t\tregisteredDescription: registered?.description,",
|
||||
"\t\t};",
|
||||
"\t} catch (error) {",
|
||||
"\t\texecuted = { error: String(error) };",
|
||||
"\t}",
|
||||
'\tpi.on("session_start", () => {',
|
||||
"\t\twriteFileSync(",
|
||||
`\t\t\t${JSON.stringify(probeOutput)},`,
|
||||
"\t\t\tJSON.stringify({",
|
||||
"\t\t\t\tall: pi.getAllTools().map((tool: any) => ({",
|
||||
"\t\t\t\t\tname: tool.name,",
|
||||
"\t\t\t\t\texposure: tool.exposure,",
|
||||
"\t\t\t\t\tdescription: tool.description,",
|
||||
"\t\t\t\t\tsourcePath: tool.sourceInfo?.path,",
|
||||
"\t\t\t\t})),",
|
||||
"\t\t\t\tactive: pi.getActiveTools(),",
|
||||
"\t\t\t\texecuted,",
|
||||
"\t\t\t}),",
|
||||
"\t\t);",
|
||||
"\t});",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const args = ["--no-extensions", "-e", "builtin:codemode"];
|
||||
for (const entry of packageJson.pi.extensions) args.push("-e", resolve(repositoryRoot, entry));
|
||||
args.push("--mode", "rpc");
|
||||
args.push("-e", probePath, "--mode", "rpc");
|
||||
|
||||
const loadEnvironment = {
|
||||
...process.env,
|
||||
@@ -313,4 +370,54 @@ test("all package extensions load together without global registration conflicts
|
||||
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension|conflicts with/u);
|
||||
|
||||
const probe = JSON.parse(await readFile(probeOutput, "utf8")) as {
|
||||
all: { name: string; exposure?: string; description?: string; sourcePath?: string }[];
|
||||
active: string[];
|
||||
executed?: {
|
||||
text?: string;
|
||||
exitCode?: number;
|
||||
hasOutputSchema?: boolean;
|
||||
registeredDescription?: string;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
const codemode = probe.all.find((tool) => tool.name === "codemode");
|
||||
assert.equal(codemode?.exposure, "model-only", "Pi's built-in codemode must load with model-only exposure");
|
||||
assert.ok(probe.active.includes("codemode"), "Tool Search must pin codemode into the active set");
|
||||
assert.ok(
|
||||
probe.all.every((tool) => tool.name !== "multi_grep"),
|
||||
"FFF's upstream-flagged multi_grep must stay unregistered (fff-override deletes PI_FFF_MULTIGREP)",
|
||||
);
|
||||
assert.ok(
|
||||
!probe.active.includes("multi_grep"),
|
||||
"multi_grep must not appear in the active tool set",
|
||||
);
|
||||
const bash = probe.all.find((tool) => tool.name === "bash");
|
||||
assert.ok(bash, "the bundle must expose the bash tool");
|
||||
assert.ok(
|
||||
bash.description?.startsWith("Execute a bash command in the current working directory."),
|
||||
"the bash override must keep Pi's upstream description prefix",
|
||||
);
|
||||
assert.ok(
|
||||
bash.description?.endsWith("which triggers a permission review."),
|
||||
"the registered bash description must end with the tools.jsonc descriptionAppend",
|
||||
);
|
||||
assert.ok(
|
||||
(bash.sourcePath ?? "").endsWith("extensions/bash-override.ts"),
|
||||
`bash must be registered by extensions/bash-override.ts, got ${bash.sourcePath}`,
|
||||
);
|
||||
const executed = probe.executed ?? {};
|
||||
assert.equal(executed.error, undefined, `bash override execute must not throw: ${executed.error}`);
|
||||
assert.equal(executed.exitCode, 0, "echo hi must exit 0 through the delegated definition");
|
||||
assert.match(executed.text ?? "", /hi/);
|
||||
assert.equal(
|
||||
executed.hasOutputSchema,
|
||||
true,
|
||||
"the bash override must keep the upstream outputSchema (structuredContent for codemode)",
|
||||
);
|
||||
assert.ok(
|
||||
executed.registeredDescription?.includes("Not for searching files"),
|
||||
"the catalog descriptionAppend must reach the registered bash description",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { accessSync, chmodSync, constants } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const LIB_SH = fileURLToPath(new URL("../scripts/lib.sh", import.meta.url));
|
||||
const SYSTEM_KITTY = "/Applications/kitty.app/Contents/MacOS/kitty";
|
||||
|
||||
function systemKittyPath(): string | undefined {
|
||||
try {
|
||||
accessSync(SYSTEM_KITTY, constants.X_OK);
|
||||
return SYSTEM_KITTY;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function runDetectKitty(home: string, pathDir: string) {
|
||||
return spawnSync(
|
||||
"sh",
|
||||
["-c", `. "$1"; detect_kitty; status=$?; printf '%s' "$kitty_bin"; exit "$status"`, "sh", LIB_SH],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, HOME: home, PATH: `${pathDir}:${process.env.PATH}` },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("detect_kitty prefers an official install over a PATH kitty", async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), "my-pi-libsh-"));
|
||||
try {
|
||||
const official = join(home, ".local", "kitty.app", "bin", "kitty");
|
||||
const pathDir = join(home, "pathbin");
|
||||
await mkdir(dirname(official), { recursive: true });
|
||||
await mkdir(pathDir, { recursive: true });
|
||||
await writeFile(official, "#!/bin/sh\n");
|
||||
chmodSync(official, 0o755);
|
||||
await symlink(official, join(pathDir, "kitty"));
|
||||
|
||||
const result = runDetectKitty(home, pathDir);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
// A real /Applications kitty also wins over $HOME/.local and PATH.
|
||||
assert.equal(result.stdout, systemKittyPath() ?? official);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("detect_kitty falls back to a PATH kitty when no official install exists", async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), "my-pi-libsh-"));
|
||||
try {
|
||||
const pathDir = join(home, "pathbin");
|
||||
const pathKitty = join(pathDir, "kitty");
|
||||
await mkdir(pathDir, { recursive: true });
|
||||
await writeFile(pathKitty, "#!/bin/sh\n");
|
||||
chmodSync(pathKitty, 0o755);
|
||||
|
||||
const result = runDetectKitty(home, pathDir);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
// The isolated HOME has no ~/.local install; only a machine-level
|
||||
// /Applications kitty (when present) still takes precedence.
|
||||
assert.equal(result.stdout, systemKittyPath() ?? pathKitty);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -43,6 +43,19 @@ test("every declared extension entry and file: dependency manifest is inside the
|
||||
}
|
||||
});
|
||||
|
||||
test("every file: dependency ships an UPSTREAM.md source record", () => {
|
||||
for (const [name, specifier] of Object.entries(packageJson.dependencies)) {
|
||||
const match = /^file:\.\/(.+)$/u.exec(specifier);
|
||||
if (!match) continue;
|
||||
const upstream = `${match[1]}/UPSTREAM.md`;
|
||||
assert.ok(
|
||||
readFileSync(join(repositoryRoot, upstream), "utf8").trim().length > 0,
|
||||
`${name} is a file: dependency — ${upstream} must exist and be non-empty`,
|
||||
);
|
||||
assert.ok(packedFiles.has(upstream), `${name} is a file: dependency — ${upstream} must be packed`);
|
||||
}
|
||||
});
|
||||
|
||||
test("relative import specifiers inside packed sources resolve to packed files", () => {
|
||||
const specifierPatterns = [
|
||||
/\bfrom\s+["'](\.{1,2}\/[^"']+)["']/gu,
|
||||
|
||||
+66
-12
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -142,19 +143,25 @@ test("template parser handles env names, braces and escapes", () => {
|
||||
const AUTH_MJS = new URL("../scripts/search-auth.mjs", import.meta.url).pathname;
|
||||
const CONFIGURE_SH = new URL("../configure.sh", import.meta.url).pathname;
|
||||
|
||||
function runMjs(args: string[], env: Record<string, string> = {}) {
|
||||
return spawnSync("node", [AUTH_MJS, ...args], { encoding: "utf8", env: { ...process.env, ...env } });
|
||||
function runMjs(args: string[], env: Record<string, string> = {}, input?: string) {
|
||||
return spawnSync("node", [AUTH_MJS, ...args], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...env },
|
||||
...(input === undefined ? {} : { input }),
|
||||
});
|
||||
}
|
||||
|
||||
test("search-auth.mjs --set writes api_key entries and preserves other providers", async () => {
|
||||
test("search-auth.mjs --set-stdin writes api_key entries and preserves other providers", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
await writeFile(
|
||||
authPath,
|
||||
JSON.stringify({ anthropic: { type: "oauth", access: "tok", refresh: "ref", expires: 42, accountId: "acc" } }),
|
||||
);
|
||||
const result = runMjs(["--auth", authPath, "--set", "tavily=tvly-new", "--set", "exa=!op read x"]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const first = runMjs(["--auth", authPath, "--set-stdin", "tavily"], {}, "tvly-new\n");
|
||||
assert.equal(first.status, 0, first.stderr);
|
||||
const second = runMjs(["--auth", authPath, "--set-stdin", "exa"], {}, " !op read x \n");
|
||||
assert.equal(second.status, 0, second.stderr);
|
||||
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
|
||||
assert.deepEqual(written.anthropic, { type: "oauth", access: "tok", refresh: "ref", expires: 42, accountId: "acc" });
|
||||
assert.deepEqual(written.tavily, { type: "api_key", key: "tvly-new" });
|
||||
@@ -163,14 +170,33 @@ test("search-auth.mjs --set writes api_key entries and preserves other providers
|
||||
assert.equal(getSearchApiKey("tavily", { authPath }), "tvly-new");
|
||||
});
|
||||
|
||||
test("search-auth.mjs preserves an existing credential env map on --set", async () => {
|
||||
test("search-auth.mjs --set is rejected: keys never travel in argv", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
const result = runMjs(["--auth", authPath, "--set", "tavily=tvly-leak"]);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /命令行参数|--set-stdin/);
|
||||
assert.equal(existsSync(authPath), false);
|
||||
});
|
||||
|
||||
test("search-auth.mjs allows only one --set-stdin and rejects empty stdin", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
const dup = runMjs(["--auth", authPath, "--set-stdin", "tavily", "--set-stdin", "exa"], {}, "key");
|
||||
assert.notEqual(dup.status, 0);
|
||||
const empty = runMjs(["--auth", authPath, "--set-stdin", "tavily"], {}, " \n");
|
||||
assert.notEqual(empty.status, 0);
|
||||
assert.equal(existsSync(authPath), false);
|
||||
});
|
||||
|
||||
test("search-auth.mjs preserves an existing credential env map on --set-stdin", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
await writeFile(
|
||||
authPath,
|
||||
JSON.stringify({ keenable: { type: "api_key", key: "old", env: { KEENABLE_API_URL: "https://k.example" } } }),
|
||||
);
|
||||
const result = runMjs(["--auth", authPath, "--set", "keenable=new-key"]);
|
||||
const result = runMjs(["--auth", authPath, "--set-stdin", "keenable"], {}, "new-key");
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
|
||||
assert.deepEqual(written.keenable, {
|
||||
@@ -183,7 +209,7 @@ test("search-auth.mjs preserves an existing credential env map on --set", async
|
||||
test("search-auth.mjs creates a missing auth.json and honors PI_CODING_AGENT_DIR", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const agentDir = join(dir, "agent");
|
||||
const result = runMjs(["--set", "keenable=keen-1"], { PI_CODING_AGENT_DIR: agentDir });
|
||||
const result = runMjs(["--set-stdin", "keenable"], { PI_CODING_AGENT_DIR: agentDir }, "keen-1");
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const written = JSON.parse(await readFile(join(agentDir, "auth.json"), "utf8")) as Record<string, any>;
|
||||
assert.deepEqual(written.keenable, { type: "api_key", key: "keen-1" });
|
||||
@@ -204,7 +230,7 @@ test("search-auth.mjs refuses to clobber a malformed auth.json", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
await writeFile(authPath, "{oops");
|
||||
const result = runMjs(["--auth", authPath, "--set", "tavily=x"]);
|
||||
const result = runMjs(["--auth", authPath, "--set-stdin", "tavily"], {}, "x");
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /不是合法 JSON|abort|clobber/i);
|
||||
assert.equal(await readFile(authPath, "utf8"), "{oops");
|
||||
@@ -238,15 +264,43 @@ test("search-auth.mjs --has and --list never print key material", async () => {
|
||||
assert.doesNotMatch(list.stdout, /supersecret/);
|
||||
});
|
||||
|
||||
test("configure.sh search writes keys through search-auth.mjs noninteractively", async () => {
|
||||
test("configure.sh search writes keys through search-auth.mjs via stdin", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
const result = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "tvly-sh", "--exa", "exa-sh"], {
|
||||
const first = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "-"], {
|
||||
encoding: "utf8",
|
||||
input: "tvly-sh\n",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(first.status, 0, first.stderr);
|
||||
const second = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--exa", "-"], {
|
||||
encoding: "utf8",
|
||||
input: "exa-sh",
|
||||
});
|
||||
assert.equal(second.status, 0, second.stderr);
|
||||
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
|
||||
assert.deepEqual(written.tavily, { type: "api_key", key: "tvly-sh" });
|
||||
assert.deepEqual(written.exa, { type: "api_key", key: "exa-sh" });
|
||||
assert.equal((await stat(authPath)).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
test("configure.sh search rejects a plaintext key argument", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
const result = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "tvly-leak"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /命令行参数/);
|
||||
assert.equal(existsSync(authPath), false);
|
||||
});
|
||||
|
||||
test("configure.sh search allows at most one stdin provider per call", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
|
||||
const authPath = join(dir, "auth.json");
|
||||
const result = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "-", "--exa", "-"], {
|
||||
encoding: "utf8",
|
||||
input: "one-key",
|
||||
});
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /最多一个/);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
stripJsoncComments,
|
||||
validateRoutingConfig,
|
||||
validateSectionsConfig,
|
||||
validateToolCatalog,
|
||||
type MutablePromptOptions,
|
||||
type RoutingRule,
|
||||
} from "../extensions/system-prompt-config.ts";
|
||||
@@ -28,7 +29,7 @@ import systemPromptExtension, {
|
||||
createSystemPromptComposer,
|
||||
getSystemPromptDumpPath,
|
||||
} from "../extensions/system-prompt.ts";
|
||||
import { guardExtensionApi } from "../extensions/prompt-guard.ts";
|
||||
import { catalogDescription, guardExtensionApi } from "../extensions/prompt-guard.ts";
|
||||
import { BUNDLE_GROUP_DEFINITIONS } from "../pi-tool-search/extensions/bundle-groups.ts";
|
||||
import { BUNDLE_TOOL_SEARCH_DEFAULTS } from "../pi-tool-search/extensions/config.ts";
|
||||
|
||||
@@ -93,6 +94,20 @@ test("routing scope validation: main/subagent accepted, other values rejected",
|
||||
);
|
||||
});
|
||||
|
||||
test("tools.jsonc validation: descriptionAppend accepted as a string, rejected otherwise", () => {
|
||||
assert.doesNotThrow(() =>
|
||||
validateToolCatalog({ version: 1, tools: { bash: { descriptionAppend: "extra" } } }),
|
||||
);
|
||||
assert.throws(
|
||||
() => validateToolCatalog({ version: 1, tools: { bash: { descriptionAppend: 5 } } }),
|
||||
/descriptionAppend must be a string/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateToolCatalog({ version: 1, tools: { bash: { descriptionAppend: true } } }),
|
||||
/descriptionAppend must be a string/,
|
||||
);
|
||||
});
|
||||
|
||||
test("sections.jsonc validation: subagentExclude defaults to [] and rejects bad shapes", () => {
|
||||
assert.deepEqual(validateSectionsConfig({ version: 1, order: ["tool_routing"] }), {
|
||||
version: 1,
|
||||
@@ -255,7 +270,7 @@ test("bundled routing rules select the same guidance the retired tool-routing ex
|
||||
const routing = loadRoutingRules();
|
||||
|
||||
const literal = renderRouting(routing.rules, ["find", "grep", "read"]);
|
||||
assert.match(literal, /Use find to identify likely files or directories/);
|
||||
assert.match(literal, /Pick the local search tool by what you already know/);
|
||||
assert.match(literal, /narrow the path, glob, or pattern instead of increasing the limit/);
|
||||
|
||||
const ssh = renderRouting(routing.rules, ["ssh_connect", "ssh_cd", "ssh_bash"]);
|
||||
@@ -267,7 +282,7 @@ test("bundled routing rules select the same guidance the retired tool-routing ex
|
||||
|
||||
const minimal = renderRouting(routing.rules, ["read"]);
|
||||
assert.doesNotMatch(minimal, /codegraph_explore first/);
|
||||
assert.doesNotMatch(minimal, /For literal search/);
|
||||
assert.doesNotMatch(minimal, /Pick the local search tool/);
|
||||
assert.doesNotMatch(minimal, /ssh_connect only when/);
|
||||
assert.match(minimal, /Stop searching once the current evidence is sufficient/);
|
||||
});
|
||||
@@ -397,7 +412,6 @@ test("composer splices the preamble, applies the catalog, and orders sections",
|
||||
read: "reads files",
|
||||
bash: "runs commands",
|
||||
edit: "edits",
|
||||
codegraph_explore: "explores",
|
||||
tool_search: "searches",
|
||||
},
|
||||
toolGuidelines: { edit: ["upstream edit rule"] },
|
||||
@@ -414,6 +428,11 @@ test("composer splices the preamble, applies the catalog, and orders sections",
|
||||
"the bundle preamble replaces Pi's default preamble",
|
||||
);
|
||||
assert.match(output, /<tool_routing>\n- Use codegraph_explore first/);
|
||||
assert.match(
|
||||
output,
|
||||
/<tools>[\s\S]*?- codegraph_explore: Explore code structure from the CodeGraph index/,
|
||||
"tools.jsonc must add a snippet for codegraph_explore, which has no upstream <tools> entry",
|
||||
);
|
||||
assert.match(output, /search files with the find\/grep tools, not bash/);
|
||||
assert.match(output, /LINE#HASH anchors/);
|
||||
assert.ok(
|
||||
@@ -604,7 +623,38 @@ test("prompt guard applies catalog description overrides at registration", () =>
|
||||
const override = loadToolCatalog().tools.edit;
|
||||
assert.ok(override && !("inherit" in override) && typeof override.description === "string");
|
||||
assert.equal(tools[0].description, override.description);
|
||||
assert.equal(tools[1].description, "upstream grep", "inherit entries keep the upstream description");
|
||||
assert.equal(tools[1].description, "upstream grep", "entries without description fields keep the upstream description");
|
||||
});
|
||||
|
||||
test("prompt guard appends the catalog descriptionAppend to the upstream description", () => {
|
||||
const { pi, tools } = makeRecordingPi();
|
||||
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI);
|
||||
|
||||
guarded.registerTool({ name: "bash", description: "upstream bash" } as never);
|
||||
guarded.registerTool({ name: "read", description: "upstream read" } as never);
|
||||
|
||||
const bashEntry = loadToolCatalog().tools.bash;
|
||||
assert.ok(bashEntry && !("inherit" in bashEntry) && typeof bashEntry.descriptionAppend === "string");
|
||||
assert.equal(tools[0].description, `upstream bash\n\n${bashEntry.descriptionAppend}`);
|
||||
assert.equal(tools[1].description, "upstream read", "inherit entries keep the upstream description");
|
||||
});
|
||||
|
||||
test("catalogDescription composes override, append, and inherit", () => {
|
||||
assert.equal(
|
||||
catalogDescription("upstream", { description: "override", descriptionAppend: "append" }),
|
||||
"override\n\nappend",
|
||||
"an append sits on top of the description override when both are set",
|
||||
);
|
||||
assert.equal(
|
||||
catalogDescription("upstream \n", { descriptionAppend: "append" }),
|
||||
"upstream\n\nappend",
|
||||
"trailing whitespace on the base is trimmed before the blank line",
|
||||
);
|
||||
assert.equal(
|
||||
catalogDescription("upstream", { inherit: true }),
|
||||
"upstream",
|
||||
"inherit entries are untouched",
|
||||
);
|
||||
});
|
||||
|
||||
test("prompt guard strips a returned systemPrompt but keeps other fields", async () => {
|
||||
|
||||
@@ -302,6 +302,10 @@ if ! command -v pi >/dev/null 2>&1; then
|
||||
exit 127
|
||||
fi
|
||||
|
||||
# Pi 自身比较本地与最新 release,一致时跳过;失败不阻断组合包升级。
|
||||
say "正在同步 Pi 最新发布版本。"
|
||||
pi update --self || record_update_failure "Pi"
|
||||
|
||||
say "正在升级 Pi 组合包:$PACKAGE_SOURCE"
|
||||
if ! pi update "$PACKAGE_SOURCE"; then
|
||||
warn "pi update 执行失败,已停止后续升级。重新运行 update.sh 即可,Pi 会自行恢复未完成的升级。"
|
||||
|
||||
Reference in New Issue
Block a user