From f9a7f743dd9af4ac6f95a0a2532e3061bc08ce74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E6=9C=8D=E5=8A=A1=E9=83=A8-=E5=8F=B6=E6=9E=97?= =?UTF-8?q?=E7=AB=8B?= <1361666059@qq.com> Date: Wed, 21 Oct 2026 23:18:34 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20Pi=20=E5=86=85?= =?UTF-8?q?=E7=BD=AE=20codemode=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=85=A8=E9=9D=A2=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - codemode:Tool Search 常驻(alwaysEnabled)、权限 allow(嵌套调用仍逐个过权限门)、 tools.jsonc 登记使用规则;RTK 跳过 parentToolCallId 嵌套调用,避免丢失 bash structuredContent;Tool Search 目录过滤 hidden / codemode(script-only)exposure; 扩展加载测试加载 builtin:codemode 并断言其常驻激活 - 搜索 key 不再经命令行参数传递:search-auth.mjs 改为 --set-stdin(先读校验再加锁), configure.sh 交互输入经管道、非交互用 -- - - CodeGraph 客户端监听子进程 stdin error,避免 EPIPE 未处理导致崩溃 - 移除 PI_TOOL_SEARCH/HERMES_MEMORY/RTK_CONFIG_PATH 环境变量入口,测试改用工厂 configPath 注入 - pi-subagents 固定 vitest / coverage-istanbul 4.1.10,修复 npm ls 版本冲突 - 补 pi-web-search / pi-worktree UPSTREAM.md 与 pi-minimal-footer AGENTS.md, files 白名单补齐并加测试;.codegraph/.gitignore 移出跟踪 - detect_kitty 优先识别官方安装目录 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .codegraph/.gitignore | 5 -- README.md | 9 ++- config/pi-permission-policy.jsonc | 3 + config/prompt/tools.jsonc | 7 ++ config/tool-search.json | 3 +- configure.sh | 34 ++++---- extensions/codegraph-client.ts | 2 + package-lock.json | 54 ++++++------- package.json | 3 + .../scripts/benchmark-memory-startup.mjs | 9 ++- pi-hermes-memory/src/config.ts | 11 +-- pi-hermes-memory/src/index.ts | 19 ++++- pi-hermes-memory/tests/lazy-startup.test.ts | 10 +-- .../tests/project-rebinding.test.ts | 10 +-- .../tests/prompt-injection.test.ts | 10 +-- pi-minimal-footer/AGENTS.md | 10 +++ pi-rtk-optimizer/AGENTS.md | 1 + pi-rtk-optimizer/src/config-store.ts | 10 +-- pi-rtk-optimizer/src/constants.ts | 8 -- pi-rtk-optimizer/src/index.test.ts | 68 +++++++++++++++- pi-rtk-optimizer/src/index.ts | 35 +++++++-- pi-rtk-optimizer/src/types-shims.d.ts | 1 + pi-subagents/package-lock.json | 4 +- pi-subagents/package.json | 4 +- pi-tool-search/AGENTS.md | 2 +- pi-tool-search/CHANGELOG.md | 2 + pi-tool-search/README.md | 9 ++- pi-tool-search/docs/dynamic-tool-loading.md | 2 +- pi-tool-search/extensions/config.ts | 3 +- pi-tool-search/extensions/index.ts | 33 ++++++-- pi-tool-search/test/bundle-groups.test.ts | 1 + pi-tool-search/test/catalog.test.ts | 1 + pi-tool-search/test/config.test.ts | 1 + pi-tool-search/test/extension.test.ts | 62 +++++++++++++-- pi-web-search/AGENTS.md | 2 +- pi-web-search/README.md | 5 +- pi-web-search/UPSTREAM.md | 9 +++ pi-worktree/UPSTREAM.md | 8 ++ scripts/lib.sh | 8 +- scripts/search-auth.mjs | 29 +++---- tests/bundle-config.test.ts | 45 ++++------- tests/codegraph.test.ts | 34 ++++++++ tests/extension-load.test.ts | 36 ++++++++- tests/lib-sh.test.ts | 70 +++++++++++++++++ tests/package-files.test.ts | 13 ++++ tests/search-auth.test.ts | 78 ++++++++++++++++--- 46 files changed, 585 insertions(+), 198 deletions(-) delete mode 100644 .codegraph/.gitignore create mode 100644 pi-minimal-footer/AGENTS.md create mode 100644 pi-web-search/UPSTREAM.md create mode 100644 pi-worktree/UPSTREAM.md create mode 100644 tests/lib-sh.test.ts diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore deleted file mode 100644 index d20c0fe..0000000 --- a/.codegraph/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# CodeGraph data files — local to each machine, not for committing. -# Ignore everything in .codegraph/ except this file itself, so transient -# files (the database, daemon.pid, sockets, logs) never show up in git. -* -!.gitignore diff --git a/README.md b/README.md index ce7ad2f..ecb4ceb 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ CodeGraph 的更新或遥测设置。 本地源码直接使用 `@earendil-works/pi-coding-agent`、当前 `typebox`、`ModelRegistry.complete()` 与 `setActiveTools()`,不安装、别名映射或加载旧 `@mariozechner` runtime,也不使用 provider payload 改写、代理执行或隐藏 `sendMessage` 循环。 -新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics`、六个记忆工具、`context_tree_query`、`overflow_query` 和 `tool_search`;Windows 上额外把内置 `powershell` 与 `bash` 作为同类本地 Shell 核心工具固定启用,非 Windows 平台则从目录中过滤它。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、Chrome 导航/交互/调试、用户交互、CodeGraph/LSP、Tavily、Exa、Keenable、Memory 查询/维护与 Skill 管理。不可用、已固定或尚未授权而未注册的可选工具会自动从动态组中滤除。 +新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics`、六个记忆工具、`context_tree_query`、`overflow_query`、Pi 内置 `codemode`(exposure `model-only`)和 `tool_search`;Windows 上额外把内置 `powershell` 与 `bash` 作为同类本地 Shell 核心工具固定启用,非 Windows 平台则从目录中过滤它。exposure 为 `hidden` 或 `codemode`(script-only)的工具一律不进入目录。`codemode` 在权限策略中为 `allow`:脚本本身无副作用,脚本发起的每个嵌套工具调用仍携带 `parentToolCallId` 经过同一权限门裁决;RTK 跳过所有嵌套调用,保证 bash 的 `structuredContent` 原样传给脚本。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、Chrome 导航/交互/调试、用户交互、CodeGraph/LSP、Tavily、Exa、Keenable、Memory 查询/维护与 Skill 管理。不可用、已固定或尚未授权而未注册的可选工具会自动从动态组中滤除。 标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具,并且当前调用不是精确加载已有分组时,才可使用当前已认证模型补充目录;精确已有分组始终立即按当前确定性目录激活,不等待无关未知工具的模型增强。模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `my-pi/tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会恢复预置目录,`/tool-search-status` 显示目录来源、容量、LRU 顺序和最近淘汰原因。 @@ -187,7 +187,8 @@ Tool Search 的配置文件是已安装组合包内的 `config/tool-search.json` "memory_remove", "skill_manage", "context_tree_query", - "overflow_query" + "overflow_query", + "codemode" ], "showToolSearchFooterStatus": false, "maxActiveGroups": 5, @@ -241,10 +242,10 @@ RTK 保留 Bash、read、build、test、lint 和 Git 输出压缩。默认 `read ### 在线搜索服务 -运行 `./configure.sh search` 可用隐藏输入依次配置 Tavily、Exa 和 Keenable;也可一次无交互配置: +运行 `./configure.sh search` 可用隐藏输入依次配置 Tavily、Exa 和 Keenable;key 不得出现在命令行参数中,非交互方式从标准输入读取(每次调用最多一个 provider): ```bash -./configure.sh search --tavily '...' --exa '...' --keenable '...' +printf '%s' "$TAVILY_API_KEY" | ./configure.sh search --tavily - ``` 只传部分参数时保留其他服务的已有值。凭据写入 Pi 自己的 diff --git a/config/pi-permission-policy.jsonc b/config/pi-permission-policy.jsonc index 5f24728..107aa7d 100644 --- a/config/pi-permission-policy.jsonc +++ b/config/pi-permission-policy.jsonc @@ -82,6 +82,9 @@ // pi-overflow's inspector reads only the extension's own stored output // directory (id-validated); pinned in Tool Search's alwaysEnabled set. "overflow_query": "allow", + // A codemode script does nothing by itself: every nested tool call it makes + // runs through this same gate (Pi emits tool_call with parentToolCallId). + "codemode": "allow", // Model-chosen URLs can exfiltrate data — same reason curl/wget ask. "web_fetch": "ask", diff --git a/config/prompt/tools.jsonc b/config/prompt/tools.jsonc index 7d585e0..295af96 100644 --- a/config/prompt/tools.jsonc +++ b/config/prompt/tools.jsonc @@ -32,6 +32,13 @@ "find": { "inherit": true }, "multi_grep": { "inherit": true }, "tool_search": { "inherit": true }, + "codemode": { + "guidelines": [ + "Use codemode to batch two or more independent tool calls (Promise.allSettled), chain dependent lookups, or filter large output down to what you need; call a single tool directly instead of wrapping it in a script.", + "Call edit, write, ssh_connect, ssh_cd, ask_user_question, and Agent directly, never from a codemode script.", + "Inside scripts, search with tools.grep, tools.find, or tools.multi_grep; never run grep, find, rg, or fd through tools.bash." + ] + }, "codegraph_explore": { "inherit": true }, "lsp_diagnostics": { "inherit": true }, diff --git a/config/tool-search.json b/config/tool-search.json index 35dff51..aea73d4 100644 --- a/config/tool-search.json +++ b/config/tool-search.json @@ -9,7 +9,8 @@ "memory_remove", "skill_manage", "context_tree_query", - "overflow_query" + "overflow_query", + "codemode" ], "showToolSearchFooterStatus": false, "maxActiveGroups": 5, diff --git a/configure.sh b/configure.sh index 815e546..5160b62 100755 --- a/configure.sh +++ b/configure.sh @@ -24,24 +24,25 @@ search_usage() { cat <<'EOF' 用法: ./configure.sh search - ./configure.sh search --tavily KEY --exa KEY --keenable KEY + ./configure.sh search --tavily - 从标准输入读取 key ./configure.sh search --migrate [search.env 路径] ./configure.sh search --list 选项: - --tavily KEY 配置 tavily api_key 凭据 - --exa KEY 配置 exa api_key 凭据 - --keenable KEY 配置 keenable api_key 凭据 + --tavily - 从标准输入读取 tavily api_key 凭据 + --exa - 从标准输入读取 exa api_key 凭据 + --keenable - 从标准输入读取 keenable api_key 凭据 --migrate 从旧 search.env 导入缺失的 key --list 显示各 provider 配置状态(不输出 key) --auth FILE 覆盖 auth.json 路径 -h, --help 显示帮助 -不传 key 参数时进入隐藏输入的交互模式;传入任意 key 参数时不交互, -未指定的 provider 保留已有值。凭据写入 Pi 的 auth.json -($PI_CODING_AGENT_DIR/auth.json 或 ~/.pi/agent/auth.json,权限 600)。 -key 支持 auth.json 原生语义:字面量、$ENV_VAR / ${ENV_VAR} 插值、 -以及 !command(执行命令取 stdout,如 "!op read op://Vault/Tavily/key")。 +key 不得出现在命令行参数中:非交互方式用 `-- -` 从标准输入 +读取,每次调用最多一个 provider 可经标准输入配置;不传参数时进入 +隐藏输入的交互模式,未指定的 provider 保留已有值。凭据写入 Pi 的 +auth.json($PI_CODING_AGENT_DIR/auth.json 或 ~/.pi/agent/auth.json, +权限 600)。key 支持 auth.json 原生语义:字面量、$ENV_VAR / ${ENV_VAR} +插值、以及 !command(执行命令取 stdout,如 "!op read op://Vault/Tavily/key")。 EOF } @@ -52,6 +53,7 @@ configure_search() { SEARCH_AUTH_FILE= search_mode=interactive search_did_work=0 + search_stdin_provider= run_search_auth() { if [ -n "$SEARCH_AUTH_FILE" ]; then @@ -69,7 +71,7 @@ configure_search() { prompt_label=$1 prompt_configured=$2 if [ ! -t 0 ]; then - warn "交互模式需要终端;请改用 --tavily/--exa/--keenable 参数。" + warn "交互模式需要终端;请改用 '-- -' 从标准输入提供 key。" return 2 fi if [ "$prompt_configured" -eq 1 ]; then @@ -88,9 +90,15 @@ configure_search() { while [ "$#" -gt 0 ]; do case $1 in --tavily|--exa|--keenable) - [ "$#" -ge 2 ] || { warn "$1 缺少 KEY"; exit 2; } + [ "$#" -ge 2 ] || { warn "$1 缺少 KEY 或 -"; exit 2; } + if [ "$2" != "-" ]; then + warn "$1 不接受明文 key:key 不得出现在命令行参数中;请用交互模式或 '$1 -' 从标准输入提供。" + exit 2 + fi + [ -z "$search_stdin_provider" ] || { warn "每次调用最多一个 provider 可经标准输入读取 key。"; exit 2; } + search_stdin_provider=${1#--} search_mode=noninteractive; search_did_work=1 - run_search_auth --set "${1#--}=$2" || exit $? + run_search_auth --set-stdin "$search_stdin_provider" || exit $? shift 2 ;; --migrate) search_mode=noninteractive; search_did_work=1 @@ -121,7 +129,7 @@ configure_search() { if search_has_key "$provider"; then configured=1; else configured=0; fi value=$(search_prompt_key "$label" "$configured") || exit $? if [ -n "$value" ]; then - run_search_auth --set "$provider=$value" || exit $? + printf '%s' "$value" | run_search_auth --set-stdin "$provider" || exit $? search_did_work=1 fi done diff --git a/extensions/codegraph-client.ts b/extensions/codegraph-client.ts index a5ec839..b75316c 100644 --- a/extensions/codegraph-client.ts +++ b/extensions/codegraph-client.ts @@ -222,6 +222,8 @@ export class CodegraphRpcClient { // ── internals ──────────────────────────────────────────────────────────── private attach(child: ChildProcess): void { + // EPIPE on a write after the child died is reported via exit/close. + child.stdin?.on("error", () => {}); child.stdout?.setEncoding("utf8"); child.stdout?.on("data", (chunk: string) => { this.stdoutBuffer += chunk; diff --git a/package-lock.json b/package-lock.json index 6bcaef2..3585d2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2763,31 +2763,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@vitest/coverage-istanbul": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.12.tgz", - "integrity": "sha512-f98TiCqF7IriBQEpm6usi56wyD4G1cRYabI15f3Jm7R355q9tlwqJ0j1oPf+tWZ7D0LPnCh3dU70duRt10erHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.29.0", - "@istanbuljs/schema": "^0.1.3", - "@jridgewell/gen-mapping": "^0.3.13", - "@jridgewell/trace-mapping": "0.3.31", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "4.1.12" - } - }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -5672,9 +5647,9 @@ "@earendil-works/pi-coding-agent": "latest", "@earendil-works/pi-tui": "latest", "@types/node": "^25.5.0", - "@vitest/coverage-istanbul": "^4.1.10", + "@vitest/coverage-istanbul": "4.1.10", "typescript": "^6.0.0", - "vitest": "^4.0.18" + "vitest": "4.1.10" }, "peerDependencies": { "@earendil-works/pi-ai": ">=0.81.0", @@ -5703,6 +5678,31 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "pi-subagents/node_modules/@vitest/coverage-istanbul": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.10.tgz", + "integrity": "sha512-AyNJ5pQRFqCX7pwB9PSTmoVKPaZ4H5IEVJfJsT+q1DYkXvZMEFYgJlyk5sfStmt9rVYRyYYRRsuBeImCOc39ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@istanbuljs/schema": "^0.1.3", + "@jridgewell/gen-mapping": "^0.3.13", + "@jridgewell/trace-mapping": "0.3.31", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.10" + } + }, "pi-subagents/node_modules/nanoid": { "version": "5.1.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", diff --git a/package.json b/package.json index 29479e2..f62c593 100644 --- a/package.json +++ b/package.json @@ -94,11 +94,13 @@ "pi-web-search/package.json", "pi-web-search/README.md", "pi-web-search/LICENSE", + "pi-web-search/UPSTREAM.md", "pi-worktree/index.ts", "pi-worktree/src", "pi-worktree/package.json", "pi-worktree/README.md", "pi-worktree/LICENSE", + "pi-worktree/UPSTREAM.md", "pi-permission-system/LICENSES", "pi-permission-system/docs/auto-review", "pi-permission-system/src", @@ -107,6 +109,7 @@ "pi-permission-system/README.md", "pi-permission-system/CHANGELOG.md", "pi-permission-system/LICENSE", + "pi-permission-system/UPSTREAM.md", "config", "scripts", "install.sh", diff --git a/pi-hermes-memory/scripts/benchmark-memory-startup.mjs b/pi-hermes-memory/scripts/benchmark-memory-startup.mjs index 060b474..5a639ee 100644 --- a/pi-hermes-memory/scripts/benchmark-memory-startup.mjs +++ b/pi-hermes-memory/scripts/benchmark-memory-startup.mjs @@ -24,10 +24,10 @@ const emit = async (event, data = {}) => { try { process.env.PI_CODING_AGENT_DIR = root; - process.env.PI_HERMES_MEMORY_CONFIG_PATH = join(root, "hermes-memory-config.json"); + const configPath = join(root, "hermes-memory-config.json"); await mkdir(globalDir); await mkdir(ctx.cwd); - await writeFile(process.env.PI_HERMES_MEMORY_CONFIG_PATH, JSON.stringify({ + await writeFile(configPath, JSON.stringify({ lazyInitialization: lazy, memoryMode: "policy-only", reviewEnabled: false, correctionDetection: false, flushOnShutdown: false, flushOnCompact: false, @@ -40,7 +40,10 @@ try { } const started = performance.now(); - const { default: register } = await import(pathToFileURL(resolve(entry)).href); + const module = await import(pathToFileURL(resolve(entry)).href); + const register = module.createHermesMemoryExtension + ? module.createHermesMemoryExtension({ configPath }) + : module.default; const imported = performance.now(); register({ on(event, handler) { diff --git a/pi-hermes-memory/src/config.ts b/pi-hermes-memory/src/config.ts index 0ca1a31..9763616 100644 --- a/pi-hermes-memory/src/config.ts +++ b/pi-hermes-memory/src/config.ts @@ -89,16 +89,7 @@ export const DEFAULT_CONFIG_PATH = fileURLToPath( new URL("../../config/pi-hermes-memory.json", import.meta.url), ); -/** - * Resolve the effective config path. `PI_HERMES_MEMORY_CONFIG_PATH` relocates - * the file (tests point it at a temp file; it is never a persisted config - * value). - */ -export function resolveConfigPath(): string { - return process.env.PI_HERMES_MEMORY_CONFIG_PATH ?? DEFAULT_CONFIG_PATH; -} - -export function loadConfig(configPath = resolveConfigPath()): MemoryConfig { +export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig { try { if (fs.existsSync(configPath)) { const raw = fs.readFileSync(configPath, "utf-8"); diff --git a/pi-hermes-memory/src/index.ts b/pi-hermes-memory/src/index.ts index e7035b6..54402f9 100644 --- a/pi-hermes-memory/src/index.ts +++ b/pi-hermes-memory/src/index.ts @@ -52,7 +52,7 @@ import { registerPreviewContextCommand } from "./handlers/preview-context.js"; import { registerStandingPinCommand } from "./handlers/standing-pin.js"; import { StandingInstructions } from "./store/standing-instructions.js"; import { STANDING_FILE } from "./constants.js"; -import { loadConfig } from "./config.js"; +import { DEFAULT_CONFIG_PATH, loadConfig } from "./config.js"; import { shouldWarnAutoConsolidationFailure } from "./auto-consolidation-warning.js"; import { detectProject, detectProjectSkills } from "./project.js"; import { buildPromptContext } from "./prompt-context.js"; @@ -90,8 +90,12 @@ export function registerProjectSkillDiscoveryHandler( }); } -export default function (pi: ExtensionAPI) { - const config = loadConfig(); +export interface HermesMemoryExtensionOptions { + configPath?: string; +} + +function hermesMemoryExtension(pi: ExtensionAPI, configPath: string) { + const config = loadConfig(configPath); const lazy = config.lazyInitialization === true && config.memoryMode === "policy-only"; let sessionContext: ExtensionContext | undefined; @@ -480,3 +484,12 @@ export default function (pi: ExtensionAPI) { } }); } + +export function createHermesMemoryExtension( + options?: HermesMemoryExtensionOptions, +): (pi: ExtensionAPI) => void { + const configPath = options?.configPath ?? DEFAULT_CONFIG_PATH; + return (pi) => hermesMemoryExtension(pi, configPath); +} + +export default createHermesMemoryExtension(); diff --git a/pi-hermes-memory/tests/lazy-startup.test.ts b/pi-hermes-memory/tests/lazy-startup.test.ts index eba4cd2..0fa7bb8 100644 --- a/pi-hermes-memory/tests/lazy-startup.test.ts +++ b/pi-hermes-memory/tests/lazy-startup.test.ts @@ -8,10 +8,10 @@ import { after, beforeEach, afterEach, describe, it } from "node:test"; // Bind the entry point to a disposable agent root, never the user's memory. const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-lazy-startup-")); const previousRoot = process.env.PI_CODING_AGENT_DIR; -const previousConfigPath = process.env.PI_HERMES_MEMORY_CONFIG_PATH; process.env.PI_CODING_AGENT_DIR = root; -process.env.PI_HERMES_MEMORY_CONFIG_PATH = path.join(root, "hermes-memory-config.json"); -const { default: registerExtension } = await import("../src/index.js"); +const configPath = path.join(root, "hermes-memory-config.json"); +const { createHermesMemoryExtension } = await import("../src/index.js"); +const registerExtension = createHermesMemoryExtension({ configPath }); const { MemoryStore } = await import("../src/store/memory-store.js"); const { DatabaseManager } = await import("../src/store/db.js"); const globalDir = path.join(root, "pi-hermes-memory"); @@ -24,7 +24,7 @@ let notifications: string[]; let ctx: any; async function configure(overrides: Record = {}) { - await fs.writeFile(path.join(root, "hermes-memory-config.json"), JSON.stringify({ + await fs.writeFile(configPath, JSON.stringify({ lazyInitialization: true, reviewEnabled: false, correctionDetection: false, flushOnCompact: false, flushOnShutdown: false, @@ -87,8 +87,6 @@ afterEach(async () => { after(async () => { if (previousRoot === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousRoot; - if (previousConfigPath === undefined) delete process.env.PI_HERMES_MEMORY_CONFIG_PATH; - else process.env.PI_HERMES_MEMORY_CONFIG_PATH = previousConfigPath; await fs.rm(root, { recursive: true, force: true }); }); diff --git a/pi-hermes-memory/tests/project-rebinding.test.ts b/pi-hermes-memory/tests/project-rebinding.test.ts index 30d1b40..3405db0 100644 --- a/pi-hermes-memory/tests/project-rebinding.test.ts +++ b/pi-hermes-memory/tests/project-rebinding.test.ts @@ -18,11 +18,11 @@ describe("session project memory rebinding", () => { const launchDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-launch-")); const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-target-")); const previousAgentRoot = process.env.PI_CODING_AGENT_DIR; - const previousConfigPath = process.env.PI_HERMES_MEMORY_CONFIG_PATH; + const configPath = path.join(agentRoot, "hermes-memory-config.json"); const previousCwd = process.cwd(); try { await fs.writeFile( - path.join(agentRoot, "hermes-memory-config.json"), + configPath, JSON.stringify({ memoryMode: "legacy-inject", reviewEnabled: false, @@ -47,10 +47,10 @@ describe("session project memory rebinding", () => { ); process.env.PI_CODING_AGENT_DIR = agentRoot; - process.env.PI_HERMES_MEMORY_CONFIG_PATH = path.join(agentRoot, "hermes-memory-config.json"); process.chdir(launchDir); // Import after setting PI_CODING_AGENT_DIR so AGENT_ROOT is test-local. - const { default: registerExtension } = await import("../src/index.js"); + const { createHermesMemoryExtension } = await import("../src/index.js"); + const registerExtension = createHermesMemoryExtension({ configPath }); const mockPi: MockPi = { handlers: {}, tools: {}, @@ -103,8 +103,6 @@ describe("session project memory rebinding", () => { process.chdir(previousCwd); if (previousAgentRoot === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousAgentRoot; - if (previousConfigPath === undefined) delete process.env.PI_HERMES_MEMORY_CONFIG_PATH; - else process.env.PI_HERMES_MEMORY_CONFIG_PATH = previousConfigPath; await fs.rm(agentRoot, { recursive: true, force: true }); await fs.rm(launchDir, { recursive: true, force: true }); await fs.rm(targetDir, { recursive: true, force: true }); diff --git a/pi-hermes-memory/tests/prompt-injection.test.ts b/pi-hermes-memory/tests/prompt-injection.test.ts index f71d16d..bdfffd9 100644 --- a/pi-hermes-memory/tests/prompt-injection.test.ts +++ b/pi-hermes-memory/tests/prompt-injection.test.ts @@ -24,10 +24,10 @@ import { after, beforeEach, describe, it } from "node:test"; // Bind the entry point to a disposable agent root, never the user's memory. const root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-prompt-injection-")); const previousRoot = process.env.PI_CODING_AGENT_DIR; -const previousConfigPath = process.env.PI_HERMES_MEMORY_CONFIG_PATH; process.env.PI_CODING_AGENT_DIR = root; -process.env.PI_HERMES_MEMORY_CONFIG_PATH = path.join(root, "hermes-memory-config.json"); -const { default: registerExtension } = await import("../src/index.js"); +const configPath = path.join(root, "hermes-memory-config.json"); +const { createHermesMemoryExtension } = await import("../src/index.js"); +const registerExtension = createHermesMemoryExtension({ configPath }); const globalDir = path.join(root, "pi-hermes-memory"); const cwd = path.join(root, "workspace"); @@ -68,7 +68,7 @@ let handlers: Record any>>; let ctx: any; async function configure(overrides: Record = {}) { - await fs.writeFile(path.join(root, "hermes-memory-config.json"), JSON.stringify({ + await fs.writeFile(configPath, JSON.stringify({ memoryMode: "policy-only", memoryPolicyStyle: "full", lazyInitialization: true, @@ -115,8 +115,6 @@ beforeEach(async () => { after(async () => { if (previousRoot === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousRoot; - if (previousConfigPath === undefined) delete process.env.PI_HERMES_MEMORY_CONFIG_PATH; - else process.env.PI_HERMES_MEMORY_CONFIG_PATH = previousConfigPath; try { await fs.rm(root, { recursive: true, force: true }); } catch { diff --git a/pi-minimal-footer/AGENTS.md b/pi-minimal-footer/AGENTS.md new file mode 100644 index 0000000..db21986 --- /dev/null +++ b/pi-minimal-footer/AGENTS.md @@ -0,0 +1,10 @@ +# my-pi 维护约定(pi-minimal-footer) + +- 扩展入口是 `pi-minimal-footer/index.ts`(`package.json` 的 `pi.extensions` 指向 `./index.ts`);`status.ts` 收集 `ctx.ui.setStatus()` 发布的非空扩展状态并渲染为 footer 片段,`settings.ts` 读取 footer 配置,`assets/` 为 provider 图标,测试在 `test/`。 +- 持久配置只读组合包 `config/pi-minimal-footer.json`(`settings.ts` 的 `readFooterSettings` 解析 `showCwd` / `showBranch`,缺失或非法时回退默认 `true`);不读写 `settings.json`,不以环境变量作持久默认。 +- 上下文仪表优先显示 pi-compaction 发布的 effective usage 快照(`globalThis[Symbol.for("my-pi.pi-compaction.service.v1")].getMetrics(sessionId)` 的 `percent` / `effectiveTokens`);无快照时回退宿主用量(`buildSessionContext` 取最后一条非 aborted assistant 消息的 `input + output + cacheRead + cacheWrite`)。 +- 来源记录在 `UPSTREAM.md`:从 `ogulcancelik/pi-extensions` 的 `pi-minimal-footer@0.1.10`(commit `77bd1e1`)导入,保留上游 LICENSE 与作者;本地改动为扩展状态渲染与 bundle 配置文件读取。 + +## 验证 + +- 在本目录内运行 `npm test`(`node --experimental-strip-types --test test/*.test.ts`)与 `npm run check`(`node --experimental-strip-types --check index.ts`)。 diff --git a/pi-rtk-optimizer/AGENTS.md b/pi-rtk-optimizer/AGENTS.md index 6085e7e..4b5a2ce 100644 --- a/pi-rtk-optimizer/AGENTS.md +++ b/pi-rtk-optimizer/AGENTS.md @@ -5,6 +5,7 @@ - RTK 默认只压缩非搜索输出,包括本地 `bash` 与远端 `ssh_bash` 的 ANSI 清理、测试聚合、构建过滤、Git 压缩、Lint 聚合;兜底截断默认关闭,新结果的展示截断由 pi-overflow 统一负责;`ssh_bash` 只复用输出处理,远端命令不得进入 RTK rewrite。 - RTK 命令改写默认关闭(`commandRewritingEnabled` 默认 `false`);默认安装不依赖系统 `rtk` CLI,只有用户主动开启命令改写时才需要 `rtk rewrite`,其命令支持策略由已安装的 `rtk rewrite` 决定,不在扩展内重复维护规则。 - FFF override 独占搜索工具与搜索输出;RTK 不得处理本地或远端搜索工具的调用/结果(含 `grep` 工具结果),也不得通过默认命令改写接管 `rg`、`grep`、`find`、`fd`。 +- RTK 不处理带 `parentToolCallId` 的嵌套调用(codemode 脚本内调用),否则会丢失 bash 的 structuredContent。 - `readCompaction.enabled`、`sourceCodeFilteringEnabled` 和 `smartTruncate.enabled` 当前均默认 `false`,源码读取保持原样;未经用户明确决定,不因节省上下文而改变这些默认值。若以后开启 read 压缩,优先考虑 `readCompaction + smartTruncate`,源码过滤仍独立评估;必须保留精确 `offset/limit` 读取、短文件和行锚点的完整性。 - read 压缩属于有损能力。任何默认值调整都必须同时说明哪些内容可能被省略、精确读取如何恢复原文,以及对 `edit` 文本匹配和排障证据的影响。 - 工具输出压缩可能损失证据。排障和审计相关改动应优先保证原始输出可恢复,并覆盖锚点完整性和截断边界。 diff --git a/pi-rtk-optimizer/src/config-store.ts b/pi-rtk-optimizer/src/config-store.ts index 6bc3243..325ee33 100644 --- a/pi-rtk-optimizer/src/config-store.ts +++ b/pi-rtk-optimizer/src/config-store.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; -import { resolveConfigPath } from "./constants.js"; +import { CONFIG_PATH } from "./constants.js"; import { toRecord } from "./record-utils.js"; import { DEFAULT_RTK_INTEGRATION_CONFIG, @@ -151,7 +151,7 @@ export function normalizeRtkIntegrationConfig(raw: unknown): RtkIntegrationConfi }; } -export function ensureConfigExists(configPath = resolveConfigPath()): EnsureConfigResult { +export function ensureConfigExists(configPath = CONFIG_PATH): EnsureConfigResult { if (existsSync(configPath)) { return { created: false }; } @@ -169,7 +169,7 @@ export function ensureConfigExists(configPath = resolveConfigPath()): EnsureConf } } -export function loadRtkIntegrationConfig(configPath = resolveConfigPath()): ConfigLoadResult { +export function loadRtkIntegrationConfig(configPath = CONFIG_PATH): ConfigLoadResult { if (!existsSync(configPath)) { return { config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG) }; } @@ -189,7 +189,7 @@ export function loadRtkIntegrationConfig(configPath = resolveConfigPath()): Conf export function saveRtkIntegrationConfig( config: RtkIntegrationConfig, - configPath = resolveConfigPath(), + configPath = CONFIG_PATH, ): ConfigSaveResult { const normalized = normalizeRtkIntegrationConfig(config); const tmpPath = `${configPath}.tmp`; @@ -218,6 +218,6 @@ export function saveRtkIntegrationConfig( } } -export function getRtkIntegrationConfigPath(configPath = resolveConfigPath()): string { +export function getRtkIntegrationConfigPath(configPath = CONFIG_PATH): string { return configPath; } diff --git a/pi-rtk-optimizer/src/constants.ts b/pi-rtk-optimizer/src/constants.ts index b10d8fe..fb1e23b 100644 --- a/pi-rtk-optimizer/src/constants.ts +++ b/pi-rtk-optimizer/src/constants.ts @@ -9,11 +9,3 @@ export const EXTENSION_NAME = "pi-rtk-optimizer"; export const CONFIG_PATH = fileURLToPath( new URL("../../config/rtk-optimizer.json", import.meta.url), ); - -/** - * Resolve the effective config path. `PI_RTK_CONFIG_PATH` relocates the file - * (tests point it at a temp file; it is never a persisted config value). - */ -export function resolveConfigPath(): string { - return process.env.PI_RTK_CONFIG_PATH ?? CONFIG_PATH; -} diff --git a/pi-rtk-optimizer/src/index.test.ts b/pi-rtk-optimizer/src/index.test.ts index c8ddac5..4c1612c 100644 --- a/pi-rtk-optimizer/src/index.test.ts +++ b/pi-rtk-optimizer/src/index.test.ts @@ -36,16 +36,15 @@ mock.module("@earendil-works/pi-tui", { const indexModule = await import("./index.ts"); const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote } = indexModule; -const rtkIntegrationExtension = indexModule.default; const { DEFAULT_RTK_INTEGRATION_CONFIG } = await import("./types.ts"); // Tests must never write the bundle's config/ file: relocate the config to a -// throwaway temp file before any config-store call resolves its path. +// throwaway temp file via the extension factory's configPath option. const TEST_CONFIG_PATH = join( mkdtempSync(join(tmpdir(), "my-pi-rtk-test-")), "rtk-optimizer.json", ); -process.env.PI_RTK_CONFIG_PATH = TEST_CONFIG_PATH; +const rtkIntegrationExtension = indexModule.createRtkOptimizerExtension({ configPath: TEST_CONFIG_PATH }); function writeTestConfig(commandRewritingEnabled: boolean): void { writeFileSync( @@ -401,6 +400,69 @@ await runTest("tool_result lifecycle merges compaction metadata with existing de assert.equal(notifications.length, 0); }); +await runTest("nested tool calls and results (parentToolCallId) are left untouched", async () => { + writeTestConfig(true); + const handlers: Record = {}; + const notifications: Notification[] = []; + let rewriteCalls = 0; + + rtkIntegrationExtension({ + exec: async (_command: string, args: string[]) => { + if (args[0] === "--version") { + return { code: 0, stdout: "rtk 1.0.0", stderr: "" }; + } + if (args[0] === "rewrite") { + rewriteCalls += 1; + return { code: 3, stdout: "rtk git status", stderr: "" }; + } + return { code: 1, stdout: "", stderr: "unexpected" }; + }, + on(eventName: string, handler: ExtensionHandler) { + handlers[eventName] = handler; + }, + registerCommand() {}, + } as never); + + const toolCallHandler = handlers.tool_call; + const toolResultHandler = handlers.tool_result; + assert.ok(toolCallHandler); + assert.ok(toolResultHandler); + + // A codemode script's nested bash result must survive intact: rewriting + // content without structuredContent would drop bash's outputSchema payload. + const nestedResult = await toolResultHandler( + { + toolName: "bash", + parentToolCallId: "codemode-1", + input: { command: "printf TODO" }, + content: [{ type: "text", text: "\x1B[31msrc/a.ts\n 1: TODO\x1B[0m\n" }], + }, + createNotificationContext(notifications), + ); + assert.ok(!nestedResult || !("content" in nestedResult)); + + const nestedCall = { + toolName: "bash", + parentToolCallId: "codemode-1", + input: { command: "git status" }, + }; + await toolCallHandler(nestedCall, createNotificationContext(notifications)); + assert.equal(nestedCall.input.command, "git status"); + assert.equal(rewriteCalls, 0); + + const directResult = await toolResultHandler( + { + toolName: "bash", + input: { command: "printf TODO" }, + content: [{ type: "text", text: "\x1B[31msrc/a.ts\n 1: TODO\x1B[0m\n" }], + }, + createNotificationContext(notifications), + ); + assert.ok(directResult && "content" in directResult); + assert.equal(firstText(directResult.content), "src/a.ts\n 1: TODO\n"); + writeTestConfig(false); +}); + await runTest("tool_call surfaces RTK rewrite errors through existing UI warning path", async () => { writeTestConfig(true); const handlers: Record, ctx: Record) => Promise | void>> = {}; diff --git a/pi-rtk-optimizer/src/index.ts b/pi-rtk-optimizer/src/index.ts index d97c8a0..ebe99c2 100644 --- a/pi-rtk-optimizer/src/index.ts +++ b/pi-rtk-optimizer/src/index.ts @@ -8,7 +8,7 @@ import { } from "./config-store.js"; import { computeRewriteDecision } from "./command-rewriter.js"; import { registerRtkIntegrationCommand } from "./command-register.js"; -import { EXTENSION_NAME } from "./constants.js"; +import { CONFIG_PATH, EXTENSION_NAME } from "./constants.js"; import { createLazyModuleLoader } from "./lazy-module-loader.js"; import { clearOutputMetrics, getOutputMetricsSummary } from "./output-metrics.js"; import type { ToolResultCompactionMetadata } from "./output-compactor.js"; @@ -103,8 +103,12 @@ export function createBoundedNoticeTracker(maxEntries: number): BoundedNoticeTra }; } -export default function rtkIntegrationExtension(pi: ExtensionAPI): void { - const initialLoad = loadRtkIntegrationConfig(); +export interface RtkOptimizerExtensionOptions { + configPath?: string; +} + +function rtkIntegrationExtension(pi: ExtensionAPI, configPath: string): void { + const initialLoad = loadRtkIntegrationConfig(configPath); let config: RtkIntegrationConfig = initialLoad.config; if (!config.enabled) { return; @@ -200,12 +204,12 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void { }; const refreshConfig = async (ctx?: ExtensionContext | ExtensionCommandContext): Promise => { - const ensured = ensureConfigExists(); + const ensured = ensureConfigExists(configPath); if (ensured.error && ctx) { warnOnce(ctx, ensured.error); } - const loaded = loadRtkIntegrationConfig(); + const loaded = loadRtkIntegrationConfig(configPath); config = loaded.config; pendingLoadWarning = loaded.warning; await refreshRuntimeStatus(); @@ -218,7 +222,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void { const setConfig = (next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void => { config = normalizeRtkIntegrationConfig(next); - const saved = saveRtkIntegrationConfig(config); + const saved = saveRtkIntegrationConfig(config, configPath); if (!saved.success && saved.error) { ctx.ui.notify(saved.error, "error"); } @@ -310,7 +314,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void { const controller = { getConfig: () => config, setConfig, - getConfigPath: getRtkIntegrationConfigPath, + getConfigPath: () => getRtkIntegrationConfigPath(configPath), getRuntimeStatus: () => runtimeStatus, refreshRuntimeStatus, getMetricsSummary: getOutputMetricsSummary, @@ -396,6 +400,10 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void { }); pi.on("tool_call", async (event, ctx) => { + if (event.parentToolCallId) { + return {}; + } + if (!config.enabled || !config.commandRewritingEnabled) { return {}; } @@ -455,6 +463,10 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void { }); pi.on("tool_result", async (event, ctx) => { + if (event.parentToolCallId) { + return {}; + } + if (!config.enabled || !config.outputCompaction.enabled) { return {}; } @@ -485,3 +497,12 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void { } }); } + +export function createRtkOptimizerExtension( + options?: RtkOptimizerExtensionOptions, +): (pi: ExtensionAPI) => void { + const configPath = options?.configPath ?? CONFIG_PATH; + return (pi) => rtkIntegrationExtension(pi, configPath); +} + +export default createRtkOptimizerExtension(); diff --git a/pi-rtk-optimizer/src/types-shims.d.ts b/pi-rtk-optimizer/src/types-shims.d.ts index 3304be5..795b5d6 100644 --- a/pi-rtk-optimizer/src/types-shims.d.ts +++ b/pi-rtk-optimizer/src/types-shims.d.ts @@ -76,6 +76,7 @@ declare module "@earendil-works/pi-coding-agent" { input: Record; content: Array>; details?: unknown; + parentToolCallId?: string; } export interface BashToolCallEvent { diff --git a/pi-subagents/package-lock.json b/pi-subagents/package-lock.json index 78bb2e7..929ed8f 100644 --- a/pi-subagents/package-lock.json +++ b/pi-subagents/package-lock.json @@ -19,9 +19,9 @@ "@earendil-works/pi-coding-agent": "latest", "@earendil-works/pi-tui": "latest", "@types/node": "^25.5.0", - "@vitest/coverage-istanbul": "^4.1.10", + "@vitest/coverage-istanbul": "4.1.10", "typescript": "^6.0.0", - "vitest": "^4.0.18" + "vitest": "4.1.10" }, "peerDependencies": { "@earendil-works/pi-ai": ">=0.81.0", diff --git a/pi-subagents/package.json b/pi-subagents/package.json index 02f6731..71aacac 100644 --- a/pi-subagents/package.json +++ b/pi-subagents/package.json @@ -61,9 +61,9 @@ "@earendil-works/pi-coding-agent": "latest", "@earendil-works/pi-tui": "latest", "@types/node": "^25.5.0", - "@vitest/coverage-istanbul": "^4.1.10", + "@vitest/coverage-istanbul": "4.1.10", "typescript": "^6.0.0", - "vitest": "^4.0.18" + "vitest": "4.1.10" }, "pi": { "extensions": [ diff --git a/pi-tool-search/AGENTS.md b/pi-tool-search/AGENTS.md index 33caf60..e322b16 100644 --- a/pi-tool-search/AGENTS.md +++ b/pi-tool-search/AGENTS.md @@ -3,7 +3,7 @@ - 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `extensions/catalog.ts`,配置读取与路径解析位于 `extensions/config.ts`(直接读取组合包 `config/tool-search.json`;目录缓存仍在 `/my-pi/tool-search/catalog-v1.json`),测试保留在 `test/`。 - 同步上游必须记录明确 tag/commit,保留 README、CHANGELOG、文档、作者和许可证说明;不得导入上游 `.pi`、嵌套 `.git`、`node_modules` 或构建产物。 - 根包必须通过 `file:./pi-tool-search` 提供本地依赖并直接加载仓库源码,不得同时安装或加载 npm `pi-tool-search` 实现;同步上游时先核对本地增量动态工具改造,禁止用旧 npm 入口或上游旧宿主实现直接覆盖。 -- 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、六个记忆工具与 `context_tree_query`、`overflow_query`,以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。配置从已安装组合包内的 `config/tool-search.json` 直接读取,文件缺失或无效时回退 `BUNDLE_TOOL_SEARCH_DEFAULTS`;扩展自身不播种、不迁移,也不再读写 `settings.json` 的 `toolSearch` 段。 +- 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、六个记忆工具与 `context_tree_query`、`overflow_query`、内置 `codemode`(exposure `model-only`),以及 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。exposure 为 `hidden` 或 `codemode`(script-only、模型不可直达)的工具一律不得进入目录或常驻集合。配置从已安装组合包内的 `config/tool-search.json` 直接读取,文件缺失或无效时回退 `BUNDLE_TOOL_SEARCH_DEFAULTS`;扩展自身不播种、不迁移,也不再读写 `settings.json` 的 `toolSearch` 段。 - 其余组合包工具必须优先使用 `extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。 - 标准组合包工具全部命中预置目录时不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。 - 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。Tool Search 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决。 diff --git a/pi-tool-search/CHANGELOG.md b/pi-tool-search/CHANGELOG.md index 03bea00..23119c7 100644 --- a/pi-tool-search/CHANGELOG.md +++ b/pi-tool-search/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased — my-pi maintained source ### Changed +- Pinned Pi's built-in `codemode` (exposure `model-only`) in `alwaysEnabled` so batching several tool calls through one script never depends on a `tool_search` activation; `platformToolPolicy` now also drops tools with `hidden` or `codemode` (script-only) exposure so they can never enter the catalog or the unknown-tools path. +- Removed the `PI_TOOL_SEARCH_CONFIG_PATH` environment override; the extension reads the bundle `config/tool-search.json`, and tests inject a path through the `createToolSearchExtension({ configPath })` factory. - Removed the retired context-mode integration: the `ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` pins left `alwaysEnabled` and the four `context-*` bundle groups were dropped. Large tool results are now bounded by pi-overflow and recoverable through the pinned `overflow_query`. - Imported upstream `v0.3.6` at `ddfb23646fd3957b791214de278e23aa393c9b13` for direct local maintenance. - Migrated runtime imports to `@earendil-works/pi-coding-agent` and current `typebox`. diff --git a/pi-tool-search/README.md b/pi-tool-search/README.md index 4c03bd5..972cb48 100644 --- a/pi-tool-search/README.md +++ b/pi-tool-search/README.md @@ -10,7 +10,7 @@ Generated metadata never replaces executable schemas. Exact names, parameter typ ## Catalog lifecycle -1. `session_start` keeps `tool_search`, Pi core tools, and configured `alwaysEnabled` names active. PowerShell is filtered out on non-Windows hosts; on Windows it is pinned beside Bash as the same local-shell capability class. +1. `session_start` keeps `tool_search`, Pi core tools, and configured `alwaysEnabled` names active. PowerShell is filtered out on non-Windows hosts; on Windows it is pinned beside Bash as the same local-shell capability class. Tools whose exposure is `hidden` or `codemode` (script-only, unreachable by the model directly) are excluded entirely; `model-only` tools such as the built-in `codemode` stay reachable and can be pinned. 2. Hidden tool definitions are hashed with the grouping constraints. 3. Every tool exposed by the standard `my-pi` bundle is matched against the checked-in catalog in `extensions/bundle-groups.ts`. If all tools are recognized, no model call and no user cache are needed. 4. User `groupOverrides` take priority over checked-in assignments. Optional or unavailable bundle tools are simply filtered out of their predefined groups. @@ -41,7 +41,7 @@ Run `/tool-search-status` to inspect the current catalog source, pinned count, d | `chrome-interaction` | Chrome click/type/fill/key/hover/drag/tap/scroll input | | `chrome-debugging` | Chrome page evaluation, console/network diagnostics, response retrieval, and file upload | -Pinned tools are removed from these dynamic groups at runtime. The defaults pin `codegraph_explore`, `lsp_diagnostics`, and all six Hermes memory tools, so none of them consume dynamic group or tool capacity. +Pinned tools are removed from these dynamic groups at runtime. The defaults pin `codegraph_explore`, `lsp_diagnostics`, all six Hermes memory tools, `context_tree_query`, `overflow_query`, and `codemode`, so none of them consume dynamic group or tool capacity. ## Loading and eviction @@ -81,7 +81,8 @@ Pi reads this configuration directly from the installed bundle's `config/tool-se "memory_remove", "skill_manage", "context_tree_query", - "overflow_query" + "overflow_query", + "codemode" ], "showToolSearchFooterStatus": false, "maxActiveGroups": 5, @@ -93,7 +94,7 @@ Pi reads this configuration directly from the installed bundle's `config/tool-se | Key | Bundle default | Description | |---|---|---| -| `alwaysEnabled` | CodeGraph explore, LSP diagnostics, all six Hermes memory tools, `context_tree_query`, `overflow_query` | Exact names pinned alongside Pi core tools | +| `alwaysEnabled` | CodeGraph explore, LSP diagnostics, all six Hermes memory tools, `context_tree_query`, `overflow_query`, `codemode` | Exact names pinned alongside Pi core tools | | `showToolSearchFooterStatus` | `false` | Show active tool/group counts | | `maxActiveGroups` | `5` | Maximum simultaneously active dynamic groups | | `maxToolsPerGroup` | `8` | Maximum size accepted from model-generated groups | diff --git a/pi-tool-search/docs/dynamic-tool-loading.md b/pi-tool-search/docs/dynamic-tool-loading.md index 964e685..0595c0c 100644 --- a/pi-tool-search/docs/dynamic-tool-loading.md +++ b/pi-tool-search/docs/dynamic-tool-loading.md @@ -2,7 +2,7 @@ `pi-tool-search` keeps full low-frequency schemas hidden behind a compact, validated group catalog. -1. `session_start` activates `tool_search`, the six cross-platform Pi core tools, and configured `alwaysEnabled` names. The my-pi defaults also pin CodeGraph exploration, LSP diagnostics, and the six Hermes memory tools. On Windows, `powershell` is pinned beside `bash`; on non-Windows hosts it is excluded. Pinned tools do not consume the dynamic-group or dynamic-tool budget. +1. `session_start` activates `tool_search`, the six cross-platform Pi core tools, and configured `alwaysEnabled` names. The my-pi defaults also pin CodeGraph exploration, LSP diagnostics, the six Hermes memory tools, and Pi's built-in `codemode` (exposure `model-only`). On Windows, `powershell` is pinned beside `bash`; on non-Windows hosts it is excluded. Tools with `hidden` or `codemode` (script-only) exposure are filtered out before catalog and pin processing. Pinned tools do not consume the dynamic-group or dynamic-tool budget. 2. Hidden definitions are hashed with grouping constraints and matched against `extensions/bundle-groups.ts`. Available standard tools receive checked-in groups; unavailable optional tools are omitted. 3. If every hidden tool is recognized, the bundle catalog is used directly. No model call or per-user cache is required. 4. Additional tools receive immediate deterministic prefix/source groups. A matching model-enriched cache is reused only if it preserves all checked-in bundle assignments. diff --git a/pi-tool-search/extensions/config.ts b/pi-tool-search/extensions/config.ts index 0e58a82..15516e3 100644 --- a/pi-tool-search/extensions/config.ts +++ b/pi-tool-search/extensions/config.ts @@ -11,8 +11,6 @@ const TOOL_SEARCH_DIR = join("my-pi", "tool-search"); * agent directory. */ export function getToolSearchConfigPath(): string { - const override = process.env.PI_TOOL_SEARCH_CONFIG_PATH; - if (override) return override; return fileURLToPath(new URL("../../config/tool-search.json", import.meta.url)); } @@ -33,6 +31,7 @@ export const BUNDLE_TOOL_SEARCH_DEFAULTS = { "skill_manage", "context_tree_query", "overflow_query", + "codemode", ], showToolSearchFooterStatus: false, maxActiveGroups: 5, diff --git a/pi-tool-search/extensions/index.ts b/pi-tool-search/extensions/index.ts index 71e9073..73ac9be 100644 --- a/pi-tool-search/extensions/index.ts +++ b/pi-tool-search/extensions/index.ts @@ -28,7 +28,7 @@ import { createBundleCatalog, type BundleCatalogResult, } from "./bundle-groups.ts"; -import { getToolSearchCatalogPath, readToolSearchConfig } from "./config.ts"; +import { getToolSearchCatalogPath, getToolSearchConfigPath, readToolSearchConfig } from "./config.ts"; const TOOL_SEARCH_NAME = "tool_search"; const POWERSHELL_TOOL_NAME = "powershell"; @@ -39,13 +39,19 @@ type CatalogSource = "bundle" | "cache" | "fallback" | "hybrid" | "model"; type ModelUsage = Awaited>["usage"]; type CatalogGenerationResult = { notice?: string; usage?: ModelUsage }; -export function platformToolPolicy( +export function platformToolPolicy( definitions: readonly T[], platform: NodeJS.Platform, ): { tools: T[]; coreTools: string[] } { + // Script-only and hidden tools are unreachable by the model directly and + // must never enter the catalog (or the pinned set); "model-only" tools like + // codemode stay reachable and can be pinned. + const reachable = definitions.filter( + (tool) => tool.exposure !== "hidden" && tool.exposure !== "codemode", + ); const tools = platform === "win32" - ? [...definitions] - : definitions.filter((tool) => tool.name !== POWERSHELL_TOOL_NAME); + ? [...reachable] + : reachable.filter((tool) => tool.name !== POWERSHELL_TOOL_NAME); const coreTools = platform === "win32" ? [...CORE_TOOLS, POWERSHELL_TOOL_NAME] : [...CORE_TOOLS]; @@ -110,11 +116,11 @@ function groupDescription(catalog: ToolCatalog, source: CatalogSource): string { ].join("\n"); } -export default function toolSearchExtension(pi: ExtensionAPI): void { +function toolSearchExtension(pi: ExtensionAPI, configPath: string): void { const agentDir = getAgentDir(); const cachePath = getToolSearchCatalogPath(agentDir); - let config = readToolSearchConfig(); + let config = readToolSearchConfig(configPath); let tools: ToolInfo[] = []; let catalog: ToolCatalog = createFallbackCatalog([], "", { maxToolsPerGroup: Math.min(config.maxToolsPerGroup, config.maxDynamicTools), @@ -440,7 +446,7 @@ export default function toolSearchExtension(pi: ExtensionAPI): void { } function refreshState(ctx: Pick, forceReset: boolean): void { - const nextConfig = readToolSearchConfig(); + const nextConfig = readToolSearchConfig(configPath); const registeredTools = pi.getAllTools().filter((tool) => tool.name !== TOOL_SEARCH_NAME); const platformPolicy = platformToolPolicy(registeredTools, process.platform); const allTools = platformPolicy.tools; @@ -542,3 +548,16 @@ export default function toolSearchExtension(pi: ExtensionAPI): void { if (group && activeGroups.has(group)) activeGroups.set(group, ++clock); }); } + +export interface ToolSearchExtensionOptions { + configPath?: string; +} + +export function createToolSearchExtension( + options?: ToolSearchExtensionOptions, +): (pi: ExtensionAPI) => void { + const configPath = options?.configPath ?? getToolSearchConfigPath(); + return (pi) => toolSearchExtension(pi, configPath); +} + +export default createToolSearchExtension(); diff --git a/pi-tool-search/test/bundle-groups.test.ts b/pi-tool-search/test/bundle-groups.test.ts index 72d9235..4a81b74 100644 --- a/pi-tool-search/test/bundle-groups.test.ts +++ b/pi-tool-search/test/bundle-groups.test.ts @@ -10,6 +10,7 @@ function tool(name: string): ToolInfo { name, description: `Full description for ${name}`, parameters: { type: "object", properties: {} } as ToolInfo["parameters"], + exposure: "direct", sourceInfo: { source: "extension", scope: "user", path: `/test/${name}.ts` } as unknown as ToolInfo["sourceInfo"], }; } diff --git a/pi-tool-search/test/catalog.test.ts b/pi-tool-search/test/catalog.test.ts index c69e801..8ccf842 100644 --- a/pi-tool-search/test/catalog.test.ts +++ b/pi-tool-search/test/catalog.test.ts @@ -21,6 +21,7 @@ function tool(name: string, description: string): ToolInfo { name, description, parameters: { type: "object", properties: { query: { type: "string" } } } as ToolInfo["parameters"], + exposure: "direct", sourceInfo: { source: "extension", scope: "user", path: `/test/${name}.ts` } as unknown as ToolInfo["sourceInfo"], }; } diff --git a/pi-tool-search/test/config.test.ts b/pi-tool-search/test/config.test.ts index 5755102..4c348c1 100644 --- a/pi-tool-search/test/config.test.ts +++ b/pi-tool-search/test/config.test.ts @@ -41,6 +41,7 @@ test("bundle defaults keep the committed tool list", () => { "skill_manage", "context_tree_query", "overflow_query", + "codemode", ]); assert.deepEqual(bundleFallbackConfig(), { alwaysEnabled: [...BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled], diff --git a/pi-tool-search/test/extension.test.ts b/pi-tool-search/test/extension.test.ts index c90640f..f524d28 100644 --- a/pi-tool-search/test/extension.test.ts +++ b/pi-tool-search/test/extension.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import test from "node:test"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; -import toolSearchExtension, { platformToolPolicy } from "../extensions/index.ts"; +import toolSearchExtension, { createToolSearchExtension, platformToolPolicy } from "../extensions/index.ts"; interface ToolSearchResult { details: { @@ -49,13 +49,12 @@ function testContext(overrides: Partial = {}): ExtensionContex test("loads whole groups and evicts the least-recently-used group", async () => { const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-extension-")); const previousAgentDir = process.env.PI_CODING_AGENT_DIR; - const previousConfigPath = process.env.PI_TOOL_SEARCH_CONFIG_PATH; + const configPath = join(agentDir, "tool-search-config.json"); process.env.PI_CODING_AGENT_DIR = agentDir; - process.env.PI_TOOL_SEARCH_CONFIG_PATH = join(agentDir, "tool-search-config.json"); try { await writeFile( - process.env.PI_TOOL_SEARCH_CONFIG_PATH, + configPath, JSON.stringify({ alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"], showToolSearchFooterStatus: false, @@ -97,7 +96,7 @@ test("loads whole groups and evicts the least-recently-used group", async () => on: (event: string, handler: (...args: any[]) => unknown) => handlers.set(event, handler), } as unknown as ExtensionAPI; - toolSearchExtension(api); + createToolSearchExtension({ configPath })(api); const ctx = testContext({ hasUI: true, ui: { @@ -159,8 +158,6 @@ test("loads whole groups and evicts the least-recently-used group", async () => } finally { if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousAgentDir; - if (previousConfigPath === undefined) delete process.env.PI_TOOL_SEARCH_CONFIG_PATH; - else process.env.PI_TOOL_SEARCH_CONFIG_PATH = previousConfigPath; await rm(agentDir, { recursive: true, force: true }); } }); @@ -328,6 +325,57 @@ test("keeps PowerShell with Bash only on Windows", () => { assert.ok(windows.coreTools.includes("powershell")); }); +test("drops hidden and script-only tools but keeps reachable exposures", () => { + const definitions = [ + sourceTool("read", "Read a file"), + { ...sourceTool("hidden_helper", "Internal helper"), exposure: "hidden" }, + { ...sourceTool("script_only", "Only callable from scripts"), exposure: "codemode" }, + { ...sourceTool("codemode", "Batch several tool calls in one script"), exposure: "model-only" }, + { ...sourceTool("lazy_tool", "Activated on demand"), exposure: "deferred" }, + ]; + + const { tools } = platformToolPolicy(definitions, "darwin"); + assert.deepEqual(tools.map((tool) => tool.name), ["read", "codemode", "lazy_tool"]); +}); + +test("pins a model-only codemode tool instead of hiding it", async () => { + const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-codemode-")); + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = agentDir; + + try { + const handlers = new Map unknown>(); + const registered = new Map(); + const activeCalls: string[][] = []; + const sourceTools = [ + sourceTool("read", "Read a file"), + { ...sourceTool("codemode", "Batch several tool calls in one script"), exposure: "model-only" }, + ]; + const api = { + getAllTools: () => sourceTools, + registerTool: (definition: unknown) => { + const tool = definition as RegisteredTool & { name: string }; + registered.set(tool.name, tool); + }, + registerCommand: () => {}, + setActiveTools: (names: string[]) => activeCalls.push([...names]), + on: (event: string, handler: (...args: any[]) => unknown) => handlers.set(event, handler), + } as unknown as ExtensionAPI; + // The bundle config pins "codemode" in alwaysEnabled; exposure "model-only" + // stays reachable, so it must land in the active set, not a dynamic group. + toolSearchExtension(api); + handlers.get("session_start")?.({}, testContext()); + + assert.ok(activeCalls.at(-1)?.includes("codemode")); + assert.ok(activeCalls.at(-1)?.includes("tool_search")); + assert.ok(activeCalls.at(-1)?.includes("read")); + } finally { + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + await rm(agentDir, { recursive: true, force: true }); + } +}); + test("loads an exact known group without waiting for unknown-tool model enrichment", async () => { const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-fast-path-")); const previousAgentDir = process.env.PI_CODING_AGENT_DIR; diff --git a/pi-web-search/AGENTS.md b/pi-web-search/AGENTS.md index 3330851..8635747 100644 --- a/pi-web-search/AGENTS.md +++ b/pi-web-search/AGENTS.md @@ -5,4 +5,4 @@ - `web_fetch` 固定走 Exa `web_fetch_exa`,失败时静默降级 Keenable → Tavily;Exa 搜索统一走 `web_search_advanced_exa` 并显式约束 `numResults` 与 `textMaxCharacters`;Tavily 默认 `search_depth: basic` 且必须在 `auth.json` 存在 `tavily` api_key 凭据才参与路由;Keenable keyless 可用,`keenable` 凭据仅提高速率限制。 - `clients/` 为纯 `fetch` 直连,不使用 `pi-mcp-adapter` 或任何 MCP SDK:Tavily 走 `api.tavily.com`(必须有 key);Keenable 复刻上游 client,保留 keyless `/public` 端点与 SSRF 防护;Exa 直接对 `https://mcp.exa.ai/mcp` 发 JSON-RPC——`initialize` 一次并缓存 `mcp-session-id`,随后 `tools/call`,HTTP 404 视为会话失效自动重建并重试一次;`exa` 凭据通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。 - 搜索 key 统一保存在 Pi 的 `auth.json`(`tavily`/`exa`/`keenable` 各一条 `api_key` 凭据,文件权限 `600` 且不得提交);key 支持 `!command` 执行、`$VAR`/`${VAR}` 插值间接寻址,`$$`/`$!` 转义。credential `env` 优先于进程 env;显式进程环境变量不再直接读取,env 注入通过 `"key": "$VAR"` 表达。 -- `configure.sh search` 与 `scripts/search-auth.mjs` 以交互或参数方式写入凭据:加锁后原子写、权限 `600`、保留全部其他条目,支持 `--migrate` 从旧 `search.env` 一次性导入;只输出配置状态,禁止回显 key。`auth.ts` 每次调用读取 `auth.json`。 +- `configure.sh search` 与 `scripts/search-auth.mjs` 以交互或标准输入方式写入凭据,key 不得出现在命令参数中:加锁后原子写、权限 `600`、保留全部其他条目,支持 `--migrate` 从旧 `search.env` 一次性导入;只输出配置状态,禁止回显 key。`auth.ts` 每次调用读取 `auth.json`。 diff --git a/pi-web-search/README.md b/pi-web-search/README.md index 40cde5c..46faa72 100644 --- a/pi-web-search/README.md +++ b/pi-web-search/README.md @@ -30,7 +30,10 @@ Keys live in Pi's `auth.json` as one `api_key` credential per provider (`tavily`, `exa`, `keenable`), read by `auth.ts` with upstream resolution semantics — literal values, `$VAR`/`${VAR}` interpolation, and `!command` indirection (e.g. `"!op read op://Vault/Tavily/key"`). `configure.sh search` -writes them (atomically, mode 600, preserving all other entries); process +writes them via its hidden interactive prompt or `scripts/search-auth.mjs +--set-stdin` reading the key from stdin — a key is never accepted as a +command-line argument (atomically, mode 600, preserving all other entries); +process environment variables are not read directly — use `"key": "$TAVILY_API_KEY"` to indirect through env. `KEENABLE_API_URL` stays env-only, or can be stored in the keenable credential's `env` map. diff --git a/pi-web-search/UPSTREAM.md b/pi-web-search/UPSTREAM.md new file mode 100644 index 0000000..bdde7be --- /dev/null +++ b/pi-web-search/UPSTREAM.md @@ -0,0 +1,9 @@ +# 上游来源 + +- 本项目完全自维护,无上游;未从任何外部仓库或 npm 包导入源码。 +- `clients/` 下 Tavily、Exa、Keenable client 是按上游语义复刻的纯 `fetch` 直连实现,遵循各自 upstream 的接口语义,未导入上游源码。 +- 实现仅依赖 Pi 扩展宿主 API(`@earendil-works/pi-coding-agent`)与 `typebox`,两者均为组合包 peer 依赖。 + +## 本地改动记录 + +- 初始实现:`index.ts`(`web_search` / `web_fetch` 统一入口与工具注册)、`router.ts`(当前主模型按内置规则选择 provider)、`clients/`(Tavily / Exa / Keenable 直连)、`auth.ts`(auth.json 凭据解析)。 diff --git a/pi-worktree/UPSTREAM.md b/pi-worktree/UPSTREAM.md new file mode 100644 index 0000000..b8a2de5 --- /dev/null +++ b/pi-worktree/UPSTREAM.md @@ -0,0 +1,8 @@ +# 上游来源 + +- 本项目完全自维护,无上游;未从任何外部仓库或 npm 包导入源码。 +- 实现仅依赖 Pi 扩展宿主 API(`@earendil-works/pi-coding-agent`、`@earendil-works/pi-tui`)与 `typebox`,均为组合包 peer 依赖;git 操作经 `execFile` argv 直接调用系统 `git`。 + +## 本地改动记录 + +- 初始实现:`index.ts`(`/wt` 斜杠命令入口)、`src/`(worktree 生命周期、状态登记、会话切换与环境复刻)。 diff --git a/scripts/lib.sh b/scripts/lib.sh index 9d755e8..3f7796d 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -17,16 +17,16 @@ warn() { detect_kitty() { kitty_bin= - if command -v kitty >/dev/null 2>&1; then - kitty_bin=$(command -v kitty) - return 0 - fi for candidate in /Applications/kitty.app/Contents/MacOS/kitty "$HOME/.local/kitty.app/bin/kitty"; do if [ -x "$candidate" ]; then kitty_bin=$candidate return 0 fi done + if command -v kitty >/dev/null 2>&1; then + kitty_bin=$(command -v kitty) + return 0 + fi return 1 } diff --git a/scripts/search-auth.mjs b/scripts/search-auth.mjs index 244df97..96ce6bf 100755 --- a/scripts/search-auth.mjs +++ b/scripts/search-auth.mjs @@ -5,7 +5,7 @@ * writes atomically with a lock directory, and keeps the file mode 600. * * Usage: - * node search-auth.mjs --set tavily=KEY [--set exa=KEY] [--set keenable=KEY] + * node search-auth.mjs --set-stdin tavily (reads the key from stdin) * node search-auth.mjs --unset tavily [--unset exa] * node search-auth.mjs --migrate [search.env path] * node search-auth.mjs --list @@ -47,7 +47,7 @@ function die(message) { function usage() { console.log(`用法: - node search-auth.mjs --set = [--set ...] + node search-auth.mjs --set-stdin (从标准输入读取 key,每次只能配置一个 provider) node search-auth.mjs --unset [--unset ...] node search-auth.mjs --migrate [search.env 路径] node search-auth.mjs --list @@ -69,17 +69,16 @@ function defaultSearchEnvPath(env = process.env) { } function parseArgs(argv) { - const result = { sets: {}, unsets: new Set(), migrate: undefined, list: false, has: undefined, authPath: undefined }; + const result = { setStdin: undefined, unsets: new Set(), migrate: undefined, list: false, has: undefined, authPath: undefined }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--set") { - const spec = argv[++i]; - const separator = spec ? spec.indexOf("=") : -1; - if (separator < 1) die("--set 需要 ="); - const provider = spec.slice(0, separator); - const value = spec.slice(separator + 1); + die("--set 已移除:key 不得出现在命令行参数中;请改用 --set-stdin 从标准输入提供。"); + } else if (arg === "--set-stdin") { + const provider = argv[++i]; if (!PROVIDERS.includes(provider)) die(`未知 provider:${provider}(支持 ${PROVIDERS.join("/")})`); - result.sets[provider] = value; + if (result.setStdin) die("每次调用最多一个 --set-stdin。"); + result.setStdin = provider; } else if (arg === "--unset") { const provider = argv[++i]; if (!PROVIDERS.includes(provider)) die(`未知 provider:${provider}(支持 ${PROVIDERS.join("/")})`); @@ -208,12 +207,15 @@ if (args.has) { process.exit(hasKey(readAuthData(authPath), args.has) ? 0 : 1); } -const mutations = Object.keys(args.sets).length + args.unsets.size + (args.migrate ? 1 : 0); +const mutations = (args.setStdin ? 1 : 0) + args.unsets.size + (args.migrate ? 1 : 0); if (mutations === 0) { usage(); process.exit(2); } +const stdinKey = args.setStdin ? readFileSync(0, "utf8").trim() : undefined; +if (args.setStdin) validateKey(args.setStdin, stdinKey); + const release = acquireLock(authPath); try { const data = readAuthData(authPath); @@ -225,10 +227,9 @@ try { changed.push(`已移除 ${provider}`); } } - for (const [provider, key] of Object.entries(args.sets)) { - validateKey(provider, key); - setKey(data, provider, key); - changed.push(`已写入 ${provider}`); + if (args.setStdin) { + setKey(data, args.setStdin, stdinKey); + changed.push(`已写入 ${args.setStdin}`); } if (args.migrate) { const source = args.migrate === true ? defaultSearchEnvPath() : args.migrate; diff --git a/tests/bundle-config.test.ts b/tests/bundle-config.test.ts index 1615755..eeb5443 100644 --- a/tests/bundle-config.test.ts +++ b/tests/bundle-config.test.ts @@ -13,10 +13,7 @@ import { getToolSearchCatalogPath, getToolSearchConfigPath, } from "../pi-tool-search/extensions/config.ts"; -import { - CONFIG_PATH as RTK_CONFIG_PATH, - resolveConfigPath as resolveRtkConfigPath, -} from "../pi-rtk-optimizer/src/constants.ts"; +import { CONFIG_PATH as RTK_CONFIG_PATH } from "../pi-rtk-optimizer/src/constants.ts"; import { CONFIG_PATH as COMPACTION_CONFIG_PATH } from "../pi-compaction/src/config.ts"; import { WORKTREE_CONFIG_PATH } from "../pi-worktree/src/settings.ts"; import { resolvePreamblePath } from "../extensions/system-prompt-config.ts"; @@ -75,33 +72,21 @@ test("every bundle JSON/JSONC config file parses", async () => { }); test("each extension resolves its config path inside the bundle config/ dir", () => { - const savedToolSearch = process.env.PI_TOOL_SEARCH_CONFIG_PATH; - const savedRtk = process.env.PI_RTK_CONFIG_PATH; - delete process.env.PI_TOOL_SEARCH_CONFIG_PATH; - delete process.env.PI_RTK_CONFIG_PATH; - try { - const inBundleConfig = (resolved: string, file: string) => - assert.equal( - resolved, - join(bundleConfigDir, file), - `expected the bundle config path for ${file}`, - ); + const inBundleConfig = (resolved: string, file: string) => + assert.equal( + resolved, + join(bundleConfigDir, file), + `expected the bundle config path for ${file}`, + ); - inBundleConfig(getGlobalConfigPath(), "pi-permission-policy.jsonc"); - inBundleConfig(getHardGuardConfigPath(), "pi-permission-hard-guards.jsonc"); - inBundleConfig(getToolSearchConfigPath(), "tool-search.json"); - inBundleConfig(RTK_CONFIG_PATH, "rtk-optimizer.json"); - inBundleConfig(resolveRtkConfigPath(), "rtk-optimizer.json"); - inBundleConfig(COMPACTION_CONFIG_PATH, "pi-compaction.json"); - inBundleConfig(WORKTREE_CONFIG_PATH, "pi-worktree.json"); - inBundleConfig(HERMES_CONFIG_PATH, "pi-hermes-memory.json"); - inBundleConfig(resolvePreamblePath(), "prompt/preamble.md"); - } finally { - if (savedToolSearch === undefined) delete process.env.PI_TOOL_SEARCH_CONFIG_PATH; - else process.env.PI_TOOL_SEARCH_CONFIG_PATH = savedToolSearch; - if (savedRtk === undefined) delete process.env.PI_RTK_CONFIG_PATH; - else process.env.PI_RTK_CONFIG_PATH = savedRtk; - } + inBundleConfig(getGlobalConfigPath(), "pi-permission-policy.jsonc"); + inBundleConfig(getHardGuardConfigPath(), "pi-permission-hard-guards.jsonc"); + inBundleConfig(getToolSearchConfigPath(), "tool-search.json"); + inBundleConfig(RTK_CONFIG_PATH, "rtk-optimizer.json"); + inBundleConfig(COMPACTION_CONFIG_PATH, "pi-compaction.json"); + inBundleConfig(WORKTREE_CONFIG_PATH, "pi-worktree.json"); + inBundleConfig(HERMES_CONFIG_PATH, "pi-hermes-memory.json"); + inBundleConfig(resolvePreamblePath(), "prompt/preamble.md"); }); test("disposable runtime state stays under the agent-dir my-pi tree", () => { diff --git a/tests/codegraph.test.ts b/tests/codegraph.test.ts index c1cace5..1cf3a06 100644 --- a/tests/codegraph.test.ts +++ b/tests/codegraph.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; import { execFileSync, spawn, @@ -13,6 +14,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { PassThrough } from "node:stream"; import { fileURLToPath } from "node:url"; import test from "node:test"; @@ -278,6 +280,38 @@ test("client close() terminates the child process", async () => { } }); +test("a stdin EPIPE after child death rejects pending requests without throwing", async () => { + const dir = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); + const child = new EventEmitter() as ChildProcess; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.exitCode = null; + child.signalCode = null; + const client = new CodegraphRpcClient({ + root: dir, + spawn: () => child, + requestTimeoutMs: 5_000, + }); + try { + const start = client.start(); + await sleep(20); + // Without the stdin "error" listener this emit throws uncaught. + child.stdin.emit( + "error", + Object.assign(new Error("write EPIPE"), { code: "EPIPE" }), + ); + child.exitCode = 1; + child.emit("exit", 1, null); + child.emit("close", 1, null); + await assert.rejects(start, /子进程退出/); + assert.equal(client.dead, true); + } finally { + await client.close(); + await rm(dir, { recursive: true, force: true }); + } +}); + // ── handler ───────────────────────────────────────────────────────────────── test("indexed project explores through the serve child", async () => { diff --git a/tests/extension-load.test.ts b/tests/extension-load.test.ts index f39849c..4472abe 100644 --- a/tests/extension-load.test.ts +++ b/tests/extension-load.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -292,9 +292,31 @@ test("all package extensions load together without global registration conflicts const home = await mkdtemp(join(tmpdir(), "my-pi-extension-load-")); const mockBin = join(home, "bin"); await mkdir(mockBin, { recursive: true }); - const args = ["--no-extensions"]; + // Probe loaded LAST: records every registered tool (name + exposure) and the + // final active set so the test can assert the pinned built-in codemode. + const probeOutput = join(home, "probe-tools.json"); + const probePath = join(home, "probe.ts"); + await writeFile( + probePath, + [ + 'import { writeFileSync } from "node:fs";', + "export default function (pi: any): void {", + '\tpi.on("session_start", () => {', + "\t\twriteFileSync(", + `\t\t\t${JSON.stringify(probeOutput)},`, + "\t\t\tJSON.stringify({", + "\t\t\t\tall: pi.getAllTools().map((tool: any) => ({ name: tool.name, exposure: tool.exposure })),", + "\t\t\t\tactive: pi.getActiveTools(),", + "\t\t\t}),", + "\t\t);", + "\t});", + "}", + "", + ].join("\n"), + ); + const args = ["--no-extensions", "-e", "builtin:codemode"]; for (const entry of packageJson.pi.extensions) args.push("-e", resolve(repositoryRoot, entry)); - args.push("--mode", "rpc"); + args.push("-e", probePath, "--mode", "rpc"); const loadEnvironment = { ...process.env, @@ -313,4 +335,12 @@ test("all package extensions load together without global registration conflicts assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension|conflicts with/u); + + const probe = JSON.parse(await readFile(probeOutput, "utf8")) as { + all: { name: string; exposure?: string }[]; + active: string[]; + }; + const codemode = probe.all.find((tool) => tool.name === "codemode"); + assert.equal(codemode?.exposure, "model-only", "Pi's built-in codemode must load with model-only exposure"); + assert.ok(probe.active.includes("codemode"), "Tool Search must pin codemode into the active set"); }); diff --git a/tests/lib-sh.test.ts b/tests/lib-sh.test.ts new file mode 100644 index 0000000..9fa9932 --- /dev/null +++ b/tests/lib-sh.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { accessSync, chmodSync, constants } from "node:fs"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const LIB_SH = fileURLToPath(new URL("../scripts/lib.sh", import.meta.url)); +const SYSTEM_KITTY = "/Applications/kitty.app/Contents/MacOS/kitty"; + +function systemKittyPath(): string | undefined { + try { + accessSync(SYSTEM_KITTY, constants.X_OK); + return SYSTEM_KITTY; + } catch { + return undefined; + } +} + +function runDetectKitty(home: string, pathDir: string) { + return spawnSync( + "sh", + ["-c", `. "$1"; detect_kitty; status=$?; printf '%s' "$kitty_bin"; exit "$status"`, "sh", LIB_SH], + { + encoding: "utf8", + env: { ...process.env, HOME: home, PATH: `${pathDir}:${process.env.PATH}` }, + }, + ); +} + +test("detect_kitty prefers an official install over a PATH kitty", async () => { + const home = await mkdtemp(join(tmpdir(), "my-pi-libsh-")); + try { + const official = join(home, ".local", "kitty.app", "bin", "kitty"); + const pathDir = join(home, "pathbin"); + await mkdir(dirname(official), { recursive: true }); + await mkdir(pathDir, { recursive: true }); + await writeFile(official, "#!/bin/sh\n"); + chmodSync(official, 0o755); + await symlink(official, join(pathDir, "kitty")); + + const result = runDetectKitty(home, pathDir); + assert.equal(result.status, 0, result.stderr); + // A real /Applications kitty also wins over $HOME/.local and PATH. + assert.equal(result.stdout, systemKittyPath() ?? official); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test("detect_kitty falls back to a PATH kitty when no official install exists", async () => { + const home = await mkdtemp(join(tmpdir(), "my-pi-libsh-")); + try { + const pathDir = join(home, "pathbin"); + const pathKitty = join(pathDir, "kitty"); + await mkdir(pathDir, { recursive: true }); + await writeFile(pathKitty, "#!/bin/sh\n"); + chmodSync(pathKitty, 0o755); + + const result = runDetectKitty(home, pathDir); + assert.equal(result.status, 0, result.stderr); + // The isolated HOME has no ~/.local install; only a machine-level + // /Applications kitty (when present) still takes precedence. + assert.equal(result.stdout, systemKittyPath() ?? pathKitty); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/tests/package-files.test.ts b/tests/package-files.test.ts index 8dacc82..95aaa3c 100644 --- a/tests/package-files.test.ts +++ b/tests/package-files.test.ts @@ -43,6 +43,19 @@ test("every declared extension entry and file: dependency manifest is inside the } }); +test("every file: dependency ships an UPSTREAM.md source record", () => { + for (const [name, specifier] of Object.entries(packageJson.dependencies)) { + const match = /^file:\.\/(.+)$/u.exec(specifier); + if (!match) continue; + const upstream = `${match[1]}/UPSTREAM.md`; + assert.ok( + readFileSync(join(repositoryRoot, upstream), "utf8").trim().length > 0, + `${name} is a file: dependency — ${upstream} must exist and be non-empty`, + ); + assert.ok(packedFiles.has(upstream), `${name} is a file: dependency — ${upstream} must be packed`); + } +}); + test("relative import specifiers inside packed sources resolve to packed files", () => { const specifierPatterns = [ /\bfrom\s+["'](\.{1,2}\/[^"']+)["']/gu, diff --git a/tests/search-auth.test.ts b/tests/search-auth.test.ts index fb5409c..61bd22b 100644 --- a/tests/search-auth.test.ts +++ b/tests/search-auth.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -142,19 +143,25 @@ test("template parser handles env names, braces and escapes", () => { const AUTH_MJS = new URL("../scripts/search-auth.mjs", import.meta.url).pathname; const CONFIGURE_SH = new URL("../configure.sh", import.meta.url).pathname; -function runMjs(args: string[], env: Record = {}) { - return spawnSync("node", [AUTH_MJS, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); +function runMjs(args: string[], env: Record = {}, input?: string) { + return spawnSync("node", [AUTH_MJS, ...args], { + encoding: "utf8", + env: { ...process.env, ...env }, + ...(input === undefined ? {} : { input }), + }); } -test("search-auth.mjs --set writes api_key entries and preserves other providers", async () => { +test("search-auth.mjs --set-stdin writes api_key entries and preserves other providers", async () => { const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-")); const authPath = join(dir, "auth.json"); await writeFile( authPath, JSON.stringify({ anthropic: { type: "oauth", access: "tok", refresh: "ref", expires: 42, accountId: "acc" } }), ); - const result = runMjs(["--auth", authPath, "--set", "tavily=tvly-new", "--set", "exa=!op read x"]); - assert.equal(result.status, 0, result.stderr); + const first = runMjs(["--auth", authPath, "--set-stdin", "tavily"], {}, "tvly-new\n"); + assert.equal(first.status, 0, first.stderr); + const second = runMjs(["--auth", authPath, "--set-stdin", "exa"], {}, " !op read x \n"); + assert.equal(second.status, 0, second.stderr); const written = JSON.parse(await readFile(authPath, "utf8")) as Record; assert.deepEqual(written.anthropic, { type: "oauth", access: "tok", refresh: "ref", expires: 42, accountId: "acc" }); assert.deepEqual(written.tavily, { type: "api_key", key: "tvly-new" }); @@ -163,14 +170,33 @@ test("search-auth.mjs --set writes api_key entries and preserves other providers assert.equal(getSearchApiKey("tavily", { authPath }), "tvly-new"); }); -test("search-auth.mjs preserves an existing credential env map on --set", async () => { +test("search-auth.mjs --set is rejected: keys never travel in argv", async () => { + const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-")); + const authPath = join(dir, "auth.json"); + const result = runMjs(["--auth", authPath, "--set", "tavily=tvly-leak"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /命令行参数|--set-stdin/); + assert.equal(existsSync(authPath), false); +}); + +test("search-auth.mjs allows only one --set-stdin and rejects empty stdin", async () => { + const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-")); + const authPath = join(dir, "auth.json"); + const dup = runMjs(["--auth", authPath, "--set-stdin", "tavily", "--set-stdin", "exa"], {}, "key"); + assert.notEqual(dup.status, 0); + const empty = runMjs(["--auth", authPath, "--set-stdin", "tavily"], {}, " \n"); + assert.notEqual(empty.status, 0); + assert.equal(existsSync(authPath), false); +}); + +test("search-auth.mjs preserves an existing credential env map on --set-stdin", async () => { const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-")); const authPath = join(dir, "auth.json"); await writeFile( authPath, JSON.stringify({ keenable: { type: "api_key", key: "old", env: { KEENABLE_API_URL: "https://k.example" } } }), ); - const result = runMjs(["--auth", authPath, "--set", "keenable=new-key"]); + const result = runMjs(["--auth", authPath, "--set-stdin", "keenable"], {}, "new-key"); assert.equal(result.status, 0, result.stderr); const written = JSON.parse(await readFile(authPath, "utf8")) as Record; assert.deepEqual(written.keenable, { @@ -183,7 +209,7 @@ test("search-auth.mjs preserves an existing credential env map on --set", async test("search-auth.mjs creates a missing auth.json and honors PI_CODING_AGENT_DIR", async () => { const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-")); const agentDir = join(dir, "agent"); - const result = runMjs(["--set", "keenable=keen-1"], { PI_CODING_AGENT_DIR: agentDir }); + const result = runMjs(["--set-stdin", "keenable"], { PI_CODING_AGENT_DIR: agentDir }, "keen-1"); assert.equal(result.status, 0, result.stderr); const written = JSON.parse(await readFile(join(agentDir, "auth.json"), "utf8")) as Record; assert.deepEqual(written.keenable, { type: "api_key", key: "keen-1" }); @@ -204,7 +230,7 @@ test("search-auth.mjs refuses to clobber a malformed auth.json", async () => { const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-")); const authPath = join(dir, "auth.json"); await writeFile(authPath, "{oops"); - const result = runMjs(["--auth", authPath, "--set", "tavily=x"]); + const result = runMjs(["--auth", authPath, "--set-stdin", "tavily"], {}, "x"); assert.notEqual(result.status, 0); assert.match(result.stderr, /不是合法 JSON|abort|clobber/i); assert.equal(await readFile(authPath, "utf8"), "{oops"); @@ -238,15 +264,43 @@ test("search-auth.mjs --has and --list never print key material", async () => { assert.doesNotMatch(list.stdout, /supersecret/); }); -test("configure.sh search writes keys through search-auth.mjs noninteractively", async () => { +test("configure.sh search writes keys through search-auth.mjs via stdin", async () => { const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-")); const authPath = join(dir, "auth.json"); - const result = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "tvly-sh", "--exa", "exa-sh"], { + const first = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "-"], { encoding: "utf8", + input: "tvly-sh\n", }); - assert.equal(result.status, 0, result.stderr); + assert.equal(first.status, 0, first.stderr); + const second = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--exa", "-"], { + encoding: "utf8", + input: "exa-sh", + }); + assert.equal(second.status, 0, second.stderr); const written = JSON.parse(await readFile(authPath, "utf8")) as Record; assert.deepEqual(written.tavily, { type: "api_key", key: "tvly-sh" }); assert.deepEqual(written.exa, { type: "api_key", key: "exa-sh" }); assert.equal((await stat(authPath)).mode & 0o777, 0o600); }); + +test("configure.sh search rejects a plaintext key argument", async () => { + const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-")); + const authPath = join(dir, "auth.json"); + const result = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "tvly-leak"], { + encoding: "utf8", + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /命令行参数/); + assert.equal(existsSync(authPath), false); +}); + +test("configure.sh search allows at most one stdin provider per call", async () => { + const dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-")); + const authPath = join(dir, "auth.json"); + const result = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--tavily", "-", "--exa", "-"], { + encoding: "utf8", + input: "one-key", + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /最多一个/); +});