mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
Merge branch 'my-pi-worktree1'
# Conflicts: # AGENTS.md # pi-tool-search/CHANGELOG.md # pi-tool-search/docs/dynamic-tool-loading.md
This commit is contained in:
@@ -25,6 +25,7 @@
|
|||||||
- `@keenable/pi-search`:加载 `keenable_search` / `keenable_fetch` 及其 skill,默认 keyless,`KEENABLE_API_KEY` 仅用于提高速率限制。
|
- `@keenable/pi-search`:加载 `keenable_search` / `keenable_fetch` 及其 skill,默认 keyless,`KEENABLE_API_KEY` 仅用于提高速率限制。
|
||||||
- `search_config.sh` 与 `extensions/search-config.ts`:以交互式或参数方式把三家搜索 key 写入用户级 `search.env`(权限 `600`),并在搜索扩展初始化前加载;显式进程环境变量优先。
|
- `search_config.sh` 与 `extensions/search-config.ts`:以交互式或参数方式把三家搜索 key 写入用户级 `search.env`(权限 `600`),并在搜索扩展初始化前加载;显式进程环境变量优先。
|
||||||
- `extensions/mcp.ts`:通过单个共享 `pi-mcp-adapter` 实例同时连接 Exa 托管 MCP 与机器现有的 `codegraph serve --mcp`,避免重复注册 Pi 的全局 MCP flag 与命令;Exa key 只通过 `x-api-key` 请求头发送,原始工具映射为统一的来源前缀形式,CodeGraph 只暴露 `codegraph_explore`。
|
- `extensions/mcp.ts`:通过单个共享 `pi-mcp-adapter` 实例同时连接 Exa 托管 MCP 与机器现有的 `codegraph serve --mcp`,避免重复注册 Pi 的全局 MCP flag 与命令;Exa key 只通过 `x-api-key` 请求头发送,原始工具映射为统一的来源前缀形式,CodeGraph 只暴露 `codegraph_explore`。
|
||||||
|
- `extensions/plugin-init.ts`:注册用户显式触发的 `/plugin_init`,在确认当前项目后只初始化缺失的 CodeGraph/Hippo 状态,并在全部成功后调用 Pi 官方热重载。
|
||||||
- `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。
|
- `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。
|
||||||
- `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。
|
- `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。
|
||||||
- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,并在权限扩展注册前将 `config/pi-permission-system.json` 同步为全局权威配置。
|
- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`,并在权限扩展注册前将 `config/pi-permission-system.json` 同步为全局权威配置。
|
||||||
@@ -34,6 +35,10 @@
|
|||||||
- 上游来源:<https://github.com/tuansondinh/pi-tool-search>
|
- 上游来源:<https://github.com/tuansondinh/pi-tool-search>
|
||||||
- 初始导入快照:`ddfb23646fd3957b791214de278e23aa393c9b13`(`v0.3.6`)。
|
- 初始导入快照:`ddfb23646fd3957b791214de278e23aa393c9b13`(`v0.3.6`)。
|
||||||
- 该目录不是 submodule,不保留嵌套 `.git`、上游 `.pi` 状态、`node_modules` 或构建产物。
|
- 该目录不是 submodule,不保留嵌套 `.git`、上游 `.pi` 状态、`node_modules` 或构建产物。
|
||||||
|
- `pi-ask-user/`:把 Pi 上游 `question.ts` 与 `questionnaire.ts` 示例整合为模型主动调用的 `ask_user_question` 工具,统一支持单题、多题、选择、自由文本、自定义回答与复核;不加载 `/qna`。
|
||||||
|
- 上游来源:<https://github.com/earendil-works/pi/tree/main/packages/coding-agent/examples/extensions>
|
||||||
|
- 初始参考快照:`dcd461925db2edf69a43c8135db1180d418afd54`(2026-08-24)。
|
||||||
|
- 该目录由本仓库直接维护,保留上游 MIT 许可证与来源说明;根组合包直接加载源码并通过本地 `file:` 依赖打包。
|
||||||
- `pi-lsp@0.1.7`:提供声明式 LSP 接入;组合包内置 `typescript-language-server@5.3.0` + `typescript@6.0.3`,并配置机器级 `kotlin-lsp --stdio` 与 `jdtls`。
|
- `pi-lsp@0.1.7`:提供声明式 LSP 接入;组合包内置 `typescript-language-server@5.3.0` + `typescript@6.0.3`,并配置机器级 `kotlin-lsp --stdio` 与 `jdtls`。
|
||||||
- `hippo-memory-pi/`:从官方 `hippo-memory` 仓库的 `extensions/pi-extension/` 导入并由本仓库直接维护;提供 session start 项目记忆注入、工具错误过滤捕获、session shutdown sleep 和 5 个 `hippo_*` 工具。
|
- `hippo-memory-pi/`:从官方 `hippo-memory` 仓库的 `extensions/pi-extension/` 导入并由本仓库直接维护;提供 session start 项目记忆注入、工具错误过滤捕获、session shutdown sleep 和 5 个 `hippo_*` 工具。
|
||||||
- 上游来源:<https://github.com/kitfunso/hippo-memory>
|
- 上游来源:<https://github.com/kitfunso/hippo-memory>
|
||||||
@@ -53,7 +58,7 @@
|
|||||||
- 初始导入快照:`f1d0efd24a7f4ae99d19e10c5f4c3770a3bdd845`(`@firstpick/pi-extension-codex-fast-mode@0.1.1`)。
|
- 初始导入快照:`f1d0efd24a7f4ae99d19e10c5f4c3770a3bdd845`(`@firstpick/pi-extension-codex-fast-mode@0.1.1`)。
|
||||||
- 该目录从上游源码仓库导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
- 该目录从上游源码仓库导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
||||||
- `pi-condense@2.9.1`:总结已完成的工具调用批次,以短 stub 替换历史原始输出,并通过 `context_tree_query` 按需恢复;组合包在用户尚未配置 `contextPrune.enabled` 时默认开启。
|
- `pi-condense@2.9.1`:总结已完成的工具调用批次,以短 stub 替换历史原始输出,并通过 `context_tree_query` 按需恢复;组合包在用户尚未配置 `contextPrune.enabled` 时默认开启。
|
||||||
- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`pi-minimal-footer`、`pi-ssh` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。
|
- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`pi-ask-user`、`pi-minimal-footer`、`pi-ssh` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。
|
||||||
- `install.sh`:先安装根组合包,再交互检查 Hippo Memory CLI、Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Hippo 项目目录与 `hippo init` 始终由用户自行决定。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。
|
- `install.sh`:先安装根组合包,再交互检查 Hippo Memory CLI、Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Hippo 项目目录与 `hippo init` 始终由用户自行决定。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。
|
||||||
- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
|
- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
|
||||||
- `uninstall.sh`:先移除根组合包;若检测到 Hippo Memory CLI,再明确询问是否卸载确认属于 npm 全局安装的 `hippo-memory`,默认保留且始终不删除 `.hippo/` 或用户记忆数据。其他共享终端环境和机器工具不卸载。
|
- `uninstall.sh`:先移除根组合包;若检测到 Hippo Memory CLI,再明确询问是否卸载确认属于 npm 全局安装的 `hippo-memory`,默认保留且始终不删除 `.hippo/` 或用户记忆数据。其他共享终端环境和机器工具不卸载。
|
||||||
@@ -68,11 +73,12 @@
|
|||||||
- Context Mode 负责避免批量读取、命令研究和网页原始内容直接撑大上下文;FFF 仍负责精确字面搜索,RTK 仍处理未走 Context Mode 的普通输出。
|
- Context Mode 负责避免批量读取、命令研究和网页原始内容直接撑大上下文;FFF 仍负责精确字面搜索,RTK 仍处理未走 Context Mode 的普通输出。
|
||||||
- Hashline 默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 和 `replaceText` 均关闭;它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
|
- Hashline 默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 和 `replaceText` 均关闭;它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
|
||||||
- 工具路由规则不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索按场景只先选一个服务:广泛发现、新闻和候选来源用 Tavily,官方/技术/论文和高确定性来源用 Exa,中文、站点限定和日期筛选用 Keenable;只有必要时才跨服务复核。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。
|
- 工具路由规则不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索按场景只先选一个服务:广泛发现、新闻和候选来源用 Tavily,官方/技术/论文和高确定性来源用 Exa,中文、站点限定和日期筛选用 Keenable;只有必要时才跨服务复核。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。
|
||||||
|
- `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具仅支持交互式 TUI、顺序执行和最多 8 题;取消或 abort 必须返回 `cancelled: true`,不得伪造答案,且 `/qna` 不属于该扩展。其成功 `details.answers` 必须保留 `question` / `answer` 字段,以匹配 AutoReview 已有的可信结构化用户交互边界。
|
||||||
- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics` 与 `tool_search`;其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组。默认最多 3 个动态组、模型组最多 8 个工具、动态工具总数最多 20,加载或组内调用更新 LRU,超限先移除最久未用组。
|
- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics` 与 `tool_search`;其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组。默认最多 3 个动态组、模型组最多 8 个工具、动态工具总数最多 20,加载或组内调用更新 LRU,超限先移除最久未用组。
|
||||||
- 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
|
- 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
|
||||||
- Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。
|
- Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。
|
||||||
- `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
|
- `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
|
||||||
- CodeGraph 扩展只配置 Pi 到外部 `codegraph` 命令的 MCP 连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。仓库不执行 `codegraph init`,不创建或管理 `.codegraph/`,也不改动索引、更新或遥测设置。
|
- CodeGraph MCP 扩展只配置 Pi 到外部 `codegraph` 命令的连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。安装、升级和普通启动流程不得执行 `codegraph init`;只有用户在目标项目显式调用 `/plugin_init` 并确认后,命令才可以 `.codegraph/codegraph.db` 与 `.hippo/hippo.db` 为权威标志初始化缺失状态。命令必须先预检两个 CLI,任一步失败不得热重载,也不得隐瞒此前已完成的部分初始化;不得改动 CodeGraph 更新或遥测设置。
|
||||||
- CodeGraph MCP 使用 `keep-alive` 并只直接暴露 `codegraph_explore`;命令缺失、项目未初始化或连接失败时不得阻止其他扩展加载。
|
- CodeGraph MCP 使用 `keep-alive` 并只直接暴露 `codegraph_explore`;命令缺失、项目未初始化或连接失败时不得阻止其他扩展加载。
|
||||||
- 所有 MCP 服务必须合并到 `extensions/mcp.ts` 创建的唯一 `pi-mcp-adapter` 实例;不得新增独立 adapter 扩展入口,否则会重复注册 `--mcp-config`、`/mcp` 等全局接口。根测试必须扫描全部本地扩展入口并强制这一不变量。
|
- 所有 MCP 服务必须合并到 `extensions/mcp.ts` 创建的唯一 `pi-mcp-adapter` 实例;不得新增独立 adapter 扩展入口,否则会重复注册 `--mcp-config`、`/mcp` 等全局接口。根测试必须扫描全部本地扩展入口并强制这一不变量。
|
||||||
- Exa MCP 使用托管 Streamable HTTP 端点和 `eager` lifecycle;`EXA_API_KEY` 通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。所有在线搜索工具统一采用“来源名 + 原始语义工具名”的命名形式,例如 `tavily_web_search`、`exa_web_search`、`keenable_search`。Exa 高级搜索必须显式约束结果数量和文本长度;Tavily 输出默认控制 `max_results` 且非必要不请求 raw content;Keenable 优先利用中文、站点和日期筛选能力。
|
- Exa MCP 使用托管 Streamable HTTP 端点和 `eager` lifecycle;`EXA_API_KEY` 通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。所有在线搜索工具统一采用“来源名 + 原始语义工具名”的命名形式,例如 `tavily_web_search`、`exa_web_search`、`keenable_search`。Exa 高级搜索必须显式约束结果数量和文本长度;Tavily 输出默认控制 `max_results` 且非必要不请求 raw content;Keenable 优先利用中文、站点和日期筛选能力。
|
||||||
@@ -196,6 +202,16 @@
|
|||||||
- 根加载顺序必须保持 `pi-permission-auto-review` → `pi-permission-system` → `pi-ssh` → `pi-tool-search`,确保 authorizer 先注册、权限服务先发布、SSH 桥接随后安装且 Tool Search 最后收集完整工具目录。
|
- 根加载顺序必须保持 `pi-permission-auto-review` → `pi-permission-system` → `pi-ssh` → `pi-tool-search`,确保 authorizer 先注册、权限服务先发布、SSH 桥接随后安装且 Tool Search 最后收集完整工具目录。
|
||||||
|
|
||||||
|
|
||||||
|
## `pi-ask-user` 开发约定
|
||||||
|
|
||||||
|
- 扩展入口是 `pi-ask-user/index.ts`,schema、归一化、TUI 与工具注册位于 `pi-ask-user/src/`,纯 helper 测试保留在 `pi-ask-user/test/`。
|
||||||
|
- 工具名必须保持 `ask_user_question`,并由 Tool Search 的独立 `user-interaction` 组按需加载;根加载顺序必须位于 `pi-tool-search` 之前。
|
||||||
|
- 参数必须有界并在运行时复核 question id、题型、选项和值的有效性;TUI 必须顺序执行、监听 abort、按 width 失效缓存,并把外层 `Focusable` 状态传播给内嵌 Editor。
|
||||||
|
- 成功结果的 `details` 必须保持 `version`、`cancelled: false` 与非空 `answers`,每个 answer 必须提供原始 `question` 和用户 `answer`;取消结果不得成为权限授权证据。修改结果结构时同步验证 `pi-permission-auto-review` transcript 解析。
|
||||||
|
- 同步上游示例时从明确 commit 移植并保留 `LICENSE`、`UPSTREAM.md` 与本地整合差异;不得恢复 `/qna`、重复的单题工具或多个扩展入口。
|
||||||
|
- 修改后至少运行包内 `npm test` / `npm run check`、Tool Search 的 typecheck/test/build、AutoReview transcript 测试、根扩展联合加载和 packed tarball 隔离安装验证。
|
||||||
|
|
||||||
|
|
||||||
## `pi-tool-search` 开发约定
|
## `pi-tool-search` 开发约定
|
||||||
|
|
||||||
- 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `pi-tool-search/extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `pi-tool-search/extensions/catalog.ts`,配置部署位于 `pi-tool-search/extensions/config.ts`,测试保留在 `pi-tool-search/test/`。
|
- 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `pi-tool-search/extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `pi-tool-search/extensions/catalog.ts`,配置部署位于 `pi-tool-search/extensions/config.ts`,测试保留在 `pi-tool-search/test/`。
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
- 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer,并在 Codex Fast 模式开启时显示 `Fast on`。
|
- 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer,并在 Codex Fast 模式开启时显示 `Fast on`。
|
||||||
- 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 后通知,并支持点击精确聚焦来源 Kitty 窗口。
|
- 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 后通知,并支持点击精确聚焦来源 Kitty 窗口。
|
||||||
- `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。
|
- `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。
|
||||||
|
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`,用一个顺序 TUI 同时支持单题、多题、选择题、文本题、自定义回答与提交前复核。
|
||||||
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,以最多 3 个动态组的 LRU 策略按组加载原始完整 schema。
|
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,以最多 3 个动态组的 LRU 策略按组加载原始完整 schema。
|
||||||
- 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。
|
- 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。
|
||||||
- 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入,负责工具、路径、MCP、硬拒绝和兜底权限基线。
|
- 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入,负责工具、路径、MCP、硬拒绝和兜底权限基线。
|
||||||
@@ -110,18 +111,23 @@ Hippo 官方 Pi Extension 存在于上游 Git 仓库的 `extensions/pi-extension
|
|||||||
`hippo-memory@1.33.0` npm tarball 中。本仓库从官方 `v1.33.0` /
|
`hippo-memory@1.33.0` npm tarball 中。本仓库从官方 `v1.33.0` /
|
||||||
`e928179a3b35e8fe5837878aed071d6025ced45c` 导入源码到 `hippo-memory-pi/` 并直接加载;
|
`e928179a3b35e8fe5837878aed071d6025ced45c` 导入源码到 `hippo-memory-pi/` 并直接加载;
|
||||||
匹配的 CLI 版本由 `config/hippo-memory-version` 声明。`install.sh` 会在 CLI 缺失时询问是否执行
|
匹配的 CLI 版本由 `config/hippo-memory-version` 声明。`install.sh` 会在 CLI 缺失时询问是否执行
|
||||||
`npm install -g hippo-memory@<固定版本>`,但不会选择项目或执行 `hippo init`。用户应进入需要记忆的
|
`npm install -g hippo-memory@<固定版本>`,但安装脚本不会选择或初始化项目。进入目标项目后可在 Pi 中执行
|
||||||
项目目录自行运行 `hippo init`。
|
`/plugin_init`:命令会显示当前目录并要求确认,以 `.codegraph/codegraph.db` 和 `.hippo/hippo.db` 为
|
||||||
|
初始化标志,只为缺失状态运行 `codegraph init` 与 `hippo init`;全部成功后通过 Pi 官方热重载重新连接 CodeGraph MCP 并重新触发 Hippo
|
||||||
|
session start。也可以继续在终端手动执行两个 CLI 的 `init` 命令。
|
||||||
|
|
||||||
默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启
|
默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启 RTK command rewriting 时,才需要另外安装 `rtk` 可执行文件。
|
||||||
`RTK command rewriting` 时,才需要另外安装 `rtk` 可执行文件。
|
|
||||||
|
|
||||||
CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自行确保 `codegraph` 在
|
CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自行确保 `codegraph` 在
|
||||||
启动 Pi 的 `PATH` 中,并在目标项目执行过 `codegraph init`。组合包不会创建或维护
|
启动 Pi 的 `PATH` 中。组合包只有在用户显式调用 `/plugin_init` 并确认当前项目后才会创建
|
||||||
`.codegraph/`,也不会修改 CodeGraph 的索引、更新或遥测设置。
|
`.codegraph/`;安装、升级和普通启动流程不会创建或维护索引,也不会修改 CodeGraph 的更新或遥测设置。
|
||||||
|
|
||||||
## 组合行为
|
## 组合行为
|
||||||
|
|
||||||
|
### 模型主动提问
|
||||||
|
|
||||||
|
`pi-ask-user/` 把 Pi 上游的单题与多题示例合并为唯一的 `ask_user_question` 工具;不提供 `/qna` 或其他用户命令。模型只有在继续任务确实缺少用户决定、偏好、确认或澄清时才应加载 `user-interaction` 组并调用它,不得重复询问直接消息中已有的信息。工具仅在交互式 TUI 中运行,最多一次提交 8 个问题,支持选择、自由文本、显式跳过、返回修改和最终复核;取消结果不会作为 AutoReview 的授权证据。
|
||||||
|
|
||||||
### 工具与搜索路由
|
### 工具与搜索路由
|
||||||
|
|
||||||
`extensions/tool-routing.ts` 不替换 Pi 默认系统提示词,而是在每轮开始前根据当前激活工具追加简短规则。代码结构、调用关系和待修改 symbol 优先使用 CodeGraph,定义、引用、类型和修改后诊断优先使用 LSP;仓库字面搜索先用 FFF `find` 缩小文件或目录范围,再在已收敛的路径中用 `grep`/`multi_grep` 获取行号。宽泛搜索命中大量结果、发生截断或达到上限时,应继续缩小路径、glob 或 pattern,而不是提高 limit 或输出全部结果。
|
`extensions/tool-routing.ts` 不替换 Pi 默认系统提示词,而是在每轮开始前根据当前激活工具追加简短规则。代码结构、调用关系和待修改 symbol 优先使用 CodeGraph,定义、引用、类型和修改后诊断优先使用 LSP;仓库字面搜索先用 FFF `find` 缩小文件或目录范围,再在已收敛的路径中用 `grep`/`multi_grep` 获取行号。宽泛搜索命中大量结果、发生截断或达到上限时,应继续缩小路径、glob 或 pattern,而不是提高 limit 或输出全部结果。
|
||||||
@@ -138,7 +144,7 @@ CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自
|
|||||||
|
|
||||||
本地源码直接使用 `@earendil-works/pi-coding-agent`、当前 `typebox`、`ModelRegistry.complete()` 与 `setActiveTools()`,不安装、别名映射或加载旧 `@mariozechner` runtime,也不使用 provider payload 改写、代理执行或隐藏 `sendMessage` 循环。
|
本地源码直接使用 `@earendil-works/pi-coding-agent`、当前 `typebox`、`ModelRegistry.complete()` 与 `setActiveTools()`,不安装、别名映射或加载旧 `@mariozechner` runtime,也不使用 provider payload 改写、代理执行或隐藏 `sendMessage` 循环。
|
||||||
|
|
||||||
新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics` 和 `tool_search`。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、CodeGraph/LSP、Tavily、Exa、Keenable、Context Mode 执行/知识库/观测/管理、Memory 查询/维护、Skill 和 MCP 管理。不可用的可选工具会自动从组中滤除,固定工具也不占动态组额度。
|
新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics` 和 `tool_search`。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、用户交互、CodeGraph/LSP、Tavily、Exa、Keenable、Context Mode 执行/知识库/观测/管理、Memory 查询/维护、Skill 和 MCP 管理。不可用的可选工具会自动从组中滤除,固定工具也不占动态组额度。
|
||||||
|
|
||||||
标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具时,才先为新增工具提供确定性分组,并可在第一次 `tool_search` 时使用当前已认证模型补充目录;模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会立即恢复预置目录,只有仍存在额外工具时才可能在下次搜索惰性增强。
|
标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具时,才先为新增工具提供确定性分组,并可在第一次 `tool_search` 时使用当前已认证模型补充目录;模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会立即恢复预置目录,只有仍存在额外工具时才可能在下次搜索惰性增强。
|
||||||
|
|
||||||
@@ -239,6 +245,14 @@ codegraph --version
|
|||||||
codegraph status
|
codegraph status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
在当前项目需要同时初始化 CodeGraph 和 Hippo 时,可直接执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/plugin_init
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令拒绝文件系统根目录和用户主目录,先确认两个 CLI 均可用,再依次初始化缺失数据库状态。任一步失败都不会热重载,也不会回滚此前已成功完成的初始化;两者已初始化时仍可用该命令快速热重载扩展。
|
||||||
|
|
||||||
### 权限基线自动部署
|
### 权限基线自动部署
|
||||||
|
|
||||||
`pi-permission-system/` 从上游 `pi-permission-system-v26.2.1` tag 导入,初始源码快照为 `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`。根包通过本地 `file:` 依赖提供其运行时依赖,并由包装入口直接加载仓库源码,不再加载 npm 包中的实现。
|
`pi-permission-system/` 从上游 `pi-permission-system-v26.2.1` tag 导入,初始源码快照为 `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`。根包通过本地 `file:` 依赖提供其运行时依赖,并由包装入口直接加载仓库源码,不再加载 npm 包中的实现。
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { stat } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join, parse, resolve } from "node:path";
|
||||||
|
|
||||||
|
interface ExecResult {
|
||||||
|
code: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginInitExecutor = (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: { cwd: string },
|
||||||
|
) => Promise<ExecResult>;
|
||||||
|
|
||||||
|
interface InitTarget {
|
||||||
|
name: "CodeGraph" | "Hippo";
|
||||||
|
command: "codegraph" | "hippo";
|
||||||
|
database: string;
|
||||||
|
initArgs(cwd: string): string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const INIT_TARGETS: InitTarget[] = [
|
||||||
|
{
|
||||||
|
name: "CodeGraph",
|
||||||
|
command: "codegraph",
|
||||||
|
database: join(".codegraph", "codegraph.db"),
|
||||||
|
initArgs: (cwd) => ["init", cwd],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Hippo",
|
||||||
|
command: "hippo",
|
||||||
|
database: join(".hippo", "hippo.db"),
|
||||||
|
initArgs: () => ["init"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function isFile(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return (await stat(path)).isFile();
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePluginInitCwd(cwd: string): string {
|
||||||
|
const projectRoot = resolve(cwd);
|
||||||
|
if (projectRoot === parse(projectRoot).root) {
|
||||||
|
throw new Error("拒绝在文件系统根目录初始化项目插件。");
|
||||||
|
}
|
||||||
|
if (projectRoot === resolve(homedir())) {
|
||||||
|
throw new Error("拒绝直接在用户主目录初始化项目插件;请先进入具体项目目录。");
|
||||||
|
}
|
||||||
|
return projectRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findMissingPluginInitializers(cwd: string): Promise<InitTarget[]> {
|
||||||
|
const projectRoot = validatePluginInitCwd(cwd);
|
||||||
|
const states = await Promise.all(
|
||||||
|
INIT_TARGETS.map(async (target) => ({
|
||||||
|
target,
|
||||||
|
initialized: await isFile(join(projectRoot, target.database)),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return states.filter(({ initialized }) => !initialized).map(({ target }) => target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactFailure(result: ExecResult): string {
|
||||||
|
const output = (result.stderr.trim() || result.stdout.trim()).replace(/\s+/gu, " ");
|
||||||
|
return output ? output.slice(0, 400) : `退出码 ${result.code}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeChecked(
|
||||||
|
exec: PluginInitExecutor,
|
||||||
|
target: InitTarget,
|
||||||
|
args: string[],
|
||||||
|
cwd: string,
|
||||||
|
phase: "检测" | "初始化",
|
||||||
|
): Promise<void> {
|
||||||
|
let result: ExecResult;
|
||||||
|
try {
|
||||||
|
result = await exec(target.command, args, { cwd });
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`${target.name} ${phase}失败:${detail}`, { cause: error });
|
||||||
|
}
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(`${target.name} ${phase}失败:${compactFailure(result)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeMissingPlugins(
|
||||||
|
cwd: string,
|
||||||
|
targets: InitTarget[],
|
||||||
|
exec: PluginInitExecutor,
|
||||||
|
onStart?: (target: InitTarget) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const projectRoot = validatePluginInitCwd(cwd);
|
||||||
|
|
||||||
|
// Preflight both CLIs before creating either project's state.
|
||||||
|
for (const target of INIT_TARGETS) {
|
||||||
|
await executeChecked(exec, target, ["--version"], projectRoot, "检测");
|
||||||
|
}
|
||||||
|
const completed: InitTarget[] = [];
|
||||||
|
for (const target of targets) {
|
||||||
|
onStart?.(target);
|
||||||
|
try {
|
||||||
|
await executeChecked(exec, target, target.initArgs(projectRoot), projectRoot, "初始化");
|
||||||
|
completed.push(target);
|
||||||
|
} catch (error) {
|
||||||
|
if (completed.length === 0) throw error;
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(
|
||||||
|
`${detail};已完成的 ${completed.map(({ name }) => name).join("、")} 初始化会保留,不会自动回滚。`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function pluginInitExtension(pi: ExtensionAPI): void {
|
||||||
|
pi.registerCommand("plugin_init", {
|
||||||
|
description: "Initialize CodeGraph and Hippo for the current project, then hot-reload Pi",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
|
||||||
|
let projectRoot: string;
|
||||||
|
let missing: InitTarget[];
|
||||||
|
try {
|
||||||
|
projectRoot = validatePluginInitCwd(ctx.cwd);
|
||||||
|
missing = await findMissingPluginInitializers(projectRoot);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = missing.length > 0 ? missing.map(({ name }) => name).join("、") : "无(均已初始化)";
|
||||||
|
const confirmed = await ctx.ui.confirm(
|
||||||
|
"初始化项目插件",
|
||||||
|
`项目:${projectRoot}\n待初始化:${pending}\n\n完成后将热重载 Pi 扩展。是否继续?`,
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initializeMissingPlugins(
|
||||||
|
projectRoot,
|
||||||
|
missing,
|
||||||
|
(command, args, options) => pi.exec(command, args, options),
|
||||||
|
(target) => ctx.ui.notify(`正在初始化 ${target.name}…`, "info"),
|
||||||
|
);
|
||||||
|
ctx.ui.notify(
|
||||||
|
missing.length > 0 ? "项目插件初始化完成,正在热重载 Pi…" : "项目插件均已初始化,正在热重载 Pi…",
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
await ctx.reload();
|
||||||
|
} catch (error) {
|
||||||
|
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+28
@@ -18,6 +18,7 @@
|
|||||||
"@tavily/pi-extension": "0.1.2",
|
"@tavily/pi-extension": "0.1.2",
|
||||||
"context-mode": "1.0.169",
|
"context-mode": "1.0.169",
|
||||||
"jiti": "2.7.0",
|
"jiti": "2.7.0",
|
||||||
|
"pi-ask-user": "file:./pi-ask-user",
|
||||||
"pi-condense": "2.9.1",
|
"pi-condense": "2.9.1",
|
||||||
"pi-context-view": "0.4.2",
|
"pi-context-view": "0.4.2",
|
||||||
"pi-hashline-edit": "0.8.3",
|
"pi-hashline-edit": "0.8.3",
|
||||||
@@ -5664,6 +5665,10 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pi-ask-user": {
|
||||||
|
"resolved": "pi-ask-user",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/pi-condense": {
|
"node_modules/pi-condense": {
|
||||||
"version": "2.9.1",
|
"version": "2.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/pi-condense/-/pi-condense-2.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/pi-condense/-/pi-condense-2.9.1.tgz",
|
||||||
@@ -7109,6 +7114,29 @@
|
|||||||
"zod": "^3.25.28 || ^4"
|
"zod": "^3.25.28 || ^4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"pi-ask-user": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "*",
|
||||||
|
"@earendil-works/pi-tui": "*",
|
||||||
|
"typebox": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@earendil-works/pi-coding-agent": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@earendil-works/pi-tui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typebox": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"pi-extension-codex-fast-mode": {
|
"pi-extension-codex-fast-mode": {
|
||||||
"name": "@firstpick/pi-extension-codex-fast-mode",
|
"name": "@firstpick/pi-extension-codex-fast-mode",
|
||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"extensions",
|
"extensions",
|
||||||
"hippo-memory-pi",
|
"hippo-memory-pi",
|
||||||
|
"pi-ask-user",
|
||||||
"pi-extension-codex-fast-mode",
|
"pi-extension-codex-fast-mode",
|
||||||
"pi-rtk-optimizer",
|
"pi-rtk-optimizer",
|
||||||
"pi-minimal-footer",
|
"pi-minimal-footer",
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
"./extensions/tavily-override.ts",
|
"./extensions/tavily-override.ts",
|
||||||
"./node_modules/@keenable/pi-search/src/index.ts",
|
"./node_modules/@keenable/pi-search/src/index.ts",
|
||||||
"./extensions/mcp.ts",
|
"./extensions/mcp.ts",
|
||||||
|
"./extensions/plugin-init.ts",
|
||||||
"./pi-rtk-optimizer/index.ts",
|
"./pi-rtk-optimizer/index.ts",
|
||||||
"./extensions/hashline.ts",
|
"./extensions/hashline.ts",
|
||||||
"./node_modules/context-mode/build/adapters/pi/extension.js",
|
"./node_modules/context-mode/build/adapters/pi/extension.js",
|
||||||
@@ -62,6 +64,7 @@
|
|||||||
"./pi-permission-auto-review/index.ts",
|
"./pi-permission-auto-review/index.ts",
|
||||||
"./extensions/permission-system.ts",
|
"./extensions/permission-system.ts",
|
||||||
"./pi-ssh/index.ts",
|
"./pi-ssh/index.ts",
|
||||||
|
"./pi-ask-user/index.ts",
|
||||||
"./extensions/tool-routing.ts",
|
"./extensions/tool-routing.ts",
|
||||||
"./pi-tool-search/extensions/index.ts"
|
"./pi-tool-search/extensions/index.ts"
|
||||||
],
|
],
|
||||||
@@ -86,6 +89,7 @@
|
|||||||
"pi-hashline-edit": "0.8.3",
|
"pi-hashline-edit": "0.8.3",
|
||||||
"pi-lsp": "0.1.7",
|
"pi-lsp": "0.1.7",
|
||||||
"pi-mcp-adapter": "2.26.0",
|
"pi-mcp-adapter": "2.26.0",
|
||||||
|
"pi-ask-user": "file:./pi-ask-user",
|
||||||
"pi-ssh": "file:./pi-ssh",
|
"pi-ssh": "file:./pi-ssh",
|
||||||
"pi-tool-search": "file:./pi-tool-search",
|
"pi-tool-search": "file:./pi-tool-search",
|
||||||
"ssh2": "1.17.0",
|
"ssh2": "1.17.0",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Mario Zechner
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# pi-ask-user
|
||||||
|
|
||||||
|
A locally maintained Pi extension that consolidates the upstream `question.ts` and `questionnaire.ts` examples into one model-initiated tool. It intentionally does not include the `/qna` command.
|
||||||
|
|
||||||
|
## Tool
|
||||||
|
|
||||||
|
The extension registers `ask_user_question`. The model can present one to eight questions in one sequential TUI interaction:
|
||||||
|
|
||||||
|
- `select`: one to eight stable `{ value, label, description? }` choices, with an optional free-form choice;
|
||||||
|
- `text`: a free-form Editor answer;
|
||||||
|
- `required: false`: lets the user explicitly skip the question;
|
||||||
|
- multiple questions: progress tabs plus a final review page.
|
||||||
|
|
||||||
|
The model should call the tool only when a missing decision, preference, or clarification is needed to continue. It should not repeat questions already answered in the user's direct messages.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- `↑` / `↓`: move through choices;
|
||||||
|
- `Enter`: select, edit, or submit;
|
||||||
|
- `Tab`, `Shift+Tab`, `←`, `→`: move between questions and review;
|
||||||
|
- `Esc`: leave text entry, or cancel from a question/review page.
|
||||||
|
|
||||||
|
The outer component propagates focus to the embedded Editor for IME cursor positioning.
|
||||||
|
|
||||||
|
## Result contract
|
||||||
|
|
||||||
|
Successful results include readable text and structured `details`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"cancelled": false,
|
||||||
|
"questions": [],
|
||||||
|
"answers": [
|
||||||
|
{
|
||||||
|
"id": "language",
|
||||||
|
"question": "Which language should be used?",
|
||||||
|
"type": "select",
|
||||||
|
"answer": "TypeScript",
|
||||||
|
"value": "typescript",
|
||||||
|
"label": "TypeScript",
|
||||||
|
"custom": false,
|
||||||
|
"skipped": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The tool name and the `question` / `answer` fields intentionally match `pi-permission-auto-review`'s trusted structured user-interaction envelope. Cancelled results are never treated as authorization evidence.
|
||||||
|
|
||||||
|
The tool requires Pi's interactive TUI mode. RPC, JSON, and print modes receive a tool error rather than an invented answer.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
See `UPSTREAM.md` for the imported reference snapshot and local differences.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Upstream sources
|
||||||
|
|
||||||
|
`pi-ask-user` is maintained directly in this repository. Its initial UI and tool design was adapted from the following MIT-licensed Pi examples:
|
||||||
|
|
||||||
|
- <https://github.com/earendil-works/pi/blob/dcd461925db2edf69a43c8135db1180d418afd54/packages/coding-agent/examples/extensions/question.ts>
|
||||||
|
- <https://github.com/earendil-works/pi/blob/dcd461925db2edf69a43c8135db1180d418afd54/packages/coding-agent/examples/extensions/questionnaire.ts>
|
||||||
|
|
||||||
|
Initial reference snapshot: `dcd461925db2edf69a43c8135db1180d418afd54` (`main`, inspected 2026-08-24).
|
||||||
|
|
||||||
|
The upstream `qna.ts` command is intentionally not included: this package only supports model-initiated questions. Local changes consolidate single and multi-question flows into one tool, add text questions, validation, bounded schemas, cancellation/abort handling, width-aware rendering, and IME focus propagation.
|
||||||
|
|
||||||
|
The copied MIT license and original copyright notice are retained in `LICENSE`.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
import { registerAskUserTool } from "./src/tool.ts";
|
||||||
|
|
||||||
|
export default function askUserExtension(pi: ExtensionAPI): void {
|
||||||
|
registerAskUserTool(pi);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { formatAnswers, normalizeQuestions, orderedAnswers } from "./src/normalize.ts";
|
||||||
|
export type {
|
||||||
|
AskUserResult,
|
||||||
|
NormalizedQuestion,
|
||||||
|
QuestionInput,
|
||||||
|
QuestionOptionInput,
|
||||||
|
QuestionType,
|
||||||
|
UserAnswer,
|
||||||
|
} from "./src/types.ts";
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-ask-user",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "A unified interactive question tool for Pi agents.",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "./index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.ts"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"index.ts",
|
||||||
|
"src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE",
|
||||||
|
"UPSTREAM.md"
|
||||||
|
],
|
||||||
|
"pi": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test test/*.test.ts",
|
||||||
|
"check": "node --experimental-strip-types --check index.ts && node --test test/*.test.ts"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "*",
|
||||||
|
"@earendil-works/pi-tui": "*",
|
||||||
|
"typebox": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@earendil-works/pi-coding-agent": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@earendil-works/pi-tui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typebox": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.19.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { NormalizedQuestion, QuestionInput, QuestionOptionInput, UserAnswer } from "./types.ts";
|
||||||
|
|
||||||
|
export const MAX_QUESTIONS = 8;
|
||||||
|
export const MAX_OPTIONS = 8;
|
||||||
|
|
||||||
|
function requiredText(value: unknown, field: string, maxLength: number): string {
|
||||||
|
if (typeof value !== "string") throw new Error(`${field} must be a string`);
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized) throw new Error(`${field} must not be empty`);
|
||||||
|
if (normalized.length > maxLength) throw new Error(`${field} must be at most ${maxLength} characters`);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOption(option: QuestionOptionInput, questionId: string, index: number): QuestionOptionInput {
|
||||||
|
return {
|
||||||
|
value: requiredText(option?.value, `questions[${questionId}].options[${index}].value`, 128),
|
||||||
|
label: requiredText(option?.label, `questions[${questionId}].options[${index}].label`, 160),
|
||||||
|
...(option?.description === undefined
|
||||||
|
? {}
|
||||||
|
: { description: requiredText(option.description, `questions[${questionId}].options[${index}].description`, 500) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeQuestions(input: readonly QuestionInput[]): NormalizedQuestion[] {
|
||||||
|
if (!Array.isArray(input) || input.length === 0) throw new Error("questions must contain at least one question");
|
||||||
|
if (input.length > MAX_QUESTIONS) throw new Error(`questions must contain at most ${MAX_QUESTIONS} questions`);
|
||||||
|
|
||||||
|
const ids = new Set<string>();
|
||||||
|
return input.map((question, index) => {
|
||||||
|
if (question === null || typeof question !== "object" || Array.isArray(question)) {
|
||||||
|
throw new Error(`questions[${index}] must be an object`);
|
||||||
|
}
|
||||||
|
const id = requiredText(question?.id, `questions[${index}].id`, 64);
|
||||||
|
if (!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(id)) {
|
||||||
|
throw new Error(`questions[${index}].id must start with a letter and contain only letters, numbers, _ or -`);
|
||||||
|
}
|
||||||
|
if (ids.has(id)) throw new Error(`duplicate question id: ${id}`);
|
||||||
|
ids.add(id);
|
||||||
|
|
||||||
|
const prompt = requiredText(question?.prompt, `questions[${index}].prompt`, 1_000);
|
||||||
|
const label = question.label === undefined
|
||||||
|
? `Q${index + 1}`
|
||||||
|
: requiredText(question.label, `questions[${index}].label`, 48);
|
||||||
|
if (question.type !== "select" && question.type !== "text") {
|
||||||
|
throw new Error(`questions[${index}].type must be select or text`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (question.allowCustom !== undefined && typeof question.allowCustom !== "boolean") {
|
||||||
|
throw new Error(`questions[${index}].allowCustom must be a boolean`);
|
||||||
|
}
|
||||||
|
if (question.required !== undefined && typeof question.required !== "boolean") {
|
||||||
|
throw new Error(`questions[${index}].required must be a boolean`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawOptions = question.options ?? [];
|
||||||
|
if (!Array.isArray(rawOptions)) throw new Error(`questions[${index}].options must be an array`);
|
||||||
|
if (rawOptions.length > MAX_OPTIONS) {
|
||||||
|
throw new Error(`questions[${index}].options must contain at most ${MAX_OPTIONS} options`);
|
||||||
|
}
|
||||||
|
if (question.type === "text" && rawOptions.length > 0) {
|
||||||
|
throw new Error(`text question ${id} must not define options`);
|
||||||
|
}
|
||||||
|
if (question.type === "select" && rawOptions.length === 0) {
|
||||||
|
throw new Error(`select question ${id} must define at least one option`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = rawOptions.map((option, optionIndex) => normalizeOption(option, id, optionIndex));
|
||||||
|
const values = new Set<string>();
|
||||||
|
for (const option of options) {
|
||||||
|
if (values.has(option.value)) throw new Error(`duplicate option value ${option.value} in question ${id}`);
|
||||||
|
values.add(option.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
prompt,
|
||||||
|
type: question.type,
|
||||||
|
options,
|
||||||
|
allowCustom: question.type === "select" && question.allowCustom !== false,
|
||||||
|
required: question.required !== false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderedAnswers(questions: readonly NormalizedQuestion[], answers: ReadonlyMap<string, UserAnswer>): UserAnswer[] {
|
||||||
|
return questions.flatMap((question) => {
|
||||||
|
const answer = answers.get(question.id);
|
||||||
|
return answer ? [answer] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAnswers(answers: readonly UserAnswer[]): string {
|
||||||
|
return answers
|
||||||
|
.map((answer) => {
|
||||||
|
if (answer.skipped) return `${answer.id}: skipped`;
|
||||||
|
if (answer.type === "text" || answer.custom) return `${answer.id}: user wrote: ${answer.answer}`;
|
||||||
|
return `${answer.id}: user selected: ${answer.label} (value: ${answer.value})`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Type } from "typebox";
|
||||||
|
|
||||||
|
import { MAX_OPTIONS, MAX_QUESTIONS } from "./normalize.ts";
|
||||||
|
|
||||||
|
const OptionSchema = Type.Object({
|
||||||
|
value: Type.String({ minLength: 1, maxLength: 128, description: "Stable machine-readable value returned when selected" }),
|
||||||
|
label: Type.String({ minLength: 1, maxLength: 160, description: "User-visible option label" }),
|
||||||
|
description: Type.Optional(Type.String({ minLength: 1, maxLength: 500, description: "Optional explanation shown below the label" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const QuestionSchema = Type.Object({
|
||||||
|
id: Type.String({
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 64,
|
||||||
|
pattern: "^[A-Za-z][A-Za-z0-9_-]*$",
|
||||||
|
description: "Unique machine-readable question identifier",
|
||||||
|
}),
|
||||||
|
label: Type.Optional(Type.String({ minLength: 1, maxLength: 48, description: "Short progress label; defaults to Q1, Q2, ..." })),
|
||||||
|
prompt: Type.String({ minLength: 1, maxLength: 1_000, description: "Full question shown to the user" }),
|
||||||
|
type: Type.String({ enum: ["select", "text"], description: "select for choices; text for a free-form answer" }),
|
||||||
|
options: Type.Optional(Type.Array(OptionSchema, {
|
||||||
|
maxItems: MAX_OPTIONS,
|
||||||
|
description: "Required and non-empty for select questions; omit for text questions",
|
||||||
|
})),
|
||||||
|
allowCustom: Type.Optional(Type.Boolean({ description: "For select questions, append a free-form answer choice (default: true)" })),
|
||||||
|
required: Type.Optional(Type.Boolean({ description: "Whether the question must be answered (default: true); optional questions can be skipped" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AskUserParamsSchema = Type.Object({
|
||||||
|
questions: Type.Array(QuestionSchema, {
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: MAX_QUESTIONS,
|
||||||
|
description: "One or more questions to present in a single sequential interaction",
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { Text } from "@earendil-works/pi-tui";
|
||||||
|
|
||||||
|
import { formatAnswers, normalizeQuestions } from "./normalize.ts";
|
||||||
|
import { AskUserParamsSchema } from "./schema.ts";
|
||||||
|
import type { AskUserResult, QuestionInput } from "./types.ts";
|
||||||
|
import { AskUserView } from "./view.ts";
|
||||||
|
|
||||||
|
function fallbackText(result: { content: Array<{ type: string; text?: string }> }): string {
|
||||||
|
return result.content.find((item) => item.type === "text")?.text ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAskUserTool(pi: ExtensionAPI): void {
|
||||||
|
pi.registerTool({
|
||||||
|
name: "ask_user_question",
|
||||||
|
label: "Ask User",
|
||||||
|
description:
|
||||||
|
"Ask the user one or more structured questions in an interactive TUI when their input is genuinely needed to continue. Supports choices, free-form text, optional custom answers, review, and cancellation. Do not ask for information already stated by the user.",
|
||||||
|
promptSnippet: "Use ask_user_question to pause and collect missing user decisions instead of guessing",
|
||||||
|
parameters: AskUserParamsSchema,
|
||||||
|
executionMode: "sequential",
|
||||||
|
|
||||||
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||||
|
const questions = normalizeQuestions(params.questions as QuestionInput[]);
|
||||||
|
if (ctx.mode !== "tui") throw new Error("ask_user_question requires interactive TUI mode");
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
const result: AskUserResult = { version: 1, cancelled: true, questions, answers: [] };
|
||||||
|
return { content: [{ type: "text", text: "User interaction was cancelled" }], details: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ctx.ui.custom<AskUserResult>((tui, theme, _keybindings, done) =>
|
||||||
|
new AskUserView(tui, theme, questions, done, signal),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.cancelled) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "User cancelled the questions" }],
|
||||||
|
details: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: formatAnswers(result.answers) }],
|
||||||
|
details: result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
renderCall(args, theme) {
|
||||||
|
const questions = Array.isArray(args.questions) ? args.questions as Array<{ id?: unknown; label?: unknown }> : [];
|
||||||
|
const labels = questions
|
||||||
|
.map((question) => typeof question.label === "string" ? question.label : typeof question.id === "string" ? question.id : "question")
|
||||||
|
.join(", ");
|
||||||
|
let text = theme.fg("toolTitle", theme.bold("ask user "));
|
||||||
|
text += theme.fg("muted", `${questions.length} question${questions.length === 1 ? "" : "s"}`);
|
||||||
|
if (labels) text += theme.fg("dim", ` (${labels})`);
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderResult(result, _options, theme) {
|
||||||
|
const details = result.details as AskUserResult | undefined;
|
||||||
|
if (!details) return new Text(fallbackText(result), 0, 0);
|
||||||
|
if (details.cancelled) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
||||||
|
const lines = details.answers.map((answer) => {
|
||||||
|
const value = answer.skipped
|
||||||
|
? theme.fg("muted", "Skipped")
|
||||||
|
: `${answer.custom || answer.type === "text" ? theme.fg("muted", "(wrote) ") : ""}${theme.fg("accent", answer.label)}`;
|
||||||
|
return `${theme.fg("success", "✓ ")}${theme.fg("accent", answer.id)}: ${value}`;
|
||||||
|
});
|
||||||
|
return new Text(lines.join("\n"), 0, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export type QuestionType = "select" | "text";
|
||||||
|
|
||||||
|
export interface QuestionOptionInput {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestionInput {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
prompt: string;
|
||||||
|
type: QuestionType;
|
||||||
|
options?: QuestionOptionInput[];
|
||||||
|
allowCustom?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedQuestion {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
prompt: string;
|
||||||
|
type: QuestionType;
|
||||||
|
options: QuestionOptionInput[];
|
||||||
|
allowCustom: boolean;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `question` and `answer` intentionally match pi-permission-auto-review's
|
||||||
|
* trusted user-interaction envelope for ask_user_question.
|
||||||
|
*/
|
||||||
|
export interface UserAnswer {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
type: QuestionType;
|
||||||
|
answer: string;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
custom: boolean;
|
||||||
|
skipped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AskUserResult {
|
||||||
|
version: 1;
|
||||||
|
cancelled: boolean;
|
||||||
|
questions: NormalizedQuestion[];
|
||||||
|
answers: UserAnswer[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
||||||
|
import {
|
||||||
|
Editor,
|
||||||
|
type Component,
|
||||||
|
type EditorTheme,
|
||||||
|
type Focusable,
|
||||||
|
Key,
|
||||||
|
matchesKey,
|
||||||
|
type TUI,
|
||||||
|
visibleWidth,
|
||||||
|
wrapTextWithAnsi,
|
||||||
|
} from "@earendil-works/pi-tui";
|
||||||
|
|
||||||
|
import { orderedAnswers } from "./normalize.ts";
|
||||||
|
import type { AskUserResult, NormalizedQuestion, QuestionOptionInput, UserAnswer } from "./types.ts";
|
||||||
|
|
||||||
|
type InputKind = "text" | "custom";
|
||||||
|
type Done = (result: AskUserResult) => void;
|
||||||
|
type RenderOption = QuestionOptionInput & { kind: "option" | "custom" | "skip" };
|
||||||
|
|
||||||
|
export class AskUserView implements Component, Focusable {
|
||||||
|
private readonly editor: Editor;
|
||||||
|
private readonly answers = new Map<string, UserAnswer>();
|
||||||
|
private readonly abortHandler: () => void;
|
||||||
|
private currentPage = 0;
|
||||||
|
private optionIndex = 0;
|
||||||
|
private inputKind: InputKind | null = null;
|
||||||
|
private validationMessage: string | undefined;
|
||||||
|
private cachedWidth: number | undefined;
|
||||||
|
private cachedLines: string[] | undefined;
|
||||||
|
private completed = false;
|
||||||
|
private _focused = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly tui: TUI,
|
||||||
|
private readonly theme: Theme,
|
||||||
|
private readonly questions: NormalizedQuestion[],
|
||||||
|
private readonly done: Done,
|
||||||
|
private readonly signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const editorTheme: EditorTheme = {
|
||||||
|
borderColor: (text) => theme.fg("accent", text),
|
||||||
|
selectList: {
|
||||||
|
selectedPrefix: (text) => theme.fg("accent", text),
|
||||||
|
selectedText: (text) => theme.fg("accent", text),
|
||||||
|
description: (text) => theme.fg("muted", text),
|
||||||
|
scrollInfo: (text) => theme.fg("dim", text),
|
||||||
|
noMatch: (text) => theme.fg("warning", text),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.editor = new Editor(tui, editorTheme);
|
||||||
|
this.editor.onSubmit = (value) => this.submitEditor(value);
|
||||||
|
this.abortHandler = () => this.finish(true);
|
||||||
|
signal?.addEventListener("abort", this.abortHandler, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get focused(): boolean {
|
||||||
|
return this._focused;
|
||||||
|
}
|
||||||
|
|
||||||
|
set focused(value: boolean) {
|
||||||
|
this._focused = value;
|
||||||
|
this.syncEditorFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncEditorFocus(): void {
|
||||||
|
this.editor.focused = this._focused && this.inputKind !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private refresh(): void {
|
||||||
|
this.cachedWidth = undefined;
|
||||||
|
this.cachedLines = undefined;
|
||||||
|
this.syncEditorFocus();
|
||||||
|
this.tui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentQuestion(): NormalizedQuestion | undefined {
|
||||||
|
return this.questions[this.currentPage];
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentOptions(): RenderOption[] {
|
||||||
|
const question = this.currentQuestion();
|
||||||
|
if (!question || question.type !== "select") return [];
|
||||||
|
const options: RenderOption[] = question.options.map((option) => ({ ...option, kind: "option" }));
|
||||||
|
if (question.allowCustom) options.push({ value: "", label: "Type something.", kind: "custom" });
|
||||||
|
if (!question.required) options.push({ value: "", label: "Skip this question.", kind: "skip" });
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private allAnswered(): boolean {
|
||||||
|
return this.questions.every((question) => this.answers.has(question.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCurrentPage(page: number): void {
|
||||||
|
this.currentPage = page;
|
||||||
|
this.inputKind = null;
|
||||||
|
this.validationMessage = undefined;
|
||||||
|
this.editor.setText("");
|
||||||
|
this.optionIndex = 0;
|
||||||
|
|
||||||
|
const question = this.currentQuestion();
|
||||||
|
const answer = question ? this.answers.get(question.id) : undefined;
|
||||||
|
if (question?.type === "select" && answer) {
|
||||||
|
const options = this.currentOptions();
|
||||||
|
const selected = options.findIndex((option) =>
|
||||||
|
answer.skipped ? option.kind === "skip" : answer.custom ? option.kind === "custom" : option.kind === "option" && option.value === answer.value,
|
||||||
|
);
|
||||||
|
this.optionIndex = Math.max(0, selected);
|
||||||
|
}
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private movePage(delta: number): void {
|
||||||
|
const pageCount = this.questions.length + 1;
|
||||||
|
this.setCurrentPage((this.currentPage + delta + pageCount) % pageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enterEditor(kind: InputKind): void {
|
||||||
|
const question = this.currentQuestion();
|
||||||
|
if (!question) return;
|
||||||
|
const existing = this.answers.get(question.id);
|
||||||
|
this.inputKind = kind;
|
||||||
|
this.validationMessage = undefined;
|
||||||
|
this.editor.setText(existing && (question.type === "text" || existing.custom) ? existing.answer : "");
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveAnswer(question: NormalizedQuestion, answer: Omit<UserAnswer, "id" | "question" | "type">): void {
|
||||||
|
this.answers.set(question.id, {
|
||||||
|
id: question.id,
|
||||||
|
question: question.prompt,
|
||||||
|
type: question.type,
|
||||||
|
...answer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private submitEditor(value: string): void {
|
||||||
|
const question = this.currentQuestion();
|
||||||
|
if (!question || !this.inputKind) return;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed && (question.required || this.inputKind === "custom")) {
|
||||||
|
this.validationMessage = "An answer is required.";
|
||||||
|
this.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveAnswer(question, {
|
||||||
|
answer: trimmed,
|
||||||
|
value: trimmed,
|
||||||
|
label: trimmed,
|
||||||
|
custom: this.inputKind === "custom",
|
||||||
|
skipped: !trimmed,
|
||||||
|
});
|
||||||
|
this.inputKind = null;
|
||||||
|
this.editor.setText("");
|
||||||
|
this.advanceAfterAnswer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectCurrentOption(): void {
|
||||||
|
const question = this.currentQuestion();
|
||||||
|
const option = this.currentOptions()[this.optionIndex];
|
||||||
|
if (!question || !option) return;
|
||||||
|
if (option.kind === "custom") {
|
||||||
|
this.enterEditor("custom");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (option.kind === "skip") {
|
||||||
|
this.saveAnswer(question, { answer: "", value: "", label: "Skipped", custom: false, skipped: true });
|
||||||
|
} else {
|
||||||
|
this.saveAnswer(question, {
|
||||||
|
answer: option.label,
|
||||||
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
custom: false,
|
||||||
|
skipped: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.advanceAfterAnswer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private advanceAfterAnswer(): void {
|
||||||
|
if (this.questions.length === 1) {
|
||||||
|
this.finish(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setCurrentPage(this.currentPage < this.questions.length - 1 ? this.currentPage + 1 : this.questions.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish(cancelled: boolean): void {
|
||||||
|
if (this.completed) return;
|
||||||
|
this.completed = true;
|
||||||
|
this.signal?.removeEventListener("abort", this.abortHandler);
|
||||||
|
this.done({
|
||||||
|
version: 1,
|
||||||
|
cancelled,
|
||||||
|
questions: this.questions,
|
||||||
|
answers: orderedAnswers(this.questions, this.answers),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInput(data: string): void {
|
||||||
|
if (this.inputKind) {
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
this.inputKind = null;
|
||||||
|
this.validationMessage = undefined;
|
||||||
|
this.editor.setText("");
|
||||||
|
this.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.editor.handleInput(data);
|
||||||
|
this.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
this.finish(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.questions.length > 1) {
|
||||||
|
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
||||||
|
this.movePage(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
||||||
|
this.movePage(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentPage === this.questions.length) {
|
||||||
|
if (matchesKey(data, Key.enter) && this.allAnswered()) this.finish(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const question = this.currentQuestion();
|
||||||
|
if (!question) return;
|
||||||
|
if (question.type === "text") {
|
||||||
|
if (!question.required && matchesKey(data, "s")) {
|
||||||
|
this.saveAnswer(question, { answer: "", value: "", label: "Skipped", custom: false, skipped: true });
|
||||||
|
this.advanceAfterAnswer();
|
||||||
|
} else if (matchesKey(data, Key.enter)) {
|
||||||
|
this.enterEditor("text");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = this.currentOptions();
|
||||||
|
if (matchesKey(data, Key.up)) {
|
||||||
|
this.optionIndex = Math.max(0, this.optionIndex - 1);
|
||||||
|
this.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesKey(data, Key.down)) {
|
||||||
|
this.optionIndex = Math.min(options.length - 1, this.optionIndex + 1);
|
||||||
|
this.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesKey(data, Key.enter)) this.selectCurrentOption();
|
||||||
|
}
|
||||||
|
|
||||||
|
render(width: number): string[] {
|
||||||
|
const renderWidth = Math.max(1, width);
|
||||||
|
if (this.cachedLines && this.cachedWidth === renderWidth) return this.cachedLines;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const question = this.currentQuestion();
|
||||||
|
const addWrapped = (text: string): void => {
|
||||||
|
lines.push(...wrapTextWithAnsi(text, renderWidth));
|
||||||
|
};
|
||||||
|
const addWrappedWithPrefix = (prefix: string, text: string): void => {
|
||||||
|
const prefixWidth = visibleWidth(prefix);
|
||||||
|
if (prefixWidth >= renderWidth) {
|
||||||
|
addWrapped(prefix + text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
|
||||||
|
const continuation = " ".repeat(prefixWidth);
|
||||||
|
wrapped.forEach((line, index) => lines.push(`${index === 0 ? prefix : continuation}${line}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(this.theme.fg("accent", "─".repeat(renderWidth)));
|
||||||
|
|
||||||
|
if (this.questions.length > 1) {
|
||||||
|
const tabs = this.questions.map((item, index) => {
|
||||||
|
const answered = this.answers.has(item.id);
|
||||||
|
const text = ` ${answered ? "■" : "□"} ${item.label} `;
|
||||||
|
return index === this.currentPage
|
||||||
|
? this.theme.bg("selectedBg", this.theme.fg("text", text))
|
||||||
|
: this.theme.fg(answered ? "success" : "muted", text);
|
||||||
|
});
|
||||||
|
const reviewText = " ✓ Review ";
|
||||||
|
tabs.push(this.currentPage === this.questions.length
|
||||||
|
? this.theme.bg("selectedBg", this.theme.fg("text", reviewText))
|
||||||
|
: this.theme.fg(this.allAnswered() ? "success" : "dim", reviewText));
|
||||||
|
addWrappedWithPrefix(" ", tabs.join(" "));
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentPage === this.questions.length) {
|
||||||
|
addWrappedWithPrefix(" ", this.theme.fg("accent", this.theme.bold("Review answers")));
|
||||||
|
lines.push("");
|
||||||
|
for (const item of this.questions) {
|
||||||
|
const answer = this.answers.get(item.id);
|
||||||
|
const value = !answer ? this.theme.fg("warning", "Unanswered") : answer.skipped ? this.theme.fg("muted", "Skipped") : this.theme.fg("text", answer.label);
|
||||||
|
addWrappedWithPrefix(" ", `${this.theme.fg("muted", `${item.label}: `)}${value}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
addWrappedWithPrefix(" ", this.allAnswered()
|
||||||
|
? this.theme.fg("success", "Press Enter to submit")
|
||||||
|
: this.theme.fg("warning", "Answer or explicitly skip every question before submitting"));
|
||||||
|
} else if (question) {
|
||||||
|
addWrappedWithPrefix(" ", this.theme.fg("text", question.prompt));
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (question.type === "select") {
|
||||||
|
for (const [index, option] of this.currentOptions().entries()) {
|
||||||
|
const selected = index === this.optionIndex;
|
||||||
|
const prefix = selected ? this.theme.fg("accent", "> ") : " ";
|
||||||
|
addWrappedWithPrefix(prefix, this.theme.fg(selected ? "accent" : option.kind === "skip" ? "muted" : "text", `${index + 1}. ${option.label}`));
|
||||||
|
if (option.description) addWrappedWithPrefix(" ", this.theme.fg("muted", option.description));
|
||||||
|
}
|
||||||
|
} else if (!this.inputKind) {
|
||||||
|
const existing = this.answers.get(question.id);
|
||||||
|
if (existing) addWrappedWithPrefix(" ", `${this.theme.fg("muted", "Current answer: ")}${existing.skipped ? "Skipped" : existing.label}`);
|
||||||
|
const action = `${existing ? "Press Enter to edit" : "Press Enter to answer"}${question.required ? "" : " • s to skip"}`;
|
||||||
|
addWrappedWithPrefix(" ", this.theme.fg("accent", action));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.inputKind) {
|
||||||
|
if (this.inputKind === "custom") lines.push("");
|
||||||
|
addWrappedWithPrefix(" ", this.theme.fg("muted", "Your answer:"));
|
||||||
|
for (const line of this.editor.render(Math.max(1, renderWidth - 2))) lines.push(` ${line}`);
|
||||||
|
if (this.validationMessage) addWrappedWithPrefix(" ", this.theme.fg("warning", this.validationMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
const help = this.inputKind
|
||||||
|
? "Enter submit • Esc go back"
|
||||||
|
: this.currentPage === this.questions.length
|
||||||
|
? "Tab/←→ navigate • Enter submit • Esc cancel"
|
||||||
|
: question?.type === "text"
|
||||||
|
? `${question.required ? "Enter write" : "Enter write • s skip"} • Tab/←→ navigate • Esc cancel`
|
||||||
|
: "↑↓ select • Enter confirm • Tab/←→ navigate • Esc cancel";
|
||||||
|
addWrappedWithPrefix(" ", this.theme.fg("dim", this.questions.length === 1 ? help.replace(" • Tab/←→ navigate", "") : help));
|
||||||
|
lines.push(this.theme.fg("accent", "─".repeat(renderWidth)));
|
||||||
|
|
||||||
|
this.cachedWidth = renderWidth;
|
||||||
|
this.cachedLines = lines;
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(): void {
|
||||||
|
this.cachedWidth = undefined;
|
||||||
|
this.cachedLines = undefined;
|
||||||
|
this.editor.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.signal?.removeEventListener("abort", this.abortHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { formatAnswers, normalizeQuestions, orderedAnswers } from "../src/normalize.ts";
|
||||||
|
import type { UserAnswer } from "../src/types.ts";
|
||||||
|
|
||||||
|
test("normalizes select and text questions with bounded defaults", () => {
|
||||||
|
const questions = normalizeQuestions([
|
||||||
|
{
|
||||||
|
id: "language",
|
||||||
|
label: " Language ",
|
||||||
|
prompt: " Choose a language ",
|
||||||
|
type: "select",
|
||||||
|
options: [{ value: "ts", label: " TypeScript " }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notes",
|
||||||
|
prompt: "Anything else?",
|
||||||
|
type: "text",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(questions[0], {
|
||||||
|
id: "language",
|
||||||
|
label: "Language",
|
||||||
|
prompt: "Choose a language",
|
||||||
|
type: "select",
|
||||||
|
options: [{ value: "ts", label: "TypeScript" }],
|
||||||
|
allowCustom: true,
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
assert.equal(questions[1]?.label, "Q2");
|
||||||
|
assert.equal(questions[1]?.allowCustom, false);
|
||||||
|
assert.equal(questions[1]?.required, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid ids, duplicate ids, invalid option combinations, and duplicate values", () => {
|
||||||
|
assert.throws(() => normalizeQuestions([{ id: "bad id", prompt: "Bad", type: "text" }]), /must start with a letter/);
|
||||||
|
assert.throws(() => normalizeQuestions([null as never]), /must be an object/);
|
||||||
|
assert.throws(() => normalizeQuestions([{ id: "bad_flag", prompt: "Bad", type: "text", required: "yes" as never }]), /must be a boolean/);
|
||||||
|
assert.throws(() => normalizeQuestions([
|
||||||
|
{ id: "same", prompt: "One", type: "text" },
|
||||||
|
{ id: "same", prompt: "Two", type: "text" },
|
||||||
|
]), /duplicate question id/);
|
||||||
|
assert.throws(() => normalizeQuestions([{ id: "pick", prompt: "Pick", type: "select" }]), /at least one option/);
|
||||||
|
assert.throws(() => normalizeQuestions([{ id: "text", prompt: "Write", type: "text", options: [{ value: "x", label: "X" }] }]), /must not define options/);
|
||||||
|
assert.throws(() => normalizeQuestions([{
|
||||||
|
id: "pick",
|
||||||
|
prompt: "Pick",
|
||||||
|
type: "select",
|
||||||
|
options: [{ value: "x", label: "X" }, { value: "x", label: "Again" }],
|
||||||
|
}]), /duplicate option value/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("orders and formats answers by question order", () => {
|
||||||
|
const questions = normalizeQuestions([
|
||||||
|
{ id: "first", prompt: "First?", type: "text" },
|
||||||
|
{ id: "second", prompt: "Second?", type: "select", options: [{ value: "yes", label: "Yes" }] },
|
||||||
|
{ id: "third", prompt: "Third?", type: "text", required: false },
|
||||||
|
]);
|
||||||
|
const answer = (id: string, overrides: Partial<UserAnswer>): UserAnswer => ({
|
||||||
|
id,
|
||||||
|
question: `${id}?`,
|
||||||
|
type: "text",
|
||||||
|
answer: id,
|
||||||
|
value: id,
|
||||||
|
label: id,
|
||||||
|
custom: false,
|
||||||
|
skipped: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
const answers = new Map<string, UserAnswer>([
|
||||||
|
["third", answer("third", { answer: "", value: "", label: "Skipped", skipped: true })],
|
||||||
|
["second", answer("second", { type: "select", answer: "Yes", value: "yes", label: "Yes" })],
|
||||||
|
["first", answer("first", { answer: "hello", value: "hello", label: "hello", custom: true })],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ordered = orderedAnswers(questions, answers);
|
||||||
|
assert.deepEqual(ordered.map((item) => item.id), ["first", "second", "third"]);
|
||||||
|
assert.equal(formatAnswers(ordered), [
|
||||||
|
"first: user wrote: hello",
|
||||||
|
"second: user selected: Yes (value: yes)",
|
||||||
|
"third: skipped",
|
||||||
|
].join("\n"));
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
- Replaced the Hermes and third-party Hippo assignments with the five tools exposed by the official Hippo Pi extension, grouped into recall and management workflows.
|
- Replaced the Hermes and third-party Hippo assignments with the five tools exposed by the official Hippo Pi extension, grouped into recall and management workflows.
|
||||||
- Added authoritative `ssh-connection`, `ssh-remote-files`, `ssh-remote-search`, and `ssh-remote-shell` groups for the maintained `pi-ssh` tools, keeping reviewed connection, bounded discovery, structured file operations, and reviewed shell execution separate; the connection workflow now requires sequential `ssh_connect` to succeed before dependent tools.
|
- Added authoritative `ssh-connection`, `ssh-remote-files`, `ssh-remote-search`, and `ssh-remote-shell` groups for the maintained `pi-ssh` tools, keeping reviewed connection, bounded discovery, structured file operations, and reviewed shell execution separate; the connection workflow now requires sequential `ssh_connect` to succeed before dependent tools.
|
||||||
- Added sequential `ssh_cd` to the authoritative remote-shell workflow and direct the model to wait for a successful workspace change before dependent remote calls, avoiding both the unknown-tools fallback and cwd races.
|
- Added sequential `ssh_cd` to the authoritative remote-shell workflow and direct the model to wait for a successful workspace change before dependent remote calls, avoiding both the unknown-tools fallback and cwd races.
|
||||||
|
- Added the authoritative `user-interaction` group for the maintained `ask_user_question` TUI tool.
|
||||||
|
|
||||||
## [0.3.6] - 2026-04-24
|
## [0.3.6] - 2026-04-24
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ Run `/tool-search-rebuild` to remove model enrichment. Standard bundle tools imm
|
|||||||
| `context-administration` | Context upgrade and destructive purge |
|
| `context-administration` | Context upgrade and destructive purge |
|
||||||
| `memory-recall` | Official Hippo recall/context/status and condensed-output recovery |
|
| `memory-recall` | Official Hippo recall/context/status and condensed-output recovery |
|
||||||
| `memory-management` | Official Hippo remember/outcome tools |
|
| `memory-management` | Official Hippo remember/outcome tools |
|
||||||
|
| `user-interaction` | `ask_user_question` model-initiated TUI questions |
|
||||||
| `mcp-management` | Shared MCP adapter management |
|
| `mcp-management` | Shared MCP adapter management |
|
||||||
|
|
||||||
Pinned tools are removed from these dynamic groups at runtime. For example, the default-pinned `codegraph_explore` and `lsp_diagnostics` do not consume the `code-intelligence` group budget.
|
Pinned tools are removed from these dynamic groups at runtime. For example, the default-pinned `codegraph_explore` and `lsp_diagnostics` do not consume the `code-intelligence` group budget.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
The standard bundle assigns the official Hippo Pi extension's five `hippo_*` tools to recall and management groups. `context_tree_query` remains in `memory-recall` because it recovers condensed tool output; retired Hermes names and third-party `tff-memory_*` names are not seeded.
|
The standard bundle assigns the official Hippo Pi extension's five `hippo_*` tools to recall and management groups. `context_tree_query` remains in `memory-recall` because it recovers condensed tool output; retired Hermes names and third-party `tff-memory_*` names are not seeded.
|
||||||
The maintained `pi-ssh` tools are also split by workflow: sequential reviewed agent-controlled connection lives in `ssh-connection`, structured `ssh_read`/`ssh_write`/`ssh_edit` file operations live in `ssh-remote-files`, bounded `ssh_find`/`ssh_grep` discovery lives in `ssh-remote-search`, and sequential reviewed workspace changes plus reviewed command execution live in `ssh-remote-shell`. The workflows tell the model to call `ssh_connect` or `ssh_cd` separately and wait for success before issuing remote calls that depend on the new connection or cwd.
|
The maintained `pi-ssh` tools are also split by workflow: sequential reviewed agent-controlled connection lives in `ssh-connection`, structured `ssh_read`/`ssh_write`/`ssh_edit` file operations live in `ssh-remote-files`, bounded `ssh_find`/`ssh_grep` discovery lives in `ssh-remote-search`, and sequential reviewed workspace changes plus reviewed command execution live in `ssh-remote-shell`. The workflows tell the model to call `ssh_connect` or `ssh_cd` separately and wait for success before issuing remote calls that depend on the new connection or cwd.
|
||||||
|
The maintained `ask_user_question` tool has its own `user-interaction` group so the model can load the complete interactive schema only when a missing decision or preference must come from the user.
|
||||||
|
|
||||||
A purely additive first load receives Pi's native dynamic-tool result propagation. A replacement that evicts one group while adding another is intentionally non-additive; current Pi detects the removal and uses its safe next-request fallback instead of attaching an invalid additive-only hint.
|
A purely additive first load receives Pi's native dynamic-tool result propagation. A replacement that evicts one group while adding another is intentionally non-additive; current Pi detects the removal and uses its safe next-request fallback instead of attaching an invalid additive-only hint.
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,15 @@ export const BUNDLE_GROUP_DEFINITIONS: BundleGroupDefinition[] = [
|
|||||||
keywords: ["remember", "outcome", "lesson", "error", "记住", "反馈", "项目经验"],
|
keywords: ["remember", "outcome", "lesson", "error", "记住", "反馈", "项目经验"],
|
||||||
tools: ["hippo_remember", "hippo_outcome"],
|
tools: ["hippo_remember", "hippo_outcome"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "user-interaction",
|
||||||
|
title: "Interactive user questions",
|
||||||
|
summary: "Pause the agent and collect one or more structured decisions from the user in the TUI.",
|
||||||
|
useWhen: ["A missing requirement, preference, confirmation, or choice must come directly from the user before continuing"],
|
||||||
|
avoidWhen: ["The user's direct messages already provide the answer, or the task can proceed without another decision"],
|
||||||
|
keywords: ["ask", "question", "clarify", "choice", "preference", "提问", "澄清", "选择", "确认"],
|
||||||
|
tools: ["ask_user_question"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "mcp-management",
|
id: "mcp-management",
|
||||||
title: "MCP management",
|
title: "MCP management",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ test("bundle seed names are unique and cover the declared my-pi tools", () => {
|
|||||||
assert.equal(result.catalog.tools.find((card) => card.name === "hippo_remember")?.primaryGroup, "memory-management");
|
assert.equal(result.catalog.tools.find((card) => card.name === "hippo_remember")?.primaryGroup, "memory-management");
|
||||||
assert.equal(result.catalog.tools.find((card) => card.name === "ssh_find")?.primaryGroup, "ssh-remote-search");
|
assert.equal(result.catalog.tools.find((card) => card.name === "ssh_find")?.primaryGroup, "ssh-remote-search");
|
||||||
assert.equal(result.catalog.tools.find((card) => card.name === "ssh_grep")?.primaryGroup, "ssh-remote-search");
|
assert.equal(result.catalog.tools.find((card) => card.name === "ssh_grep")?.primaryGroup, "ssh-remote-search");
|
||||||
|
assert.equal(result.catalog.tools.find((card) => card.name === "ask_user_question")?.primaryGroup, "user-interaction");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("user overrides take priority and unknown tools retain deterministic fallback groups", () => {
|
test("user overrides take priority and unknown tools retain deterministic fallback groups", () => {
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ test("all package extensions load together without global registration conflicts
|
|||||||
files: string[];
|
files: string[];
|
||||||
pi: { extensions: string[] };
|
pi: { extensions: string[] };
|
||||||
};
|
};
|
||||||
|
assert.ok(
|
||||||
|
packageJson.pi.extensions.includes("./extensions/plugin-init.ts"),
|
||||||
|
"the bundle must expose the project plugin initializer command",
|
||||||
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
packageJson.pi.extensions.includes("./hippo-memory-pi/index.ts"),
|
packageJson.pi.extensions.includes("./hippo-memory-pi/index.ts"),
|
||||||
"the bundle must load the vendored official Hippo Memory extension",
|
"the bundle must load the vendored official Hippo Memory extension",
|
||||||
@@ -33,6 +37,9 @@ test("all package extensions load together without global registration conflicts
|
|||||||
assert.equal(packageJson.dependencies.jiti, "2.7.0", "the packed helper must load local TypeScript outside Pi");
|
assert.equal(packageJson.dependencies.jiti, "2.7.0", "the packed helper must load local TypeScript outside Pi");
|
||||||
assert.ok(packageJson.files.includes("pi-ssh"), "the packed bundle must include the locally maintained SSH source");
|
assert.ok(packageJson.files.includes("pi-ssh"), "the packed bundle must include the locally maintained SSH source");
|
||||||
assert.ok(packageJson.files.includes("ssh_config.sh"), "the packed bundle must include the SSH host import helper");
|
assert.ok(packageJson.files.includes("ssh_config.sh"), "the packed bundle must include the SSH host import helper");
|
||||||
|
assert.ok(packageJson.pi.extensions.includes("./pi-ask-user/index.ts"), "the bundle must load pi-ask-user");
|
||||||
|
assert.equal(packageJson.dependencies["pi-ask-user"], "file:./pi-ask-user");
|
||||||
|
assert.ok(packageJson.files.includes("pi-ask-user"), "the packed bundle must include pi-ask-user source");
|
||||||
const permissionConfig = JSON.parse(
|
const permissionConfig = JSON.parse(
|
||||||
await readFile(join(repositoryRoot, "config", "pi-permission-system.json"), "utf8"),
|
await readFile(join(repositoryRoot, "config", "pi-permission-system.json"), "utf8"),
|
||||||
) as { authorizerChain: string[]; permission: Record<string, unknown> };
|
) as { authorizerChain: string[]; permission: Record<string, unknown> };
|
||||||
@@ -49,6 +56,11 @@ test("all package extensions load together without global registration conflicts
|
|||||||
packageJson.pi.extensions.indexOf("./pi-tool-search/extensions/index.ts"),
|
packageJson.pi.extensions.indexOf("./pi-tool-search/extensions/index.ts"),
|
||||||
"pi-ssh tools must register before Tool Search builds its catalog",
|
"pi-ssh tools must register before Tool Search builds its catalog",
|
||||||
);
|
);
|
||||||
|
assert.ok(
|
||||||
|
packageJson.pi.extensions.indexOf("./pi-ask-user/index.ts") <
|
||||||
|
packageJson.pi.extensions.indexOf("./pi-tool-search/extensions/index.ts"),
|
||||||
|
"ask_user_question must register before Tool Search builds its catalog",
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
packageJson.dependencies["@firstpick/pi-extension-codex-fast-mode"],
|
packageJson.dependencies["@firstpick/pi-extension-codex-fast-mode"],
|
||||||
"file:./pi-extension-codex-fast-mode",
|
"file:./pi-extension-codex-fast-mode",
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { homedir, tmpdir } from "node:os";
|
||||||
|
import { join, parse } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import pluginInitExtension, {
|
||||||
|
findMissingPluginInitializers,
|
||||||
|
initializeMissingPlugins,
|
||||||
|
validatePluginInitCwd,
|
||||||
|
} from "../extensions/plugin-init.ts";
|
||||||
|
|
||||||
|
test("initializes missing CodeGraph and Hippo state after preflighting both CLIs", async () => {
|
||||||
|
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
|
||||||
|
const calls: Array<{ command: string; args: string[]; cwd: string }> = [];
|
||||||
|
try {
|
||||||
|
const targets = await findMissingPluginInitializers(cwd);
|
||||||
|
assert.deepEqual(targets.map(({ name }) => name), ["CodeGraph", "Hippo"]);
|
||||||
|
|
||||||
|
await initializeMissingPlugins(cwd, targets, async (command, args, options) => {
|
||||||
|
calls.push({ command, args, cwd: options.cwd });
|
||||||
|
return { code: 0, stdout: "ok", stderr: "" };
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
{ command: "codegraph", args: ["--version"], cwd },
|
||||||
|
{ command: "hippo", args: ["--version"], cwd },
|
||||||
|
{ command: "codegraph", args: ["init", cwd], cwd },
|
||||||
|
{ command: "hippo", args: ["init"], cwd },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await rm(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the CLIs' database files as authoritative initialization markers", async () => {
|
||||||
|
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
|
||||||
|
const calls: string[] = [];
|
||||||
|
try {
|
||||||
|
await mkdir(join(cwd, ".codegraph"));
|
||||||
|
await mkdir(join(cwd, ".hippo"));
|
||||||
|
assert.deepEqual(
|
||||||
|
(await findMissingPluginInitializers(cwd)).map(({ name }) => name),
|
||||||
|
["CodeGraph", "Hippo"],
|
||||||
|
"bare state directories must not count as initialized",
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeFile(join(cwd, ".codegraph", "codegraph.db"), "");
|
||||||
|
assert.deepEqual((await findMissingPluginInitializers(cwd)).map(({ name }) => name), ["Hippo"]);
|
||||||
|
await writeFile(join(cwd, ".hippo", "hippo.db"), "");
|
||||||
|
const targets = await findMissingPluginInitializers(cwd);
|
||||||
|
assert.deepEqual(targets, []);
|
||||||
|
|
||||||
|
await initializeMissingPlugins(cwd, targets, async (command, args) => {
|
||||||
|
calls.push(`${command} ${args.join(" ")}`);
|
||||||
|
return { code: 0, stdout: "ok", stderr: "" };
|
||||||
|
});
|
||||||
|
assert.deepEqual(calls, ["codegraph --version", "hippo --version"]);
|
||||||
|
} finally {
|
||||||
|
await rm(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects unsafe project roots", () => {
|
||||||
|
assert.throws(() => validatePluginInitCwd(parse(homedir()).root), /文件系统根目录/);
|
||||||
|
assert.throws(() => validatePluginInitCwd(homedir()), /用户主目录/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preflights both CLIs before creating project state", async () => {
|
||||||
|
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
|
||||||
|
const calls: string[] = [];
|
||||||
|
try {
|
||||||
|
const targets = await findMissingPluginInitializers(cwd);
|
||||||
|
await assert.rejects(
|
||||||
|
initializeMissingPlugins(cwd, targets, async (command, args) => {
|
||||||
|
calls.push(`${command} ${args.join(" ")}`);
|
||||||
|
return command === "hippo"
|
||||||
|
? { code: 1, stdout: "", stderr: "not installed" }
|
||||||
|
: { code: 0, stdout: "ok", stderr: "" };
|
||||||
|
}),
|
||||||
|
/Hippo 检测失败:not installed/,
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls, ["codegraph --version", "hippo --version"]);
|
||||||
|
} finally {
|
||||||
|
await rm(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plugin_init confirms the target and hot-reloads after initialization", async () => {
|
||||||
|
let command: { handler: (args: string, ctx: any) => Promise<void> } | undefined;
|
||||||
|
const calls: string[] = [];
|
||||||
|
const pi = {
|
||||||
|
registerCommand(name: string, definition: typeof command) {
|
||||||
|
assert.equal(name, "plugin_init");
|
||||||
|
command = definition;
|
||||||
|
},
|
||||||
|
exec: async (program: string, args: string[]) => {
|
||||||
|
calls.push(`${program} ${args.join(" ")}`);
|
||||||
|
return { code: 0, stdout: "ok", stderr: "" };
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
pluginInitExtension(pi);
|
||||||
|
assert.ok(command);
|
||||||
|
const registeredCommand = command!;
|
||||||
|
|
||||||
|
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
|
||||||
|
let reloaded = false;
|
||||||
|
let confirmation = "";
|
||||||
|
try {
|
||||||
|
await registeredCommand.handler("", {
|
||||||
|
cwd,
|
||||||
|
hasUI: true,
|
||||||
|
ui: {
|
||||||
|
confirm: async (_title: string, message: string) => {
|
||||||
|
confirmation = message;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
notify() {},
|
||||||
|
},
|
||||||
|
reload: async () => {
|
||||||
|
reloaded = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.match(confirmation, new RegExp(cwd.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
"codegraph --version",
|
||||||
|
"hippo --version",
|
||||||
|
`codegraph init ${cwd}`,
|
||||||
|
"hippo init",
|
||||||
|
]);
|
||||||
|
assert.equal(reloaded, true);
|
||||||
|
} finally {
|
||||||
|
await rm(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plugin_init reports retained partial state and does not reload when a later initializer fails", async () => {
|
||||||
|
let command: { handler: (args: string, ctx: any) => Promise<void> } | undefined;
|
||||||
|
const pi = {
|
||||||
|
registerCommand(_name: string, definition: typeof command) {
|
||||||
|
command = definition;
|
||||||
|
},
|
||||||
|
exec: async (program: string, args: string[]) => ({
|
||||||
|
code: program === "hippo" && args[0] === "init" ? 1 : 0,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "mock failure",
|
||||||
|
}),
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
pluginInitExtension(pi);
|
||||||
|
assert.ok(command);
|
||||||
|
const registeredCommand = command!;
|
||||||
|
|
||||||
|
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
|
||||||
|
let reloaded = false;
|
||||||
|
let error = "";
|
||||||
|
try {
|
||||||
|
await registeredCommand.handler("", {
|
||||||
|
cwd,
|
||||||
|
hasUI: true,
|
||||||
|
ui: {
|
||||||
|
confirm: async () => true,
|
||||||
|
notify(message: string, level: string) {
|
||||||
|
if (level === "error") error = message;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reload: async () => {
|
||||||
|
reloaded = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.match(error, /Hippo 初始化失败:mock failure/);
|
||||||
|
assert.match(error, /已完成的 CodeGraph 初始化会保留,不会自动回滚/);
|
||||||
|
assert.equal(reloaded, false);
|
||||||
|
} finally {
|
||||||
|
await rm(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plugin_init stops without executing or reloading when confirmation is cancelled", async () => {
|
||||||
|
let command: { handler: (args: string, ctx: any) => Promise<void> } | undefined;
|
||||||
|
let executed = false;
|
||||||
|
const pi = {
|
||||||
|
registerCommand(_name: string, definition: typeof command) {
|
||||||
|
command = definition;
|
||||||
|
},
|
||||||
|
exec: async () => {
|
||||||
|
executed = true;
|
||||||
|
return { code: 0, stdout: "", stderr: "" };
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
pluginInitExtension(pi);
|
||||||
|
const registeredCommand = command!;
|
||||||
|
|
||||||
|
const cwd = await mkdtemp(join(tmpdir(), "my-pi-plugin-init-"));
|
||||||
|
let reloaded = false;
|
||||||
|
try {
|
||||||
|
await registeredCommand.handler("", {
|
||||||
|
cwd,
|
||||||
|
hasUI: true,
|
||||||
|
ui: { confirm: async () => false, notify() {} },
|
||||||
|
reload: async () => {
|
||||||
|
reloaded = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(executed, false);
|
||||||
|
assert.equal(reloaded, false);
|
||||||
|
} finally {
|
||||||
|
await rm(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user