feat: 接入 Pi 内置 codemode,并修复全面审查发现的问题

- codemode:Tool Search 常驻(alwaysEnabled)、权限 allow(嵌套调用仍逐个过权限门)、
  tools.jsonc 登记使用规则;RTK 跳过 parentToolCallId 嵌套调用,避免丢失 bash
  structuredContent;Tool Search 目录过滤 hidden / codemode(script-only)exposure;
  扩展加载测试加载 builtin:codemode 并断言其常驻激活
- 搜索 key 不再经命令行参数传递:search-auth.mjs 改为 --set-stdin(先读校验再加锁),
  configure.sh 交互输入经管道、非交互用 --<provider> -
- CodeGraph 客户端监听子进程 stdin error,避免 EPIPE 未处理导致崩溃
- 移除 PI_TOOL_SEARCH/HERMES_MEMORY/RTK_CONFIG_PATH 环境变量入口,测试改用工厂 configPath 注入
- pi-subagents 固定 vitest / coverage-istanbul 4.1.10,修复 npm ls 版本冲突
- 补 pi-web-search / pi-worktree UPSTREAM.md 与 pi-minimal-footer AGENTS.md,
  files 白名单补齐并加测试;.codegraph/.gitignore 移出跟踪
- detect_kitty 优先识别官方安装目录

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-21 23:18:34 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 56cbc83296
commit f9a7f743dd
46 changed files with 585 additions and 198 deletions
-5
View File
@@ -1,5 +0,0 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+5 -4
View File
@@ -167,7 +167,7 @@ CodeGraph 的更新或遥测设置。
本地源码直接使用 `@earendil-works/pi-coding-agent`、当前 `typebox`、`ModelRegistry.complete()` 与 `setActiveTools()`,不安装、别名映射或加载旧 `@mariozechner` runtime,也不使用 provider payload 改写、代理执行或隐藏 `sendMessage` 循环。 本地源码直接使用 `@earendil-works/pi-coding-agent`、当前 `typebox`、`ModelRegistry.complete()` 与 `setActiveTools()`,不安装、别名映射或加载旧 `@mariozechner` runtime,也不使用 provider payload 改写、代理执行或隐藏 `sendMessage` 循环。
新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics`、六个记忆工具、`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 顺序和最近淘汰原因。 标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 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", "memory_remove",
"skill_manage", "skill_manage",
"context_tree_query", "context_tree_query",
"overflow_query" "overflow_query",
"codemode"
], ],
"showToolSearchFooterStatus": false, "showToolSearchFooterStatus": false,
"maxActiveGroups": 5, "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 ```bash
./configure.sh search --tavily '...' --exa '...' --keenable '...' printf '%s' "$TAVILY_API_KEY" | ./configure.sh search --tavily -
``` ```
只传部分参数时保留其他服务的已有值。凭据写入 Pi 自己的 只传部分参数时保留其他服务的已有值。凭据写入 Pi 自己的
+3
View File
@@ -82,6 +82,9 @@
// pi-overflow's inspector reads only the extension's own stored output // pi-overflow's inspector reads only the extension's own stored output
// directory (id-validated); pinned in Tool Search's alwaysEnabled set. // directory (id-validated); pinned in Tool Search's alwaysEnabled set.
"overflow_query": "allow", "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. // Model-chosen URLs can exfiltrate data — same reason curl/wget ask.
"web_fetch": "ask", "web_fetch": "ask",
+7
View File
@@ -32,6 +32,13 @@
"find": { "inherit": true }, "find": { "inherit": true },
"multi_grep": { "inherit": true }, "multi_grep": { "inherit": true },
"tool_search": { "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 }, "codegraph_explore": { "inherit": true },
"lsp_diagnostics": { "inherit": true }, "lsp_diagnostics": { "inherit": true },
+2 -1
View File
@@ -9,7 +9,8 @@
"memory_remove", "memory_remove",
"skill_manage", "skill_manage",
"context_tree_query", "context_tree_query",
"overflow_query" "overflow_query",
"codemode"
], ],
"showToolSearchFooterStatus": false, "showToolSearchFooterStatus": false,
"maxActiveGroups": 5, "maxActiveGroups": 5,
+21 -13
View File
@@ -24,24 +24,25 @@ search_usage() {
cat <<'EOF' cat <<'EOF'
用法: 用法:
./configure.sh search ./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 --migrate [search.env 路径]
./configure.sh search --list ./configure.sh search --list
选项: 选项:
--tavily KEY 配置 tavily api_key 凭据 --tavily - 从标准输入读取 tavily api_key 凭据
--exa KEY 配置 exa api_key 凭据 --exa - 从标准输入读取 exa api_key 凭据
--keenable KEY 配置 keenable api_key 凭据 --keenable - 从标准输入读取 keenable api_key 凭据
--migrate 从旧 search.env 导入缺失的 key --migrate 从旧 search.env 导入缺失的 key
--list 显示各 provider 配置状态(不输出 key) --list 显示各 provider 配置状态(不输出 key)
--auth FILE 覆盖 auth.json 路径 --auth FILE 覆盖 auth.json 路径
-h, --help 显示帮助 -h, --help 显示帮助
不传 key 参数时进入隐藏输入的交互模式;传入任意 key 参数时不交互, key 不得出现在命令行参数中:非交互方式用 `--<provider> -` 从标准输入
未指定的 provider 保留已有值。凭据写入 Pi 的 auth.json 读取,每次调用最多一个 provider 可经标准输入配置;不传参数时进入
($PI_CODING_AGENT_DIR/auth.json 或 ~/.pi/agent/auth.json,权限 600)。 隐藏输入的交互模式,未指定的 provider 保留已有值。凭据写入 Pi 的
key 支持 auth.json 原生语义:字面量、$ENV_VAR / ${ENV_VAR} 插值、 auth.json($PI_CODING_AGENT_DIR/auth.json 或 ~/.pi/agent/auth.json,
以及 !command(执行命令取 stdout,如 "!op read op://Vault/Tavily/key")。 权限 600)。key 支持 auth.json 原生语义:字面量、$ENV_VAR / ${ENV_VAR}
插值、以及 !command(执行命令取 stdout,如 "!op read op://Vault/Tavily/key")。
EOF EOF
} }
@@ -52,6 +53,7 @@ configure_search() {
SEARCH_AUTH_FILE= SEARCH_AUTH_FILE=
search_mode=interactive search_mode=interactive
search_did_work=0 search_did_work=0
search_stdin_provider=
run_search_auth() { run_search_auth() {
if [ -n "$SEARCH_AUTH_FILE" ]; then if [ -n "$SEARCH_AUTH_FILE" ]; then
@@ -69,7 +71,7 @@ configure_search() {
prompt_label=$1 prompt_label=$1
prompt_configured=$2 prompt_configured=$2
if [ ! -t 0 ]; then if [ ! -t 0 ]; then
warn "交互模式需要终端;请改用 --tavily/--exa/--keenable 参数。" warn "交互模式需要终端;请改用 '--<provider> -' 从标准输入提供 key。"
return 2 return 2
fi fi
if [ "$prompt_configured" -eq 1 ]; then if [ "$prompt_configured" -eq 1 ]; then
@@ -88,9 +90,15 @@ configure_search() {
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case $1 in case $1 in
--tavily|--exa|--keenable) --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 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 ;; shift 2 ;;
--migrate) --migrate)
search_mode=noninteractive; search_did_work=1 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 if search_has_key "$provider"; then configured=1; else configured=0; fi
value=$(search_prompt_key "$label" "$configured") || exit $? value=$(search_prompt_key "$label" "$configured") || exit $?
if [ -n "$value" ]; then 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 search_did_work=1
fi fi
done done
+2
View File
@@ -222,6 +222,8 @@ export class CodegraphRpcClient {
// ── internals ──────────────────────────────────────────────────────────── // ── internals ────────────────────────────────────────────────────────────
private attach(child: ChildProcess): void { 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?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => { child.stdout?.on("data", (chunk: string) => {
this.stdoutBuffer += chunk; this.stdoutBuffer += chunk;
+27 -27
View File
@@ -2763,31 +2763,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@vitest/expect": {
"version": "4.1.10", "version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", "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-coding-agent": "latest",
"@earendil-works/pi-tui": "latest", "@earendil-works/pi-tui": "latest",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@vitest/coverage-istanbul": "^4.1.10", "@vitest/coverage-istanbul": "4.1.10",
"typescript": "^6.0.0", "typescript": "^6.0.0",
"vitest": "^4.0.18" "vitest": "4.1.10"
}, },
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-ai": ">=0.81.0", "@earendil-works/pi-ai": ">=0.81.0",
@@ -5703,6 +5678,31 @@
"undici-types": ">=7.24.0 <7.24.7" "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": { "pi-subagents/node_modules/nanoid": {
"version": "5.1.16", "version": "5.1.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
+3
View File
@@ -94,11 +94,13 @@
"pi-web-search/package.json", "pi-web-search/package.json",
"pi-web-search/README.md", "pi-web-search/README.md",
"pi-web-search/LICENSE", "pi-web-search/LICENSE",
"pi-web-search/UPSTREAM.md",
"pi-worktree/index.ts", "pi-worktree/index.ts",
"pi-worktree/src", "pi-worktree/src",
"pi-worktree/package.json", "pi-worktree/package.json",
"pi-worktree/README.md", "pi-worktree/README.md",
"pi-worktree/LICENSE", "pi-worktree/LICENSE",
"pi-worktree/UPSTREAM.md",
"pi-permission-system/LICENSES", "pi-permission-system/LICENSES",
"pi-permission-system/docs/auto-review", "pi-permission-system/docs/auto-review",
"pi-permission-system/src", "pi-permission-system/src",
@@ -107,6 +109,7 @@
"pi-permission-system/README.md", "pi-permission-system/README.md",
"pi-permission-system/CHANGELOG.md", "pi-permission-system/CHANGELOG.md",
"pi-permission-system/LICENSE", "pi-permission-system/LICENSE",
"pi-permission-system/UPSTREAM.md",
"config", "config",
"scripts", "scripts",
"install.sh", "install.sh",
@@ -24,10 +24,10 @@ const emit = async (event, data = {}) => {
try { try {
process.env.PI_CODING_AGENT_DIR = root; 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(globalDir);
await mkdir(ctx.cwd); 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", lazyInitialization: lazy, memoryMode: "policy-only",
reviewEnabled: false, correctionDetection: false, reviewEnabled: false, correctionDetection: false,
flushOnShutdown: false, flushOnCompact: false, flushOnShutdown: false, flushOnCompact: false,
@@ -40,7 +40,10 @@ try {
} }
const started = performance.now(); const started = performance.now();
const { default: register } = await import(pathToFileURL(resolve(entry)).href); const module = await import(pathToFileURL(resolve(entry)).href);
const register = module.createHermesMemoryExtension
? module.createHermesMemoryExtension({ configPath })
: module.default;
const imported = performance.now(); const imported = performance.now();
register({ register({
on(event, handler) { on(event, handler) {
+1 -10
View File
@@ -89,16 +89,7 @@ export const DEFAULT_CONFIG_PATH = fileURLToPath(
new URL("../../config/pi-hermes-memory.json", import.meta.url), new URL("../../config/pi-hermes-memory.json", import.meta.url),
); );
/** export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
* 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 {
try { try {
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
const raw = fs.readFileSync(configPath, "utf-8"); const raw = fs.readFileSync(configPath, "utf-8");
+16 -3
View File
@@ -52,7 +52,7 @@ import { registerPreviewContextCommand } from "./handlers/preview-context.js";
import { registerStandingPinCommand } from "./handlers/standing-pin.js"; import { registerStandingPinCommand } from "./handlers/standing-pin.js";
import { StandingInstructions } from "./store/standing-instructions.js"; import { StandingInstructions } from "./store/standing-instructions.js";
import { STANDING_FILE } from "./constants.js"; import { STANDING_FILE } from "./constants.js";
import { loadConfig } from "./config.js"; import { DEFAULT_CONFIG_PATH, loadConfig } from "./config.js";
import { shouldWarnAutoConsolidationFailure } from "./auto-consolidation-warning.js"; import { shouldWarnAutoConsolidationFailure } from "./auto-consolidation-warning.js";
import { detectProject, detectProjectSkills } from "./project.js"; import { detectProject, detectProjectSkills } from "./project.js";
import { buildPromptContext } from "./prompt-context.js"; import { buildPromptContext } from "./prompt-context.js";
@@ -90,8 +90,12 @@ export function registerProjectSkillDiscoveryHandler(
}); });
} }
export default function (pi: ExtensionAPI) { export interface HermesMemoryExtensionOptions {
const config = loadConfig(); configPath?: string;
}
function hermesMemoryExtension(pi: ExtensionAPI, configPath: string) {
const config = loadConfig(configPath);
const lazy = config.lazyInitialization === true && config.memoryMode === "policy-only"; const lazy = config.lazyInitialization === true && config.memoryMode === "policy-only";
let sessionContext: ExtensionContext | undefined; let sessionContext: ExtensionContext | undefined;
@@ -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();
+4 -6
View File
@@ -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. // 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 root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-lazy-startup-"));
const previousRoot = process.env.PI_CODING_AGENT_DIR; const previousRoot = process.env.PI_CODING_AGENT_DIR;
const previousConfigPath = process.env.PI_HERMES_MEMORY_CONFIG_PATH;
process.env.PI_CODING_AGENT_DIR = root; process.env.PI_CODING_AGENT_DIR = root;
process.env.PI_HERMES_MEMORY_CONFIG_PATH = path.join(root, "hermes-memory-config.json"); const configPath = path.join(root, "hermes-memory-config.json");
const { default: registerExtension } = await import("../src/index.js"); const { createHermesMemoryExtension } = await import("../src/index.js");
const registerExtension = createHermesMemoryExtension({ configPath });
const { MemoryStore } = await import("../src/store/memory-store.js"); const { MemoryStore } = await import("../src/store/memory-store.js");
const { DatabaseManager } = await import("../src/store/db.js"); const { DatabaseManager } = await import("../src/store/db.js");
const globalDir = path.join(root, "pi-hermes-memory"); const globalDir = path.join(root, "pi-hermes-memory");
@@ -24,7 +24,7 @@ let notifications: string[];
let ctx: any; let ctx: any;
async function configure(overrides: Record<string, unknown> = {}) { async function configure(overrides: Record<string, unknown> = {}) {
await fs.writeFile(path.join(root, "hermes-memory-config.json"), JSON.stringify({ await fs.writeFile(configPath, JSON.stringify({
lazyInitialization: true, lazyInitialization: true,
reviewEnabled: false, correctionDetection: false, reviewEnabled: false, correctionDetection: false,
flushOnCompact: false, flushOnShutdown: false, flushOnCompact: false, flushOnShutdown: false,
@@ -87,8 +87,6 @@ afterEach(async () => {
after(async () => { after(async () => {
if (previousRoot === undefined) delete process.env.PI_CODING_AGENT_DIR; if (previousRoot === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousRoot; 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 }); await fs.rm(root, { recursive: true, force: true });
}); });
@@ -18,11 +18,11 @@ describe("session project memory rebinding", () => {
const launchDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-launch-")); const launchDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-launch-"));
const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-target-")); const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-project-rebind-target-"));
const previousAgentRoot = process.env.PI_CODING_AGENT_DIR; const previousAgentRoot = process.env.PI_CODING_AGENT_DIR;
const previousConfigPath = process.env.PI_HERMES_MEMORY_CONFIG_PATH; const configPath = path.join(agentRoot, "hermes-memory-config.json");
const previousCwd = process.cwd(); const previousCwd = process.cwd();
try { try {
await fs.writeFile( await fs.writeFile(
path.join(agentRoot, "hermes-memory-config.json"), configPath,
JSON.stringify({ JSON.stringify({
memoryMode: "legacy-inject", memoryMode: "legacy-inject",
reviewEnabled: false, reviewEnabled: false,
@@ -47,10 +47,10 @@ describe("session project memory rebinding", () => {
); );
process.env.PI_CODING_AGENT_DIR = agentRoot; process.env.PI_CODING_AGENT_DIR = agentRoot;
process.env.PI_HERMES_MEMORY_CONFIG_PATH = path.join(agentRoot, "hermes-memory-config.json");
process.chdir(launchDir); process.chdir(launchDir);
// Import after setting PI_CODING_AGENT_DIR so AGENT_ROOT is test-local. // Import after setting PI_CODING_AGENT_DIR so AGENT_ROOT is test-local.
const { default: registerExtension } = await import("../src/index.js"); const { createHermesMemoryExtension } = await import("../src/index.js");
const registerExtension = createHermesMemoryExtension({ configPath });
const mockPi: MockPi = { const mockPi: MockPi = {
handlers: {}, handlers: {},
tools: {}, tools: {},
@@ -103,8 +103,6 @@ describe("session project memory rebinding", () => {
process.chdir(previousCwd); process.chdir(previousCwd);
if (previousAgentRoot === undefined) delete process.env.PI_CODING_AGENT_DIR; if (previousAgentRoot === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousAgentRoot; 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(agentRoot, { recursive: true, force: true });
await fs.rm(launchDir, { recursive: true, force: true }); await fs.rm(launchDir, { recursive: true, force: true });
await fs.rm(targetDir, { recursive: true, force: true }); await fs.rm(targetDir, { recursive: true, force: true });
@@ -24,10 +24,10 @@ import { after, beforeEach, describe, it } from "node:test";
// Bind the entry point to a disposable agent root, never the user's memory. // 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 root = await fs.mkdtemp(path.join(os.tmpdir(), "pi-prompt-injection-"));
const previousRoot = process.env.PI_CODING_AGENT_DIR; const previousRoot = process.env.PI_CODING_AGENT_DIR;
const previousConfigPath = process.env.PI_HERMES_MEMORY_CONFIG_PATH;
process.env.PI_CODING_AGENT_DIR = root; process.env.PI_CODING_AGENT_DIR = root;
process.env.PI_HERMES_MEMORY_CONFIG_PATH = path.join(root, "hermes-memory-config.json"); const configPath = path.join(root, "hermes-memory-config.json");
const { default: registerExtension } = await import("../src/index.js"); const { createHermesMemoryExtension } = await import("../src/index.js");
const registerExtension = createHermesMemoryExtension({ configPath });
const globalDir = path.join(root, "pi-hermes-memory"); const globalDir = path.join(root, "pi-hermes-memory");
const cwd = path.join(root, "workspace"); const cwd = path.join(root, "workspace");
@@ -68,7 +68,7 @@ let handlers: Record<string, Array<(event: any, ctx: any) => any>>;
let ctx: any; let ctx: any;
async function configure(overrides: Record<string, unknown> = {}) { async function configure(overrides: Record<string, unknown> = {}) {
await fs.writeFile(path.join(root, "hermes-memory-config.json"), JSON.stringify({ await fs.writeFile(configPath, JSON.stringify({
memoryMode: "policy-only", memoryMode: "policy-only",
memoryPolicyStyle: "full", memoryPolicyStyle: "full",
lazyInitialization: true, lazyInitialization: true,
@@ -115,8 +115,6 @@ beforeEach(async () => {
after(async () => { after(async () => {
if (previousRoot === undefined) delete process.env.PI_CODING_AGENT_DIR; if (previousRoot === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousRoot; 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 { try {
await fs.rm(root, { recursive: true, force: true }); await fs.rm(root, { recursive: true, force: true });
} catch { } catch {
+10
View File
@@ -0,0 +1,10 @@
# my-pi 维护约定(pi-minimal-footer)
- 扩展入口是 `pi-minimal-footer/index.ts`(`package.json` 的 `pi.extensions` 指向 `./index.ts`);`status.ts` 收集 `ctx.ui.setStatus()` 发布的非空扩展状态并渲染为 footer 片段,`settings.ts` 读取 footer 配置,`assets/` 为 provider 图标,测试在 `test/`。
- 持久配置只读组合包 `config/pi-minimal-footer.json`(`settings.ts` 的 `readFooterSettings` 解析 `showCwd` / `showBranch`,缺失或非法时回退默认 `true`);不读写 `settings.json`,不以环境变量作持久默认。
- 上下文仪表优先显示 pi-compaction 发布的 effective usage 快照(`globalThis[Symbol.for("my-pi.pi-compaction.service.v1")].getMetrics(sessionId)` 的 `percent` / `effectiveTokens`);无快照时回退宿主用量(`buildSessionContext` 取最后一条非 aborted assistant 消息的 `input + output + cacheRead + cacheWrite`)。
- 来源记录在 `UPSTREAM.md`:从 `ogulcancelik/pi-extensions` 的 `pi-minimal-footer@0.1.10`(commit `77bd1e1`)导入,保留上游 LICENSE 与作者;本地改动为扩展状态渲染与 bundle 配置文件读取。
## 验证
- 在本目录内运行 `npm test`(`node --experimental-strip-types --test test/*.test.ts`)与 `npm run check`(`node --experimental-strip-types --check index.ts`)。
+1
View File
@@ -5,6 +5,7 @@
- RTK 默认只压缩非搜索输出,包括本地 `bash` 与远端 `ssh_bash` 的 ANSI 清理、测试聚合、构建过滤、Git 压缩、Lint 聚合;兜底截断默认关闭,新结果的展示截断由 pi-overflow 统一负责;`ssh_bash` 只复用输出处理,远端命令不得进入 RTK rewrite。 - RTK 默认只压缩非搜索输出,包括本地 `bash` 与远端 `ssh_bash` 的 ANSI 清理、测试聚合、构建过滤、Git 压缩、Lint 聚合;兜底截断默认关闭,新结果的展示截断由 pi-overflow 统一负责;`ssh_bash` 只复用输出处理,远端命令不得进入 RTK rewrite。
- RTK 命令改写默认关闭(`commandRewritingEnabled` 默认 `false`);默认安装不依赖系统 `rtk` CLI,只有用户主动开启命令改写时才需要 `rtk rewrite`,其命令支持策略由已安装的 `rtk rewrite` 决定,不在扩展内重复维护规则。 - RTK 命令改写默认关闭(`commandRewritingEnabled` 默认 `false`);默认安装不依赖系统 `rtk` CLI,只有用户主动开启命令改写时才需要 `rtk rewrite`,其命令支持策略由已安装的 `rtk rewrite` 决定,不在扩展内重复维护规则。
- FFF override 独占搜索工具与搜索输出;RTK 不得处理本地或远端搜索工具的调用/结果(含 `grep` 工具结果),也不得通过默认命令改写接管 `rg`、`grep`、`find`、`fd`。 - FFF override 独占搜索工具与搜索输出;RTK 不得处理本地或远端搜索工具的调用/结果(含 `grep` 工具结果),也不得通过默认命令改写接管 `rg`、`grep`、`find`、`fd`。
- RTK 不处理带 `parentToolCallId` 的嵌套调用(codemode 脚本内调用),否则会丢失 bash 的 structuredContent。
- `readCompaction.enabled`、`sourceCodeFilteringEnabled` 和 `smartTruncate.enabled` 当前均默认 `false`,源码读取保持原样;未经用户明确决定,不因节省上下文而改变这些默认值。若以后开启 read 压缩,优先考虑 `readCompaction + smartTruncate`,源码过滤仍独立评估;必须保留精确 `offset/limit` 读取、短文件和行锚点的完整性。 - `readCompaction.enabled`、`sourceCodeFilteringEnabled` 和 `smartTruncate.enabled` 当前均默认 `false`,源码读取保持原样;未经用户明确决定,不因节省上下文而改变这些默认值。若以后开启 read 压缩,优先考虑 `readCompaction + smartTruncate`,源码过滤仍独立评估;必须保留精确 `offset/limit` 读取、短文件和行锚点的完整性。
- read 压缩属于有损能力。任何默认值调整都必须同时说明哪些内容可能被省略、精确读取如何恢复原文,以及对 `edit` 文本匹配和排障证据的影响。 - read 压缩属于有损能力。任何默认值调整都必须同时说明哪些内容可能被省略、精确读取如何恢复原文,以及对 `edit` 文本匹配和排障证据的影响。
- 工具输出压缩可能损失证据。排障和审计相关改动应优先保证原始输出可恢复,并覆盖锚点完整性和截断边界。 - 工具输出压缩可能损失证据。排障和审计相关改动应优先保证原始输出可恢复,并覆盖锚点完整性和截断边界。
+5 -5
View File
@@ -1,6 +1,6 @@
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { resolveConfigPath } from "./constants.js"; import { CONFIG_PATH } from "./constants.js";
import { toRecord } from "./record-utils.js"; import { toRecord } from "./record-utils.js";
import { import {
DEFAULT_RTK_INTEGRATION_CONFIG, 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)) { if (existsSync(configPath)) {
return { created: false }; 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)) { if (!existsSync(configPath)) {
return { config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG) }; return { config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG) };
} }
@@ -189,7 +189,7 @@ export function loadRtkIntegrationConfig(configPath = resolveConfigPath()): Conf
export function saveRtkIntegrationConfig( export function saveRtkIntegrationConfig(
config: RtkIntegrationConfig, config: RtkIntegrationConfig,
configPath = resolveConfigPath(), configPath = CONFIG_PATH,
): ConfigSaveResult { ): ConfigSaveResult {
const normalized = normalizeRtkIntegrationConfig(config); const normalized = normalizeRtkIntegrationConfig(config);
const tmpPath = `${configPath}.tmp`; 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; return configPath;
} }
-8
View File
@@ -9,11 +9,3 @@ export const EXTENSION_NAME = "pi-rtk-optimizer";
export const CONFIG_PATH = fileURLToPath( export const CONFIG_PATH = fileURLToPath(
new URL("../../config/rtk-optimizer.json", import.meta.url), 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;
}
+65 -3
View File
@@ -36,16 +36,15 @@ mock.module("@earendil-works/pi-tui", {
const indexModule = await import("./index.ts"); const indexModule = await import("./index.ts");
const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote } = indexModule; const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote } = indexModule;
const rtkIntegrationExtension = indexModule.default;
const { DEFAULT_RTK_INTEGRATION_CONFIG } = await import("./types.ts"); const { DEFAULT_RTK_INTEGRATION_CONFIG } = await import("./types.ts");
// Tests must never write the bundle's config/ file: relocate the config to a // 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( const TEST_CONFIG_PATH = join(
mkdtempSync(join(tmpdir(), "my-pi-rtk-test-")), mkdtempSync(join(tmpdir(), "my-pi-rtk-test-")),
"rtk-optimizer.json", "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 { function writeTestConfig(commandRewritingEnabled: boolean): void {
writeFileSync( writeFileSync(
@@ -401,6 +400,69 @@ await runTest("tool_result lifecycle merges compaction metadata with existing de
assert.equal(notifications.length, 0); assert.equal(notifications.length, 0);
}); });
await runTest("nested tool calls and results (parentToolCallId) are left untouched", async () => {
writeTestConfig(true);
const handlers: Record<string, ExtensionHandler> = {};
const notifications: Notification[] = [];
let rewriteCalls = 0;
rtkIntegrationExtension({
exec: async (_command: string, args: string[]) => {
if (args[0] === "--version") {
return { code: 0, stdout: "rtk 1.0.0", stderr: "" };
}
if (args[0] === "rewrite") {
rewriteCalls += 1;
return { code: 3, stdout: "rtk git status", stderr: "" };
}
return { code: 1, stdout: "", stderr: "unexpected" };
},
on(eventName: string, handler: ExtensionHandler) {
handlers[eventName] = handler;
},
registerCommand() {},
} as never);
const toolCallHandler = handlers.tool_call;
const toolResultHandler = handlers.tool_result;
assert.ok(toolCallHandler);
assert.ok(toolResultHandler);
// A codemode script's nested bash result must survive intact: rewriting
// content without structuredContent would drop bash's outputSchema payload.
const nestedResult = await toolResultHandler(
{
toolName: "bash",
parentToolCallId: "codemode-1",
input: { command: "printf TODO" },
content: [{ type: "text", text: "\x1B[31msrc/a.ts\n 1: TODO\x1B[0m\n" }],
},
createNotificationContext(notifications),
);
assert.ok(!nestedResult || !("content" in nestedResult));
const nestedCall = {
toolName: "bash",
parentToolCallId: "codemode-1",
input: { command: "git status" },
};
await toolCallHandler(nestedCall, createNotificationContext(notifications));
assert.equal(nestedCall.input.command, "git status");
assert.equal(rewriteCalls, 0);
const directResult = await toolResultHandler(
{
toolName: "bash",
input: { command: "printf TODO" },
content: [{ type: "text", text: "\x1B[31msrc/a.ts\n 1: TODO\x1B[0m\n" }],
},
createNotificationContext(notifications),
);
assert.ok(directResult && "content" in directResult);
assert.equal(firstText(directResult.content), "src/a.ts\n 1: TODO\n");
writeTestConfig(false);
});
await runTest("tool_call surfaces RTK rewrite errors through existing UI warning path", async () => { await runTest("tool_call surfaces RTK rewrite errors through existing UI warning path", async () => {
writeTestConfig(true); writeTestConfig(true);
const handlers: Record<string, (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>> = {}; const handlers: Record<string, (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>> = {};
+28 -7
View File
@@ -8,7 +8,7 @@ import {
} from "./config-store.js"; } from "./config-store.js";
import { computeRewriteDecision } from "./command-rewriter.js"; import { computeRewriteDecision } from "./command-rewriter.js";
import { registerRtkIntegrationCommand } from "./command-register.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 { createLazyModuleLoader } from "./lazy-module-loader.js";
import { clearOutputMetrics, getOutputMetricsSummary } from "./output-metrics.js"; import { clearOutputMetrics, getOutputMetricsSummary } from "./output-metrics.js";
import type { ToolResultCompactionMetadata } from "./output-compactor.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 { export interface RtkOptimizerExtensionOptions {
const initialLoad = loadRtkIntegrationConfig(); configPath?: string;
}
function rtkIntegrationExtension(pi: ExtensionAPI, configPath: string): void {
const initialLoad = loadRtkIntegrationConfig(configPath);
let config: RtkIntegrationConfig = initialLoad.config; let config: RtkIntegrationConfig = initialLoad.config;
if (!config.enabled) { if (!config.enabled) {
return; return;
@@ -200,12 +204,12 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
}; };
const refreshConfig = async (ctx?: ExtensionContext | ExtensionCommandContext): Promise<void> => { const refreshConfig = async (ctx?: ExtensionContext | ExtensionCommandContext): Promise<void> => {
const ensured = ensureConfigExists(); const ensured = ensureConfigExists(configPath);
if (ensured.error && ctx) { if (ensured.error && ctx) {
warnOnce(ctx, ensured.error); warnOnce(ctx, ensured.error);
} }
const loaded = loadRtkIntegrationConfig(); const loaded = loadRtkIntegrationConfig(configPath);
config = loaded.config; config = loaded.config;
pendingLoadWarning = loaded.warning; pendingLoadWarning = loaded.warning;
await refreshRuntimeStatus(); await refreshRuntimeStatus();
@@ -218,7 +222,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
const setConfig = (next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void => { const setConfig = (next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void => {
config = normalizeRtkIntegrationConfig(next); config = normalizeRtkIntegrationConfig(next);
const saved = saveRtkIntegrationConfig(config); const saved = saveRtkIntegrationConfig(config, configPath);
if (!saved.success && saved.error) { if (!saved.success && saved.error) {
ctx.ui.notify(saved.error, "error"); ctx.ui.notify(saved.error, "error");
} }
@@ -310,7 +314,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
const controller = { const controller = {
getConfig: () => config, getConfig: () => config,
setConfig, setConfig,
getConfigPath: getRtkIntegrationConfigPath, getConfigPath: () => getRtkIntegrationConfigPath(configPath),
getRuntimeStatus: () => runtimeStatus, getRuntimeStatus: () => runtimeStatus,
refreshRuntimeStatus, refreshRuntimeStatus,
getMetricsSummary: getOutputMetricsSummary, getMetricsSummary: getOutputMetricsSummary,
@@ -396,6 +400,10 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
}); });
pi.on("tool_call", async (event, ctx) => { pi.on("tool_call", async (event, ctx) => {
if (event.parentToolCallId) {
return {};
}
if (!config.enabled || !config.commandRewritingEnabled) { if (!config.enabled || !config.commandRewritingEnabled) {
return {}; return {};
} }
@@ -455,6 +463,10 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
}); });
pi.on("tool_result", async (event, ctx) => { pi.on("tool_result", async (event, ctx) => {
if (event.parentToolCallId) {
return {};
}
if (!config.enabled || !config.outputCompaction.enabled) { if (!config.enabled || !config.outputCompaction.enabled) {
return {}; return {};
} }
@@ -485,3 +497,12 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
} }
}); });
} }
export function createRtkOptimizerExtension(
options?: RtkOptimizerExtensionOptions,
): (pi: ExtensionAPI) => void {
const configPath = options?.configPath ?? CONFIG_PATH;
return (pi) => rtkIntegrationExtension(pi, configPath);
}
export default createRtkOptimizerExtension();
+1
View File
@@ -76,6 +76,7 @@ declare module "@earendil-works/pi-coding-agent" {
input: Record<string, unknown>; input: Record<string, unknown>;
content: Array<Record<string, unknown>>; content: Array<Record<string, unknown>>;
details?: unknown; details?: unknown;
parentToolCallId?: string;
} }
export interface BashToolCallEvent { export interface BashToolCallEvent {
+2 -2
View File
@@ -19,9 +19,9 @@
"@earendil-works/pi-coding-agent": "latest", "@earendil-works/pi-coding-agent": "latest",
"@earendil-works/pi-tui": "latest", "@earendil-works/pi-tui": "latest",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@vitest/coverage-istanbul": "^4.1.10", "@vitest/coverage-istanbul": "4.1.10",
"typescript": "^6.0.0", "typescript": "^6.0.0",
"vitest": "^4.0.18" "vitest": "4.1.10"
}, },
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-ai": ">=0.81.0", "@earendil-works/pi-ai": ">=0.81.0",
+2 -2
View File
@@ -61,9 +61,9 @@
"@earendil-works/pi-coding-agent": "latest", "@earendil-works/pi-coding-agent": "latest",
"@earendil-works/pi-tui": "latest", "@earendil-works/pi-tui": "latest",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@vitest/coverage-istanbul": "^4.1.10", "@vitest/coverage-istanbul": "4.1.10",
"typescript": "^6.0.0", "typescript": "^6.0.0",
"vitest": "^4.0.18" "vitest": "4.1.10"
}, },
"pi": { "pi": {
"extensions": [ "extensions": [
+1 -1
View File
@@ -3,7 +3,7 @@
- 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `extensions/catalog.ts`,配置读取与路径解析位于 `extensions/config.ts`(直接读取组合包 `config/tool-search.json`;目录缓存仍在 `<agentDir>/my-pi/tool-search/catalog-v1.json`),测试保留在 `test/`。 - 扩展入口是 `pi-tool-search/extensions/index.ts`,组合包权威分组位于 `extensions/bundle-groups.ts`,目录生成/校验/缓存位于 `extensions/catalog.ts`,配置读取与路径解析位于 `extensions/config.ts`(直接读取组合包 `config/tool-search.json`;目录缓存仍在 `<agentDir>/my-pi/tool-search/catalog-v1.json`),测试保留在 `test/`。
- 同步上游必须记录明确 tag/commit,保留 README、CHANGELOG、文档、作者和许可证说明;不得导入上游 `.pi`、嵌套 `.git`、`node_modules` 或构建产物。 - 同步上游必须记录明确 tag/commit,保留 README、CHANGELOG、文档、作者和许可证说明;不得导入上游 `.pi`、嵌套 `.git`、`node_modules` 或构建产物。
- 根包必须通过 `file:./pi-tool-search` 提供本地依赖并直接加载仓库源码,不得同时安装或加载 npm `pi-tool-search` 实现;同步上游时先核对本地增量动态工具改造,禁止用旧 npm 入口或上游旧宿主实现直接覆盖。 - 根包必须通过 `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 顺序与最近淘汰原因。 - 其余组合包工具必须优先使用 `extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。
- 标准组合包工具全部命中预置目录时不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。 - 标准组合包工具全部命中预置目录时不得调用模型、创建用户缓存或发送隐藏 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` 只能在调用时裁决。 - 必须直接使用当前 `@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` 只能在调用时裁决。
+2
View File
@@ -3,6 +3,8 @@
## Unreleased — my-pi maintained source ## Unreleased — my-pi maintained source
### Changed ### 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`. - 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. - Imported upstream `v0.3.6` at `ddfb23646fd3957b791214de278e23aa393c9b13` for direct local maintenance.
- Migrated runtime imports to `@earendil-works/pi-coding-agent` and current `typebox`. - Migrated runtime imports to `@earendil-works/pi-coding-agent` and current `typebox`.
+5 -4
View File
@@ -10,7 +10,7 @@ Generated metadata never replaces executable schemas. Exact names, parameter typ
## Catalog lifecycle ## 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. 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. 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. 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-interaction` | Chrome click/type/fill/key/hover/drag/tap/scroll input |
| `chrome-debugging` | Chrome page evaluation, console/network diagnostics, response retrieval, and file upload | | `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 ## Loading and eviction
@@ -81,7 +81,8 @@ Pi reads this configuration directly from the installed bundle's `config/tool-se
"memory_remove", "memory_remove",
"skill_manage", "skill_manage",
"context_tree_query", "context_tree_query",
"overflow_query" "overflow_query",
"codemode"
], ],
"showToolSearchFooterStatus": false, "showToolSearchFooterStatus": false,
"maxActiveGroups": 5, "maxActiveGroups": 5,
@@ -93,7 +94,7 @@ Pi reads this configuration directly from the installed bundle's `config/tool-se
| Key | Bundle default | Description | | 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 | | `showToolSearchFooterStatus` | `false` | Show active tool/group counts |
| `maxActiveGroups` | `5` | Maximum simultaneously active dynamic groups | | `maxActiveGroups` | `5` | Maximum simultaneously active dynamic groups |
| `maxToolsPerGroup` | `8` | Maximum size accepted from model-generated groups | | `maxToolsPerGroup` | `8` | Maximum size accepted from model-generated groups |
+1 -1
View File
@@ -2,7 +2,7 @@
`pi-tool-search` keeps full low-frequency schemas hidden behind a compact, validated group catalog. `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. 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. 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. 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.
+1 -2
View File
@@ -11,8 +11,6 @@ const TOOL_SEARCH_DIR = join("my-pi", "tool-search");
* agent directory. * agent directory.
*/ */
export function getToolSearchConfigPath(): string { 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)); return fileURLToPath(new URL("../../config/tool-search.json", import.meta.url));
} }
@@ -33,6 +31,7 @@ export const BUNDLE_TOOL_SEARCH_DEFAULTS = {
"skill_manage", "skill_manage",
"context_tree_query", "context_tree_query",
"overflow_query", "overflow_query",
"codemode",
], ],
showToolSearchFooterStatus: false, showToolSearchFooterStatus: false,
maxActiveGroups: 5, maxActiveGroups: 5,
+26 -7
View File
@@ -28,7 +28,7 @@ import {
createBundleCatalog, createBundleCatalog,
type BundleCatalogResult, type BundleCatalogResult,
} from "./bundle-groups.ts"; } from "./bundle-groups.ts";
import { getToolSearchCatalogPath, readToolSearchConfig } from "./config.ts"; import { getToolSearchCatalogPath, getToolSearchConfigPath, readToolSearchConfig } from "./config.ts";
const TOOL_SEARCH_NAME = "tool_search"; const TOOL_SEARCH_NAME = "tool_search";
const POWERSHELL_TOOL_NAME = "powershell"; const POWERSHELL_TOOL_NAME = "powershell";
@@ -39,13 +39,19 @@ type CatalogSource = "bundle" | "cache" | "fallback" | "hybrid" | "model";
type ModelUsage = Awaited<ReturnType<ExtensionContext["modelRegistry"]["complete"]>>["usage"]; type ModelUsage = Awaited<ReturnType<ExtensionContext["modelRegistry"]["complete"]>>["usage"];
type CatalogGenerationResult = { notice?: string; usage?: ModelUsage }; type CatalogGenerationResult = { notice?: string; usage?: ModelUsage };
export function platformToolPolicy<T extends { name: string }>( export function platformToolPolicy<T extends { name: string; exposure?: string }>(
definitions: readonly T[], definitions: readonly T[],
platform: NodeJS.Platform, platform: NodeJS.Platform,
): { tools: T[]; coreTools: string[] } { ): { 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" const tools = platform === "win32"
? [...definitions] ? [...reachable]
: definitions.filter((tool) => tool.name !== POWERSHELL_TOOL_NAME); : reachable.filter((tool) => tool.name !== POWERSHELL_TOOL_NAME);
const coreTools = platform === "win32" const coreTools = platform === "win32"
? [...CORE_TOOLS, POWERSHELL_TOOL_NAME] ? [...CORE_TOOLS, POWERSHELL_TOOL_NAME]
: [...CORE_TOOLS]; : [...CORE_TOOLS];
@@ -110,11 +116,11 @@ function groupDescription(catalog: ToolCatalog, source: CatalogSource): string {
].join("\n"); ].join("\n");
} }
export default function toolSearchExtension(pi: ExtensionAPI): void { function toolSearchExtension(pi: ExtensionAPI, configPath: string): void {
const agentDir = getAgentDir(); const agentDir = getAgentDir();
const cachePath = getToolSearchCatalogPath(agentDir); const cachePath = getToolSearchCatalogPath(agentDir);
let config = readToolSearchConfig(); let config = readToolSearchConfig(configPath);
let tools: ToolInfo[] = []; let tools: ToolInfo[] = [];
let catalog: ToolCatalog = createFallbackCatalog([], "", { let catalog: ToolCatalog = createFallbackCatalog([], "", {
maxToolsPerGroup: Math.min(config.maxToolsPerGroup, config.maxDynamicTools), maxToolsPerGroup: Math.min(config.maxToolsPerGroup, config.maxDynamicTools),
@@ -440,7 +446,7 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
} }
function refreshState(ctx: Pick<ExtensionContext, "ui">, forceReset: boolean): void { function refreshState(ctx: Pick<ExtensionContext, "ui">, forceReset: boolean): void {
const nextConfig = readToolSearchConfig(); const nextConfig = readToolSearchConfig(configPath);
const registeredTools = pi.getAllTools().filter((tool) => tool.name !== TOOL_SEARCH_NAME); const registeredTools = pi.getAllTools().filter((tool) => tool.name !== TOOL_SEARCH_NAME);
const platformPolicy = platformToolPolicy(registeredTools, process.platform); const platformPolicy = platformToolPolicy(registeredTools, process.platform);
const allTools = platformPolicy.tools; const allTools = platformPolicy.tools;
@@ -542,3 +548,16 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
if (group && activeGroups.has(group)) activeGroups.set(group, ++clock); if (group && activeGroups.has(group)) activeGroups.set(group, ++clock);
}); });
} }
export interface ToolSearchExtensionOptions {
configPath?: string;
}
export function createToolSearchExtension(
options?: ToolSearchExtensionOptions,
): (pi: ExtensionAPI) => void {
const configPath = options?.configPath ?? getToolSearchConfigPath();
return (pi) => toolSearchExtension(pi, configPath);
}
export default createToolSearchExtension();
@@ -10,6 +10,7 @@ function tool(name: string): ToolInfo {
name, name,
description: `Full description for ${name}`, description: `Full description for ${name}`,
parameters: { type: "object", properties: {} } as ToolInfo["parameters"], parameters: { type: "object", properties: {} } as ToolInfo["parameters"],
exposure: "direct",
sourceInfo: { source: "extension", scope: "user", path: `/test/${name}.ts` } as unknown as ToolInfo["sourceInfo"], sourceInfo: { source: "extension", scope: "user", path: `/test/${name}.ts` } as unknown as ToolInfo["sourceInfo"],
}; };
} }
+1
View File
@@ -21,6 +21,7 @@ function tool(name: string, description: string): ToolInfo {
name, name,
description, description,
parameters: { type: "object", properties: { query: { type: "string" } } } as ToolInfo["parameters"], 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"], sourceInfo: { source: "extension", scope: "user", path: `/test/${name}.ts` } as unknown as ToolInfo["sourceInfo"],
}; };
} }
+1
View File
@@ -41,6 +41,7 @@ test("bundle defaults keep the committed tool list", () => {
"skill_manage", "skill_manage",
"context_tree_query", "context_tree_query",
"overflow_query", "overflow_query",
"codemode",
]); ]);
assert.deepEqual(bundleFallbackConfig(), { assert.deepEqual(bundleFallbackConfig(), {
alwaysEnabled: [...BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled], alwaysEnabled: [...BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled],
+55 -7
View File
@@ -5,7 +5,7 @@ import { join } from "node:path";
import test from "node:test"; import test from "node:test";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; 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 { interface ToolSearchResult {
details: { details: {
@@ -49,13 +49,12 @@ function testContext(overrides: Partial<ExtensionContext> = {}): ExtensionContex
test("loads whole groups and evicts the least-recently-used group", async () => { test("loads whole groups and evicts the least-recently-used group", async () => {
const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-extension-")); const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-extension-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR; 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_CODING_AGENT_DIR = agentDir;
process.env.PI_TOOL_SEARCH_CONFIG_PATH = join(agentDir, "tool-search-config.json");
try { try {
await writeFile( await writeFile(
process.env.PI_TOOL_SEARCH_CONFIG_PATH, configPath,
JSON.stringify({ JSON.stringify({
alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"], alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"],
showToolSearchFooterStatus: false, 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), on: (event: string, handler: (...args: any[]) => unknown) => handlers.set(event, handler),
} as unknown as ExtensionAPI; } as unknown as ExtensionAPI;
toolSearchExtension(api); createToolSearchExtension({ configPath })(api);
const ctx = testContext({ const ctx = testContext({
hasUI: true, hasUI: true,
ui: { ui: {
@@ -159,8 +158,6 @@ test("loads whole groups and evicts the least-recently-used group", async () =>
} finally { } finally {
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousAgentDir; 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 }); 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")); assert.ok(windows.coreTools.includes("powershell"));
}); });
test("drops hidden and script-only tools but keeps reachable exposures", () => {
const definitions = [
sourceTool("read", "Read a file"),
{ ...sourceTool("hidden_helper", "Internal helper"), exposure: "hidden" },
{ ...sourceTool("script_only", "Only callable from scripts"), exposure: "codemode" },
{ ...sourceTool("codemode", "Batch several tool calls in one script"), exposure: "model-only" },
{ ...sourceTool("lazy_tool", "Activated on demand"), exposure: "deferred" },
];
const { tools } = platformToolPolicy(definitions, "darwin");
assert.deepEqual(tools.map((tool) => tool.name), ["read", "codemode", "lazy_tool"]);
});
test("pins a model-only codemode tool instead of hiding it", async () => {
const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-codemode-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
try {
const handlers = new Map<string, (...args: any[]) => unknown>();
const registered = new Map<string, RegisteredTool>();
const activeCalls: string[][] = [];
const sourceTools = [
sourceTool("read", "Read a file"),
{ ...sourceTool("codemode", "Batch several tool calls in one script"), exposure: "model-only" },
];
const api = {
getAllTools: () => sourceTools,
registerTool: (definition: unknown) => {
const tool = definition as RegisteredTool & { name: string };
registered.set(tool.name, tool);
},
registerCommand: () => {},
setActiveTools: (names: string[]) => activeCalls.push([...names]),
on: (event: string, handler: (...args: any[]) => unknown) => handlers.set(event, handler),
} as unknown as ExtensionAPI;
// The bundle config pins "codemode" in alwaysEnabled; exposure "model-only"
// stays reachable, so it must land in the active set, not a dynamic group.
toolSearchExtension(api);
handlers.get("session_start")?.({}, testContext());
assert.ok(activeCalls.at(-1)?.includes("codemode"));
assert.ok(activeCalls.at(-1)?.includes("tool_search"));
assert.ok(activeCalls.at(-1)?.includes("read"));
} finally {
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
await rm(agentDir, { recursive: true, force: true });
}
});
test("loads an exact known group without waiting for unknown-tool model enrichment", async () => { 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 agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-fast-path-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR; const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
+1 -1
View File
@@ -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` 凭据仅提高速率限制。 - `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、仓库文件或日志。 - `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"` 表达。 - 搜索 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`。
+4 -1
View File
@@ -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 (`tavily`, `exa`, `keenable`), read by `auth.ts` with upstream resolution
semantics — literal values, `$VAR`/`${VAR}` interpolation, and `!command` semantics — literal values, `$VAR`/`${VAR}` interpolation, and `!command`
indirection (e.g. `"!op read op://Vault/Tavily/key"`). `configure.sh search` 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"` 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 to indirect through env. `KEENABLE_API_URL` stays env-only, or can be stored
in the keenable credential's `env` map. in the keenable credential's `env` map.
+9
View File
@@ -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 凭据解析)。
+8
View File
@@ -0,0 +1,8 @@
# 上游来源
- 本项目完全自维护,无上游;未从任何外部仓库或 npm 包导入源码。
- 实现仅依赖 Pi 扩展宿主 API(`@earendil-works/pi-coding-agent`、`@earendil-works/pi-tui`)与 `typebox`,均为组合包 peer 依赖;git 操作经 `execFile` argv 直接调用系统 `git`。
## 本地改动记录
- 初始实现:`index.ts`(`/wt` 斜杠命令入口)、`src/`(worktree 生命周期、状态登记、会话切换与环境复刻)。
+4 -4
View File
@@ -17,16 +17,16 @@ warn() {
detect_kitty() { detect_kitty() {
kitty_bin= 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 for candidate in /Applications/kitty.app/Contents/MacOS/kitty "$HOME/.local/kitty.app/bin/kitty"; do
if [ -x "$candidate" ]; then if [ -x "$candidate" ]; then
kitty_bin=$candidate kitty_bin=$candidate
return 0 return 0
fi fi
done done
if command -v kitty >/dev/null 2>&1; then
kitty_bin=$(command -v kitty)
return 0
fi
return 1 return 1
} }
+15 -14
View File
@@ -5,7 +5,7 @@
* writes atomically with a lock directory, and keeps the file mode 600. * writes atomically with a lock directory, and keeps the file mode 600.
* *
* Usage: * 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 --unset tavily [--unset exa]
* node search-auth.mjs --migrate [search.env path] * node search-auth.mjs --migrate [search.env path]
* node search-auth.mjs --list * node search-auth.mjs --list
@@ -47,7 +47,7 @@ function die(message) {
function usage() { function usage() {
console.log(`用法: console.log(`用法:
node search-auth.mjs --set <provider>=<key> [--set ...] node search-auth.mjs --set-stdin <provider> (从标准输入读取 key,每次只能配置一个 provider)
node search-auth.mjs --unset <provider> [--unset ...] node search-auth.mjs --unset <provider> [--unset ...]
node search-auth.mjs --migrate [search.env 路径] node search-auth.mjs --migrate [search.env 路径]
node search-auth.mjs --list node search-auth.mjs --list
@@ -69,17 +69,16 @@ function defaultSearchEnvPath(env = process.env) {
} }
function parseArgs(argv) { 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++) { for (let i = 0; i < argv.length; i++) {
const arg = argv[i]; const arg = argv[i];
if (arg === "--set") { if (arg === "--set") {
const spec = argv[++i]; die("--set 已移除:key 不得出现在命令行参数中;请改用 --set-stdin 从标准输入提供。");
const separator = spec ? spec.indexOf("=") : -1; } else if (arg === "--set-stdin") {
if (separator < 1) die("--set 需要 <provider>=<key>"); const provider = argv[++i];
const provider = spec.slice(0, separator);
const value = spec.slice(separator + 1);
if (!PROVIDERS.includes(provider)) die(`未知 provider:${provider}(支持 ${PROVIDERS.join("/")})`); 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") { } else if (arg === "--unset") {
const provider = argv[++i]; const provider = argv[++i];
if (!PROVIDERS.includes(provider)) die(`未知 provider:${provider}(支持 ${PROVIDERS.join("/")})`); 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); 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) { if (mutations === 0) {
usage(); usage();
process.exit(2); process.exit(2);
} }
const stdinKey = args.setStdin ? readFileSync(0, "utf8").trim() : undefined;
if (args.setStdin) validateKey(args.setStdin, stdinKey);
const release = acquireLock(authPath); const release = acquireLock(authPath);
try { try {
const data = readAuthData(authPath); const data = readAuthData(authPath);
@@ -225,10 +227,9 @@ try {
changed.push(`已移除 ${provider}`); changed.push(`已移除 ${provider}`);
} }
} }
for (const [provider, key] of Object.entries(args.sets)) { if (args.setStdin) {
validateKey(provider, key); setKey(data, args.setStdin, stdinKey);
setKey(data, provider, key); changed.push(`已写入 ${args.setStdin}`);
changed.push(`已写入 ${provider}`);
} }
if (args.migrate) { if (args.migrate) {
const source = args.migrate === true ? defaultSearchEnvPath() : args.migrate; const source = args.migrate === true ? defaultSearchEnvPath() : args.migrate;
+15 -30
View File
@@ -13,10 +13,7 @@ import {
getToolSearchCatalogPath, getToolSearchCatalogPath,
getToolSearchConfigPath, getToolSearchConfigPath,
} from "../pi-tool-search/extensions/config.ts"; } from "../pi-tool-search/extensions/config.ts";
import { import { CONFIG_PATH as RTK_CONFIG_PATH } from "../pi-rtk-optimizer/src/constants.ts";
CONFIG_PATH as RTK_CONFIG_PATH,
resolveConfigPath as resolveRtkConfigPath,
} from "../pi-rtk-optimizer/src/constants.ts";
import { CONFIG_PATH as COMPACTION_CONFIG_PATH } from "../pi-compaction/src/config.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 { WORKTREE_CONFIG_PATH } from "../pi-worktree/src/settings.ts";
import { resolvePreamblePath } from "../extensions/system-prompt-config.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", () => { test("each extension resolves its config path inside the bundle config/ dir", () => {
const savedToolSearch = process.env.PI_TOOL_SEARCH_CONFIG_PATH; const inBundleConfig = (resolved: string, file: string) =>
const savedRtk = process.env.PI_RTK_CONFIG_PATH; assert.equal(
delete process.env.PI_TOOL_SEARCH_CONFIG_PATH; resolved,
delete process.env.PI_RTK_CONFIG_PATH; join(bundleConfigDir, file),
try { `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(getGlobalConfigPath(), "pi-permission-policy.jsonc");
inBundleConfig(getHardGuardConfigPath(), "pi-permission-hard-guards.jsonc"); inBundleConfig(getHardGuardConfigPath(), "pi-permission-hard-guards.jsonc");
inBundleConfig(getToolSearchConfigPath(), "tool-search.json"); inBundleConfig(getToolSearchConfigPath(), "tool-search.json");
inBundleConfig(RTK_CONFIG_PATH, "rtk-optimizer.json"); inBundleConfig(RTK_CONFIG_PATH, "rtk-optimizer.json");
inBundleConfig(resolveRtkConfigPath(), "rtk-optimizer.json"); inBundleConfig(COMPACTION_CONFIG_PATH, "pi-compaction.json");
inBundleConfig(COMPACTION_CONFIG_PATH, "pi-compaction.json"); inBundleConfig(WORKTREE_CONFIG_PATH, "pi-worktree.json");
inBundleConfig(WORKTREE_CONFIG_PATH, "pi-worktree.json"); inBundleConfig(HERMES_CONFIG_PATH, "pi-hermes-memory.json");
inBundleConfig(HERMES_CONFIG_PATH, "pi-hermes-memory.json"); inBundleConfig(resolvePreamblePath(), "prompt/preamble.md");
inBundleConfig(resolvePreamblePath(), "prompt/preamble.md");
} finally {
if (savedToolSearch === undefined) delete process.env.PI_TOOL_SEARCH_CONFIG_PATH;
else process.env.PI_TOOL_SEARCH_CONFIG_PATH = savedToolSearch;
if (savedRtk === undefined) delete process.env.PI_RTK_CONFIG_PATH;
else process.env.PI_RTK_CONFIG_PATH = savedRtk;
}
}); });
test("disposable runtime state stays under the agent-dir my-pi tree", () => { test("disposable runtime state stays under the agent-dir my-pi tree", () => {
+34
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { import {
execFileSync, execFileSync,
spawn, spawn,
@@ -13,6 +14,7 @@ import {
} from "node:fs/promises"; } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { PassThrough } from "node:stream";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import test from "node:test"; 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 ───────────────────────────────────────────────────────────────── // ── handler ─────────────────────────────────────────────────────────────────
test("indexed project explores through the serve child", async () => { test("indexed project explores through the serve child", async () => {
+33 -3
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { existsSync } from "node:fs"; 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 { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; 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 home = await mkdtemp(join(tmpdir(), "my-pi-extension-load-"));
const mockBin = join(home, "bin"); const mockBin = join(home, "bin");
await mkdir(mockBin, { recursive: true }); 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)); 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 = { const loadEnvironment = {
...process.env, ...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.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension|conflicts with/u); 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");
}); });
+70
View File
@@ -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 });
}
});
+13
View File
@@ -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", () => { test("relative import specifiers inside packed sources resolve to packed files", () => {
const specifierPatterns = [ const specifierPatterns = [
/\bfrom\s+["'](\.{1,2}\/[^"']+)["']/gu, /\bfrom\s+["'](\.{1,2}\/[^"']+)["']/gu,
+66 -12
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; 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 AUTH_MJS = new URL("../scripts/search-auth.mjs", import.meta.url).pathname;
const CONFIGURE_SH = new URL("../configure.sh", import.meta.url).pathname; const CONFIGURE_SH = new URL("../configure.sh", import.meta.url).pathname;
function runMjs(args: string[], env: Record<string, string> = {}) { function runMjs(args: string[], env: Record<string, string> = {}, input?: string) {
return spawnSync("node", [AUTH_MJS, ...args], { encoding: "utf8", env: { ...process.env, ...env } }); 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 dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json"); const authPath = join(dir, "auth.json");
await writeFile( await writeFile(
authPath, authPath,
JSON.stringify({ anthropic: { type: "oauth", access: "tok", refresh: "ref", expires: 42, accountId: "acc" } }), 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"]); const first = runMjs(["--auth", authPath, "--set-stdin", "tavily"], {}, "tvly-new\n");
assert.equal(result.status, 0, result.stderr); assert.equal(first.status, 0, first.stderr);
const second = runMjs(["--auth", authPath, "--set-stdin", "exa"], {}, " !op read x \n");
assert.equal(second.status, 0, second.stderr);
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>; const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
assert.deepEqual(written.anthropic, { type: "oauth", access: "tok", refresh: "ref", expires: 42, accountId: "acc" }); assert.deepEqual(written.anthropic, { type: "oauth", access: "tok", refresh: "ref", expires: 42, accountId: "acc" });
assert.deepEqual(written.tavily, { type: "api_key", key: "tvly-new" }); 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"); 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 dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json"); const authPath = join(dir, "auth.json");
await writeFile( await writeFile(
authPath, authPath,
JSON.stringify({ keenable: { type: "api_key", key: "old", env: { KEENABLE_API_URL: "https://k.example" } } }), 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); assert.equal(result.status, 0, result.stderr);
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>; const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
assert.deepEqual(written.keenable, { 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 () => { 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 dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const agentDir = join(dir, "agent"); 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); assert.equal(result.status, 0, result.stderr);
const written = JSON.parse(await readFile(join(agentDir, "auth.json"), "utf8")) as Record<string, any>; const written = JSON.parse(await readFile(join(agentDir, "auth.json"), "utf8")) as Record<string, any>;
assert.deepEqual(written.keenable, { type: "api_key", key: "keen-1" }); 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 dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json"); const authPath = join(dir, "auth.json");
await writeFile(authPath, "{oops"); 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.notEqual(result.status, 0);
assert.match(result.stderr, /不是合法 JSON|abort|clobber/i); assert.match(result.stderr, /不是合法 JSON|abort|clobber/i);
assert.equal(await readFile(authPath, "utf8"), "{oops"); 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/); 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 dir = await mkdtemp(join(tmpdir(), "my-pi-auth-write-"));
const authPath = join(dir, "auth.json"); 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", encoding: "utf8",
input: "tvly-sh\n",
}); });
assert.equal(result.status, 0, result.stderr); assert.equal(first.status, 0, first.stderr);
const second = spawnSync("sh", [CONFIGURE_SH, "search", "--auth", authPath, "--exa", "-"], {
encoding: "utf8",
input: "exa-sh",
});
assert.equal(second.status, 0, second.stderr);
const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>; const written = JSON.parse(await readFile(authPath, "utf8")) as Record<string, any>;
assert.deepEqual(written.tavily, { type: "api_key", key: "tvly-sh" }); assert.deepEqual(written.tavily, { type: "api_key", key: "tvly-sh" });
assert.deepEqual(written.exa, { type: "api_key", key: "exa-sh" }); assert.deepEqual(written.exa, { type: "api_key", key: "exa-sh" });
assert.equal((await stat(authPath)).mode & 0o777, 0o600); 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, /最多一个/);
});