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