diff --git a/AGENTS.md b/AGENTS.md index 8d71674..47ce70d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,10 @@ - 上游来源: - 初始导入快照:`ddfb23646fd3957b791214de278e23aa393c9b13`(`v0.3.6`)。 - 该目录不是 submodule,不保留嵌套 `.git`、上游 `.pi` 状态、`node_modules` 或构建产物。 +- `pi-ask-user/`:把 Pi 上游 `question.ts` 与 `questionnaire.ts` 示例整合为模型主动调用的 `ask_user_question` 工具,统一支持单题、多题、选择、自由文本、自定义回答与复核;不加载 `/qna`。 +- 上游来源: +- 初始参考快照:`dcd461925db2edf69a43c8135db1180d418afd54`(2026-08-24)。 +- 该目录由本仓库直接维护,保留上游 MIT 许可证与来源说明;根组合包直接加载源码并通过本地 `file:` 依赖打包。 - `pi-lsp@0.1.7`:提供声明式 LSP 接入;组合包内置 `typescript-language-server@5.3.0` + `typescript@6.0.3`,并配置机器级 `kotlin-lsp --stdio` 与 `jdtls`。 - `hippo-memory-pi/`:从官方 `hippo-memory` 仓库的 `extensions/pi-extension/` 导入并由本仓库直接维护;提供 session start 项目记忆注入、工具错误过滤捕获、session shutdown sleep 和 5 个 `hippo_*` 工具。 - 上游来源: @@ -50,7 +54,7 @@ - 初始导入快照:`f1d0efd24a7f4ae99d19e10c5f4c3770a3bdd845`(`@firstpick/pi-extension-codex-fast-mode@0.1.1`)。 - 该目录从上游源码仓库导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。 - `pi-condense@2.9.1`:总结已完成的工具调用批次,以短 stub 替换历史原始输出,并通过 `context_tree_query` 按需恢复;组合包在用户尚未配置 `contextPrune.enabled` 时默认开启。 -- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`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`、`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 单行紧凑主题)。 - `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。 - `uninstall.sh`:先移除根组合包;若检测到 Hippo Memory CLI,再明确询问是否卸载确认属于 npm 全局安装的 `hippo-memory`,默认保留且始终不删除 `.hippo/` 或用户记忆数据。其他共享终端环境和机器工具不卸载。 @@ -65,6 +69,7 @@ - Context Mode 负责避免批量读取、命令研究和网页原始内容直接撑大上下文;FFF 仍负责精确字面搜索,RTK 仍处理未走 Context Mode 的普通输出。 - 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 或倾倒全部结果解决。 +- `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 不得调用模型、创建用户缓存或发送隐藏 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 兼容循环。 @@ -180,6 +185,16 @@ - 根加载顺序必须保持 `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/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/`。 diff --git a/README.md b/README.md index aea1c2b..7a6379f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - `@ogulcancelik/pi-codex-compaction@0.1.3`:为 `openai-codex` 提供原生远程压缩。 - 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer,并在 Codex Fast 模式开启时显示 `Fast on`。 - `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。 +- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`,用一个顺序 TUI 同时支持单题、多题、选择题、文本题、自定义回答与提交前复核。 - 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,以最多 3 个动态组的 LRU 策略按组加载原始完整 schema。 - 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。 - 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入,负责工具、路径、MCP、硬拒绝和兜底权限基线。 @@ -122,6 +123,10 @@ 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 或输出全部结果。 @@ -138,7 +143,7 @@ CodeGraph 本体不由组合包安装。需要使用 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` 和 `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` 会立即恢复预置目录,只有仍存在额外工具时才可能在下次搜索惰性增强。 diff --git a/package-lock.json b/package-lock.json index 155875b..36d6537 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@tavily/pi-extension": "0.1.2", "context-mode": "1.0.169", "jiti": "2.7.0", + "pi-ask-user": "file:./pi-ask-user", "pi-condense": "2.9.1", "pi-context-view": "0.4.2", "pi-hashline-edit": "0.8.3", @@ -5659,6 +5660,10 @@ "dev": true, "license": "MIT" }, + "node_modules/pi-ask-user": { + "resolved": "pi-ask-user", + "link": true + }, "node_modules/pi-condense": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/pi-condense/-/pi-condense-2.9.1.tgz", @@ -7104,6 +7109,29 @@ "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": { "name": "@firstpick/pi-extension-codex-fast-mode", "version": "0.1.1", diff --git a/package.json b/package.json index dbb53be..99f2a90 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "files": [ "extensions", "hippo-memory-pi", + "pi-ask-user", "pi-extension-codex-fast-mode", "pi-rtk-optimizer", "pi-minimal-footer", @@ -61,6 +62,7 @@ "./pi-permission-auto-review/index.ts", "./extensions/permission-system.ts", "./pi-ssh/index.ts", + "./pi-ask-user/index.ts", "./extensions/tool-routing.ts", "./pi-tool-search/extensions/index.ts" ], @@ -84,6 +86,7 @@ "pi-hashline-edit": "0.8.3", "pi-lsp": "0.1.7", "pi-mcp-adapter": "2.26.0", + "pi-ask-user": "file:./pi-ask-user", "pi-ssh": "file:./pi-ssh", "pi-tool-search": "file:./pi-tool-search", "ssh2": "1.17.0", diff --git a/pi-ask-user/LICENSE b/pi-ask-user/LICENSE new file mode 100644 index 0000000..4864295 --- /dev/null +++ b/pi-ask-user/LICENSE @@ -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. diff --git a/pi-ask-user/README.md b/pi-ask-user/README.md new file mode 100644 index 0000000..fa64524 --- /dev/null +++ b/pi-ask-user/README.md @@ -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. diff --git a/pi-ask-user/UPSTREAM.md b/pi-ask-user/UPSTREAM.md new file mode 100644 index 0000000..2157dd5 --- /dev/null +++ b/pi-ask-user/UPSTREAM.md @@ -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: + +- +- + +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`. diff --git a/pi-ask-user/index.ts b/pi-ask-user/index.ts new file mode 100644 index 0000000..8b68111 --- /dev/null +++ b/pi-ask-user/index.ts @@ -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"; diff --git a/pi-ask-user/package.json b/pi-ask-user/package.json new file mode 100644 index 0000000..ee4f6c8 --- /dev/null +++ b/pi-ask-user/package.json @@ -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" + } +} diff --git a/pi-ask-user/src/normalize.ts b/pi-ask-user/src/normalize.ts new file mode 100644 index 0000000..05ebdcf --- /dev/null +++ b/pi-ask-user/src/normalize.ts @@ -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(); + 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(); + 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): 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"); +} diff --git a/pi-ask-user/src/schema.ts b/pi-ask-user/src/schema.ts new file mode 100644 index 0000000..0e9801d --- /dev/null +++ b/pi-ask-user/src/schema.ts @@ -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", + }), +}); diff --git a/pi-ask-user/src/tool.ts b/pi-ask-user/src/tool.ts new file mode 100644 index 0000000..a95b6dd --- /dev/null +++ b/pi-ask-user/src/tool.ts @@ -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((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); + }, + }); +} diff --git a/pi-ask-user/src/types.ts b/pi-ask-user/src/types.ts new file mode 100644 index 0000000..3f2543f --- /dev/null +++ b/pi-ask-user/src/types.ts @@ -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[]; +} diff --git a/pi-ask-user/src/view.ts b/pi-ask-user/src/view.ts new file mode 100644 index 0000000..f7a9b35 --- /dev/null +++ b/pi-ask-user/src/view.ts @@ -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(); + 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): 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); + } +} diff --git a/pi-ask-user/test/normalize.test.ts b/pi-ask-user/test/normalize.test.ts new file mode 100644 index 0000000..74128d9 --- /dev/null +++ b/pi-ask-user/test/normalize.test.ts @@ -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 => ({ + id, + question: `${id}?`, + type: "text", + answer: id, + value: id, + label: id, + custom: false, + skipped: false, + ...overrides, + }); + const answers = new Map([ + ["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")); +}); diff --git a/pi-tool-search/CHANGELOG.md b/pi-tool-search/CHANGELOG.md index 20d8101..d48d986 100644 --- a/pi-tool-search/CHANGELOG.md +++ b/pi-tool-search/CHANGELOG.md @@ -12,6 +12,7 @@ - Added checked-in authoritative groups for every tool exposed by the standard my-pi bundle, eliminating first-use model generation and user caching unless unrecognized third-party tools are present. - 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. +- Added the authoritative `user-interaction` group for the maintained `ask_user_question` TUI tool. ## [0.3.6] - 2026-04-24 diff --git a/pi-tool-search/README.md b/pi-tool-search/README.md index 0415dc1..99cd63d 100644 --- a/pi-tool-search/README.md +++ b/pi-tool-search/README.md @@ -41,6 +41,7 @@ Run `/tool-search-rebuild` to remove model enrichment. Standard bundle tools imm | `context-administration` | Context upgrade and destructive purge | | `memory-recall` | Official Hippo recall/context/status and condensed-output recovery | | `memory-management` | Official Hippo remember/outcome tools | +| `user-interaction` | `ask_user_question` model-initiated TUI questions | | `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. diff --git a/pi-tool-search/docs/dynamic-tool-loading.md b/pi-tool-search/docs/dynamic-tool-loading.md index 4fb4fdb..d83f529 100644 --- a/pi-tool-search/docs/dynamic-tool-loading.md +++ b/pi-tool-search/docs/dynamic-tool-loading.md @@ -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 maintained `pi-ssh` tools are also split by workflow: 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 the reviewed `ssh_bash` command surface stays isolated in `ssh-remote-shell`. +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. diff --git a/pi-tool-search/extensions/bundle-groups.ts b/pi-tool-search/extensions/bundle-groups.ts index 9f3650a..2150eba 100644 --- a/pi-tool-search/extensions/bundle-groups.ts +++ b/pi-tool-search/extensions/bundle-groups.ts @@ -157,6 +157,15 @@ export const BUNDLE_GROUP_DEFINITIONS: BundleGroupDefinition[] = [ keywords: ["remember", "outcome", "lesson", "error", "记住", "反馈", "项目经验"], 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", title: "MCP management", diff --git a/pi-tool-search/test/bundle-groups.test.ts b/pi-tool-search/test/bundle-groups.test.ts index 694f726..d18e41d 100644 --- a/pi-tool-search/test/bundle-groups.test.ts +++ b/pi-tool-search/test/bundle-groups.test.ts @@ -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 === "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 === "ask_user_question")?.primaryGroup, "user-interaction"); }); test("user overrides take priority and unknown tools retain deterministic fallback groups", () => { diff --git a/tests/extension-load.test.ts b/tests/extension-load.test.ts index 0907859..6fa8c89 100644 --- a/tests/extension-load.test.ts +++ b/tests/extension-load.test.ts @@ -37,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.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.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( await readFile(join(repositoryRoot, "config", "pi-permission-system.json"), "utf8"), ) as { authorizerChain: string[]; permission: Record }; @@ -52,6 +55,11 @@ test("all package extensions load together without global registration conflicts packageJson.pi.extensions.indexOf("./pi-tool-search/extensions/index.ts"), "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( packageJson.dependencies["@firstpick/pi-extension-codex-fast-mode"], "file:./pi-extension-codex-fast-mode",