chore: initialize my-pi extension bundle

This commit is contained in:
云服务部-叶林立
2026-08-18 12:30:48 +08:00
parent 7b7b332912
commit 0637139a16
62 changed files with 11055 additions and 1 deletions
+1 -1
View File
@@ -6,6 +6,7 @@
# Node artifact files
node_modules/
dist/
.pi/npm/
# Compiled Java class files
*.class
@@ -47,4 +48,3 @@ Thumbs.db
*.flv
*.mov
*.wmv
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+57
View File
@@ -0,0 +1,57 @@
# 仓库说明
本仓库用于维护可通过一次 `pi install` 部署的个人 Pi 扩展组合包。根 `package.json` 是唯一安装入口;依赖、默认行为和必要配置必须由组合包自身声明或部署,不把安装后的手工配置作为默认流程。每个自维护扩展使用独立的顶层目录,扩展源码、测试和说明应保留在各自目录内。
## 当前扩展
- `pi-rtk-optimizer/`:默认只负责非搜索工具输出压缩;RTK 命令改写为可选能力。
- 上游来源:<https://github.com/MasuRii/pi-rtk-optimizer>
- 初始导入快照:`d155d253cb2f1358e34e717d47a82ebccb08cb8e`2026-07-03`0.9.0`)。
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git`
- `extensions/fff-override.ts`:强制使用 FFF 官方 `override` 模式,统一接管 `find``grep``multi_grep` 和 FFF 的 `@` 补全;显式 CLI flag 仍遵循 FFF 官方优先级。
- `extensions/permission-system.ts`:在权限扩展注册前,将 `config/pi-permission-system.json` 同步为全局权威配置。
- 根包还固定安装 `pi-context-view``@firstpick/pi-extension-codex-fast-mode``@gotgenes/pi-permission-system`
## 当前职责与默认行为
- FFF 独占字面搜索。RTK 不得处理 `grep``find``multi_grep` 的调用或结果,也不得通过默认命令改写接管 `rg``grep``find``fd`
- RTK 默认只压缩非搜索输出,包括 Bash ANSI 清理、测试聚合、构建过滤、Git 压缩和 Lint 聚合,并记录压缩统计。
- `commandRewritingEnabled` 默认 `false`。默认安装不依赖系统 `rtk` CLI;只有用户主动开启命令改写时才需要 `rtk rewrite`
- `readCompaction.enabled``sourceCodeFilteringEnabled``smartTruncate.enabled` 当前均默认 `false`,源码读取保持原样。未经用户明确决定,不因节省上下文而改变这些默认值。
- 若以后开启 read 压缩,优先考虑 `readCompaction + smartTruncate`,源码过滤仍独立评估;必须保留精确 `offset/limit` 读取、短文件和行锚点的完整性。
- `pi-context-view` 只观察上下文占用,不参与压缩策略。
- Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier,由 `/fast-mode` 在会话内控制。
- 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜,敏感凭据路径拒绝,外部目录、Git 写操作、破坏性系统操作、网络和安装操作询问。
## 修改边界
- 优先在目标扩展目录内完成改动;不要让一个扩展依赖另一个扩展的未公开内部实现。
- 保留原项目的 `LICENSE`、版权信息和必要的来源说明。
- 扩展运行目录中的 `config.json`、日志、构建产物、覆盖率目录和依赖目录属于本地状态,不应提交;`config/pi-permission-system.json` 是组合包的权威源配置,必须提交并维护。
- 外部 Pi 扩展依赖必须在根 `package.json` 中使用精确版本,并更新根 `package-lock.json`;不要用仓库级 `.pi/settings.json` 代替组合包依赖。
- Pi 核心包只作为宿主 peer dependencies,不得在组合包内再安装或打包一套 Pi runtime;保留根 `.npmrc` 的 peer 安装策略。
- 上游仅作为参考来源。同步上游改动时先核对本仓库已有修改,再按明确范围移植;不要直接覆盖本地实现。
- 未经明确要求,不执行发布、提交、推送或安装到用户 Pi 运行目录等外部写操作。
## `pi-rtk-optimizer` 开发约定
- 组合包运行环境为 Node.js 22.19 或更高版本;RTK 子目录开发验证还需要 npm、Bun 和项目声明的开发依赖。
- 扩展入口是 `pi-rtk-optimizer/index.ts`,主要实现位于 `pi-rtk-optimizer/src/`
- RTK 命令改写默认关闭;若用户主动开启,其命令支持策略由已安装的 `rtk rewrite` 决定,不在扩展内重复维护规则。
- FFF override 独占搜索工具与搜索输出;RTK 不得处理 `grep` 工具结果。
- read 压缩属于有损能力。任何默认值调整都必须同时说明哪些内容可能被省略、精确读取如何恢复原文,以及对 `edit` 文本匹配和排障证据的影响。
- 修改配置结构时同步检查默认值、归一化逻辑、设置界面、类型定义、README 示例和相关测试。
- 工具输出压缩可能损失证据。排障和审计相关改动应优先保证原始输出可恢复,并覆盖锚点完整性和截断边界。
## 验证
组合包依赖或加载入口变化时,至少验证根 `npm install` 幂等、锁文件有效,以及全部扩展可在隔离的临时 Pi agent 目录加载。权限配置变化时使用当前固定版本的 `pi-permission-system` schema 校验,并验证包装入口部署后的文件与仓库源配置一致。
`pi-rtk-optimizer/` 内按改动范围选择最小充分验证:
- `npm run build`TypeScript 转译检查。
- `npm run typecheck`:完整类型检查。
- `npm run test`:运行 Bun 测试。
- `npm run check`:类型、测试和打包检查的完整验证。
若环境缺少依赖或未执行某项验证,交付时明确说明,不以静态检查代替运行结果。
+64
View File
@@ -0,0 +1,64 @@
# my-pi
个人维护的 Pi 扩展组合包。安装一次即可加载并配置:
- `@ff-labs/pi-fff@0.10.5`:使用官方 `override` 模式接管 `find``grep``multi_grep`
- 本仓库维护的 `pi-rtk-optimizer`:只处理 Bash/read 等输出压缩,默认不改写命令,也不处理搜索工具结果。
- `pi-context-view@0.4.2`:查看上下文占用。
- `@firstpick/pi-extension-codex-fast-mode@0.1.1`:为 Codex provider 提供会话级 `/fast-mode`
- `@gotgenes/pi-permission-system@26.2.1`:加载组合包自带的权限基线。
## 安装
仓库推送后,通过 Git 源安装整个组合包:
```bash
pi install git:git@bitbucket.org:siakitem/my-pi.git
```
`package.json` 使用精确版本,`package-lock.json` 固定完整依赖树。不要再用项目级
`.pi/settings.json` 重复安装这些扩展,否则同一扩展可能被加载两次。
默认配置不需要系统 `rtk` CLI。只有以后在 `/rtk` 中主动开启
`RTK command rewriting` 时,才需要另外安装 `rtk` 可执行文件。
## 组合行为
### 搜索归 FFF
`extensions/fff-override.ts` 强制设置 FFF 官方的 `PI_FFF_MODE=override`(显式传入
`--fff-mode` 仍按 FFF 官方优先级覆盖它)。FFF 替换
Pi 的 `find``grep`,并提供 `multi_grep`RTK 对所有 `grep` 工具结果直接跳过,
也默认关闭 Bash 命令改写,因此不会把 `rg``grep``find``fd` 改写到 RTK。
### 输出压缩归 RTK
RTK 保留 Bash、read、build、test、lint 和 Git 输出压缩。默认 `read` 的有损压缩
仍关闭;可用 `/rtk` 查看或调整。旧机器已有的 RTK 配置若没有
`commandRewritingEnabled` 字段,也会按 `false` 归一化,不需要迁移配置。
### 权限基线自动部署
`extensions/permission-system.ts` 在权限扩展注册前,把仓库中的
`config/pi-permission-system.json` 同步到 Pi agent 目录。仓库文件是权威配置;直接在
运行目录通过 UI 修改的策略会在下次加载组合包时被覆盖,长期调整应提交到本仓库。
当前策略按类别划分:
- 默认工具:默认允许,避免常规编码操作反复确认;FFF 的 `grep``find`
`multi_grep` 明确允许。
- 搜索边界:直接通过 Bash 调用 `rg``grep``find``fd``git grep` 拒绝,统一走 FFF。
- 路径与凭据:外部目录询问;环境文件、SSH/GPG、云凭据、Keychain 和 Pi 认证文件拒绝;
`.env.example` 作为无密钥模板允许。
- Git:状态、diff、日志、对象查看等只读命令允许;其他 Git 命令询问。
- 系统与破坏性操作:删除、移动、权限修改、提权、进程控制、磁盘和系统服务操作询问。
- 网络与安装:SSH、文件传输、下载和包安装/卸载询问。
- MCP 与 Skill:列举 MCP 状态允许,调用其他 MCP 工具询问;本地 Skill 加载允许。
`permissionReviewLog` 已开启,后续可依据真实命中记录继续收敛规则。
### Codex fast mode
该扩展只在 `openai-codex` provider 的 Responses 请求上加入
`service_tier: "priority"`。使用 `/fast-mode on|off|status` 控制当前会话,不影响
其他 provider。
+107
View File
@@ -0,0 +1,107 @@
{
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-packages/main/packages/pi-permission-system/schemas/permissions.schema.json",
"debugLog": false,
"permissionReviewLog": true,
"yoloMode": false,
"doublePressToConfirm": true,
"permission": {
"*": "allow",
"grep": "allow",
"find": "allow",
"multi_grep": "allow",
"external_directory": {
"*": "ask"
},
"path": {
"*": "allow",
".git/*": "ask",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow",
"~/.ssh": "deny",
"~/.ssh/*": "deny",
"~/.gnupg": "deny",
"~/.gnupg/*": "deny",
"~/.aws": "deny",
"~/.aws/*": "deny",
"~/.kube": "deny",
"~/.kube/*": "deny",
"~/.docker/config.json": "deny",
"~/.config/gh/hosts.yml": "deny",
"~/.netrc": "deny",
"~/.npmrc": "deny",
"~/.pypirc": "deny",
"~/Library/Keychains": "deny",
"~/Library/Keychains/*": "deny",
"~/.pi/agent/auth.json": "deny",
"~/.pi/agent/models.json": "deny",
"~/.pi/agent/models-store.json": "deny"
},
"bash": {
"*": "allow",
"rg*": "deny",
"grep*": "deny",
"find*": "deny",
"fd*": "deny",
"git *": "ask",
"git status*": "allow",
"git diff*": "allow",
"git log*": "allow",
"git show*": "allow",
"git rev-parse*": "allow",
"git ls-files*": "allow",
"git grep*": "deny",
"git branch": "allow",
"git branch --show-current*": "allow",
"git remote -v": "allow",
"git remote get-url*": "allow",
"rm *": "ask",
"/bin/rm *": "ask",
"rmdir *": "ask",
"/bin/rmdir *": "ask",
"unlink *": "ask",
"/usr/bin/unlink *": "ask",
"mv *": "ask",
"/bin/mv *": "ask",
"truncate *": "ask",
"dd *": "ask",
"chmod *": "ask",
"chown *": "ask",
"sudo *": "ask",
"su *": "ask",
"kill *": "ask",
"pkill *": "ask",
"diskutil *": "ask",
"security *": "ask",
"launchctl *": "ask",
"osascript *": "ask",
"open *": "ask",
"ssh *": "ask",
"scp *": "ask",
"sftp *": "ask",
"rsync *": "ask",
"curl *": "ask",
"wget *": "ask",
"npm install*": "ask",
"npm uninstall*": "ask",
"npm update*": "ask",
"pnpm add*": "ask",
"pnpm install*": "ask",
"bun add*": "ask",
"bun install*": "ask",
"pip install*": "ask",
"pip3 install*": "ask",
"brew install*": "ask",
"brew uninstall*": "ask",
"printenv*": "ask"
},
"mcp": {
"*": "ask",
"mcp_status": "allow",
"mcp_list": "allow"
},
"skill": {
"*": "allow"
}
}
}
+8
View File
@@ -0,0 +1,8 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import fffExtension from "../node_modules/@ff-labs/pi-fff/src/index.ts";
/** Load FFF as the bundle's canonical `find` and `grep` implementation. */
export default function fffOverrideExtension(pi: ExtensionAPI): void {
process.env.PI_FFF_MODE = "override";
fffExtension(pi);
}
+31
View File
@@ -0,0 +1,31 @@
import permissionSystemExtension from "../node_modules/@gotgenes/pi-permission-system/src/index.ts";
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const BUNDLE_CONFIG_PATH = fileURLToPath(new URL("../config/pi-permission-system.json", import.meta.url));
function deployBundlePermissionConfig(): void {
const targetPath = join(getAgentDir(), "extensions", "pi-permission-system", "config.json");
const bundledConfig = readFileSync(BUNDLE_CONFIG_PATH, "utf8");
try {
if (readFileSync(targetPath, "utf8") === bundledConfig) {
return;
}
} catch {
// Missing or unreadable target: replace it with the bundle-owned baseline.
}
mkdirSync(dirname(targetPath), { recursive: true });
const temporaryPath = `${targetPath}.my-pi.tmp`;
writeFileSync(temporaryPath, bundledConfig, "utf8");
renameSync(temporaryPath, targetPath);
}
/** Deploy the bundle-owned policy before registering permission hooks. */
export default function bundledPermissionSystemExtension(pi: ExtensionAPI): void {
deployBundlePermissionConfig();
permissionSystemExtension(pi);
}
+549
View File
@@ -0,0 +1,549 @@
{
"name": "my-pi",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "my-pi",
"version": "0.1.0",
"dependencies": {
"@ff-labs/pi-fff": "0.10.5",
"@firstpick/pi-extension-codex-fast-mode": "0.1.1",
"@gotgenes/pi-permission-system": "26.2.1",
"pi-context-view": "0.4.2"
},
"engines": {
"node": ">=22.19.0"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"@sinclair/typebox": "*"
},
"peerDependenciesMeta": {
"@earendil-works/pi-coding-agent": {
"optional": true
},
"@earendil-works/pi-tui": {
"optional": true
},
"@sinclair/typebox": {
"optional": true
}
}
},
"node_modules/@ff-labs/fff-bin-android-arm64": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-android-arm64/-/fff-bin-android-arm64-0.10.5.tgz",
"integrity": "sha512-idn1m9ycwv0Dzz6ih4H8e/X1WpGF6kglUxqP78oYgoC2sgAdPtLxD6hIAZK13p8isK7H8EMwrMze/4lynEDcKQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@ff-labs/fff-bin-darwin-arm64": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-arm64/-/fff-bin-darwin-arm64-0.10.5.tgz",
"integrity": "sha512-tG0VSt6fs3C2/Y6NDp57YJVXGEnrJ4F/e5gDrFCbNeuwJe9LmcCKh6oHWUEZOaaymYT90kQmwuXZTvORP3G6Vw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@ff-labs/fff-bin-darwin-x64": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-x64/-/fff-bin-darwin-x64-0.10.5.tgz",
"integrity": "sha512-pwdFJUB/oeO4S/SfmRDdE5ipbAMYoVtRMwTZ12Wn2H46PgNU5DWnSjHhUNTLuKmkQU/ZIJL5Sn9j/fzwuIzcoA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@ff-labs/fff-bin-linux-arm64-gnu": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-gnu/-/fff-bin-linux-arm64-gnu-0.10.5.tgz",
"integrity": "sha512-prbY72gR+VPoSxl1InglwSwVHM0kErYMjUOpdvjQhS8eRdXYL3hTuYQv9L7fiB0mqaDzcwFa339uMr3p8ujDrw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-linux-arm64-musl": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-musl/-/fff-bin-linux-arm64-musl-0.10.5.tgz",
"integrity": "sha512-MQN/CEKn4c4toooGCmZ6N8cP9mhgE4tnR6rr6a4k9JjH0xi05fYdG4+BabPkr+l/mDc01DsRpSKVNBmvoZ3IlQ==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-linux-x64-gnu": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-gnu/-/fff-bin-linux-x64-gnu-0.10.5.tgz",
"integrity": "sha512-8NzFGGyqrFaCtb+SPTFd0xQGUlHCJMf0KYGwuca46IP37nN4OtDT1wnyZ9oaPDZ+2qqyxox10hCnqGlmj3PJzQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-linux-x64-musl": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-musl/-/fff-bin-linux-x64-musl-0.10.5.tgz",
"integrity": "sha512-mioRbn8aAW2Jmm4W0w3poePo65iSE+REfO+Jc0iqsjirImEy6pKt7dswj+7LSU6RPWwPsMb4POTMENwNLMlJww==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-win32-arm64": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-arm64/-/fff-bin-win32-arm64-0.10.5.tgz",
"integrity": "sha512-GIZiea0AhuUDtJtXO2mGWs3wil1OR6lpLKOqXMW8EhHVHs8SB+xwzOgZ3Hg7YLEJm+Ui/2tFW/NLbqRuxpmtrQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@ff-labs/fff-bin-win32-x64": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-x64/-/fff-bin-win32-x64-0.10.5.tgz",
"integrity": "sha512-EvCWb7wLrtCvkziksAnTVxcGa0roaWNC978t4nG3ESKEty/Mvrj8kaLAzqWfuA2Xk+gTW+qisJYxa4FqFnqR0Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@ff-labs/fff-bun": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bun/-/fff-bun-0.10.5.tgz",
"integrity": "sha512-n4t5e4mzbEAXO6SyPHx8ajECGvfTZ/y4TNCqvKSwROpcTqfobaq2T+MdsfsKyA+j+B11NmpCVLjVhCTel8iB8Q==",
"cpu": [
"x64",
"arm64"
],
"license": "MIT",
"os": [
"darwin",
"linux",
"win32",
"android"
],
"engines": {
"bun": ">=1.0.0"
},
"optionalDependencies": {
"@ff-labs/fff-bin-android-arm64": "0.10.5",
"@ff-labs/fff-bin-darwin-arm64": "0.10.5",
"@ff-labs/fff-bin-darwin-x64": "0.10.5",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.5",
"@ff-labs/fff-bin-linux-arm64-musl": "0.10.5",
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.5",
"@ff-labs/fff-bin-linux-x64-musl": "0.10.5",
"@ff-labs/fff-bin-win32-arm64": "0.10.5",
"@ff-labs/fff-bin-win32-x64": "0.10.5"
}
},
"node_modules/@ff-labs/fff-node": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-node/-/fff-node-0.10.5.tgz",
"integrity": "sha512-O4KZoO9lZluTqdHUYgd23xpEX/u7G87qQTgUIgtJ4BFTh1x/p243/M6AU2HdSHYWK+vrMRAdDXDzeUW/9rjk3Q==",
"cpu": [
"x64",
"arm64"
],
"license": "MIT",
"os": [
"darwin",
"linux",
"win32",
"android"
],
"dependencies": {
"ffi-rs": "^1.0.0"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@ff-labs/fff-bin-android-arm64": "0.10.5",
"@ff-labs/fff-bin-darwin-arm64": "0.10.5",
"@ff-labs/fff-bin-darwin-x64": "0.10.5",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.5",
"@ff-labs/fff-bin-linux-arm64-musl": "0.10.5",
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.5",
"@ff-labs/fff-bin-linux-x64-musl": "0.10.5",
"@ff-labs/fff-bin-win32-arm64": "0.10.5",
"@ff-labs/fff-bin-win32-x64": "0.10.5"
}
},
"node_modules/@ff-labs/pi-fff": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@ff-labs/pi-fff/-/pi-fff-0.10.5.tgz",
"integrity": "sha512-0Uli9Nb5josAf0TsQlyJZvi/syVye/3TRQf/LEUuJaA5J8YnObko0BlN5BHSguuP+wQUEEm8jIFvOzKx5X5Yxw==",
"license": "MIT",
"dependencies": {
"@ff-labs/fff-bun": "0.10.5",
"@ff-labs/fff-node": "0.10.5"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"@sinclair/typebox": "*"
}
},
"node_modules/@firstpick/pi-extension-codex-fast-mode": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@firstpick/pi-extension-codex-fast-mode/-/pi-extension-codex-fast-mode-0.1.1.tgz",
"integrity": "sha512-P/+chadW+mDDwKgy6v3lvhtJVwFKuDEmROnk4ei2oJBkZJbjgNSVGV2ZgEaRoOACmbmvwaaXPnG5WId5kgTPnA==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
}
},
"node_modules/@gotgenes/pi-permission-system": {
"version": "26.2.1",
"resolved": "https://registry.npmjs.org/@gotgenes/pi-permission-system/-/pi-permission-system-26.2.1.tgz",
"integrity": "sha512-ivYhyExHZ/Y8E4mG3ROUSRD9aB9GZvehU+lSkBj2ZjGmzdny7aCGBebXL0CeP1kbTFjIBm1m/Pm9nXLXbRccaQ==",
"license": "MIT",
"dependencies": {
"tree-sitter-bash": "^0.25.1",
"web-tree-sitter": "^0.26.9",
"zod": "^4.4.3"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": ">=0.79.0",
"@earendil-works/pi-tui": ">=0.79.0"
}
},
"node_modules/@yuuang/ffi-rs-android-arm64": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-android-arm64/-/ffi-rs-android-arm64-1.3.7.tgz",
"integrity": "sha512-t6Wx3Xll6c07Nuk0k3xnZsxKFxlshm92i0U/BiTHc6kQbvu+fMJF+gKsj4yEj886jH51CM3EqZT9Xdhq9CdUVw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-darwin-arm64": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-arm64/-/ffi-rs-darwin-arm64-1.3.7.tgz",
"integrity": "sha512-OueBlUFBT9IwD9pQnoYs0UszRBEySskfrEPXXfvKfGjL/DXnfn6kUheQ3oIP6sSmshVGNQUwrTCPo6feAa4QjA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-darwin-x64": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-x64/-/ffi-rs-darwin-x64-1.3.7.tgz",
"integrity": "sha512-x4mxXOKwSgwYx6OBAKrW+Ocp8um2KoPpUwaD0Z2tmR6EekoRvFGN7s/eEK1avqCxcsLN7/MzGOnCCFyGpOVfPA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm-gnueabihf": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm-gnueabihf/-/ffi-rs-linux-arm-gnueabihf-1.3.7.tgz",
"integrity": "sha512-9r//Z022QYVoIAY458Dpk8HWkisk/y4NJC3d52RlkdsLD+9p0zn0cKKSoMF/xUsOWDFSmOf+cwU/Xadi/wxFMQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm64-gnu": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-gnu/-/ffi-rs-linux-arm64-gnu-1.3.7.tgz",
"integrity": "sha512-nrW4MlFlyQInfxTsI5wtcIpbV4KlHLWo2BCk4CHIETlUT+PUspNFqDg+Byy7My3hpCZlXp0WsMVP/3N0LlybNw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm64-musl": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-musl/-/ffi-rs-linux-arm64-musl-1.3.7.tgz",
"integrity": "sha512-eLLx4P8DzNnuPif19nAvKPaf88lSf6KFbDetH3nAIRZvI495h0jigN835Ayr7uAADssohGfO9MPMH33DXmFErA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-x64-gnu": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-gnu/-/ffi-rs-linux-x64-gnu-1.3.7.tgz",
"integrity": "sha512-upEz1Q98T51x2In872fsjgHCJbE3e8r9JxhJD+5NvSMk6BpieYWRodTbsMxV6oKWmfl12loKwF2j4OBSWKl0lA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-x64-musl": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-musl/-/ffi-rs-linux-x64-musl-1.3.7.tgz",
"integrity": "sha512-YOcOkIwVvIhpwuGJ428h15vpE0KnT3teFG0g2J31FrBxrVh9IH4HeTGXjelI3l9SI5gyiZDnD4PQnQ1U6oqTcA==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-arm64-msvc": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-arm64-msvc/-/ffi-rs-win32-arm64-msvc-1.3.7.tgz",
"integrity": "sha512-ZfYzLV1w7Mhzh9XRScEMpw9oazIEiwO+0+TYHHRL882d5oD0Q99YikmRhdifsoHCCQefKmWF/4BXCeFkzg8NFA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-ia32-msvc": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-ia32-msvc/-/ffi-rs-win32-ia32-msvc-1.3.7.tgz",
"integrity": "sha512-ifYz+f+giKpT39lKWDru+om6QnA9J7J953ns60xHgB46ESZvPd/Y2W5+PesRNC9U+vnFEiLZ9CNC+R3fBWC1pg==",
"cpu": [
"x64",
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-x64-msvc": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-x64-msvc/-/ffi-rs-win32-x64-msvc-1.3.7.tgz",
"integrity": "sha512-H3s4wOLtZtKtmuPjUNE+bhnSKxxpXcuxG70jRC8dUMHqIm5iCvdoZ4XYDeq7/uUYlySerXFmRL4dAVCX/TUNTg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/ffi-rs": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/ffi-rs/-/ffi-rs-1.3.7.tgz",
"integrity": "sha512-5MMZQS2t/6f/ec3sx0f51gBcKGZPGfolPAW04lvLOSYnr7gmOKs8YzH1Rh+34WGO+VtcgcTuxaUUoJLcR4Tirg==",
"license": "MIT",
"optionalDependencies": {
"@yuuang/ffi-rs-android-arm64": "1.3.7",
"@yuuang/ffi-rs-darwin-arm64": "1.3.7",
"@yuuang/ffi-rs-darwin-x64": "1.3.7",
"@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.7",
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.7",
"@yuuang/ffi-rs-linux-arm64-musl": "1.3.7",
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.7",
"@yuuang/ffi-rs-linux-x64-musl": "1.3.7",
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.7",
"@yuuang/ffi-rs-win32-ia32-msvc": "1.3.7",
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.7"
}
},
"node_modules/node-addon-api": {
"version": "8.9.2",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/pi-context-view": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/pi-context-view/-/pi-context-view-0.4.2.tgz",
"integrity": "sha512-ZD3LxB2Dn7dCDUBOijfLLbTgAN+xkNQWMP2Qoib/MxjkprX9Jd2TFt3pHyzpcCAeWocCHc09Ru3Bmj8QVZgYTQ==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*"
}
},
"node_modules/tree-sitter-bash": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/tree-sitter-bash/-/tree-sitter-bash-0.25.1.tgz",
"integrity": "sha512-7hMytuYIMoXOq24yRulgIxthE9YmggZIOHCyPTTuJcu6EU54tYD+4G39cUb28kxC6jMf/AbPfWGLQtgPTdh3xw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.2.1",
"node-gyp-build": "^4.8.2"
},
"peerDependencies": {
"tree-sitter": "^0.25.0"
},
"peerDependenciesMeta": {
"tree-sitter": {
"optional": true
}
}
},
"node_modules/web-tree-sitter": {
"version": "0.26.12",
"resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.26.12.tgz",
"integrity": "sha512-fvqTNZQBGUgUgfP0mHw+iHf9Yf6bRQrp0A3pSf2v/hSKxkT1beCoIWoLVmlPL7O6dmySfSb/t1aJoJvrgTRStw==",
"license": "MIT"
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
{
"name": "my-pi",
"version": "0.1.0",
"private": true,
"description": "Personal Pi extension bundle.",
"type": "module",
"keywords": [
"pi-package",
"pi-extension"
],
"files": [
"extensions",
"pi-rtk-optimizer",
"config",
"README.md",
"AGENTS.md"
],
"pi": {
"extensions": [
"./extensions/fff-override.ts",
"./pi-rtk-optimizer/index.ts",
"./node_modules/pi-context-view/src/index.ts",
"./node_modules/@firstpick/pi-extension-codex-fast-mode/index.ts",
"./extensions/permission-system.ts"
]
},
"dependencies": {
"@ff-labs/pi-fff": "0.10.5",
"@firstpick/pi-extension-codex-fast-mode": "0.1.1",
"@gotgenes/pi-permission-system": "26.2.1",
"pi-context-view": "0.4.2"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"@sinclair/typebox": "*"
},
"peerDependenciesMeta": {
"@earendil-works/pi-coding-agent": {
"optional": true
},
"@earendil-works/pi-tui": {
"optional": true
},
"@sinclair/typebox": {
"optional": true
}
},
"engines": {
"node": ">=22.19.0"
}
}
+9
View File
@@ -0,0 +1,9 @@
node_modules/
*.log
.DS_Store
dist/
coverage/
config.json
*.tmp
.pi-rtk-optimizer-check.mjs
owned
+8
View File
@@ -0,0 +1,8 @@
node_modules
config.json
*.log
.git
.gitignore
.npmignore
tsconfig.json
.pi-rtk-optimizer-check.mjs
+224
View File
@@ -0,0 +1,224 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.9.0] - 2026-07-03
### Changed
- Extracted a lazy module loader, shell-quote state machine, compaction state, and content-block helpers. ([4229513](https://github.com/MasuRii/pi-rtk-optimizer/commit/422951343759b47e81443273731469481723365f) [8c39b94](https://github.com/MasuRii/pi-rtk-optimizer/commit/8c39b94965879aef5c39ab74d19c6ff20f437e02))
- Consolidated the config store and extracted border-line rendering in the Zellij modal. ([ccdee3d](https://github.com/MasuRii/pi-rtk-optimizer/commit/ccdee3df3a36246941bace9b138dc93e38a6363b) [762ee6a](https://github.com/MasuRii/pi-rtk-optimizer/commit/762ee6a9ca1e68fc4f705532d87c4130a337985c))
- Renamed inline test files to the `.test.ts` convention. ([25ec5eb](https://github.com/MasuRii/pi-rtk-optimizer/commit/25ec5eb361d9d22404f08f9d6186677fdcbd2c74))
- Added the owned extension directory to `.gitignore`. ([af2e851](https://github.com/MasuRii/pi-rtk-optimizer/commit/af2e85173379fb46e3a2c4ada23bec9de0b47aa0))
- Updated README with badges, a Ko-fi link, and a refreshed file tree. ([e70ca70](https://github.com/MasuRii/pi-rtk-optimizer/commit/e70ca70de7925cc56a02c6d7c43a2065b2ebc74f))
- Widened Pi peer dependency compatibility to include `^0.80.0` and added vulnerability overrides (`protobufjs`, `ws`). ([92137b2](https://github.com/MasuRii/pi-rtk-optimizer/commit/92137b2689988c64922478176ff60396e35efff2))
### Fixed
- Ignored string and comment braces in source filtering. ([85fbd28](https://github.com/MasuRii/pi-rtk-optimizer/commit/85fbd28edceb780d254ea0dc6cb2b31c6f572b37))
- Counted Unicode pass/fail symbols in the test-output fallback. ([b59b18d](https://github.com/MasuRii/pi-rtk-optimizer/commit/b59b18d26d83a016cc2fd04ffab8e80b50540f54))
### Removed
- Removed the emoji and rtk-hook-warning techniques. ([8f07417](https://github.com/MasuRii/pi-rtk-optimizer/commit/8f07417d1d539fd62cb97cd4e30431a45f819642))
- Removed the unused ripgrep rewrite. ([ccdee3d](https://github.com/MasuRii/pi-rtk-optimizer/commit/ccdee3df3a36246941bace9b138dc93e38a6363b))
## [0.8.3] - 2026-06-16
### Fixed
- Added a runtime-agnostic `mock.module` shim so tests using `node:test` module mocking also pass under Bun's `bun:test` compatibility layer.
- Deep-cloned fallback default config objects in `config-store.ts` to prevent caller mutations from leaking into subsequent config loads.
## [0.8.2] - 2026-06-01
### Changed
- Deferred output compactor and configuration modal loading during extension bootstrap.
- Replaced technique barrel imports with direct module imports.
- Kept inline test entrypoints on Bun while using a runtime-agnostic test helper for compatibility.
- Widened Pi peer dependency ranges to include `^0.77.0 || ^0.78.0`.
## [0.8.1] - 2026-05-26
### Changed
- Widened `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` peer dependency ranges to `^0.74.0 || ^0.75.0`.
## [0.8.0] - 2026-05-22
### Added
- Added tabbed `/rtk` settings modal groups with left/right tab navigation and context-aware help for search and value changes.
- Added anchor-safe `read` compaction that detects hashline/anchored read output and preserves complete edit anchors during source filtering, smart truncation, and hard truncation.
### Changed
- Updated package metadata and lockfile version to `0.8.0` and migrated Pi peer dependency metadata to the `@earendil-works` scope.
## [0.7.1] - 2026-05-04
### Changed
- Clarified the README architecture inventory for delegated `rtk rewrite` ownership and documented Bun as a development verification prerequisite.
- Pinned TypeScript and esbuild as dev dependencies so build and bundle checks use locked local tooling.
- Added RTK executable path visibility to runtime verification output and documented audit/debug config expectations.
### Fixed
- Hardened `RTK_DB_PATH` shell quoting against inherited temp paths containing command-substitution syntax.
- Made the custom test helper await async tests before reporting pass.
- Preserved RTK rewrite error details through the extension's existing UI warning path.
- Expanded Windows command and rewritten-pipeline fixups for leading compound-command cases.
- Normalized compaction technique return handling while preserving existing output behavior.
- Added lifecycle and vendored modal regression coverage for high-risk extension event paths.
## [0.7.0] - 2026-04-30
### Added
- Added opt-in `readCompaction` controls for `read` output so lossy source filtering and smart truncation stay disabled unless explicitly enabled.
### Changed
- Updated README and example configuration defaults for safer read-compaction behavior and troubleshooting guidance.
- Updated `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` peer dependencies to ^0.72.0.
## [0.6.0] - 2026-04-27
### Changed
- **Breaking:** Command rewriting now delegates rewrite decisions to the installed `rtk rewrite` command, making RTK the source of truth for command support, shell parsing, bypasses, and compound-command behavior instead of the extension's local rewrite rule tables.
### Removed
- **Breaking:** Removed the rewrite category configuration surface (`rewriteGitGithub`, `rewriteFilesystem`, `rewriteRust`, `rewriteJavaScript`, `rewritePython`, `rewriteGo`, `rewriteContainers`, `rewriteNetwork`, and `rewritePackageManagers`) from configuration normalization, examples, settings UI, and documentation. Configure rewrite policy in RTK itself instead of this extension.
## [0.5.5] - 2026-04-24
### Changed
- Config path resolution now uses Pi's `getAgentDir()` API so `PI_CODING_AGENT_DIR` is respected for extension config paths (thanks to @tynanbe for PR #3).
- Global skill-read preservation paths now resolve through Pi's agent directory so `PI_CODING_AGENT_DIR` is respected (thanks to @tynanbe for PR #3).
- Source-filter troubleshooting note injection now only runs when output compaction, source filtering, and read truncation safeguards are active (thanks to @philipbjorge for PR #4).
- Updated `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` peer dependencies to ^0.70.0.
- Clarified README and settings modal copy for global extension/config paths, skill directory paths, source-filter note behavior, architecture, and event hooks.
### Removed
- Removed the unused local asset directory.
- Removed the `session_switch` event refresh handler.
## [0.5.3] - 2026-04-01
### Changed
- Updated README.md with new background image source URL
- Aligned npm keywords for better package discoverability
- Added Related Pi Extensions cross-linking section to README
## [0.5.2] - 2026-04-01
### Changed
- Updated `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` peer dependencies to ^0.64.0
- Improved RTK note message to guide users through '/rtk' toggle in Pi TUI
## [0.5.1] - 2026-03-24
### Fixed
- RTK_DB_PATH environment variable now correctly scoped to rewritten producer commands only — Windows commands now use subshell scoping `{ RTK_DB_PATH=...; ... }` instead of leaking the prefix into the rewritten command
- Command rewrite pipeline now applies environment scoping BEFORE shell safety fixups to prevent env prefix stripping
### Added
- `shell-env-prefix.ts` module for splitting leading environment variable assignments from commands
- `splitLeadingEnvAssignments()` function to properly extract `ENV=value` prefixes before command analysis
### Changed
- Refactored `rtk-command-environment.ts` to use the new `splitLeadingEnvAssignments` utility
- Refactored `rewrite-pipeline-safety.ts` to preserve env prefixes when analyzing and rewriting rtk commands
### Tests
- Added test coverage for RTK_DB_PATH scoping on Windows vs Unix platforms
- Verified env prefix is preserved through the rewrite pipeline
## [0.5.0] - 2026-03-23
### Added
- RTK_DB_PATH environment variable support for rewritten commands — enables RTK history database isolation per session
- Tool execution sanitizer to strip RTK self-diagnostics from streamed bash results before TUI rendering
- Tracking of active bash commands by tool call ID for output sanitization
- `rtk-command-environment.ts` module for platform-specific temp directory resolution and shell-safe quoting
### Changed
- Updated `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` peer dependencies to ^0.62.0
- Simplified RTK hook warning detection — removed unused command-specific patterns and consolidated detection logic
- Focus on canonical hook warning messages that RTK emits
- Updated tests to verify simplified behavior and ensure non-hook RTK output is preserved verbatim
### Tests
- Added additional coverage tests for edge cases
- Added tests for output compactor behavior with RTK diagnostics
- Added tests for emoji stripping in RTK output
## [0.4.0] - 2026-03-12
### Added
- Command rewrite bypass system with safety patterns for dangerous operations
- `shouldBypassWholeCommandRewrite` to prevent rewriting of unsafe compound commands
- Bypass patterns for `find`, `grep`, `rg`, `ls` with action detection
- Inline command flag detection for `bash`, `powershell`, and `cmd` shells
- `path-utils` module for cross-technique path handling
- Comprehensive test coverage with shared test helpers
- Additional coverage tests for edge cases
### Changed
- Extended `rewrite-bypass` with bypass patterns for interactive container shells
- Improved command rewriter test coverage
- Removed deprecated `compat-commands` module
## [0.3.3] - 2026-03-07
### Added
- Added rewrite bypass rules for structured `gh` output commands and non-interactive container shell sessions.
- Added dedicated runtime guard helpers and test coverage for rewrite-mode availability behavior.
- Added repository lockfile plus additional command rewriter and runtime guard tests.
### Changed
- Updated README documentation to reflect rewrite bypass behavior, runtime guard semantics, source filtering details, and expanded development verification commands.
- Added a dedicated `typecheck` script and expanded `check` to run typecheck plus the full test suite.
- Routed `pnpm dlx` commands through the RTK proxy path instead of the generic pnpm wrapper.
### Fixed
- Improved command tokenization so `sed` scripts, shell separators, redirects, and background operators do not break later rewrites.
- Preserved exact `read` output at the 80-line smart-truncation threshold instead of compacting boundary-sized results.
- Preserved userscript metadata blocks during source filtering.
- Limited RTK-missing command suppression to rewrite mode so suggest mode still produces guidance.
## [0.3.2] - 2026-03-04
### Fixed
- Use absolute GitHub raw URL for README image to fix npm display
## [0.3.1] - 2026-03-04
### Changed
- Rewrote README.md with professional documentation standards
- Added comprehensive feature documentation, configuration reference, and usage examples
## [0.3.0] - 2026-03-02
### Changed
- Renamed extension/package from `rtk-integration` to `pi-rtk-optimizer` to better reflect its full purpose: RTK command rewrite plus tool-output compaction optimization.
- Updated extension identity references across config path resolution, modal UI labeling, installation commands, package metadata, and build check artifact naming.
## [0.2.0] - 2026-03-02
### Changed
- Reorganized extension into a publish-ready package layout:
- moved implementation modules into `src/`
- kept root `index.ts` as stable Pi auto-discovery entrypoint
- added `config/config.example.json` for distributable config starter
- Vendored modal UI dependency as `src/zellij-modal.ts` so the package no longer depends on sibling extension paths.
- Updated TypeScript project includes for the new modular layout.
### Added
- Public repository scaffolding:
- `README.md`
- `CHANGELOG.md`
- `LICENSE`
- `.gitignore`
- `.npmignore`
- Distribution metadata in `package.json`:
- `description`, `keywords`, `files`, `engines`, `publishConfig`, repository links
- standard `build`, `lint`, `test`, and `check` scripts
- Credits section referencing upstream inspiration projects:
- `mcowger/pi-rtk`
- `rtk-ai/rtk`
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 MasuRii
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+311
View File
@@ -0,0 +1,311 @@
<div align="center">
# pi-rtk-optimizer
[![npm version](https://img.shields.io/npm/v/pi-rtk-optimizer?style=for-the-badge)](https://www.npmjs.com/package/pi-rtk-optimizer)
[![License](https://img.shields.io/github/license/MasuRii/pi-rtk-optimizer?style=for-the-badge)](LICENSE)
[![Platform](https://img.shields.io/badge/Platform-macOS%20%7C%20Linux%20%7C%20Windows-blue?style=for-the-badge)]()
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Y8Y01PSSVR)
> Optional RTK command rewriting and non-search tool output compaction for the Pi coding agent.
<img width="1360" height="752" alt="image" src="https://github.com/user-attachments/assets/f4536889-62ec-429a-984e-dc0de9f1f709" />
**pi-rtk-optimizer** compacts noisy `bash` and `read` output. RTK CLI command rewriting is available but disabled by default, and search tool output is deliberately left to FFF.
</div>
## Features
### Command Rewriting
- Optional **automatic rewriting** or **suggestion-only** mode for common development workflows (disabled by default)
- Delegates bash command rewrite decisions to the installed `rtk rewrite` command, keeping RTK as the source of truth for supported commands, shell parsing, bypasses, and compound-command behavior
- Runtime guard when `rtk` binary is unavailable (raw commands run unchanged and repeated missing-binary rewrite probes are avoided)
- `/rtk show` and `/rtk verify` surface the resolved `rtk` executable path when the host can resolve it
- Pi-specific shell safety fixups for rewritten commands on Windows
### Output Compaction Pipeline
Multi-stage pipeline to reduce token consumption:
| Stage | Description |
|-------|-------------|
| ANSI Stripping | Removes terminal color/formatting codes |
| Test Aggregation | Summarizes test runner output (pass/fail counts) |
| Build Filtering | Extracts errors/warnings from build output |
| Git Compaction | Condenses `git status`, `git log`, `git diff` output |
| Linter Aggregation | Summarizes linting tool output |
| Source Code Filtering | `none`, `minimal`, or `aggressive` comment/whitespace removal with userscript metadata preservation |
| Smart Truncation | Preserves file boundaries and important lines while keeping 80-line reads exact |
| Anchor-Safe Read Compaction | Detects hashline/anchored `read` output and preserves complete edit anchors when filtering or truncating anchored lines |
| Hard Truncation | Final character limit enforcement |
### Interactive Settings
- Tabbed TUI settings modal via `/rtk` command
- Real-time configuration changes without restart
- Command completions for all subcommands
### Session Metrics
- Tracks compaction savings per tool type
- View statistics with `/rtk stats`
## Installation
### Local Extension Folder
Place this folder in one of the following locations:
```text
~/.pi/agent/extensions/pi-rtk-optimizer # Global default (when PI_CODING_AGENT_DIR is unset)
$PI_CODING_AGENT_DIR/extensions/pi-rtk-optimizer # Global when PI_CODING_AGENT_DIR is set
.pi/extensions/pi-rtk-optimizer # Project-specific
```
Pi auto-discovers extensions in these paths on startup.
### npm Package
```bash
pi install npm:pi-rtk-optimizer
```
### Git Repository
```bash
pi install git:github.com/MasuRii/pi-rtk-optimizer
```
## Usage
### Settings Modal
Open the interactive settings modal:
```
/rtk
```
Use ←/→ to switch tabs, ↑/↓ to navigate settings in the active tab, type to search, Enter/Space to cycle values, and Escape to close.
### Subcommands
| Command | Description |
|---------|-------------|
| `/rtk` | Open settings modal |
| `/rtk show` | Display current configuration and runtime status |
| `/rtk path` | Show config file path |
| `/rtk verify` | Check if `rtk` binary is available |
| `/rtk stats` | Show output compaction metrics for current session |
| `/rtk clear-stats` | Reset compaction metrics |
| `/rtk reset` | Reset all settings to defaults |
| `/rtk help` | Display usage help |
## Configuration
Configuration is stored at:
```text
Default global path: ~/.pi/agent/extensions/pi-rtk-optimizer/config.json
Actual global path: $PI_CODING_AGENT_DIR/extensions/pi-rtk-optimizer/config.json when PI_CODING_AGENT_DIR is set
```
A starter template is included at `config/config.example.json`.
For audit or debugging sessions, keep `showRewriteNotifications` enabled and disable lossy `read` compaction/source filtering before gathering evidence. Existing `config.json` files are user-owned runtime state; do not overwrite local choices unless you intentionally want to change live extension behavior.
### Configuration Options
#### Top-Level Settings
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enabled` | boolean | `true` | Master switch for all extension features |
| `commandRewritingEnabled` | boolean | `false` | Enable optional RTK CLI command rewriting |
| `mode` | string | `"rewrite"` | `"rewrite"` (auto-rewrite) or `"suggest"` (notify only) |
| `guardWhenRtkMissing` | boolean | `true` | Run original commands when rtk binary unavailable |
| `showRewriteNotifications` | boolean | `true` | Show rewrite notices in TUI |
#### Rewrite Source
Bash command support is intentionally resolved by the installed `rtk` binary through `rtk rewrite`. The extension does not maintain duplicate rewrite rules or category classifiers; update/configure RTK itself for command support policy.
> **Breaking in 0.6.0:** Rewrite category toggles (`rewriteGitGithub`, `rewriteFilesystem`, `rewriteRust`, `rewriteJavaScript`, `rewritePython`, `rewriteGo`, `rewriteContainers`, `rewriteNetwork`, and `rewritePackageManagers`) were removed from the extension config surface. Existing rewrite policy should be configured in RTK because the extension now delegates rewrite ownership to `rtk rewrite`.
#### Output Compaction Settings
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `outputCompaction.enabled` | boolean | `true` | Enable output compaction pipeline |
| `outputCompaction.stripAnsi` | boolean | `true` | Remove ANSI escape codes |
| `outputCompaction.readCompaction.enabled` | boolean | `false` | Enable lossy compaction for `read` output; defaults off so code reads stay exact |
| `outputCompaction.sourceCodeFilteringEnabled` | boolean | `false` | Enable source code filtering for `read` output when read compaction is enabled |
| `outputCompaction.preserveExactSkillReads` | boolean | `false` | Keep reads under configured Pi/global/project skill directories exact, bypassing read compaction |
| `outputCompaction.sourceCodeFiltering` | string | `"none"` | Filter level: `"none"`, `"minimal"`, `"aggressive"` |
| `outputCompaction.aggregateTestOutput` | boolean | `true` | Summarize test runner output |
| `outputCompaction.filterBuildOutput` | boolean | `true` | Filter build/compile output |
| `outputCompaction.compactGitOutput` | boolean | `true` | Compact git command output |
| `outputCompaction.aggregateLinterOutput` | boolean | `true` | Summarize linter output |
| `outputCompaction.trackSavings` | boolean | `true` | Track compaction metrics |
Skill-read preservation covers the global Pi skills directory (`~/.pi/agent/skills` by default, or `$PI_CODING_AGENT_DIR/skills` when set), `~/.agents/skills`, project `.pi/skills`, and ancestor `.agents/skills` directories.
When `read` output uses Pi hashline/anchor prefixes, the compactor treats each anchored line as an indivisible edit anchor. Source filtering and truncation may omit anchored lines, but retained lines keep their complete anchor prefixes; hard truncation inserts an anchor-safe marker instead of cutting through an anchor.
#### Truncation Settings
| Option | Type | Default | Range | Description |
|--------|------|---------|-------|-------------|
| `outputCompaction.smartTruncate.enabled` | boolean | `false` | — | Enable smart line-based truncation for read output when read compaction is enabled |
| `outputCompaction.smartTruncate.maxLines` | number | `220` | 404000 | Maximum lines after smart truncation |
| `outputCompaction.truncate.enabled` | boolean | `true` | — | Enable hard character truncation |
| `outputCompaction.truncate.maxChars` | number | `12000` | 1000200000 | Maximum characters in final output |
### Source Code Filtering Levels
| Level | Behavior |
|-------|----------|
| `none` | No filtering applied |
| `minimal` | Removes non-doc comments, collapses blank lines |
| `aggressive` | Keeps imports, constants, and signatures while replacing implementation details |
> **Note:** When read compaction, source filtering, and read truncation safeguards are active, Pi injects a troubleshooting note for repeated file-edit mismatches. If edits fail because "old text does not match," disable read compaction via `/rtk`, re-read the file, apply the edit, then re-enable compaction.
### Example Configuration
```json
{
"enabled": true,
"commandRewritingEnabled": false,
"mode": "rewrite",
"guardWhenRtkMissing": true,
"showRewriteNotifications": true,
"outputCompaction": {
"enabled": true,
"stripAnsi": true,
"readCompaction": {
"enabled": false
},
"sourceCodeFilteringEnabled": false,
"preserveExactSkillReads": false,
"sourceCodeFiltering": "none",
"aggregateTestOutput": true,
"filterBuildOutput": true,
"compactGitOutput": true,
"aggregateLinterOutput": true,
"trackSavings": true,
"smartTruncate": {
"enabled": false,
"maxLines": 220
},
"truncate": {
"enabled": true,
"maxChars": 12000
}
}
}
```
## Technical Details
### Architecture
```
index.ts # Pi auto-discovery entrypoint
src/
├── index.ts # Extension bootstrap and event wiring
├── command-register.ts # Lazy /rtk command registration
├── command-completions.ts # /rtk subcommand completions
├── command-rewriter.ts # Command rewrite decision adapter for RTK delegation
├── rtk-rewrite-provider.ts # Calls `rtk rewrite` as the rewrite source of truth
├── rtk-executable-resolver.ts # Cross-platform rtk executable discovery
├── runtime-guard.ts # Runtime availability guard helpers for rewrite mode
├── rewrite-pipeline-safety.ts # Shell-safety fixups for rewritten commands
├── rtk-command-environment.ts # RTK_DB_PATH scoping for rewritten commands
├── shell-env-prefix.ts # Environment assignment parsing helpers
├── windows-command-helpers.ts # Windows bash compatibility
├── output-compactor.ts # Tool result compaction pipeline
├── output-metrics.ts # Savings tracking and reporting
├── tool-execution-sanitizer.ts # Streaming bash execution output sanitizer
├── config-store.ts # Config load/save with normalization
├── config-modal.ts # TUI settings modal and /rtk handler
├── boolean-format.ts # Boolean display helpers
├── constants.ts # Shared extension constants
├── record-utils.ts # Record/object guards
├── types.ts # Shared config/runtime types
├── types-shims.d.ts # Ambient Pi package shims for local typecheck
├── zellij-modal.ts # Vendored modal renderer used by settings UI
└── techniques/ # Compaction technique implementations
├── ansi.ts # ANSI code stripping
├── build.ts # Build output filtering
├── command-detection.ts # Tool command detection helpers
├── git.ts # Git output compaction
├── index.ts # Technique re-export surface
├── linter.ts # Linter output aggregation
├── path-utils.ts # Cross-platform path shortening
├── search.ts # Search result grouping
├── source.ts # Source code filtering
├── test-output.ts # Test output aggregation
└── truncate.ts # Smart and hard truncation
```
### Event Hooks
The extension hooks into Pi's event system:
- **`tool_call`** — Rewrites bash commands to rtk equivalents or emits suggestions
- **`tool_result`** — Compacts completed tool output before context consumption
- **`tool_execution_start` / `tool_execution_update` / `tool_execution_end`** — Tracks and sanitizes streamed bash output
- **`before_agent_start`** — Conditionally injects source-filter troubleshooting guidance
- **`session_start` / `agent_end`** — Refreshes config and clears in-session tracking state
- **Registered `/rtk` command** — Handles settings, status, verification, stats, and reset subcommands
### Windows Compatibility
Automatic fixes applied on Windows:
- `cd /d <path>``cd "<normalized-path>"` (converts backslashes)
- Prepends `PYTHONIOENCODING=utf-8` for Python commands
### Dependencies
- **Peer dependencies:** `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`
- **Runtime:** Node.js ≥20, optional `rtk` binary for command rewriting
- **Development verification:** Node.js ≥24 and npm for Node/tsx test scripts using Node's experimental test module mocks
## Development
```bash
# Transpile-only TypeScript build check
npm run build
# Full typecheck
npm run typecheck
# Run Node/tsx tests
npm run test
# Full verification
npm run check
# Bundle sanity check
npm run build:check
```
## Credits
Inspired by:
- [mcowger/pi-rtk](https://github.com/mcowger/pi-rtk)
- [rtk-ai/rtk](https://github.com/rtk-ai/rtk)
## Related Pi Extensions
- [pi-tool-display](https://github.com/MasuRii/pi-tool-display) — Compact tool rendering and diff visualization
- [pi-permission-system](https://github.com/MasuRii/pi-permission-system) — Permission enforcement for tool and command access
- [pi-smart-voice-notify](https://github.com/MasuRii/pi-smart-voice-notify) — Multi-channel TTS and sound notifications
- [pi-image-tools](https://github.com/MasuRii/pi-image-tools) — Image attachment and inline preview
## License
[MIT](LICENSE) © MasuRii
@@ -0,0 +1,30 @@
{
"enabled": true,
"commandRewritingEnabled": false,
"mode": "rewrite",
"guardWhenRtkMissing": true,
"showRewriteNotifications": true,
"outputCompaction": {
"enabled": true,
"stripAnsi": true,
"readCompaction": {
"enabled": false
},
"sourceCodeFilteringEnabled": false,
"preserveExactSkillReads": false,
"truncate": {
"enabled": true,
"maxChars": 12000
},
"sourceCodeFiltering": "none",
"smartTruncate": {
"enabled": false,
"maxLines": 220
},
"aggregateTestOutput": true,
"filterBuildOutput": true,
"compactGitOutput": true,
"aggregateLinterOutput": true,
"trackSavings": true
}
}
+3
View File
@@ -0,0 +1,3 @@
import rtkIntegrationExtension from "./src/index.js";
export default rtkIntegrationExtension;
+1619
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
{
"name": "pi-rtk-optimizer",
"version": "0.9.0",
"description": "Pi extension that optimizes RTK command rewriting and tool output compaction for the coding agent.",
"type": "module",
"main": "./index.ts",
"exports": {
".": "./index.ts"
},
"files": [
"index.ts",
"src",
"config/config.example.json",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"scripts": {
"build": "tsc -p tsconfig.json --noCheck",
"typecheck": "tsc -p tsconfig.json",
"test": "bun ./src/output-compactor.test.ts && bun ./src/command-rewriter.test.ts && bun ./src/runtime-guard.test.ts && bun ./src/package-lock-integrity.test.ts && bun ./src/additional-coverage.test.ts && bun ./src/config-modal.test.ts && bun ./src/index.test.ts",
"check": "npm run typecheck && npm run test && npm run build:check",
"build:check": "esbuild ./index.ts --bundle --platform=node --format=esm --outfile=./.pi-rtk-optimizer-check.mjs --external:@earendil-works/pi-coding-agent --external:@earendil-works/pi-tui && node -e \"import { unlinkSync } from 'node:fs'; unlinkSync('./.pi-rtk-optimizer-check.mjs');\"",
"postinstall": "node -e \"const fs=require('fs'),cp=require('child_process'),p=require('path');const cwd=process.cwd();const normalized=cwd.split(p.sep).join('/');if(!normalized.includes('/.pi/agent/extensions/'))process.exit(0);const s=p.resolve(cwd,'../../scripts/patch-vulnerable-deps.mjs');if(!fs.existsSync(s))process.exit(0);const r=cp.spawnSync(process.execPath,[s,'--target',cwd,'--quiet'],{stdio:'inherit'});process.exit(r.status||0)\""
},
"keywords": [
"pi-package",
"pi",
"pi-extension",
"pi-coding-agent",
"coding-agent",
"rtk",
"token-optimization",
"tool-compaction",
"output-compaction",
"command-rewrite",
"optimization"
],
"author": "MasuRii",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/MasuRii/pi-rtk-optimizer.git"
},
"bugs": {
"url": "https://github.com/MasuRii/pi-rtk-optimizer/issues"
},
"homepage": "https://github.com/MasuRii/pi-rtk-optimizer#readme",
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"esbuild": "0.28.1",
"typescript": "6.0.3"
},
"pi": {
"extensions": [
"./index.ts"
]
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.78.0 || ^0.79.0 || ^0.80.0",
"@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.78.0 || ^0.79.0 || ^0.80.0"
},
"overrides": {
"protobufjs": "7.6.3",
"ws": "8.21.0"
}
}
@@ -0,0 +1,453 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { clearOutputMetrics, getOutputMetricsSummary, trackOutputSavings } from "./output-metrics.ts";
import { mock, runTest } from "./test-helpers.test.ts";
import { matchesCommandPatterns, normalizeCommandForDetection } from "./techniques/command-detection.ts";
import { compactPath } from "./techniques/path-utils.ts";
import { filterAggressive } from "./techniques/source.ts";
import { aggregateTestOutput } from "./techniques/test-output.ts";
import { applyWindowsBashCompatibilityFixes } from "./windows-command-helpers.ts";
import { applyRewrittenCommandShellSafetyFixups } from "./rewrite-pipeline-safety.ts";
import { applyRtkCommandEnvironment } from "./rtk-command-environment.ts";
import { sanitizeStreamingBashExecutionResult } from "./tool-execution-sanitizer.ts";
mock.module("@earendil-works/pi-coding-agent", {
namedExports: {
getAgentDir: () => "/tmp/.pi/agent",
},
});
const {
ensureConfigExists,
getRtkIntegrationConfigPath,
loadRtkIntegrationConfig,
normalizeRtkIntegrationConfig,
saveRtkIntegrationConfig,
} = await import("./config-store.ts");
function makeTempConfigPath(): string {
return `${getRtkIntegrationConfigPath()}.test-${Date.now()}-${Math.random().toString(16).slice(2)}.json`;
}
function cleanupFile(path: string): void {
for (const candidate of [path, `${path}.tmp`]) {
try {
if (existsSync(candidate)) {
unlinkSync(candidate);
}
} catch {
// Ignore cleanup failures in tests.
}
}
}
runTest("config-store normalizes invalid values and clamps numeric ranges", () => {
const normalized = normalizeRtkIntegrationConfig({
enabled: "yes",
mode: "invalid",
rewriteGitGithub: false,
outputCompaction: {
stripAnsi: false,
sourceCodeFilteringEnabled: "sometimes",
sourceCodeFiltering: "extreme",
truncate: {
enabled: true,
maxChars: 12,
},
smartTruncate: {
enabled: true,
maxLines: 999_999,
},
trackSavings: false,
},
});
assert.equal(normalized.enabled, true);
assert.equal(normalized.mode, "rewrite");
assert.equal(Object.hasOwn(normalized, "rewriteGitGithub"), false);
assert.equal(normalized.outputCompaction.stripAnsi, false);
assert.equal(normalized.outputCompaction.readCompaction.enabled, true);
assert.equal(normalized.outputCompaction.sourceCodeFilteringEnabled, true);
assert.equal(normalized.outputCompaction.sourceCodeFiltering, "minimal");
assert.equal(normalized.outputCompaction.truncate.maxChars, 1_000);
assert.equal(normalized.outputCompaction.smartTruncate.maxLines, 4_000);
assert.equal(normalized.outputCompaction.trackSavings, false);
});
runTest("config-store uses safer read defaults when readCompaction is explicit", () => {
const normalized = normalizeRtkIntegrationConfig({
outputCompaction: {
readCompaction: { enabled: false },
},
});
assert.equal(normalized.outputCompaction.readCompaction.enabled, false);
assert.equal(normalized.outputCompaction.sourceCodeFilteringEnabled, false);
assert.equal(normalized.outputCompaction.sourceCodeFiltering, "none");
assert.equal(normalized.outputCompaction.smartTruncate.enabled, false);
});
runTest("config-store can ensure, save, and reload isolated config files", () => {
const tempPath = makeTempConfigPath();
cleanupFile(tempPath);
try {
const ensured = ensureConfigExists(tempPath);
assert.equal(ensured.error, undefined);
assert.equal(existsSync(tempPath), true);
const defaultLoad = loadRtkIntegrationConfig(tempPath);
assert.equal(defaultLoad.warning, undefined);
assert.equal(defaultLoad.config.mode, "rewrite");
assert.equal(defaultLoad.config.outputCompaction.readCompaction.enabled, false);
const saved = saveRtkIntegrationConfig(
{
...defaultLoad.config,
mode: "suggest",
outputCompaction: {
...defaultLoad.config.outputCompaction,
truncate: {
...defaultLoad.config.outputCompaction.truncate,
maxChars: 250_000,
},
},
},
tempPath,
);
assert.equal(saved.success, true);
const reloaded = loadRtkIntegrationConfig(tempPath);
assert.equal(reloaded.config.mode, "suggest");
assert.equal(reloaded.config.outputCompaction.truncate.maxChars, 200_000);
assert.ok(readFileSync(tempPath, "utf-8").endsWith("\n"));
} finally {
cleanupFile(tempPath);
}
});
runTest("config-store falls back to defaults when JSON is invalid", () => {
const tempPath = makeTempConfigPath();
cleanupFile(tempPath);
try {
writeFileSync(tempPath, "{not valid json", "utf-8");
const loaded = loadRtkIntegrationConfig(tempPath);
assert.equal(loaded.config.mode, "rewrite");
assert.ok((loaded.warning ?? "").includes(tempPath));
assert.ok((loaded.warning ?? "").includes("Failed to parse"));
} finally {
cleanupFile(tempPath);
}
});
runTest("config-store malformed-file defaults are isolated from caller mutation", () => {
const tempPath = makeTempConfigPath();
cleanupFile(tempPath);
try {
writeFileSync(tempPath, "{not valid json", "utf-8");
const firstLoad = loadRtkIntegrationConfig(tempPath);
firstLoad.config.outputCompaction.truncate.maxChars = 42_424;
firstLoad.config.outputCompaction.readCompaction.enabled = true;
const secondLoad = loadRtkIntegrationConfig(tempPath);
assert.equal(secondLoad.config.outputCompaction.truncate.maxChars, 12_000);
assert.equal(secondLoad.config.outputCompaction.readCompaction.enabled, false);
} finally {
cleanupFile(tempPath);
}
});
runTest("output metrics summarize tracked savings and clear state", () => {
clearOutputMetrics();
assert.equal(getOutputMetricsSummary(), "RTK output compaction metrics: no data yet.");
const first = trackOutputSavings("1234567890", "12345", "bash", ["ansi", "truncate"]);
assert.equal(first.tool, "bash");
assert.equal(first.techniques, "ansi,truncate");
assert.equal(first.savingsPercent, 50);
trackOutputSavings("123456", "1234", "read", []);
const summary = getOutputMetricsSummary();
assert.ok(summary.includes("calls=2, saved=7 chars (43.8%)"));
assert.ok(summary.includes("- bash: 1 calls, saved 5 chars (50.0%)"));
assert.ok(summary.includes("- read: 1 calls, saved 2 chars (33.3%)"));
clearOutputMetrics();
assert.equal(getOutputMetricsSummary(), "RTK output compaction metrics: no data yet.");
});
runTest("aggressive source filtering ignores string and inline comment braces while tracking implementation blocks", () => {
const withLiteralBrace = [
"function first() {",
' const value = "{";',
" return value;",
"}",
"function second() {",
" return true;",
"}",
].join("\n");
const withoutLiteralBrace = [
"function first() {",
' const value = "plain";',
" return value;",
"}",
"function second() {",
" return true;",
"}",
].join("\n");
const withInlineCommentBrace = [
"function first() {",
" const value = 1; // {",
" return value;",
"}",
"function second() {",
" return true;",
"}",
].join("\n");
const withoutInlineCommentBrace = [
"function first() {",
" const value = 1; // no brace",
" return value;",
"}",
"function second() {",
" return true;",
"}",
].join("\n");
assert.equal(filterAggressive(withLiteralBrace, "typescript"), filterAggressive(withoutLiteralBrace, "typescript"));
assert.equal(filterAggressive(withInlineCommentBrace, "typescript"), filterAggressive(withoutInlineCommentBrace, "typescript"));
});
runTest("test output fallback counts unicode pass and fail symbols", () => {
const result = aggregateTestOutput("✓ creates user\n✔ updates user\n✕ deletes user\n✗ archives user\n", "bun test");
assert.ok(result?.includes("PASS: 2 passed"));
assert.ok(result?.includes("FAIL: 2 failed"));
});
runTest("command detection ignores env prefixes, blank lines, and chained suffixes", () => {
assert.equal(normalizeCommandForDetection("NODE_ENV=test FOO=bar npm test && echo done"), "npm test");
assert.equal(normalizeCommandForDetection("\n\n PYTHONPATH=src git status\n echo later"), "git status");
assert.equal(normalizeCommandForDetection(" "), null);
assert.equal(matchesCommandPatterns("CI=1 bun test | head -5", [/^bun test/]), true);
assert.equal(matchesCommandPatterns("echo hello", [/^bun test/]), false);
});
runTest("RTK command environment preserves explicit leading RTK_DB_PATH overrides", () => {
const command = 'RTK_DB_PATH="/custom/history.db" rtk git diff';
assert.equal(applyRtkCommandEnvironment(command), command);
const singleQuotedCommand = "RTK_DB_PATH='/custom/it'\\''s/history.db' rtk git diff";
assert.equal(applyRtkCommandEnvironment(singleQuotedCommand), singleQuotedCommand);
const exportedCommand = 'export RTK_DB_PATH="/custom/history.db"; rtk git diff';
assert.equal(applyRtkCommandEnvironment(exportedCommand), exportedCommand);
});
runTest("RTK command environment respects inherited RTK_DB_PATH values", () => {
const previousRtkDbPath = process.env.RTK_DB_PATH;
const command = "rtk git status";
try {
process.env.RTK_DB_PATH = "/persistent/shared/history.db";
assert.equal(applyRtkCommandEnvironment(command), command);
} finally {
if (previousRtkDbPath === undefined) {
delete process.env.RTK_DB_PATH;
} else {
process.env.RTK_DB_PATH = previousRtkDbPath;
}
}
});
runTest("RTK command environment ignores blank inherited RTK_DB_PATH values", () => {
const previousRtkDbPath = process.env.RTK_DB_PATH;
try {
process.env.RTK_DB_PATH = " ";
assert.match(applyRtkCommandEnvironment("rtk git status"), /^export RTK_DB_PATH=/);
} finally {
if (previousRtkDbPath === undefined) {
delete process.env.RTK_DB_PATH;
} else {
process.env.RTK_DB_PATH = previousRtkDbPath;
}
}
});
runTest("RTK command environment single-quotes hostile temp paths", () => {
const previousTmpDir = process.env.TMPDIR;
const previousTmp = process.env.TMP;
const previousTemp = process.env.TEMP;
const hostilePath = process.platform === "win32" ? "C:\\Temp\\$(touch owned)`bad`'dir" : "/tmp/$(touch owned)`bad`'dir";
try {
process.env.TMPDIR = hostilePath;
process.env.TMP = hostilePath;
process.env.TEMP = hostilePath;
const rewritten = applyRtkCommandEnvironment("rtk git status");
assert.ok(rewritten.startsWith("export RTK_DB_PATH='"));
assert.ok(rewritten.includes("$(touch owned)`bad`'\\''dir"));
assert.ok(rewritten.endsWith("; rtk git status"));
assert.equal(/^export RTK_DB_PATH=\"/.test(rewritten), false);
} finally {
process.env.TMPDIR = previousTmpDir;
process.env.TMP = previousTmp;
process.env.TEMP = previousTemp;
}
});
runTest("path compaction preserves the tail and handles Windows separators", () => {
const unixPath = "/Users/example/projects/pi-rtk-optimizer/src/techniques/path-utils.ts";
const compactUnixPath = compactPath(unixPath, 28);
assert.ok(compactUnixPath.length <= 28);
assert.ok(compactUnixPath.endsWith("path-utils.ts"));
assert.ok(compactUnixPath.includes("/"));
const windowsPath = "C:\\Users\\Administrator\\Documents\\pi-rtk-optimizer\\src\\windows-command-helpers.ts";
const compactWindowsPath = compactPath(windowsPath, 30);
assert.ok(compactWindowsPath.length <= 30);
assert.equal(compactWindowsPath.includes("\\"), true);
assert.ok(compactWindowsPath.endsWith("windows-command-helpers.ts"));
assert.equal(compactPath("src/file.ts", 40), "src/file.ts");
});
runTest("windows bash compatibility rewrites only when the runtime is Windows", () => {
const command = "cd /d C:\\Users\\Administrator\\project && python script.py";
const fixed = applyWindowsBashCompatibilityFixes(command, "win32");
assert.deepEqual(fixed.applied, ["cd-/d", "python-utf8"]);
assert.equal(
fixed.command,
'PYTHONIOENCODING=utf-8 cd "C:/Users/Administrator/project" && python script.py',
);
const unchanged = applyWindowsBashCompatibilityFixes(command, "linux");
assert.deepEqual(unchanged.applied, []);
assert.equal(unchanged.command, command);
const alreadyUtf8 = applyWindowsBashCompatibilityFixes("PYTHONIOENCODING=utf-8 python script.py", "win32");
assert.deepEqual(alreadyUtf8.applied, []);
assert.equal(alreadyUtf8.command, "PYTHONIOENCODING=utf-8 python script.py");
});
runTest("windows bash compatibility rewrites compound cd slash-d operators", () => {
assert.equal(
applyWindowsBashCompatibilityFixes("cd /d C:\\work || echo failed", "win32").command,
'cd "C:/work" || echo failed',
);
assert.equal(
applyWindowsBashCompatibilityFixes("cd /d C:\\work ; echo done", "win32").command,
'cd "C:/work" ; echo done',
);
assert.equal(
applyWindowsBashCompatibilityFixes("cd /d C:\\work | cat", "win32").command,
'cd "C:/work" | cat',
);
assert.equal(
applyWindowsBashCompatibilityFixes('cd /d "C:\\work space" || echo failed', "win32").command,
'cd "C:/work space" || echo failed',
);
});
runTest("rewrite pipeline safety buffers rewritten Windows producer commands", () => {
const rewritten = applyRewrittenCommandShellSafetyFixups("rtk git diff | grep TODO", "win32");
assert.ok(rewritten.includes('mktemp'));
assert.ok(rewritten.includes('trap'));
assert.ok(rewritten.includes('rtk git diff > "$__pi_rtk_pipe_tmp"'));
assert.ok(rewritten.includes('(grep TODO) < "$__pi_rtk_pipe_tmp"'));
assert.equal(
applyRewrittenCommandShellSafetyFixups("rtk git diff | grep TODO", "linux"),
"rtk git diff | grep TODO",
);
assert.equal(applyRewrittenCommandShellSafetyFixups("git diff | grep TODO", "win32"), "git diff | grep TODO");
});
runTest("rewrite pipeline safety buffers leading pipelines before compound suffixes", () => {
const andCommand = applyRewrittenCommandShellSafetyFixups("rtk git diff | grep TODO && echo done", "win32");
assert.ok(andCommand.includes('(grep TODO) < "$__pi_rtk_pipe_tmp"'));
assert.ok(andCommand.endsWith("&& echo done"));
const orCommand = applyRewrittenCommandShellSafetyFixups("rtk git diff | grep TODO || echo none", "win32");
assert.ok(orCommand.includes('(grep TODO) < "$__pi_rtk_pipe_tmp"'));
assert.ok(orCommand.endsWith("|| echo none"));
const semicolonCommand = applyRewrittenCommandShellSafetyFixups("rtk git diff | grep TODO; echo done", "win32");
assert.ok(semicolonCommand.includes('(grep TODO) < "$__pi_rtk_pipe_tmp"'));
assert.ok(semicolonCommand.endsWith("; echo done"));
});
runTest("rewrite pipeline safety keeps exported RTK_DB_PATH on rewritten producer commands", () => {
const envScopedCommand = applyRtkCommandEnvironment("rtk git diff agent/extensions/pi-multi-auth/account-manager.ts | head -200");
const rewritten = applyRewrittenCommandShellSafetyFixups(envScopedCommand, "win32");
assert.ok(rewritten.startsWith("export RTK_DB_PATH="));
assert.equal(rewritten.startsWith("RTK_DB_PATH="), false);
assert.ok(rewritten.includes("; {"));
assert.ok(
rewritten.includes('rtk git diff agent/extensions/pi-multi-auth/account-manager.ts > "$__pi_rtk_pipe_tmp"'),
);
assert.ok(rewritten.includes('(head -200) < "$__pi_rtk_pipe_tmp"'));
assert.equal(applyRewrittenCommandShellSafetyFixups(envScopedCommand, "linux"), envScopedCommand);
});
runTest("rewrite pipeline safety buffers explicit RTK_DB_PATH export preludes", () => {
const command = 'export RTK_DB_PATH="/custom/history.db"; rtk git diff | head -200';
const rewritten = applyRewrittenCommandShellSafetyFixups(command, "win32");
assert.ok(rewritten.startsWith('export RTK_DB_PATH="/custom/history.db"; {'));
assert.ok(rewritten.includes('rtk git diff > "$__pi_rtk_pipe_tmp"'));
assert.ok(rewritten.includes('(head -200) < "$__pi_rtk_pipe_tmp"'));
assert.equal(applyRewrittenCommandShellSafetyFixups(command, "linux"), command);
});
runTest("RTK command environment uses export prelude for shell compound commands", () => {
const rewritten = applyRtkCommandEnvironment('for d in a b; do echo "$d"; done');
assert.ok(/^export RTK_DB_PATH=/.test(rewritten));
assert.ok(/; for d in a b; do echo "\$d"; done$/.test(rewritten));
});
runTest("streaming sanitizer strips ANSI codes and preserves non-text blocks", () => {
const ansiResult = {
content: [
{ type: "text", text: "\x1B[32mworking tree clean\x1B[0m\n" },
{ type: "image", url: "ignored" },
],
};
const ansiSanitization = sanitizeStreamingBashExecutionResult(ansiResult, "rtk git status");
assert.equal(ansiSanitization.changed, true);
assert.equal(
((ansiSanitization.result as typeof ansiResult).content[0] as { text: string }).text,
"working tree clean\n",
);
assert.equal((ansiResult.content[0] as { text: string }).text, "\x1B[32mworking tree clean\x1B[0m\n");
assert.deepEqual((ansiSanitization.result as typeof ansiResult).content[1], { type: "image", url: "ignored" });
const plainResult = {
content: [
{
type: "text",
text: "[rtk] warning: builtin filters: parse failure\n\nworking tree clean\n",
},
],
};
const plainSanitization = sanitizeStreamingBashExecutionResult(plainResult, "rtk git status");
assert.equal(plainSanitization.changed, false);
assert.equal(plainSanitization.result, plainResult);
assert.equal(
(plainResult.content[0] as { text: string }).text,
"[rtk] warning: builtin filters: parse failure\n\nworking tree clean\n",
);
});
console.log("All additional coverage tests passed.");
+3
View File
@@ -0,0 +1,3 @@
export function toOnOff(value: boolean, truthyLabel = "on", falsyLabel = "off"): string {
return value ? truthyLabel : falsyLabel;
}
@@ -0,0 +1,49 @@
import type { AutocompleteItem } from "@earendil-works/pi-tui";
interface CompletionDefinition {
name: string;
description: string;
}
const TOP_LEVEL_SUBCOMMANDS: CompletionDefinition[] = [
{ name: "show", description: "Show current RTK config + runtime summary" },
{ name: "path", description: "Show RTK config file path" },
{ name: "verify", description: "Check whether rtk binary is available" },
{ name: "stats", description: "Show output compaction metrics" },
{ name: "clear-stats", description: "Clear output compaction metrics" },
{ name: "reset", description: "Reset RTK settings to defaults" },
{ name: "help", description: "Show usage help" },
];
function startsWithFilter(value: string, prefix: string): boolean {
if (!prefix) {
return true;
}
return value.startsWith(prefix);
}
function mapCompletions(values: CompletionDefinition[]): AutocompleteItem[] {
return values.map((entry) => ({
value: entry.name,
label: entry.name,
description: entry.description,
}));
}
export function getRtkArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
const normalized = argumentPrefix.trimStart().toLowerCase();
if (!normalized) {
return mapCompletions(TOP_LEVEL_SUBCOMMANDS);
}
if (normalized.includes(" ")) {
return null;
}
const filtered = TOP_LEVEL_SUBCOMMANDS.filter((entry) => startsWithFilter(entry.name, normalized));
if (filtered.length === 0) {
return null;
}
return mapCompletions(filtered);
}
+27
View File
@@ -0,0 +1,27 @@
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import { getRtkArgumentCompletions } from "./command-completions.js";
import { createLazyModuleLoader } from "./lazy-module-loader.js";
import type { RtkIntegrationConfig, RuntimeStatus } from "./types.js";
export interface RtkIntegrationController {
getConfig(): RtkIntegrationConfig;
setConfig(next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void;
getConfigPath(): string;
getRuntimeStatus(): RuntimeStatus;
refreshRuntimeStatus(): Promise<RuntimeStatus>;
getMetricsSummary(): string;
clearMetrics(): void;
}
const loadCommandModalModule = createLazyModuleLoader<typeof import("./config-modal.js")>("./config-modal.js");
export function registerRtkIntegrationCommand(pi: ExtensionAPI, controller: RtkIntegrationController): void {
pi.registerCommand("rtk", {
description: "Configure RTK rewrite and output compaction integration",
getArgumentCompletions: getRtkArgumentCompletions,
handler: async (args, ctx) => {
const { handleRtkIntegrationCommand } = await loadCommandModalModule();
await handleRtkIntegrationCommand(args, ctx, controller);
},
});
}
@@ -0,0 +1,224 @@
import assert from "node:assert/strict";
import { computeRewriteDecision } from "./command-rewriter.ts";
import { resolveRtkRewrite } from "./rtk-rewrite-provider.ts";
import { cloneDefaultConfig, runTest } from "./test-helpers.test.ts";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
function createMockPi(execResult: { code: number; stdout?: string; stderr?: string }): ExtensionAPI {
return {
exec: async (command: string) => {
if (command === "which" || command === "where") {
return { code: 0, stdout: "/usr/local/bin/rtk\n", stderr: "" };
}
return execResult;
},
} as unknown as ExtensionAPI;
}
await runTest("rtk rewrite uses resolved POSIX executable path", async () => {
const calls: Array<{ command: string; args: string[] }> = [];
const pi = {
exec: async (command: string, args: string[]) => {
calls.push({ command, args });
if (command === "which") {
return { code: 0, stdout: "/opt/rtk/bin/rtk\n", stderr: "" };
}
return { code: 3, stdout: "rtk git status", stderr: "" };
},
} as unknown as ExtensionAPI;
const result = await resolveRtkRewrite(pi, "git status", { platform: "linux" });
assert.equal(result.changed, true);
assert.equal(result.rewrittenCommand, "rtk git status");
assert.equal(result.executableResolution?.resolvedPath, "/opt/rtk/bin/rtk");
assert.deepEqual(calls.map((call) => call.command), ["which", "/opt/rtk/bin/rtk"]);
});
await runTest("rtk rewrite uses resolved Windows executable path", async () => {
const calls: Array<{ command: string; args: string[] }> = [];
const pi = {
exec: async (command: string, args: string[]) => {
calls.push({ command, args });
if (command === "where") {
return { code: 0, stdout: "C:\\Tools\\rtk.exe\r\nC:\\Other\\rtk.exe\r\n", stderr: "" };
}
return { code: 3, stdout: "rtk git status", stderr: "" };
},
} as unknown as ExtensionAPI;
const result = await resolveRtkRewrite(pi, "git status", { platform: "win32" });
assert.equal(result.changed, true);
assert.equal(result.executableResolution?.resolvedPath, "C:\\Tools\\rtk.exe");
assert.deepEqual(calls.map((call) => call.command), ["where", "C:\\Tools\\rtk.exe"]);
});
await runTest("rtk rewrite preserves behavior when executable path resolution fails", async () => {
const calls: Array<{ command: string; args: string[] }> = [];
const pi = {
exec: async (command: string, args: string[]) => {
calls.push({ command, args });
if (command === "which") {
return { code: 1, stdout: "", stderr: "not found" };
}
return { code: 3, stdout: "rtk git status", stderr: "" };
},
} as unknown as ExtensionAPI;
const result = await resolveRtkRewrite(pi, "git status", { platform: "linux" });
assert.equal(result.changed, true);
assert.equal(result.executableResolution?.command, "rtk");
assert.ok(result.executableResolution?.warning?.includes("which failed"));
assert.deepEqual(calls.map((call) => call.command), ["which", "rtk"]);
});
await runTest("empty command unchanged", async () => {
const config = cloneDefaultConfig();
const decision = await computeRewriteDecision("", config, createMockPi({ code: 1 }));
assert.equal(decision.changed, false);
assert.equal(decision.reason, "empty");
});
await runTest("already rtk unchanged", async () => {
const config = cloneDefaultConfig();
const decision = await computeRewriteDecision("rtk status", config, createMockPi({ code: 1 }));
assert.equal(decision.changed, false);
assert.equal(decision.reason, "already_rtk");
});
await runTest("env-prefixed rtk command is treated as already RTK and never re-rewritten", async () => {
let execCallCount = 0;
const pi = {
exec: async () => {
execCallCount += 1;
return { code: 0, stdout: "rtk rtk status", stderr: "" };
},
} as unknown as ExtensionAPI;
const command = "CI=1 RTK_DB_PATH=/tmp/history.db rtk status";
const decision = await computeRewriteDecision(command, cloneDefaultConfig(), pi, {
executableResolution: { command: "rtk", resolver: "which" },
});
assert.equal(decision.changed, false);
assert.equal(decision.rewrittenCommand, command);
assert.equal(decision.reason, "already_rtk");
assert.equal(execCallCount, 0);
});
await runTest("rtk unsupported heredoc result leaves command unchanged", async () => {
const config = cloneDefaultConfig();
const decision = await computeRewriteDecision("cat <<EOF", config, createMockPi({ code: 1 }));
assert.equal(decision.changed, false);
assert.equal(decision.reason, "no_match");
});
await runTest("quoted heredoc marker is delegated to RTK rewrite", async () => {
const config = cloneDefaultConfig();
const command = 'echo "<<not heredoc" && git status';
const decision = await computeRewriteDecision(
command,
config,
createMockPi({ code: 3, stdout: 'echo "<<not heredoc" && rtk git status' }),
);
assert.equal(decision.changed, true);
assert.equal(decision.rewrittenCommand, 'echo "<<not heredoc" && rtk git status');
assert.equal(decision.reason, "ok");
});
await runTest("rg rewrite delegates to rtk grep proxy", async () => {
const config = cloneDefaultConfig();
const command = "cd /workspace && rg -n --glob '!node_modules/**' --glob '!dist/**' \"needle\" src";
const rewritten = "cd /workspace && rtk grep -n --glob '!node_modules/**' --glob '!dist/**' \"needle\" src";
const decision = await computeRewriteDecision(
command,
config,
createMockPi({
code: 3,
stdout: rewritten,
}),
);
assert.equal(decision.changed, true);
assert.equal(decision.rewrittenCommand, rewritten);
assert.equal(decision.reason, "ok");
});
await runTest("legacy category toggles do not pre-filter RTK rewrite source of truth", async () => {
const config = { ...cloneDefaultConfig(), rewriteGitGithub: false };
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 3, stdout: "rtk git status" }));
assert.equal(decision.changed, true);
assert.equal(decision.rewrittenCommand, "rtk git status");
assert.equal(decision.reason, "ok");
});
await runTest("rtk exit 0 rewrites", async () => {
const config = cloneDefaultConfig();
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 0, stdout: "rtk git status" }));
assert.equal(decision.changed, true);
assert.equal(decision.rewrittenCommand, "rtk git status");
assert.equal(decision.reason, "ok");
});
await runTest("rtk exit 3 rewrites", async () => {
const config = cloneDefaultConfig();
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 3, stdout: "rtk git status" }));
assert.equal(decision.changed, true);
assert.equal(decision.rewrittenCommand, "rtk git status");
assert.equal(decision.reason, "ok");
});
await runTest("exit 1 leaves unchanged", async () => {
const config = cloneDefaultConfig();
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 1 }));
assert.equal(decision.changed, false);
assert.equal(decision.reason, "no_match");
});
await runTest("exit 2 leaves unchanged and surfaces RTK detail", async () => {
const config = cloneDefaultConfig();
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 2, stderr: "denied" }));
assert.equal(decision.changed, false);
assert.equal(decision.reason, "no_match");
assert.equal(decision.warning, "denied");
});
await runTest("unknown category passes through to RTK", async () => {
const config = cloneDefaultConfig();
const pi = createMockPi({ code: 0, stdout: "rtk custom" });
const decision = await computeRewriteDecision("custom-cmd", config, pi);
assert.equal(decision.changed, true);
assert.equal(decision.rewrittenCommand, "rtk custom");
assert.equal(decision.reason, "ok");
});
await runTest("exec error/timeout leaves unchanged and surfaces error detail", async () => {
const config = cloneDefaultConfig();
const pi = {
exec: async () => {
throw new Error("timeout");
},
} as unknown as ExtensionAPI;
const decision = await computeRewriteDecision("git status", config, pi);
assert.equal(decision.changed, false);
assert.equal(decision.reason, "no_match");
assert.equal(decision.warning, "timeout");
});
await runTest("compound commands forwarded to RTK", async () => {
const config = cloneDefaultConfig();
let capturedArgs: string[] = [];
const pi = {
exec: async (_cmd: string, args: string[]) => {
capturedArgs = args;
return { code: 0, stdout: "rtk result" };
},
} as unknown as ExtensionAPI;
const decision = await computeRewriteDecision("git status && cargo test", config, pi);
assert.equal(decision.changed, true);
assert.deepEqual(capturedArgs, ["rewrite", "git status && cargo test"]);
});
console.log("All command-rewriter tests passed.");
+48
View File
@@ -0,0 +1,48 @@
import { resolveRtkRewrite, type RtkRewriteProviderOptions } from "./rtk-rewrite-provider.js";
import { splitLeadingEnvAssignments } from "./shell-env-prefix.js";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { RtkIntegrationConfig } from "./types.js";
export interface RewriteDecision {
changed: boolean;
originalCommand: string;
rewrittenCommand: string;
reason: "ok" | "empty" | "already_rtk" | "no_match";
warning?: string;
}
export async function computeRewriteDecision(
command: string,
_config: RtkIntegrationConfig,
pi: ExtensionAPI,
rewriteOptions: RtkRewriteProviderOptions = {},
): Promise<RewriteDecision> {
if (!command || !command.trim()) {
return { changed: false, originalCommand: command, rewrittenCommand: command, reason: "empty" };
}
const trimmedStart = command.trimStart();
const effectiveCommand = splitLeadingEnvAssignments(trimmedStart).command.trimStart();
if (effectiveCommand === "rtk" || effectiveCommand.startsWith("rtk ")) {
return { changed: false, originalCommand: command, rewrittenCommand: command, reason: "already_rtk" };
}
const result = await resolveRtkRewrite(pi, command, rewriteOptions);
if (result.changed && result.rewrittenCommand) {
return {
changed: true,
originalCommand: command,
rewrittenCommand: result.rewrittenCommand,
reason: "ok",
};
}
return {
changed: false,
originalCommand: command,
rewrittenCommand: command,
reason: "no_match",
warning: result.error,
};
}
+240
View File
@@ -0,0 +1,240 @@
import assert from "node:assert/strict";
import { cloneDefaultConfig, mock, runTest } from "./test-helpers.test.ts";
mock.module("@earendil-works/pi-coding-agent", {
namedExports: {
getAgentDir: () => "/tmp/.pi/agent",
getSettingsListTheme: () => ({}),
},
});
const settingsListInputs: string[] = [];
const settingsListUpdates: Array<{ id: string; value: string }> = [];
mock.module("@earendil-works/pi-tui", {
namedExports: {
Box: class {
addChild(): void {}
},
Container: class {
addChild(): void {}
render(): string[] {
return ["settings-content"];
}
invalidate(): void {}
},
SettingsList: class {
handleInput(data: string): void {
settingsListInputs.push(data);
}
updateValue(id: string, value: string): void {
settingsListUpdates.push({ id, value });
}
},
Spacer: class {},
Text: class {},
truncateToWidth: (text: string, width: number) => text.slice(0, width),
visibleWidth: (text: string) => text.length,
},
});
function stripAnsi(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, "");
}
const { registerRtkIntegrationCommand } = await import("./command-register.ts");
const { ZellijModal, ZellijSettingsModal } = await import("./zellij-modal.ts");
const { getRtkArgumentCompletions } = await import("./command-completions.ts");
type Notification = { message: string; level: "info" | "warning" | "error" };
interface CommandContextStub {
hasUI: boolean;
ui: {
notify(message: string, level: "info" | "warning" | "error"): void;
custom<T>(): Promise<T>;
};
}
function createNotifyContext(hasUI: boolean): { ctx: CommandContextStub; notifications: Notification[] } {
const notifications: Notification[] = [];
return {
ctx: {
hasUI,
ui: {
notify(message: string, level: "info" | "warning" | "error") {
notifications.push({ message, level });
},
async custom<T>(): Promise<T> {
throw new Error("custom UI should not be invoked in config-modal tests");
},
},
},
notifications,
};
}
function lastNotification(notifications: Notification[]): Notification {
return notifications[notifications.length - 1] as Notification;
}
function createThemeStub(): { fg: (_name: string, text: string) => string; bold: (text: string) => string } {
return {
fg: (_name: string, text: string) => text,
bold: (text: string) => text,
};
}
runTest("zellij settings modal renders overlay frame and delegates non-enter input", () => {
settingsListInputs.length = 0;
settingsListUpdates.length = 0;
const settingsModal = new ZellijSettingsModal(
{
title: "RTK Integration Settings",
settings: [
{
id: "enabled",
label: "Enabled",
description: "Enable integration",
currentValue: "on",
values: ["on", "off"],
},
],
onChange: () => {},
onClose: () => {},
helpText: "Esc: close",
},
createThemeStub() as never,
);
const modal = new ZellijModal(settingsModal, {
titleBar: {
left: { text: "RTK Integration Settings", maxWidth: 30, color: "accent" },
right: { text: "pi-rtk-optimizer", maxWidth: 20, color: "dim" },
},
helpUndertitle: { text: "Esc: close", color: "dim" },
overlay: { anchor: "center", width: 86, maxHeight: "85%", margin: 1 },
});
const rendered = modal.renderModal(86);
settingsModal.handleInput("\r");
settingsModal.handleInput("j");
settingsModal.updateValue("enabled", "off");
assert.equal(rendered.visibleWidth, 86);
assert.equal(rendered.contentWidth, 82);
assert.ok(stripAnsi(rendered.lines[0] ?? "").includes("RTK Integration Settings"));
assert.ok(stripAnsi(rendered.lines[rendered.lines.length - 1] ?? "").includes("Esc: close"));
assert.deepEqual(modal.getOverlayOptions(), {
overlay: true,
overlayOptions: { anchor: "center", width: 86, maxHeight: "85%", margin: 1 },
});
assert.deepEqual(settingsListInputs, ["j"]);
assert.deepEqual(settingsListUpdates, [{ id: "enabled", value: "off" }]);
});
runTest("command completions return top-level and filtered RTK subcommands", () => {
const topLevel = getRtkArgumentCompletions("");
assert.ok(Array.isArray(topLevel));
assert.ok(topLevel.some((item) => item.value === "show"));
assert.ok(topLevel.some((item) => item.value === "clear-stats"));
const filtered = getRtkArgumentCompletions("st");
assert.deepEqual(
filtered?.map((item) => item.value),
["stats"],
);
assert.equal(getRtkArgumentCompletions("show extra"), null);
assert.equal(getRtkArgumentCompletions("zzz"), null);
});
await runTest("config modal command handlers route RTK subcommands to controller actions", async () => {
const config = cloneDefaultConfig();
const controllerState = {
config,
cleared: 0,
refreshed: 0,
lastSavedMode: "",
};
const controller = {
getConfig: () => controllerState.config,
setConfig: (next: typeof config, _ctx: unknown) => {
controllerState.config = next;
controllerState.lastSavedMode = next.mode;
},
getConfigPath: () => "C:/tmp/pi-rtk-optimizer/config.json",
getRuntimeStatus: () => ({ rtkAvailable: false, lastError: "not found" }),
refreshRuntimeStatus: async () => {
controllerState.refreshed += 1;
return { rtkAvailable: true, rtkExecutablePath: "C:/Tools/rtk.exe" };
},
getMetricsSummary: () => "metrics summary",
clearMetrics: () => {
controllerState.cleared += 1;
},
};
let registeredName = "";
type RegisteredCommandDefinition = {
description: string;
getArgumentCompletions?: (argumentPrefix: string) => Array<{ value: string; label: string; description?: string }> | null;
handler: (args: string, ctx: CommandContextStub) => Promise<void>;
};
let definition: RegisteredCommandDefinition | undefined;
registerRtkIntegrationCommand(
{
registerCommand(name: string, nextDefinition: RegisteredCommandDefinition) {
registeredName = name;
definition = nextDefinition;
},
} as never,
controller as never,
);
assert.equal(registeredName, "rtk");
if (!definition) {
throw new Error("Expected /rtk command definition to be registered");
}
assert.ok(definition.description.includes("Configure RTK rewrite"));
assert.ok(typeof definition.getArgumentCompletions === "function");
const infoCtx = createNotifyContext(true);
await definition.handler("help", infoCtx.ctx);
assert.ok(lastNotification(infoCtx.notifications).message.includes("Usage: /rtk"));
await definition.handler("show", infoCtx.ctx);
assert.ok(lastNotification(infoCtx.notifications).message.includes("mode=rewrite"));
assert.ok(lastNotification(infoCtx.notifications).message.includes("rewriteSource=rtk"));
assert.equal(lastNotification(infoCtx.notifications).message.includes("categories="), false);
await definition.handler("path", infoCtx.ctx);
assert.equal(lastNotification(infoCtx.notifications).message, "rtk config: C:/tmp/pi-rtk-optimizer/config.json");
await definition.handler("verify", infoCtx.ctx);
assert.equal(controllerState.refreshed, 1);
assert.equal(lastNotification(infoCtx.notifications).level, "info");
assert.ok(lastNotification(infoCtx.notifications).message.includes("available at C:/Tools/rtk.exe"));
await definition.handler("stats", infoCtx.ctx);
assert.equal(lastNotification(infoCtx.notifications).message, "metrics summary");
await definition.handler("clear-stats", infoCtx.ctx);
assert.equal(controllerState.cleared, 1);
assert.equal(lastNotification(infoCtx.notifications).message, "RTK metrics cleared.");
await definition.handler("reset", infoCtx.ctx);
assert.equal(controllerState.lastSavedMode, "rewrite");
assert.equal(lastNotification(infoCtx.notifications).message, "RTK integration settings reset to defaults.");
await definition.handler("unknown", infoCtx.ctx);
assert.equal(lastNotification(infoCtx.notifications).level, "warning");
assert.ok(lastNotification(infoCtx.notifications).message.includes("Usage: /rtk"));
const headlessCtx = createNotifyContext(false);
await definition.handler("", headlessCtx.ctx);
assert.equal(lastNotification(headlessCtx.notifications).message, "/rtk requires interactive TUI mode.");
});
console.log("All config-modal tests passed.");
+586
View File
@@ -0,0 +1,586 @@
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import type { SettingItem } from "@earendil-works/pi-tui";
import { toOnOff } from "./boolean-format.js";
import type { RtkIntegrationController } from "./command-register.js";
import { ZellijModal, ZellijSettingsModal } from "./zellij-modal.js";
import {
DEFAULT_RTK_INTEGRATION_CONFIG,
RTK_SOURCE_FILTER_LEVELS,
type RtkIntegrationConfig,
type RuntimeStatus,
} from "./types.js";
interface SettingValueSyncTarget {
updateValue(id: string, value: string): void;
}
const ON_OFF = ["on", "off"];
const MODE_VALUES = ["rewrite", "suggest"];
const SOURCE_FILTER_VALUES = [...RTK_SOURCE_FILTER_LEVELS];
const TRUNCATE_MAX_CHAR_VALUES = ["4000", "8000", "12000", "20000", "50000", "100000", "200000"];
const SMART_TRUNCATE_LINE_VALUES = ["40", "80", "120", "160", "220", "320", "500", "1000", "2000", "4000"];
const RTK_USAGE_TEXT =
"Usage: /rtk [show|path|verify|stats|clear-stats|reset|help] (or run /rtk with no args to open settings modal)";
const SETTINGS_TAB_DEFINITIONS = [
{
label: "General",
settingIds: ["enabled", "mode", "showRewriteNotifications", "guardWhenRtkMissing"],
},
{
label: "Compaction",
settingIds: [
"outputCompactionEnabled",
"outputStripAnsi",
"outputAggregateTestOutput",
"outputFilterBuildOutput",
"outputCompactGitOutput",
"outputAggregateLinterOutput",
"outputGroupSearchOutput",
"outputTrackSavings",
],
},
{
label: "Read & Source",
settingIds: [
"outputReadCompactionEnabled",
"outputSourceFilteringEnabled",
"outputSourceFiltering",
"outputPreserveExactSkillReads",
],
},
{
label: "Truncation",
settingIds: [
"outputTruncateEnabled",
"outputTruncateMaxChars",
"outputSmartTruncate",
"outputSmartTruncateMaxLines",
],
},
] as const;
function buildTabbedSettingGroups(settings: SettingItem[]): Array<{ label: string; settings: SettingItem[] }> {
const byId = new Map(settings.map((setting) => [setting.id, setting]));
const assignedIds = new Set<string>();
const tabs = SETTINGS_TAB_DEFINITIONS.map(({ label, settingIds }) => ({
label,
settings: settingIds.map((id) => {
const setting = byId.get(id);
if (!setting) {
throw new Error(`Missing setting item for tab '${label}': ${id}`);
}
if (assignedIds.has(id)) {
throw new Error(`Setting item assigned to multiple tabs: ${id}`);
}
assignedIds.add(id);
return setting;
}),
}));
const unassignedIds = settings.map((setting) => setting.id).filter((id) => !assignedIds.has(id));
if (unassignedIds.length > 0) {
throw new Error(`Unassigned setting items: ${unassignedIds.join(", ")}`);
}
return tabs;
}
function parseSourceFilterLevel(
value: string,
): RtkIntegrationConfig["outputCompaction"]["sourceCodeFiltering"] | undefined {
return SOURCE_FILTER_VALUES.includes(value as (typeof SOURCE_FILTER_VALUES)[number])
? (value as RtkIntegrationConfig["outputCompaction"]["sourceCodeFiltering"])
: undefined;
}
function parseIntegerInRange(value: string, min: number, max: number): number | undefined {
if (!/^\d+$/.test(value)) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
return undefined;
}
return parsed;
}
function summarizeRuntimeStatus(runtimeStatus: RuntimeStatus): string {
const runtime = runtimeStatus.rtkAvailable
? "rtk=available"
: `rtk=missing${runtimeStatus.lastError ? ` (${runtimeStatus.lastError})` : ""}`;
const executable = runtimeStatus.rtkExecutablePath
? `, rtkPath=${runtimeStatus.rtkExecutablePath}`
: runtimeStatus.rtkExecutableResolutionWarning
? `, rtkPath=unresolved (${runtimeStatus.rtkExecutableResolutionWarning})`
: "";
return `${runtime}${executable}`;
}
function summarizeConfig(config: RtkIntegrationConfig, runtimeStatus: RuntimeStatus): string {
return `enabled=${config.enabled}, commandRewriting=${config.commandRewritingEnabled}, mode=${config.mode}, rewriteSource=rtk, rewriteNotice=${config.showRewriteNotifications}, compaction=${config.outputCompaction.enabled}, readCompaction=${config.outputCompaction.readCompaction.enabled}, sourceFilterEnabled=${config.outputCompaction.sourceCodeFilteringEnabled}, preserveSkillReads=${config.outputCompaction.preserveExactSkillReads}, sourceFilter=${config.outputCompaction.sourceCodeFiltering}, ${summarizeRuntimeStatus(runtimeStatus)}`;
}
function buildSettingItems(config: RtkIntegrationConfig): SettingItem[] {
return [
{
id: "enabled",
label: "RTK integration enabled",
description: "Master switch for rewrite, suggestions, and output compaction",
currentValue: toOnOff(config.enabled),
values: ON_OFF,
},
{
id: "commandRewritingEnabled",
label: "RTK command rewriting",
description: "Optional RTK CLI command rewriting; off keeps FFF in full control of search",
currentValue: toOnOff(config.commandRewritingEnabled),
values: ON_OFF,
},
{
id: "mode",
label: "Rewrite mode",
description: "rewrite = auto-rewrite bash commands, suggest = notify only",
currentValue: config.mode,
values: MODE_VALUES,
},
{
id: "showRewriteNotifications",
label: "Show rewrite notifications",
description: "Show 'RTK rewrite: old -> new' notice in TUI",
currentValue: toOnOff(config.showRewriteNotifications),
values: ON_OFF,
},
{
id: "guardWhenRtkMissing",
label: "Guard when rtk missing",
description: "If on, raw commands run unchanged when rtk binary is unavailable",
currentValue: toOnOff(config.guardWhenRtkMissing),
values: ON_OFF,
},
{
id: "outputCompactionEnabled",
label: "Output compaction enabled",
description: "Compact bash/read tool results to reduce token usage; search results are untouched",
currentValue: toOnOff(config.outputCompaction.enabled),
values: ON_OFF,
},
{
id: "outputStripAnsi",
label: "Strip ANSI in output",
description: "Remove color/control codes from tool output before further compaction",
currentValue: toOnOff(config.outputCompaction.stripAnsi),
values: ON_OFF,
},
{
id: "outputReadCompactionEnabled",
label: "Read compaction enabled",
description: "If off, read tool output stays exact; build/test/git/grep compaction can still run",
currentValue: toOnOff(config.outputCompaction.readCompaction.enabled),
values: ON_OFF,
},
{
id: "outputTruncateEnabled",
label: "Hard truncation enabled",
description: "Apply max character cap after other compaction techniques",
currentValue: toOnOff(config.outputCompaction.truncate.enabled),
values: ON_OFF,
},
{
id: "outputTruncateMaxChars",
label: "Hard truncation max chars",
description: "Maximum characters kept when hard truncation is enabled",
currentValue: String(config.outputCompaction.truncate.maxChars),
values: TRUNCATE_MAX_CHAR_VALUES,
},
{
id: "outputSourceFilteringEnabled",
label: "Read source filtering enabled",
description: "If off, read output skips source-code filtering regardless of selected level",
currentValue: toOnOff(config.outputCompaction.sourceCodeFilteringEnabled),
values: ON_OFF,
},
{
id: "outputPreserveExactSkillReads",
label: "Preserve exact skill reads",
description: "If on, read results under the global Pi skills directory (default: ~/.pi/agent/skills, respects PI_CODING_AGENT_DIR), ~/.agents/skills, .pi/skills, and ancestor .agents/skills skip read compaction",
currentValue: toOnOff(config.outputCompaction.preserveExactSkillReads),
values: ON_OFF,
},
{
id: "outputSourceFiltering",
label: "Read source filtering",
description: "none|minimal|aggressive for read output compaction",
currentValue: config.outputCompaction.sourceCodeFiltering,
values: SOURCE_FILTER_VALUES,
},
{
id: "outputSmartTruncate",
label: "Read smart truncation",
description: "Keep signatures/imports when read output has many lines",
currentValue: toOnOff(config.outputCompaction.smartTruncate.enabled),
values: ON_OFF,
},
{
id: "outputSmartTruncateMaxLines",
label: "Read smart truncation max lines",
description: "Target max lines for smart truncation in read outputs",
currentValue: String(config.outputCompaction.smartTruncate.maxLines),
values: SMART_TRUNCATE_LINE_VALUES,
},
{
id: "outputAggregateTestOutput",
label: "Aggregate test output",
description: "Summarize test command output to failures and key totals",
currentValue: toOnOff(config.outputCompaction.aggregateTestOutput),
values: ON_OFF,
},
{
id: "outputFilterBuildOutput",
label: "Filter build output",
description: "Reduce build noise and keep key error/warning lines",
currentValue: toOnOff(config.outputCompaction.filterBuildOutput),
values: ON_OFF,
},
{
id: "outputCompactGitOutput",
label: "Compact git output",
description: "Condense git command output for lower token usage",
currentValue: toOnOff(config.outputCompaction.compactGitOutput),
values: ON_OFF,
},
{
id: "outputAggregateLinterOutput",
label: "Aggregate linter output",
description: "Summarize linter output by file and issue type",
currentValue: toOnOff(config.outputCompaction.aggregateLinterOutput),
values: ON_OFF,
},
{
id: "outputTrackSavings",
label: "Track output savings",
description: "Collect in-session compaction metrics for /rtk stats",
currentValue: toOnOff(config.outputCompaction.trackSavings),
values: ON_OFF,
},
];
}
function applySetting(config: RtkIntegrationConfig, id: string, value: string): RtkIntegrationConfig {
switch (id) {
case "enabled":
return { ...config, enabled: value === "on" };
case "commandRewritingEnabled":
return { ...config, commandRewritingEnabled: value === "on" };
case "mode":
return { ...config, mode: value === "suggest" ? "suggest" : "rewrite" };
case "showRewriteNotifications":
return { ...config, showRewriteNotifications: value === "on" };
case "guardWhenRtkMissing":
return { ...config, guardWhenRtkMissing: value === "on" };
case "outputCompactionEnabled":
return {
...config,
outputCompaction: { ...config.outputCompaction, enabled: value === "on" },
};
case "outputStripAnsi":
return {
...config,
outputCompaction: { ...config.outputCompaction, stripAnsi: value === "on" },
};
case "outputReadCompactionEnabled":
return {
...config,
outputCompaction: {
...config.outputCompaction,
readCompaction: { enabled: value === "on" },
},
};
case "outputTruncateEnabled":
return {
...config,
outputCompaction: {
...config.outputCompaction,
truncate: {
...config.outputCompaction.truncate,
enabled: value === "on",
},
},
};
case "outputTruncateMaxChars": {
const parsed = parseIntegerInRange(value, 1_000, 200_000);
return {
...config,
outputCompaction: {
...config.outputCompaction,
truncate: {
...config.outputCompaction.truncate,
maxChars: parsed ?? config.outputCompaction.truncate.maxChars,
},
},
};
}
case "outputSourceFilteringEnabled":
return {
...config,
outputCompaction: {
...config.outputCompaction,
sourceCodeFilteringEnabled: value === "on",
},
};
case "outputPreserveExactSkillReads":
return {
...config,
outputCompaction: {
...config.outputCompaction,
preserveExactSkillReads: value === "on",
},
};
case "outputSourceFiltering": {
const parsedValue = parseSourceFilterLevel(value);
return {
...config,
outputCompaction: {
...config.outputCompaction,
sourceCodeFiltering: parsedValue ?? config.outputCompaction.sourceCodeFiltering,
},
};
}
case "outputSmartTruncate":
return {
...config,
outputCompaction: {
...config.outputCompaction,
smartTruncate: {
...config.outputCompaction.smartTruncate,
enabled: value === "on",
},
},
};
case "outputSmartTruncateMaxLines": {
const parsed = parseIntegerInRange(value, 40, 4_000);
return {
...config,
outputCompaction: {
...config.outputCompaction,
smartTruncate: {
...config.outputCompaction.smartTruncate,
maxLines: parsed ?? config.outputCompaction.smartTruncate.maxLines,
},
},
};
}
case "outputAggregateTestOutput":
return {
...config,
outputCompaction: {
...config.outputCompaction,
aggregateTestOutput: value === "on",
},
};
case "outputFilterBuildOutput":
return {
...config,
outputCompaction: {
...config.outputCompaction,
filterBuildOutput: value === "on",
},
};
case "outputCompactGitOutput":
return {
...config,
outputCompaction: {
...config.outputCompaction,
compactGitOutput: value === "on",
},
};
case "outputAggregateLinterOutput":
return {
...config,
outputCompaction: {
...config.outputCompaction,
aggregateLinterOutput: value === "on",
},
};
case "outputTrackSavings":
return {
...config,
outputCompaction: {
...config.outputCompaction,
trackSavings: value === "on",
},
};
default:
return config;
}
}
function syncSettingValues(settingsList: SettingValueSyncTarget, config: RtkIntegrationConfig): void {
settingsList.updateValue("enabled", toOnOff(config.enabled));
settingsList.updateValue("commandRewritingEnabled", toOnOff(config.commandRewritingEnabled));
settingsList.updateValue("mode", config.mode);
settingsList.updateValue("showRewriteNotifications", toOnOff(config.showRewriteNotifications));
settingsList.updateValue("guardWhenRtkMissing", toOnOff(config.guardWhenRtkMissing));
settingsList.updateValue("outputCompactionEnabled", toOnOff(config.outputCompaction.enabled));
settingsList.updateValue("outputStripAnsi", toOnOff(config.outputCompaction.stripAnsi));
settingsList.updateValue("outputReadCompactionEnabled", toOnOff(config.outputCompaction.readCompaction.enabled));
settingsList.updateValue("outputTruncateEnabled", toOnOff(config.outputCompaction.truncate.enabled));
settingsList.updateValue("outputTruncateMaxChars", String(config.outputCompaction.truncate.maxChars));
settingsList.updateValue("outputSourceFilteringEnabled", toOnOff(config.outputCompaction.sourceCodeFilteringEnabled));
settingsList.updateValue("outputPreserveExactSkillReads", toOnOff(config.outputCompaction.preserveExactSkillReads));
settingsList.updateValue("outputSourceFiltering", config.outputCompaction.sourceCodeFiltering);
settingsList.updateValue("outputSmartTruncate", toOnOff(config.outputCompaction.smartTruncate.enabled));
settingsList.updateValue("outputSmartTruncateMaxLines", String(config.outputCompaction.smartTruncate.maxLines));
settingsList.updateValue("outputAggregateTestOutput", toOnOff(config.outputCompaction.aggregateTestOutput));
settingsList.updateValue("outputFilterBuildOutput", toOnOff(config.outputCompaction.filterBuildOutput));
settingsList.updateValue("outputCompactGitOutput", toOnOff(config.outputCompaction.compactGitOutput));
settingsList.updateValue("outputAggregateLinterOutput", toOnOff(config.outputCompaction.aggregateLinterOutput));
settingsList.updateValue("outputTrackSavings", toOnOff(config.outputCompaction.trackSavings));
}
async function openSettingsModal(ctx: ExtensionCommandContext, controller: RtkIntegrationController): Promise<void> {
const overlayOptions = { anchor: "center" as const, width: 86, maxHeight: "85%" as const, margin: 1 };
await ctx.ui.custom<void>(
(tui, theme, _keybindings, done) => {
let current = controller.getConfig();
let settingsModal: ZellijSettingsModal | null = null;
const allSettings = buildSettingItems(current);
const tabs = buildTabbedSettingGroups(allSettings);
settingsModal = new ZellijSettingsModal(
{
title: "Pi RTK Optimizer",
tabs,
activeTabIndex: 0,
onChange: (id, newValue) => {
current = applySetting(current, id, newValue);
controller.setConfig(current, ctx);
current = controller.getConfig();
if (settingsModal) {
syncSettingValues(settingsModal, current);
}
},
onClose: () => done(),
helpText: `Config: ${controller.getConfigPath()}`,
enableSearch: true,
},
theme,
);
const modal = new ZellijModal(
settingsModal,
{
borderStyle: "rounded",
titleBar: {
left: "Pi RTK Optimizer",
},
helpUndertitle: {
variants: [
"←/→ tabs • Type to search • Enter/Space change • Esc close",
"←/→ tabs • Type to search • Esc close",
"←/→ tabs • Esc close",
],
color: "dim",
},
overlay: overlayOptions,
},
theme,
);
return {
render(width: number) {
return modal.renderModal(width).lines;
},
invalidate() {
modal.invalidate();
},
handleInput(data: string) {
modal.handleInput(data);
tui.requestRender();
},
};
},
{ overlay: true, overlayOptions },
);
}
async function handleArgs(
args: string,
ctx: ExtensionCommandContext,
controller: RtkIntegrationController,
): Promise<boolean> {
const normalized = (args ?? "").trim().toLowerCase();
if (!normalized) {
return false;
}
if (normalized === "help") {
ctx.ui.notify(RTK_USAGE_TEXT, "info");
return true;
}
if (normalized === "show") {
ctx.ui.notify(`rtk: ${summarizeConfig(controller.getConfig(), controller.getRuntimeStatus())}`, "info");
return true;
}
if (normalized === "path") {
ctx.ui.notify(`rtk config: ${controller.getConfigPath()}`, "info");
return true;
}
if (normalized === "verify") {
const runtimeStatus = await controller.refreshRuntimeStatus();
if (runtimeStatus.rtkAvailable) {
const pathDetail = runtimeStatus.rtkExecutablePath ? ` at ${runtimeStatus.rtkExecutablePath}` : "";
ctx.ui.notify(`RTK binary is available${pathDetail}.`, "info");
} else {
ctx.ui.notify(
`RTK binary is not available${runtimeStatus.lastError ? `: ${runtimeStatus.lastError}` : ""}.`,
"warning",
);
}
return true;
}
if (normalized === "stats") {
ctx.ui.notify(controller.getMetricsSummary(), "info");
return true;
}
if (normalized === "clear-stats") {
controller.clearMetrics();
ctx.ui.notify("RTK metrics cleared.", "info");
return true;
}
if (normalized === "reset") {
controller.setConfig({ ...DEFAULT_RTK_INTEGRATION_CONFIG }, ctx);
ctx.ui.notify("RTK integration settings reset to defaults.", "info");
return true;
}
ctx.ui.notify(RTK_USAGE_TEXT, "warning");
return true;
}
export async function handleRtkIntegrationCommand(
args: string,
ctx: ExtensionCommandContext,
controller: RtkIntegrationController,
): Promise<void> {
if (await handleArgs(args, ctx, controller)) {
return;
}
if (!ctx.hasUI) {
ctx.ui.notify("/rtk requires interactive TUI mode.", "warning");
return;
}
await openSettingsModal(ctx, controller);
}
+223
View File
@@ -0,0 +1,223 @@
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { CONFIG_PATH } from "./constants.js";
import { toRecord } from "./record-utils.js";
import {
DEFAULT_RTK_INTEGRATION_CONFIG,
RTK_MODES,
RTK_SOURCE_FILTER_LEVELS,
type ConfigLoadResult,
type ConfigSaveResult,
type EnsureConfigResult,
type RtkIntegrationConfig,
type RtkSourceFilterLevel,
} from "./types.js";
function toBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function toInteger(value: unknown, fallback: number, min: number, max: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
const rounded = Math.round(value);
return Math.max(min, Math.min(max, rounded));
}
function toMode(value: unknown): RtkIntegrationConfig["mode"] {
return RTK_MODES.includes(value as RtkIntegrationConfig["mode"])
? (value as RtkIntegrationConfig["mode"])
: DEFAULT_RTK_INTEGRATION_CONFIG.mode;
}
function toSourceFilterLevel(value: unknown, fallback: RtkSourceFilterLevel): RtkSourceFilterLevel {
return RTK_SOURCE_FILTER_LEVELS.includes(value as RtkSourceFilterLevel)
? (value as RtkSourceFilterLevel)
: fallback;
}
function hasOwnProperty(source: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(source, key);
}
export function normalizeRtkIntegrationConfig(raw: unknown): RtkIntegrationConfig {
const source = toRecord(raw);
const outputCompactionSource = toRecord(source.outputCompaction);
const readCompactionSource = toRecord(outputCompactionSource.readCompaction);
const truncateSource = toRecord(outputCompactionSource.truncate);
const smartTruncateSource = toRecord(outputCompactionSource.smartTruncate);
const hasReadCompaction = hasOwnProperty(outputCompactionSource, "readCompaction");
const legacyReadCompactionFallback = !hasReadCompaction;
const sourceFilteringFallback = legacyReadCompactionFallback
? true
: DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.sourceCodeFilteringEnabled;
const sourceFilterLevelFallback = legacyReadCompactionFallback
? "minimal"
: DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.sourceCodeFiltering;
const smartTruncateEnabledFallback = legacyReadCompactionFallback
? true
: DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.smartTruncate.enabled;
return {
enabled: toBoolean(source.enabled, DEFAULT_RTK_INTEGRATION_CONFIG.enabled),
commandRewritingEnabled: toBoolean(
source.commandRewritingEnabled,
DEFAULT_RTK_INTEGRATION_CONFIG.commandRewritingEnabled,
),
mode: toMode(source.mode),
guardWhenRtkMissing: toBoolean(
source.guardWhenRtkMissing,
DEFAULT_RTK_INTEGRATION_CONFIG.guardWhenRtkMissing,
),
showRewriteNotifications: toBoolean(
source.showRewriteNotifications,
DEFAULT_RTK_INTEGRATION_CONFIG.showRewriteNotifications,
),
outputCompaction: {
enabled: toBoolean(
outputCompactionSource.enabled,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.enabled,
),
stripAnsi: toBoolean(
outputCompactionSource.stripAnsi,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.stripAnsi,
),
readCompaction: {
enabled: hasReadCompaction
? toBoolean(
readCompactionSource.enabled,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.readCompaction.enabled,
)
: true,
},
sourceCodeFilteringEnabled: toBoolean(
outputCompactionSource.sourceCodeFilteringEnabled,
sourceFilteringFallback,
),
preserveExactSkillReads: toBoolean(
outputCompactionSource.preserveExactSkillReads,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.preserveExactSkillReads,
),
truncate: {
enabled: toBoolean(
truncateSource.enabled,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.truncate.enabled,
),
maxChars: toInteger(
truncateSource.maxChars,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.truncate.maxChars,
1_000,
200_000,
),
},
sourceCodeFiltering: toSourceFilterLevel(
outputCompactionSource.sourceCodeFiltering,
sourceFilterLevelFallback,
),
smartTruncate: {
enabled: toBoolean(
smartTruncateSource.enabled,
smartTruncateEnabledFallback,
),
maxLines: toInteger(
smartTruncateSource.maxLines,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.smartTruncate.maxLines,
40,
4_000,
),
},
aggregateTestOutput: toBoolean(
outputCompactionSource.aggregateTestOutput,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.aggregateTestOutput,
),
filterBuildOutput: toBoolean(
outputCompactionSource.filterBuildOutput,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.filterBuildOutput,
),
compactGitOutput: toBoolean(
outputCompactionSource.compactGitOutput,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.compactGitOutput,
),
aggregateLinterOutput: toBoolean(
outputCompactionSource.aggregateLinterOutput,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.aggregateLinterOutput,
),
trackSavings: toBoolean(
outputCompactionSource.trackSavings,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.trackSavings,
),
},
};
}
export function ensureConfigExists(configPath = CONFIG_PATH): EnsureConfigResult {
if (existsSync(configPath)) {
return { created: false };
}
try {
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, `${JSON.stringify(DEFAULT_RTK_INTEGRATION_CONFIG, null, 2)}\n`, "utf-8");
return { created: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
created: false,
error: `Failed to create ${configPath}: ${message}`,
};
}
}
export function loadRtkIntegrationConfig(configPath = CONFIG_PATH): ConfigLoadResult {
if (!existsSync(configPath)) {
return { config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG) };
}
try {
const rawText = readFileSync(configPath, "utf-8");
const parsed = JSON.parse(rawText) as unknown;
return { config: normalizeRtkIntegrationConfig(parsed) };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG),
warning: `Failed to parse ${configPath}: ${message}`,
};
}
}
export function saveRtkIntegrationConfig(
config: RtkIntegrationConfig,
configPath = CONFIG_PATH,
): ConfigSaveResult {
const normalized = normalizeRtkIntegrationConfig(config);
const tmpPath = `${configPath}.tmp`;
try {
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(tmpPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf-8");
renameSync(tmpPath, configPath);
return { success: true };
} catch (error) {
try {
if (existsSync(tmpPath)) {
unlinkSync(tmpPath);
}
} catch (cleanupError) {
// Best-effort cleanup: a stale tmp-file removal failure must not
// mask the original save error reported below.
void cleanupError;
}
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to save ${configPath}: ${message}`,
};
}
}
export function getRtkIntegrationConfigPath(configPath = CONFIG_PATH): string {
return configPath;
}
+6
View File
@@ -0,0 +1,6 @@
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { join } from "node:path";
export const EXTENSION_NAME = "pi-rtk-optimizer";
export const CONFIG_DIR = join(getAgentDir(), "extensions", EXTENSION_NAME);
export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
+445
View File
@@ -0,0 +1,445 @@
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { mock, runTest } from "./test-helpers.test.ts";
mock.module("@earendil-works/pi-coding-agent", {
namedExports: {
getAgentDir: () => "/tmp/.pi/agent",
getSettingsListTheme: () => ({}),
isToolCallEventType: (toolName: string, event: Record<string, unknown>) => event.toolName === toolName,
},
});
mock.module("@earendil-works/pi-tui", {
namedExports: {
Box: class {},
Container: class {
addChild(): void {}
render(): string[] {
return [];
}
invalidate(): void {}
},
SettingsList: class {
handleInput(): void {}
updateValue(): void {}
},
Spacer: class {},
Text: class {},
truncateToWidth: (text: string) => text,
visibleWidth: (text: string) => text.length,
},
});
const indexModule = await import("./index.ts");
const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote, injectGuidelineIntoPrompt } = indexModule;
const rtkIntegrationExtension = indexModule.default;
const { DEFAULT_RTK_INTEGRATION_CONFIG } = await import("./types.ts");
const { CONFIG_PATH } = await import("./constants.ts");
function writeTestConfig(commandRewritingEnabled: boolean): void {
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
writeFileSync(
CONFIG_PATH,
`${JSON.stringify({ ...DEFAULT_RTK_INTEGRATION_CONFIG, commandRewritingEnabled }, null, 2)}\n`,
"utf-8",
);
}
type Notification = { message: string; level: "info" | "warning" | "error" };
type ExtensionHandler = (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>;
function createNotificationContext(notifications: Notification[]): Record<string, unknown> {
return {
hasUI: true,
ui: {
notify(message: string, level: "info" | "warning" | "error") {
notifications.push({ message, level });
},
},
};
}
function firstText(content: unknown): string {
if (!Array.isArray(content) || content.length === 0) {
return "";
}
const block = content[0] as { type?: string; text?: string };
return block.type === "text" && typeof block.text === "string" ? block.text : "";
}
function configWith(overrides: {
enabled?: boolean;
compactionEnabled?: boolean;
readCompactionEnabled?: boolean;
sourceFilteringEnabled?: boolean;
sourceFilteringLevel?: "none" | "minimal" | "aggressive";
smartTruncateEnabled?: boolean;
truncateEnabled?: boolean;
}): typeof DEFAULT_RTK_INTEGRATION_CONFIG {
const base = DEFAULT_RTK_INTEGRATION_CONFIG;
return {
...base,
enabled: overrides.enabled ?? base.enabled,
outputCompaction: {
...base.outputCompaction,
enabled: overrides.compactionEnabled ?? base.outputCompaction.enabled,
readCompaction: {
...base.outputCompaction.readCompaction,
enabled: overrides.readCompactionEnabled ?? base.outputCompaction.readCompaction.enabled,
},
sourceCodeFilteringEnabled:
overrides.sourceFilteringEnabled ?? base.outputCompaction.sourceCodeFilteringEnabled,
sourceCodeFiltering: overrides.sourceFilteringLevel ?? base.outputCompaction.sourceCodeFiltering,
smartTruncate: {
...base.outputCompaction.smartTruncate,
enabled: overrides.smartTruncateEnabled ?? base.outputCompaction.smartTruncate.enabled,
},
truncate: {
...base.outputCompaction.truncate,
enabled: overrides.truncateEnabled ?? base.outputCompaction.truncate.enabled,
},
},
};
}
runTest("bounded notice tracker evicts old entries and supports reset", () => {
const tracker = createBoundedNoticeTracker(2);
assert.equal(tracker.remember("first"), true);
assert.equal(tracker.remember("second"), true);
assert.equal(tracker.remember("first"), false);
assert.equal(tracker.remember("third"), true);
assert.equal(tracker.remember("second"), false);
assert.equal(tracker.remember("first"), true);
tracker.reset();
assert.equal(tracker.remember("third"), true);
});
runTest("bounded notice tracker coerces invalid limits to a safe minimum", () => {
const tracker = createBoundedNoticeTracker(0);
assert.equal(tracker.remember("alpha"), true);
assert.equal(tracker.remember("beta"), true);
assert.equal(tracker.remember("alpha"), true);
});
runTest("source-filter note injected when source filtering is active", () => {
assert.equal(
shouldInjectSourceFilterTroubleshootingNote(
configWith({
readCompactionEnabled: true,
sourceFilteringEnabled: true,
sourceFilteringLevel: "minimal",
smartTruncateEnabled: true,
}),
),
true,
);
assert.equal(
shouldInjectSourceFilterTroubleshootingNote(
configWith({
readCompactionEnabled: true,
sourceFilteringEnabled: true,
sourceFilteringLevel: "aggressive",
smartTruncateEnabled: true,
}),
),
true,
);
});
runTest("source-filter note skipped when extension is disabled", () => {
assert.equal(shouldInjectSourceFilterTroubleshootingNote(configWith({ enabled: false })), false);
});
runTest("source-filter note skipped when compaction is disabled", () => {
assert.equal(shouldInjectSourceFilterTroubleshootingNote(configWith({ compactionEnabled: false })), false);
});
runTest("source-filter note skipped when read compaction is disabled", () => {
assert.equal(
shouldInjectSourceFilterTroubleshootingNote(
configWith({
readCompactionEnabled: false,
sourceFilteringEnabled: true,
sourceFilteringLevel: "minimal",
smartTruncateEnabled: true,
}),
),
false,
);
});
runTest("source-filter note skipped when source filtering flag is off", () => {
assert.equal(
shouldInjectSourceFilterTroubleshootingNote(configWith({ sourceFilteringEnabled: false })),
false,
);
});
runTest("source-filter note skipped when filtering level is 'none'", () => {
assert.equal(
shouldInjectSourceFilterTroubleshootingNote(
configWith({ sourceFilteringEnabled: true, sourceFilteringLevel: "none" }),
),
false,
);
});
runTest("source-filter note skipped when all read filtering safeguards are disabled", () => {
assert.equal(
shouldInjectSourceFilterTroubleshootingNote(
configWith({ smartTruncateEnabled: false, truncateEnabled: false }),
),
false,
);
});
runTest("injectGuidelineIntoPrompt inserts bullet inside Guidelines section when header is at index 0", () => {
const guideline = "Test guideline for RTK troubleshooting.";
const prompt = [
"Guidelines:",
"- Use mcp for MCP discovery first",
"- Be concise in your responses",
"",
"<project_context>",
"Project-specific instructions",
].join("\n");
const result = injectGuidelineIntoPrompt(prompt, guideline);
assert.ok(result.includes(guideline), "guideline must be present");
const bullet = `- ${guideline}`;
assert.ok(result.includes(bullet), "guideline must be a bullet line");
const guidelinesStart = result.indexOf("Guidelines:\n");
const bulletIndex = result.indexOf(bullet);
const projectContextIndex = result.indexOf("\n<project_context>");
assert.equal(guidelinesStart, 0, "Guidelines header must be at index 0");
assert.ok(bulletIndex > guidelinesStart, "bullet must be after Guidelines header");
assert.ok(projectContextIndex !== -1, "project_context section must exist");
assert.ok(bulletIndex < projectContextIndex, "bullet must be before project_context section");
assert.equal(injectGuidelineIntoPrompt(result, guideline), result, "should be idempotent");
});
runTest("injectGuidelineIntoPrompt inserts bullet inside Guidelines section when header is mid-prompt", () => {
const guideline = "Test guideline for RTK troubleshooting.";
const prompt = [
"You are an expert coding assistant.",
"",
"Guidelines:",
"- Be concise in your responses",
"",
"Pi documentation:",
"- Main documentation: /path/to/readme",
].join("\n");
const result = injectGuidelineIntoPrompt(prompt, guideline);
const bullet = `- ${guideline}`;
assert.ok(result.includes(bullet), "guideline must be a bullet line");
const guidelinesStart = result.indexOf("\nGuidelines:\n");
const bulletIndex = result.indexOf(bullet);
const piDocsIndex = result.indexOf("\nPi documentation:");
assert.ok(guidelinesStart !== -1, "Guidelines header must exist");
assert.ok(bulletIndex > guidelinesStart, "bullet must be after Guidelines header");
assert.ok(bulletIndex < piDocsIndex, "bullet must be before Pi documentation section");
});
runTest("injectGuidelineIntoPrompt falls back to appending when no Guidelines section exists", () => {
const guideline = "Test guideline for RTK troubleshooting.";
const prompt = "You are a coding assistant with no guidelines section.";
const result = injectGuidelineIntoPrompt(prompt, guideline);
assert.ok(result.includes(guideline), "guideline must be present");
assert.ok(result.endsWith(guideline), "guideline must be appended at the end");
});
await runTest("session_start refreshes RTK provenance and runtime guard skips missing rewrites", async () => {
writeTestConfig(true);
const handlers: Record<string, ExtensionHandler> = {};
const notifications: Notification[] = [];
const execCommands: string[] = [];
let rtkAvailable = false;
let rewriteCalls = 0;
rtkIntegrationExtension({
exec: async (command: string, args: string[]) => {
execCommands.push(command);
if (command === "which" || command === "where") {
return { code: 0, stdout: "/opt/rtk/bin/rtk\n", stderr: "" };
}
if (args[0] === "--version") {
return rtkAvailable
? { code: 0, stdout: "rtk 1.0.0", stderr: "" }
: { code: 1, stdout: "", stderr: "missing rtk" };
}
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 sessionStartHandler = handlers.session_start;
const toolCallHandler = handlers.tool_call;
assert.ok(sessionStartHandler);
assert.ok(toolCallHandler);
await sessionStartHandler({}, createNotificationContext(notifications));
const skippedEvent = { toolName: "bash", input: { command: "git status" } };
await toolCallHandler(skippedEvent, createNotificationContext(notifications));
assert.equal((skippedEvent.input as { command: string }).command, "git status");
assert.equal(rewriteCalls, 0);
assert.ok(notifications.some((notice) => notice.message.includes("rtk binary unavailable")));
rtkAvailable = true;
await sessionStartHandler({}, createNotificationContext(notifications));
const rewrittenEvent = { toolName: "bash", input: { command: "git status" } };
await toolCallHandler(rewrittenEvent, createNotificationContext(notifications));
assert.equal(rewriteCalls, 1);
assert.ok((rewrittenEvent.input as { command: string }).command.includes("rtk git status"));
assert.ok(execCommands.includes("/opt/rtk/bin/rtk"));
writeTestConfig(false);
});
await runTest("tool execution lifecycle sanitizes streamed bash output", async () => {
const handlers: Record<string, ExtensionHandler> = {};
rtkIntegrationExtension({
exec: async () => ({ code: 0, stdout: "rtk 1.0.0", stderr: "" }),
on(eventName: string, handler: ExtensionHandler) {
handlers[eventName] = handler;
},
registerCommand() {},
} as never);
const startHandler = handlers.tool_execution_start;
const updateHandler = handlers.tool_execution_update;
const endHandler = handlers.tool_execution_end;
assert.ok(startHandler);
assert.ok(updateHandler);
assert.ok(endHandler);
await startHandler(
{ toolName: "bash", toolCallId: "bash-1", args: { command: "rtk git status" } },
{},
);
const updateEvent = {
toolName: "bash",
toolCallId: "bash-1",
args: { command: "rtk git status" },
partialResult: {
content: [
{
type: "text",
text: "\x1B[32mworking tree clean\x1B[0m\n",
},
],
},
};
await updateHandler(updateEvent, {});
assert.equal(firstText(updateEvent.partialResult.content), "working tree clean\n");
const endEvent = {
toolName: "bash",
toolCallId: "bash-1",
result: { content: [{ type: "text", text: "\x1B[31merror: build failed\x1B[0m\n" }] },
};
await endHandler(endEvent, {});
assert.equal(firstText(endEvent.result.content), "error: build failed\n");
});
await runTest("tool_result lifecycle merges compaction metadata with existing details", async () => {
const handlers: Record<string, ExtensionHandler> = {};
const notifications: Notification[] = [];
rtkIntegrationExtension({
exec: async () => ({ code: 0, stdout: "rtk 1.0.0", stderr: "" }),
on(eventName: string, handler: ExtensionHandler) {
handlers[eventName] = handler;
},
registerCommand() {},
} as never);
const toolResultHandler = handlers.tool_result;
assert.ok(toolResultHandler);
const result = await toolResultHandler(
{
toolName: "bash",
input: { command: "printf TODO" },
content: [{ type: "text", text: "\x1B[31msrc/a.ts\n 1: TODO\x1B[0m\n" }],
details: { metadata: { requestId: "abc" }, traceId: "trace-1" },
},
createNotificationContext(notifications),
);
assert.ok(result);
assert.equal(firstText(result.content), "src/a.ts\n 1: TODO\n");
assert.equal((result.details as { traceId?: string }).traceId, "trace-1");
const details = result.details as { rtkCompaction?: { applied: boolean }; metadata?: Record<string, unknown> };
assert.equal(details.rtkCompaction?.applied, true);
assert.deepEqual(details.metadata?.requestId, "abc");
assert.equal((details.metadata?.rtkCompaction as { applied?: boolean } | undefined)?.applied, true);
assert.equal(notifications.length, 0);
});
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>> = {};
const notifications: Notification[] = [];
rtkIntegrationExtension({
exec: async (_command: string, args: string[]) => {
if (args[0] === "--version") {
return { code: 0, stdout: "rtk 1.0.0", stderr: "" };
}
return { code: 2, stdout: "", stderr: "denied unsafe rewrite" };
},
on(eventName: string, handler: (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>) {
handlers[eventName] = handler;
},
registerCommand() {},
} as never);
const toolCallHandler = handlers.tool_call;
assert.ok(toolCallHandler);
const event = { toolName: "bash", input: { command: "git status" } };
await toolCallHandler(event, {
hasUI: true,
ui: {
notify(message: string, level: "info" | "warning" | "error") {
notifications.push({ message, level });
},
},
});
assert.equal((event.input as { command: string }).command, "git status");
assert.equal(notifications.length, 1);
assert.equal(notifications[0]?.level, "warning");
assert.ok(notifications[0]?.message.includes("rtk rewrite skipped"));
assert.ok(notifications[0]?.message.includes("denied unsafe rewrite"));
writeTestConfig(false);
});
console.log("All index tests passed.");
+543
View File
@@ -0,0 +1,543 @@
import { isToolCallEventType, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
import {
ensureConfigExists,
getRtkIntegrationConfigPath,
loadRtkIntegrationConfig,
normalizeRtkIntegrationConfig,
saveRtkIntegrationConfig,
} from "./config-store.js";
import { computeRewriteDecision } from "./command-rewriter.js";
import { registerRtkIntegrationCommand } from "./command-register.js";
import { 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";
import { toRecord } from "./record-utils.js";
import { applyRtkCommandEnvironment } from "./rtk-command-environment.js";
import { resolveRtkExecutable, type RtkExecutableResolution } from "./rtk-executable-resolver.js";
import { applyRewrittenCommandShellSafetyFixups } from "./rewrite-pipeline-safety.js";
import { shouldRequireRtkAvailabilityForCommandHandling, shouldSkipCommandHandlingWhenRtkMissing } from "./runtime-guard.js";
import { sanitizeStreamingBashExecutionResult } from "./tool-execution-sanitizer.js";
import type { RtkIntegrationConfig, RuntimeStatus } from "./types.js";
import { applyWindowsBashCompatibilityFixes } from "./windows-command-helpers.js";
function trimMessage(raw: string, maxLength = 220): string {
const clean = raw.replace(/\s+/g, " ").trim();
if (clean.length <= maxLength) {
return clean;
}
return `${clean.slice(0, maxLength - 1)}`;
}
const SOURCE_FILTER_TROUBLESHOOTING_NOTE =
"RTK note: If file edits repeatedly fail because old text does not match, ask the user to manually run '/rtk' in the Pi TUI, disable 'Read compaction enabled', re-read the file, apply the edit, then ask the user to manually re-enable it in the Pi TUI.";
/**
* Inject a guideline bullet into the Guidelines section of the system prompt.
*
* Locates the `Guidelines:` block and inserts the bullet after the last
* existing guideline, preserving the section structure. Falls back to
* appending at the end when the Guidelines section cannot be found.
*/
export function injectGuidelineIntoPrompt(systemPrompt: string, guideline: string): string {
if (!systemPrompt || systemPrompt.includes(guideline)) {
return systemPrompt;
}
const bullet = `- ${guideline}`;
// "Guidelines:" may appear at the very start of the prompt (index 0) or
// after a newline. Check both cases so the header is always detected.
let guidelinesHeaderIndex = systemPrompt.indexOf("\nGuidelines:\n");
let headerLength = "\nGuidelines:\n".length;
if (guidelinesHeaderIndex === -1 && systemPrompt.startsWith("Guidelines:\n")) {
guidelinesHeaderIndex = 0;
headerLength = "Guidelines:\n".length;
}
if (guidelinesHeaderIndex === -1) {
return `${systemPrompt}\n\n${guideline}`;
}
const linesStart = guidelinesHeaderIndex + headerLength;
const remainder = systemPrompt.slice(linesStart);
const lines = remainder.split("\n");
let consumedChars = 0;
for (const line of lines) {
if (line === "") {
break;
}
if (/^[-*+\s]/.test(line)) {
consumedChars += line.length + 1;
continue;
}
break;
}
const insertAt = consumedChars === 0 ? linesStart : linesStart + consumedChars - 1;
const before = systemPrompt.slice(0, insertAt);
const after = systemPrompt.slice(insertAt);
const needsNewlineBefore = before.length > 0 && !before.endsWith("\n");
const needsNewlineAfter = after.length > 0 && !after.startsWith("\n");
return [before, needsNewlineBefore ? "\n" : "", bullet, needsNewlineAfter ? "\n" : "", after].join("");
}
const loadOutputCompactorModule = createLazyModuleLoader<typeof import("./output-compactor.js")>("./output-compactor.js");
export function shouldInjectSourceFilterTroubleshootingNote(config: RtkIntegrationConfig): boolean {
const compaction = config.outputCompaction;
return (
config.enabled &&
compaction.enabled &&
compaction.readCompaction.enabled &&
compaction.sourceCodeFilteringEnabled &&
compaction.sourceCodeFiltering !== "none" &&
(compaction.smartTruncate.enabled || compaction.truncate.enabled)
);
}
function mergeCompactionDetails(
existingDetails: unknown,
compaction: ToolResultCompactionMetadata,
): Record<string, unknown> {
const baseDetails = toRecord(existingDetails);
const baseMetadata = toRecord(baseDetails.metadata);
const nextDetails: Record<string, unknown> = {
...baseDetails,
rtkCompaction: compaction,
metadata: {
...baseMetadata,
rtkCompaction: compaction,
},
};
if (Object.keys(baseDetails).length === 0 && existingDetails !== undefined) {
nextDetails.rawDetails = existingDetails;
}
return nextDetails;
}
export interface BoundedNoticeTracker {
remember(key: string): boolean;
reset(): void;
}
export function createBoundedNoticeTracker(maxEntries: number): BoundedNoticeTracker {
const normalizedLimit = Math.max(1, Math.floor(maxEntries));
const seen = new Set<string>();
const order: string[] = [];
return {
remember(key: string): boolean {
if (seen.has(key)) {
return false;
}
seen.add(key);
order.push(key);
while (order.length > normalizedLimit) {
const evicted = order.shift();
if (evicted !== undefined) {
seen.delete(evicted);
}
}
return true;
},
reset(): void {
seen.clear();
order.length = 0;
},
};
}
export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
const initialLoad = loadRtkIntegrationConfig();
let config: RtkIntegrationConfig = initialLoad.config;
if (!config.enabled) {
return;
}
let pendingLoadWarning = initialLoad.warning;
let runtimeStatus: RuntimeStatus = { rtkAvailable: false };
const warnedMessages = createBoundedNoticeTracker(100);
const suggestionNotices = createBoundedNoticeTracker(200);
const activeBashCommands = new Map<string, string>();
let missingRtkWarningShown = false;
const formatRewriteNotice = (originalCommand: string, rewrittenCommand: string): string => {
const original = trimMessage(originalCommand, 100);
const rewritten = trimMessage(rewrittenCommand, 120);
return `RTK rewrite: ${original} -> ${rewritten}`;
};
const formatRewriteWarning = (command: string, warning: string): string => {
const target = trimMessage(command, 100);
const detail = trimMessage(warning, 120);
return `${EXTENSION_NAME}: rtk rewrite skipped for '${target}' (${detail}).`;
};
const warnOnce = (
ctx: ExtensionContext | ExtensionCommandContext,
message: string,
level: "warning" | "error" = "warning",
): void => {
if (!warnedMessages.remember(message)) {
return;
}
if (ctx.hasUI) {
ctx.ui.notify(message, level);
}
};
const clearTrackedBashCommands = (): void => {
activeBashCommands.clear();
};
const trackBashCommand = (toolCallId: unknown, args: unknown): void => {
if (typeof toolCallId !== "string") {
return;
}
const argsRecord = toRecord(args);
const command = typeof argsRecord.command === "string" ? argsRecord.command.trim() : "";
if (!command) {
activeBashCommands.delete(toolCallId);
return;
}
activeBashCommands.set(toolCallId, command);
};
const getTrackedBashCommand = (toolCallId: unknown): string | undefined => {
if (typeof toolCallId !== "string") {
return undefined;
}
return activeBashCommands.get(toolCallId);
};
const forgetTrackedBashCommand = (toolCallId: unknown): void => {
if (typeof toolCallId !== "string") {
return;
}
activeBashCommands.delete(toolCallId);
};
/**
* Shared guard for bash tool-execution events: skips when compaction is
* disabled, normalizes the event to a record, tracks the bash command, and
* returns the record for further handler-specific processing.
*/
const recordBashEventIfEnabled = (
event: unknown,
): Record<string, unknown> | null => {
if (!config.enabled || !config.outputCompaction.enabled) {
return null;
}
const eventRecord = toRecord(event);
if (eventRecord.toolName !== "bash") {
return null;
}
trackBashCommand(eventRecord.toolCallId, eventRecord.args);
return eventRecord;
};
const refreshConfig = async (ctx?: ExtensionContext | ExtensionCommandContext): Promise<void> => {
const ensured = ensureConfigExists();
if (ensured.error && ctx) {
warnOnce(ctx, ensured.error);
}
const loaded = loadRtkIntegrationConfig();
config = loaded.config;
pendingLoadWarning = loaded.warning;
await refreshRuntimeStatus();
if (pendingLoadWarning && ctx) {
warnOnce(ctx, pendingLoadWarning);
pendingLoadWarning = undefined;
}
};
const setConfig = (next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void => {
config = normalizeRtkIntegrationConfig(next);
const saved = saveRtkIntegrationConfig(config);
if (!saved.success && saved.error) {
ctx.ui.notify(saved.error, "error");
}
};
const refreshRuntimeStatus = async (): Promise<RuntimeStatus> => {
if (!config.commandRewritingEnabled) {
runtimeStatus = { rtkAvailable: false };
return runtimeStatus;
}
let executableResolution: RtkExecutableResolution | undefined;
try {
executableResolution = await resolveRtkExecutable(pi);
const result = await pi.exec(executableResolution.command, ["--version"], { timeout: 5000 });
if (result.code === 0) {
runtimeStatus = {
rtkAvailable: true,
lastCheckedAt: Date.now(),
rtkExecutablePath: executableResolution.resolvedPath,
rtkExecutableCommand: executableResolution.command,
rtkExecutableResolver: executableResolution.resolver,
rtkExecutableResolutionWarning: executableResolution.warning,
};
missingRtkWarningShown = false;
return runtimeStatus;
}
const detail = trimMessage(
`${result.stderr || ""} ${result.stdout || ""} ${result.code ? `(exit ${result.code})` : ""}`,
);
runtimeStatus = {
rtkAvailable: false,
lastCheckedAt: Date.now(),
lastError: detail || `exit ${result.code}`,
rtkExecutablePath: executableResolution.resolvedPath,
rtkExecutableCommand: executableResolution.command,
rtkExecutableResolver: executableResolution.resolver,
rtkExecutableResolutionWarning: executableResolution.warning,
};
return runtimeStatus;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
runtimeStatus = {
rtkAvailable: false,
lastCheckedAt: Date.now(),
lastError: trimMessage(message),
rtkExecutablePath: executableResolution?.resolvedPath,
rtkExecutableCommand: executableResolution?.command,
rtkExecutableResolver: executableResolution?.resolver,
rtkExecutableResolutionWarning: executableResolution?.warning,
};
return runtimeStatus;
}
};
const maybeWarnRtkMissing = (ctx: ExtensionContext): void => {
if (!config.enabled || !config.commandRewritingEnabled || !config.guardWhenRtkMissing) {
return;
}
if (runtimeStatus.rtkAvailable) {
missingRtkWarningShown = false;
return;
}
if (missingRtkWarningShown) {
return;
}
missingRtkWarningShown = true;
const reason = runtimeStatus.lastError ? ` (${runtimeStatus.lastError})` : "";
const handling = config.mode === "suggest" ? "rewrite suggestions" : "command rewrite";
warnOnce(ctx, `${EXTENSION_NAME}: rtk binary unavailable, ${handling} bypassed${reason}.`);
};
const ensureRuntimeStatusFresh = async (): Promise<void> => {
if (!shouldRequireRtkAvailabilityForCommandHandling(config)) {
return;
}
const now = Date.now();
const isStale = !runtimeStatus.lastCheckedAt || now - runtimeStatus.lastCheckedAt > 30_000;
if (isStale) {
await refreshRuntimeStatus();
}
};
const controller = {
getConfig: () => config,
setConfig,
getConfigPath: getRtkIntegrationConfigPath,
getRuntimeStatus: () => runtimeStatus,
refreshRuntimeStatus,
getMetricsSummary: getOutputMetricsSummary,
clearMetrics: clearOutputMetrics,
};
registerRtkIntegrationCommand(pi, controller);
pi.on("session_start", async (_event, ctx) => {
warnedMessages.reset();
suggestionNotices.reset();
clearTrackedBashCommands();
missingRtkWarningShown = false;
await refreshConfig(ctx);
maybeWarnRtkMissing(ctx);
});
pi.on("agent_end", async () => {
clearTrackedBashCommands();
});
pi.on("tool_execution_start", async (event) => {
recordBashEventIfEnabled(event);
});
pi.on("tool_execution_update", async (event) => {
const eventRecord = recordBashEventIfEnabled(event);
if (!eventRecord) {
return;
}
const sanitization = sanitizeStreamingBashExecutionResult(
eventRecord.partialResult,
getTrackedBashCommand(eventRecord.toolCallId),
);
if (sanitization.changed) {
eventRecord.partialResult = sanitization.result;
}
});
pi.on("tool_execution_end", async (event) => {
const eventRecord = toRecord(event);
if (eventRecord.toolName !== "bash") {
return;
}
try {
if (config.enabled && config.outputCompaction.enabled) {
const sanitization = sanitizeStreamingBashExecutionResult(
eventRecord.result,
getTrackedBashCommand(eventRecord.toolCallId),
);
if (sanitization.changed) {
eventRecord.result = sanitization.result;
}
}
} finally {
forgetTrackedBashCommand(eventRecord.toolCallId);
}
});
pi.on("before_agent_start", async (event, ctx) => {
await ensureRuntimeStatusFresh();
maybeWarnRtkMissing(ctx);
if (!shouldInjectSourceFilterTroubleshootingNote(config)) {
return {};
}
if (event.systemPrompt.includes(SOURCE_FILTER_TROUBLESHOOTING_NOTE)) {
return {};
}
const updatedPrompt = injectGuidelineIntoPrompt(event.systemPrompt, SOURCE_FILTER_TROUBLESHOOTING_NOTE);
if (updatedPrompt === event.systemPrompt) {
return {};
}
return {
systemPrompt: updatedPrompt,
};
});
pi.on("tool_call", async (event, ctx) => {
if (!config.enabled || !config.commandRewritingEnabled) {
return {};
}
if (!isToolCallEventType("bash", event)) {
return {};
}
if (config.mode === "rewrite") {
const compatibility = applyWindowsBashCompatibilityFixes(event.input.command);
if (compatibility.command !== event.input.command) {
event.input.command = compatibility.command;
}
}
await ensureRuntimeStatusFresh();
if (shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus)) {
return {};
}
let executableResolution: RtkExecutableResolution | undefined;
if (runtimeStatus.rtkExecutableCommand) {
const resolver: RtkExecutableResolution["resolver"] =
runtimeStatus.rtkExecutableResolver === "where" ? "where" : "which";
executableResolution = {
command: runtimeStatus.rtkExecutableCommand,
resolvedPath: runtimeStatus.rtkExecutablePath,
resolver,
warning: runtimeStatus.rtkExecutableResolutionWarning,
};
}
const decision = await computeRewriteDecision(event.input.command, config, pi, { executableResolution });
if (!decision.changed) {
if (decision.warning) {
warnOnce(ctx, formatRewriteWarning(decision.originalCommand, decision.warning));
}
return {};
}
if (config.mode === "rewrite") {
if (config.showRewriteNotifications && ctx.hasUI) {
ctx.ui.notify(formatRewriteNotice(decision.originalCommand, decision.rewrittenCommand), "info");
}
const envScopedRewrittenCommand = applyRtkCommandEnvironment(decision.rewrittenCommand);
event.input.command = applyRewrittenCommandShellSafetyFixups(envScopedRewrittenCommand);
return {};
}
if (config.mode === "suggest") {
const suggestionKey = `${decision.originalCommand}:${decision.rewrittenCommand}`;
if (suggestionNotices.remember(suggestionKey) && ctx.hasUI) {
ctx.ui.notify(`RTK suggestion: ${decision.rewrittenCommand}`, "info");
}
}
return {};
});
pi.on("tool_result", async (event, ctx) => {
if (!config.enabled || !config.outputCompaction.enabled) {
return {};
}
try {
const { compactToolResult } = await loadOutputCompactorModule();
const outcome = compactToolResult(
{
toolName: event.toolName,
input: event.input,
content: event.content,
},
config,
);
if (!outcome.changed || !outcome.content) {
return {};
}
return {
content: outcome.content,
details: outcome.metadata ? mergeCompactionDetails(event.details, outcome.metadata) : undefined,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warnOnce(ctx, `${EXTENSION_NAME}: output compaction failed, using raw output (${trimMessage(message)}).`);
return {};
}
});
}
@@ -0,0 +1,14 @@
/**
* Creates a memoized lazy loader for a dynamically imported module.
*
* The first call triggers `import(specifier)`; subsequent calls reuse the
* cached promise. This avoids re-importing the module on every invocation
* while keeping the heavy module out of the synchronous startup path.
*/
export function createLazyModuleLoader<T>(specifier: string): () => Promise<T> {
let cached: Promise<T> | undefined;
return (): Promise<T> => {
cached ??= import(specifier) as Promise<T>;
return cached;
};
}
@@ -0,0 +1,629 @@
import assert from "node:assert/strict";
import { join } from "node:path";
import { cloneDefaultConfig, mock, runTest } from "./test-helpers.test.ts";
const TEST_AGENT_DIR = "/tmp/.pi/agent";
mock.module("@earendil-works/pi-coding-agent", {
namedExports: {
getAgentDir: () => TEST_AGENT_DIR,
},
});
const { compactToolResult } = await import("./output-compactor.ts");
function buildReadContent(lineCount: number): string {
const lines: string[] = [];
for (let index = 0; index < lineCount; index += 1) {
if (index % 2 === 0) {
lines.push(`// comment ${index}`);
} else {
lines.push(`const value${index} = ${index};`);
}
}
return `${lines.join("\n")}\n`;
}
function setReadCompaction(config: ReturnType<typeof cloneDefaultConfig>, enabled: boolean): void {
config.outputCompaction.readCompaction = { enabled };
}
function firstTextBlock(content: unknown[] | undefined): string {
if (!Array.isArray(content) || content.length === 0) {
return "";
}
const first = content[0] as { type?: string; text?: string };
if (first?.type !== "text" || typeof first.text !== "string") {
return "";
}
return first.text;
}
const OUTPUT_EMOJI_MARKERS = ["✓", "✔", "❌", "⚠️", "⚠", "📋", "📄", "🔍", "✅", "⏭️", "📌", "📝", "❓", "•"];
function compactBashOutput(command: string, text: string): string {
const result = compactToolResult(
{
toolName: "bash",
input: { command },
content: [{ type: "text", text }],
},
cloneDefaultConfig(),
);
assert.equal(result.changed, true);
return firstTextBlock(result.content);
}
function assertNoOutputEmoji(text: string): void {
for (const marker of OUTPUT_EMOJI_MARKERS) {
assert.equal(text.includes(marker), false, `Unexpected output emoji marker: ${marker}`);
}
}
function assertNoPartialHashlineAnchors(text: string): void {
for (const line of text.split(/\r?\n/)) {
if (/^\s*\d+\s*#[A-Za-z0-9_-]{2,32}:/.test(line)) {
assert.equal(line.endsWith("..."), false, `Anchor line was partially truncated: ${line}`);
}
}
}
runTest("precision read with offset keeps exact output (no source/smart/hard truncation)", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 500;
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(220);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts", offset: 1 },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("precision read with limit keeps exact output", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 500;
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(220);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts", limit: 200 },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("default read output stays exact when read compaction is disabled by default", () => {
const config = cloneDefaultConfig();
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "aggressive";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 500;
const content = buildReadContent(220);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("normal read compacts and adds banner when read compaction is enabled", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(220);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("source:minimal"));
const compacted = firstTextBlock(result.content);
assert.ok(compacted.startsWith("[RTK compacted output:"));
assert.ok(compacted.includes("source:minimal"));
});
runTest("line-anchor read output compacts without corrupting LINE#HASH anchors", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 5000;
const content = Array.from({ length: 120 }, (_value, index) => {
const lineNumber = index + 1;
const sourceLine = lineNumber % 2 === 0 ? `const value${lineNumber} = ${lineNumber};` : `// comment ${lineNumber}`;
return `${String(lineNumber).padStart(3, " ")}#ZP:${sourceLine}`;
}).join("\n");
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("source:minimal"));
const compacted = firstTextBlock(result.content);
assert.ok(compacted.startsWith("[RTK compacted output:"));
assert.ok(compacted.includes("source:minimal"));
assert.match(compacted, /\n\s*2#ZP:const value2 = 2;/);
assert.equal(compacted.includes("#ZP:// comment"), false);
assertNoPartialHashlineAnchors(compacted);
});
runTest("colon-pipe anchor read output compacts without requiring hashline extension", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 5000;
const content = [
"Read sample.ts: 120 lines",
"",
...Array.from({ length: 120 }, (_value, index) => {
const lineNumber = index + 1;
const sourceLine = lineNumber % 2 === 0 ? `const value${lineNumber} = ${lineNumber};` : `// comment ${lineNumber}`;
return `${lineNumber}:${(lineNumber % 256).toString(16).padStart(2, "0")}|${sourceLine}`;
}),
].join("\n");
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("source:minimal"));
const compacted = firstTextBlock(result.content);
assert.ok(compacted.includes("Read sample.ts: 120 lines"));
assert.match(compacted, /\n2:02\|const value2 = 2;/);
assert.equal(compacted.includes("|// comment"), false);
});
runTest("compact LINEHASH pipe anchors from oh-my-pi style reads", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 5000;
const content = Array.from({ length: 120 }, (_value, index) => {
const lineNumber = index + 1;
const hash = lineNumber % 2 === 0 ? "sr" : "ab";
const sourceLine = lineNumber % 2 === 0 ? `const value${lineNumber} = ${lineNumber};` : `// comment ${lineNumber}`;
return `${lineNumber}${hash}|${sourceLine}`;
}).join("\n");
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("source:minimal"));
const compacted = firstTextBlock(result.content);
assert.match(compacted, /\n2sr\|const value2 = 2;/);
assert.equal(compacted.includes("|// comment"), false);
});
runTest("compact hashline-tools file wrapper while preserving non-anchor wrapper lines", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 5000;
const content = [
"<file>",
...Array.from({ length: 120 }, (_value, index) => {
const lineNumber = index + 1;
const sourceLine = lineNumber % 2 === 0 ? `const value${lineNumber} = ${lineNumber};` : `// comment ${lineNumber}`;
return `${lineNumber}#ZM:${sourceLine}`;
}),
"",
"(End of file - 120 total lines)",
"</file>",
].join("\n");
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("source:minimal"));
const compacted = firstTextBlock(result.content);
assert.ok(compacted.includes("<file>"));
assert.ok(compacted.includes("(End of file - 120 total lines)"));
assert.ok(compacted.includes("</file>"));
assert.match(compacted, /\n2#ZM:const value2 = 2;/);
assert.equal(compacted.includes("#ZM:// comment"), false);
});
runTest("anchor-safe read hard truncation preserves whole hashline anchors", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = false;
config.outputCompaction.smartTruncate.enabled = false;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 350;
const content = Array.from({ length: 120 }, (_value, index) => {
const lineNumber = index + 1;
return `${lineNumber}#ZP:const value${lineNumber} = "${"x".repeat(40)}";`;
}).join("\n");
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("truncate"));
const compacted = firstTextBlock(result.content);
assert.ok(compacted.includes("anchor-safe truncate"));
assertNoPartialHashlineAnchors(compacted);
});
runTest("incidental single anchor-like line does not disable normal read compaction", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = [`1#ZP:not an anchored read`, buildReadContent(120)].join("\n");
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("source:minimal") || result.techniques.includes("smart-truncate"));
});
runTest("short read output stays exact below threshold", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
const content = buildReadContent(40);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("read output stays exact at the 80-line boundary with trailing newline", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(80);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("read output compacts once the content exceeds the 80-line exactness threshold when read compaction is enabled", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(81);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("source:minimal") || result.techniques.includes("smart-truncate"));
});
runTest("source file reads skip lossy source filtering when truncation safeguards are not needed", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = false;
config.outputCompaction.truncate.enabled = false;
const content = buildReadContent(120);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
assert.equal(firstTextBlock(result.content), "");
});
runTest("skill reads stay exact when preserveExactSkillReads is enabled for user skills", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.preserveExactSkillReads = true;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 500;
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(220);
const result = compactToolResult(
{
toolName: "read",
input: { path: join(TEST_AGENT_DIR, "skills", "example", "SKILL.md") },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("project .pi skill reads stay exact when preserveExactSkillReads is enabled", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.preserveExactSkillReads = true;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 500;
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(220);
const result = compactToolResult(
{
toolName: "read",
input: { path: ".pi/skills/example/SKILL.md" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("ancestor .agents skill reads stay exact when preserveExactSkillReads is enabled", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.preserveExactSkillReads = true;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 500;
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(220);
const result = compactToolResult(
{
toolName: "read",
input: { path: "../.agents/skills/example/SKILL.md" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("build output uses plain-text status markers", () => {
const compacted = compactBashOutput("npm run build", "Compiling app v0.1.0\n");
assert.equal(compacted, "[OK] Build successful (1 units compiled)");
assertNoOutputEmoji(compacted);
});
runTest("git status output uses plain-text labels", () => {
const compacted = compactBashOutput(
"git status --short --branch",
"## main...origin/main\nM staged.ts\n M modified.ts\n?? new.ts\nUU conflict.ts\n",
);
assert.ok(compacted.startsWith("Branch: main\n"));
assert.ok(compacted.includes("Staged: 1 files\n staged.ts\n"));
assert.ok(compacted.includes("Modified: 1 files\n modified.ts\n"));
assert.ok(compacted.includes("Untracked: 1 files\n new.ts\n"));
assert.ok(compacted.includes("Conflicts: 1 files"));
assertNoOutputEmoji(compacted);
});
runTest("git diff output uses plain-text file markers", () => {
const compacted = compactBashOutput(
"git diff",
"diff --git a/src/example.ts b/src/example.ts\n@@ -1 +1 @@\n-oldValue\n+newValue\n",
);
assert.ok(compacted.includes("\n> src/example.ts\n"));
assertNoOutputEmoji(compacted);
});
runTest("linter success output uses plain-text status markers", () => {
const compacted = compactBashOutput("npx eslint .", "");
assert.equal(compacted, "[OK] ESLint: No issues found");
assertNoOutputEmoji(compacted);
});
runTest("test output uses plain-text labels and bullets", () => {
const compacted = compactBashOutput(
"bun test",
"3 passed, 1 failed, 2 skipped\nFAIL src/example.test.ts\n Expected: true\n Received: false\n\n\n",
);
assert.ok(compacted.includes("Test Results:"));
assert.ok(compacted.includes("PASS: 3 passed"));
assert.ok(compacted.includes("FAIL: 1 failed"));
assert.ok(compacted.includes("SKIP: 2 skipped"));
assert.ok(compacted.includes(" - FAIL src/example.test.ts"));
assertNoOutputEmoji(compacted);
});
runTest("all grep output stays intact because FFF owns search", () => {
const text = "src/a.ts [modified in git]\n 1: const match = true;\n\n[Continue with cursor=\"fff_c1\"]";
const result = compactToolResult(
{
toolName: "grep",
input: { pattern: "match" },
content: [{ type: "text", text }],
},
cloneDefaultConfig(),
);
assert.equal(result.changed, false);
assert.equal(result.content, undefined);
assert.deepEqual(result.techniques, []);
});
runTest("git diff compaction skips already-compacted RTK-shaped output", () => {
const result = compactToolResult(
{
toolName: "bash",
input: { command: "git diff -- agent/extensions/pi-mcp-adapter/package.json" },
content: [{ type: "text", text: "agent/extensions/pi-mcp-adapter/package.json | 2 +-\n\n--- Changes ---\n\n> agent/extensions/pi-mcp-adapter/package.json\n @@ -38,7 +38,7 @@\n - \"@earendil-works/pi-coding-agent\": \"^0.58.1\",\n" }],
},
cloneDefaultConfig(),
);
assert.equal(result.changed, false);
assert.equal(result.content, undefined);
assert.deepEqual(result.techniques, []);
});
runTest("non-hook RTK warnings are preserved verbatim", () => {
const result = compactToolResult(
{
toolName: "bash",
input: { command: "FOO=1 rtk git status" },
content: [{ type: "text", text: "[rtk] warning: builtin filters: parse failure\n\nworking tree clean\n" }],
},
cloneDefaultConfig(),
);
assert.equal(result.changed, false);
assert.equal(result.content, undefined);
assert.deepEqual(result.techniques, []);
});
runTest("quoted hook warning text is preserved as payload", () => {
const quotedHookText = 'const warning = "No hook installed — run `rtk init -g` for automatic token savings";\n';
const result = compactToolResult(
{
toolName: "bash",
input: { command: "echo probe" },
content: [{ type: "text", text: quotedHookText }],
},
cloneDefaultConfig(),
);
assert.equal(result.changed, false);
assert.equal(result.content, undefined);
assert.deepEqual(result.techniques, []);
});
console.log("All output-compactor tests passed.");
+680
View File
@@ -0,0 +1,680 @@
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { homedir } from "node:os";
import { dirname, join, resolve, sep } from "node:path";
import {
aggregateLinterOutput,
aggregateTestOutput,
compactGitOutput,
detectLanguage,
filterBuildOutput,
filterSourceCode,
smartTruncate,
stripAnsiFast,
truncate,
} from "./techniques/index.js";
import { trackOutputSavings } from "./output-metrics.js";
import { mapTextContentBlocks, toRecord } from "./record-utils.js";
import type { RtkIntegrationConfig } from "./types.js";
interface ToolResultLikeEvent {
toolName: string;
input?: unknown;
content?: unknown;
}
export interface ToolResultCompactionMetadata {
applied: boolean;
techniques: string[];
truncated: boolean;
originalCharCount: number;
compactedCharCount: number;
originalLineCount: number;
compactedLineCount: number;
}
export interface ToolResultCompactionOutcome {
changed: boolean;
content?: unknown[];
techniques: string[];
metadata?: ToolResultCompactionMetadata;
}
interface AnchoredReadLine {
lineNumber: number;
content: string;
originalLine: string;
}
interface AnchorSafeReadLine {
text: string;
content: string;
}
interface AnchorSafeReadParts {
prefixLines: string[];
anchoredLines: AnchoredReadLine[];
suffixLines: string[];
trailingNewline: boolean;
}
const LOSSY_TECHNIQUE_PREFIXES = [
"build",
"test",
"git",
"linter",
"search",
"truncate",
"smart-truncate",
"source:",
] as const;
const READ_EXACT_OUTPUT_LINE_THRESHOLD = 80;
const READ_COMPACTION_BANNER_PREFIX = "[RTK compacted output:";
const ANCHORED_READ_LINE_MIN_MATCHES = 2;
const ANCHORED_READ_LINE_MIN_RATIO = 0.5;
const ANCHORED_READ_LINE_SAMPLE_LIMIT = 200;
const ANCHORED_READ_LINE_PATTERNS = [
/^\s*(?:>>>|>>|[>+\-*]+)?\s*(\d+)\s*#\s*[A-Za-z0-9_-]{2,32}:(.*)$/,
/^\s*(?:>>>|>>|[>+\-*]+)?\s*(\d+)\s*:\s*[A-Za-z0-9_-]{1,32}\|(.*)$/,
/^\s*(?:>>>|>>|[>+\-*]+)?\s*(\d+)[a-z]{2}\|(.*)$/,
] as const;
const ANCHORED_READ_INFORMATIONAL_LINE_PATTERN = /^\s*(?:$|<\/?file>|\.{3}|\[[^\]]+\]|Read\s+.+:\s+\d+\s+lines\b)/;
const USER_SKILL_ROOTS = [join(getAgentDir(), "skills"), join(homedir(), ".agents", "skills")];
function normalizePathForComparison(path: string): string {
return process.platform === "win32" ? path.toLowerCase() : path;
}
function isPathUnderRoot(targetPath: string, rootPath: string): boolean {
const normalizedTarget = normalizePathForComparison(resolve(targetPath));
const normalizedRoot = normalizePathForComparison(resolve(rootPath));
if (normalizedTarget === normalizedRoot) {
return true;
}
const rootWithSeparator = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
return normalizedTarget.startsWith(rootWithSeparator);
}
function isUnderAnyAncestorAgentsSkills(targetPath: string): boolean {
let currentDir = resolve(process.cwd());
while (true) {
if (isPathUnderRoot(targetPath, join(currentDir, ".agents", "skills"))) {
return true;
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
return false;
}
currentDir = parentDir;
}
}
function isSkillReadPath(filePath: string): boolean {
if (!filePath.trim()) {
return false;
}
const resolvedPath = resolve(filePath);
if (USER_SKILL_ROOTS.some((root) => isPathUnderRoot(resolvedPath, root))) {
return true;
}
if (isPathUnderRoot(resolvedPath, join(process.cwd(), ".pi", "skills"))) {
return true;
}
return isUnderAnyAncestorAgentsSkills(resolvedPath);
}
function toArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function normalizeCommand(input: Record<string, unknown>): string | undefined {
const raw = input.command;
if (typeof raw === "string" && raw.trim()) {
return raw;
}
return undefined;
}
function normalizePath(input: Record<string, unknown>): string {
const raw = input.path;
if (typeof raw === "string") {
return raw;
}
return "";
}
function hasExplicitReadRange(input: Record<string, unknown>): boolean {
return input.offset !== undefined || input.limit !== undefined;
}
function splitReadLines(text: string): { lines: string[]; trailingNewline: boolean } {
if (!text) {
return { lines: [], trailingNewline: false };
}
const trailingNewline = text.endsWith("\n");
const lines = text.split(/\r?\n/);
if (trailingNewline) {
lines.pop();
}
return { lines, trailingNewline };
}
function joinReadLines(lines: string[], trailingNewline: boolean): string {
const joined = lines.join("\n");
return trailingNewline && joined ? `${joined}\n` : joined;
}
function parseAnchoredReadLine(line: string): AnchoredReadLine | undefined {
for (const pattern of ANCHORED_READ_LINE_PATTERNS) {
const match = line.match(pattern);
if (!match) {
continue;
}
const lineNumber = Number.parseInt(match[1] ?? "", 10);
if (!Number.isSafeInteger(lineNumber) || lineNumber <= 0) {
continue;
}
const content = match[2] ?? "";
return {
lineNumber,
content,
originalLine: line,
};
}
return undefined;
}
function parseAnchoredReadLineNumber(line: string): number | undefined {
return parseAnchoredReadLine(line)?.lineNumber;
}
function looksLikeAnchoredLineOutput(text: string, parseLineNumber: (line: string) => number | undefined): boolean {
let matchCount = 0;
let relevantLineCount = 0;
let previousMatchedLineNumber: number | undefined;
let hasIncreasingAnchors = false;
for (const line of splitReadLines(text).lines.slice(0, ANCHORED_READ_LINE_SAMPLE_LIMIT)) {
if (!ANCHORED_READ_INFORMATIONAL_LINE_PATTERN.test(line)) {
relevantLineCount += 1;
}
const lineNumber = parseLineNumber(line);
if (lineNumber === undefined) {
continue;
}
matchCount += 1;
if (previousMatchedLineNumber !== undefined && lineNumber > previousMatchedLineNumber) {
hasIncreasingAnchors = true;
}
previousMatchedLineNumber = lineNumber;
}
if (matchCount < ANCHORED_READ_LINE_MIN_MATCHES || !hasIncreasingAnchors) {
return false;
}
const ratioBase = Math.max(relevantLineCount, matchCount);
return matchCount / ratioBase >= ANCHORED_READ_LINE_MIN_RATIO;
}
function looksLikeAnchoredReadOutput(text: string): boolean {
return looksLikeAnchoredLineOutput(text, parseAnchoredReadLineNumber);
}
function shouldPreserveExactReadOutput(
text: string,
input: Record<string, unknown>,
config: RtkIntegrationConfig,
): boolean {
if (!config.outputCompaction.readCompaction.enabled) {
return true;
}
if (hasExplicitReadRange(input)) {
return true;
}
if (config.outputCompaction.preserveExactSkillReads && isSkillReadPath(normalizePath(input))) {
return true;
}
return countLines(text) <= READ_EXACT_OUTPUT_LINE_THRESHOLD;
}
function shouldApplyReadSourceFiltering(text: string, config: RtkIntegrationConfig): boolean {
const compaction = config.outputCompaction;
const lineCount = countLines(text);
return (
(compaction.smartTruncate.enabled && lineCount > compaction.smartTruncate.maxLines) ||
(compaction.truncate.enabled && text.length > compaction.truncate.maxChars)
);
}
function extractAnchoredReadParts(text: string): AnchorSafeReadParts | undefined {
if (!looksLikeAnchoredReadOutput(text)) {
return undefined;
}
const { lines, trailingNewline } = splitReadLines(text);
const parsedLines = lines.map((line) => parseAnchoredReadLine(line));
const firstAnchorIndex = parsedLines.findIndex((line) => line !== undefined);
if (firstAnchorIndex === -1) {
return undefined;
}
let lastAnchorIndex = firstAnchorIndex;
for (let index = parsedLines.length - 1; index >= firstAnchorIndex; index -= 1) {
if (parsedLines[index] !== undefined) {
lastAnchorIndex = index;
break;
}
}
const anchoredLines: AnchoredReadLine[] = [];
for (let index = firstAnchorIndex; index <= lastAnchorIndex; index += 1) {
const anchoredLine = parsedLines[index];
if (!anchoredLine) {
return undefined;
}
anchoredLines.push(anchoredLine);
}
return {
prefixLines: lines.slice(0, firstAnchorIndex),
anchoredLines,
suffixLines: lines.slice(lastAnchorIndex + 1),
trailingNewline,
};
}
function toAnchorSafeReadLines(anchoredLines: AnchoredReadLine[]): AnchorSafeReadLine[] {
return anchoredLines.map((line) => ({
text: line.originalLine,
content: line.content,
}));
}
function renderAnchorSafeReadBody(lines: AnchorSafeReadLine[]): string {
return lines.map((line) => line.text).join("\n");
}
function renderAnchorSafeReadText(parts: AnchorSafeReadParts, lines: AnchorSafeReadLine[]): string {
return joinReadLines(
[...parts.prefixLines, ...lines.map((line) => line.text), ...parts.suffixLines],
parts.trailingNewline,
);
}
function remapTransformedContentToAnchorSafeLines(
sourceLines: AnchorSafeReadLine[],
transformedContent: string,
): AnchorSafeReadLine[] {
const transformedLines = splitReadLines(transformedContent).lines;
const remappedLines: AnchorSafeReadLine[] = [];
let searchStartIndex = 0;
for (const transformedLine of transformedLines) {
let matchedIndex = -1;
for (let index = searchStartIndex; index < sourceLines.length; index += 1) {
if (sourceLines[index]?.content === transformedLine) {
matchedIndex = index;
break;
}
}
if (matchedIndex === -1) {
remappedLines.push({
text: transformedLine,
content: transformedLine,
});
continue;
}
remappedLines.push(sourceLines[matchedIndex]!);
searchStartIndex = matchedIndex + 1;
}
return remappedLines;
}
function truncateAnchorSafeReadLines(lines: AnchorSafeReadLine[], maxChars: number): AnchorSafeReadLine[] {
if (renderAnchorSafeReadBody(lines).length <= maxChars) {
return lines;
}
const marker = "[RTK anchor-safe truncate: remaining anchored read lines omitted to preserve complete anchors]";
const truncatedLines: AnchorSafeReadLine[] = [];
let charCount = 0;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]!;
const separatorLength = truncatedLines.length > 0 ? 1 : 0;
const nextCharCount = charCount + separatorLength + line.text.length;
const remainingAfter = lines.length - index - 1;
const markerLength = remainingAfter > 0 ? (nextCharCount > 0 ? 1 : 0) + marker.length : 0;
if (nextCharCount + markerLength > maxChars) {
const markerLine = { text: marker, content: marker };
return truncatedLines.length > 0 ? [...truncatedLines, markerLine] : [markerLine];
}
truncatedLines.push(line);
charCount = nextCharCount;
}
return truncatedLines;
}
function compactAnchoredReadText(
text: string,
filePath: string,
config: RtkIntegrationConfig,
): { text: string; techniques: string[] } {
const parts = extractAnchoredReadParts(text);
if (!parts) {
return { text, techniques: [] };
}
let lines = toAnchorSafeReadLines(parts.anchoredLines);
const techniques: string[] = [];
const compaction = config.outputCompaction;
const language = detectLanguage(filePath);
if (
compaction.sourceCodeFilteringEnabled &&
compaction.sourceCodeFiltering !== "none" &&
shouldApplyReadSourceFiltering(text, config)
) {
const currentSource = lines.map((line) => line.content).join("\n");
const filtered = normalizeTechniqueResult(
filterSourceCode(currentSource, language, compaction.sourceCodeFiltering),
currentSource,
);
const filteredLines = remapTransformedContentToAnchorSafeLines(lines, filtered);
if (renderAnchorSafeReadBody(filteredLines) !== renderAnchorSafeReadBody(lines)) {
lines = filteredLines;
techniques.push(`source:${compaction.sourceCodeFiltering}`);
}
}
if (compaction.smartTruncate.enabled && lines.length > compaction.smartTruncate.maxLines) {
const currentSource = lines.map((line) => line.content).join("\n");
const compacted = smartTruncate(currentSource, compaction.smartTruncate.maxLines, language);
const compactedLines = remapTransformedContentToAnchorSafeLines(lines, compacted);
if (renderAnchorSafeReadBody(compactedLines) !== renderAnchorSafeReadBody(lines)) {
lines = compactedLines;
techniques.push("smart-truncate");
}
}
if (compaction.truncate.enabled && renderAnchorSafeReadText(parts, lines).length > compaction.truncate.maxChars) {
const nonBodyOverhead = renderAnchorSafeReadText(parts, []).length;
const bodyMaxChars = Math.max(1, compaction.truncate.maxChars - nonBodyOverhead);
const truncatedLines = truncateAnchorSafeReadLines(lines, bodyMaxChars);
if (renderAnchorSafeReadBody(truncatedLines) !== renderAnchorSafeReadBody(lines)) {
lines = truncatedLines;
techniques.push("truncate");
}
}
return {
text: renderAnchorSafeReadText(parts, lines),
techniques,
};
}
function formatReadCompactionBanner(techniques: string[]): string {
return `${READ_COMPACTION_BANNER_PREFIX} ${techniques.join(", ")}]`;
}
function countLines(text: string): number {
if (!text) {
return 0;
}
const normalized = text.endsWith("\n") ? text.slice(0, -1) : text;
if (!normalized) {
return 1;
}
return normalized.split("\n").length;
}
function hasLossyCompaction(techniques: string[]): boolean {
return techniques.some((technique) =>
LOSSY_TECHNIQUE_PREFIXES.some((prefix) =>
prefix.endsWith(":") ? technique.startsWith(prefix) : technique === prefix,
),
);
}
function normalizeTechniqueResult(result: string | null, currentText: string): string {
return result === null ? currentText : result;
}
interface CompactionState {
text: string;
techniques: string[];
}
/** Strips ANSI escape codes when enabled, recording the "ansi" technique on change. */
function applyAnsiStripping(state: CompactionState, compaction: RtkIntegrationConfig["outputCompaction"]): void {
if (!compaction.stripAnsi) {
return;
}
const stripped = stripAnsiFast(state.text);
if (stripped !== state.text) {
state.text = stripped;
state.techniques.push("ansi");
}
}
/** Applies hard character truncation when enabled and the threshold is exceeded. */
function applyTruncation(state: CompactionState, compaction: RtkIntegrationConfig["outputCompaction"]): void {
if (compaction.truncate.enabled && state.text.length > compaction.truncate.maxChars) {
state.text = truncate(state.text, compaction.truncate.maxChars);
state.techniques.push("truncate");
}
}
/**
* Applies a single nullable-result compaction technique: runs `transform`, keeps
* its result when it differs from the current text, and records `technique`.
* Mirrors the `normalizeTechniqueResult(...) !== current → push` idiom shared
* across the bash/read compactors.
*/
function applyNullableTechnique(
state: CompactionState,
transform: (text: string) => string | null,
technique: string,
): void {
const compacted = normalizeTechniqueResult(transform(state.text), state.text);
if (compacted !== state.text) {
state.text = compacted;
state.techniques.push(technique);
}
}
function applyConditionalTechnique(
state: CompactionState,
enabled: boolean,
transform: (text: string) => string | null,
technique: string,
): void {
if (enabled) {
applyNullableTechnique(state, transform, technique);
}
}
function beginCompaction(
text: string,
config: RtkIntegrationConfig,
): { state: CompactionState; compaction: RtkIntegrationConfig["outputCompaction"] } {
const state: CompactionState = { text, techniques: [] };
const compaction = config.outputCompaction;
applyAnsiStripping(state, compaction);
return { state, compaction };
}
function applyReadCompactionBanner(state: CompactionState): void {
if (state.techniques.length > 0 && !state.text.startsWith(READ_COMPACTION_BANNER_PREFIX)) {
state.text = `${formatReadCompactionBanner(state.techniques)}\n${state.text}`;
}
}
function compactBashText(
text: string,
command: string | undefined,
config: RtkIntegrationConfig,
): { text: string; techniques: string[] } {
const { state, compaction } = beginCompaction(text, config);
applyConditionalTechnique(state, compaction.filterBuildOutput, (t) => filterBuildOutput(t, command), "build");
applyConditionalTechnique(state, compaction.aggregateTestOutput, (t) => aggregateTestOutput(t, command), "test");
applyConditionalTechnique(state, compaction.compactGitOutput, (t) => compactGitOutput(t, command), "git");
applyConditionalTechnique(state, compaction.aggregateLinterOutput, (t) => aggregateLinterOutput(t, command), "linter");
applyTruncation(state, compaction);
return { text: state.text, techniques: state.techniques };
}
function compactReadText(
text: string,
filePath: string,
config: RtkIntegrationConfig,
preserveExactReadOutput: boolean,
): { text: string; techniques: string[] } {
if (preserveExactReadOutput) {
return { text, techniques: [] };
}
const { state, compaction } = beginCompaction(text, config);
if (looksLikeAnchoredReadOutput(state.text)) {
const anchored = compactAnchoredReadText(state.text, filePath, config);
state.text = anchored.text;
state.techniques.push(...anchored.techniques);
applyReadCompactionBanner(state);
return { text: state.text, techniques: state.techniques };
}
const language = detectLanguage(filePath);
// Only apply lossy source filtering when a downstream line/char safeguard would otherwise trigger.
if (
compaction.sourceCodeFilteringEnabled &&
compaction.sourceCodeFiltering !== "none" &&
shouldApplyReadSourceFiltering(text, config)
) {
applyNullableTechnique(
state,
(t) => filterSourceCode(t, language, compaction.sourceCodeFiltering),
`source:${compaction.sourceCodeFiltering}`,
);
}
if (compaction.smartTruncate.enabled) {
const lineCount = state.text.split("\n").length;
if (lineCount > compaction.smartTruncate.maxLines) {
const compacted = smartTruncate(state.text, compaction.smartTruncate.maxLines, language);
if (compacted !== state.text) {
state.text = compacted;
state.techniques.push("smart-truncate");
}
}
}
applyTruncation(state, compaction);
applyReadCompactionBanner(state);
return { text: state.text, techniques: state.techniques };
}
export function compactToolResult(
event: ToolResultLikeEvent,
config: RtkIntegrationConfig,
): ToolResultCompactionOutcome {
if (!config.outputCompaction.enabled) {
return { changed: false, techniques: [] };
}
const input = toRecord(event.input);
const sourceContent = toArray(event.content);
if (sourceContent.length === 0) {
return { changed: false, techniques: [] };
}
const allTechniques = new Set<string>();
const originalChunks: string[] = [];
const filteredChunks: string[] = [];
const { changed, mapped: nextContent } = mapTextContentBlocks(sourceContent, (contentBlock) => {
let transformed = { text: contentBlock.text, techniques: [] as string[] };
if (event.toolName === "bash") {
transformed = compactBashText(contentBlock.text, normalizeCommand(input), config);
} else if (event.toolName === "read") {
const normalizedPath = normalizePath(input);
transformed = compactReadText(
contentBlock.text,
normalizedPath,
config,
shouldPreserveExactReadOutput(contentBlock.text, input, config),
);
}
for (const technique of transformed.techniques) {
allTechniques.add(technique);
}
originalChunks.push(contentBlock.text);
filteredChunks.push(transformed.text);
return transformed.text !== contentBlock.text ? transformed.text : null;
});
if (!changed) {
return { changed: false, techniques: [] };
}
const techniques = Array.from(allTechniques);
const originalText = originalChunks.join("\n");
const compactedText = filteredChunks.join("\n");
if (config.outputCompaction.trackSavings) {
trackOutputSavings(originalText, compactedText, event.toolName, techniques);
}
const metadata: ToolResultCompactionMetadata = {
applied: true,
techniques,
truncated: hasLossyCompaction(techniques),
originalCharCount: originalText.length,
compactedCharCount: compactedText.length,
originalLineCount: countLines(originalText),
compactedLineCount: countLines(compactedText),
};
return {
changed: true,
content: nextContent,
techniques,
metadata,
};
}
+69
View File
@@ -0,0 +1,69 @@
export interface OutputMetricRecord {
timestamp: string;
tool: string;
techniques: string;
originalChars: number;
filteredChars: number;
savingsPercent: number;
}
const outputMetrics: OutputMetricRecord[] = [];
export function trackOutputSavings(
original: string,
filtered: string,
tool: string,
techniques: string[],
): OutputMetricRecord {
const originalChars = original.length;
const filteredChars = filtered.length;
const savingsPercent =
originalChars > 0 ? Math.round((((originalChars - filteredChars) / originalChars) * 100) * 100) / 100 : 0;
const record: OutputMetricRecord = {
timestamp: new Date().toISOString(),
tool,
techniques: techniques.join(",") || "none",
originalChars,
filteredChars,
savingsPercent,
};
outputMetrics.push(record);
return record;
}
export function clearOutputMetrics(): void {
outputMetrics.length = 0;
}
export function getOutputMetricsSummary(): string {
if (outputMetrics.length === 0) {
return "RTK output compaction metrics: no data yet.";
}
const totalOriginal = outputMetrics.reduce((sum, metric) => sum + metric.originalChars, 0);
const totalFiltered = outputMetrics.reduce((sum, metric) => sum + metric.filteredChars, 0);
const totalSaved = totalOriginal - totalFiltered;
const savingsPercent = totalOriginal > 0 ? (totalSaved / totalOriginal) * 100 : 0;
const byTool = new Map<string, { count: number; originalChars: number; filteredChars: number }>();
for (const metric of outputMetrics) {
const existing = byTool.get(metric.tool) ?? { count: 0, originalChars: 0, filteredChars: 0 };
existing.count += 1;
existing.originalChars += metric.originalChars;
existing.filteredChars += metric.filteredChars;
byTool.set(metric.tool, existing);
}
let result = "RTK output compaction metrics\n";
result += `calls=${outputMetrics.length}, saved=${totalSaved.toLocaleString()} chars (${savingsPercent.toFixed(1)}%)\n`;
for (const [tool, stats] of byTool.entries()) {
const toolSaved = stats.originalChars - stats.filteredChars;
const toolSavingsPercent = stats.originalChars > 0 ? (toolSaved / stats.originalChars) * 100 : 0;
result += `- ${tool}: ${stats.count} calls, saved ${toolSaved.toLocaleString()} chars (${toolSavingsPercent.toFixed(1)}%)\n`;
}
return result.trimEnd();
}
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { runTest } from "./test-helpers.test.ts";
type PackageLockPackage = {
version?: unknown;
bin?: Record<string, unknown>;
};
type PackageLock = {
packages?: Record<string, PackageLockPackage>;
};
const packageLockPath = fileURLToPath(new URL("../package-lock.json", import.meta.url));
function loadPackageLock(): PackageLock {
return JSON.parse(readFileSync(packageLockPath, "utf-8")) as PackageLock;
}
function packageEntries(): Array<[string, PackageLockPackage]> {
return Object.entries(loadPackageLock().packages ?? {});
}
runTest("package-lock is install-safe and npm-idempotent", () => {
const entries = packageEntries();
const missingVersions = entries
.filter(([packagePath, metadata]) => packagePath !== "" && typeof metadata.version !== "string")
.map(([packagePath]) => packagePath);
const unnormalizedBinPaths = entries.flatMap(([packagePath, metadata]) =>
Object.entries(metadata.bin ?? {})
.filter(([, binPath]) => typeof binPath === "string" && binPath.startsWith("./"))
.map(([binName, binPath]) => `${packagePath}:${binName}=${binPath}`),
);
assert.deepEqual(
{ missingVersions, unnormalizedBinPaths },
{ missingVersions: [], unnormalizedBinPaths: [] },
[
"package-lock.json must be safe for npm install --omit=dev and stay unchanged after install.",
"Missing versions reproduce npm's Invalid Version failure.",
"Leading ./ bin paths reproduce npm lockfile normalization diffs after install.",
].join(" "),
);
});
console.log("All package-lock integrity tests passed.");
+50
View File
@@ -0,0 +1,50 @@
export function toRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
export interface TextContentBlock {
type: string;
text?: string;
[key: string]: unknown;
}
/**
* Narrows an unknown tool-result content block to a text block carrying a
* string `text` payload. Shared by the compactor and the streaming sanitizer
* so both walk content blocks with one consistent guard.
*/
export function isTextContentBlock(block: unknown): block is TextContentBlock & { text: string } {
if (!block || typeof block !== "object" || Array.isArray(block)) {
return false;
}
const contentBlock = block as TextContentBlock;
return contentBlock.type === "text" && typeof contentBlock.text === "string";
}
/**
* Walks tool-result content blocks, invoking `transform` for each text block.
* Returns the mapped content and whether any text block changed. Non-text
* blocks pass through untouched. Shared by the compactor and the streaming
* sanitizer so both walk content with one consistent loop.
*/
export function mapTextContentBlocks(
content: unknown[],
transform: (block: TextContentBlock & { text: string }) => string | null,
): { changed: boolean; mapped: unknown[] } {
let changed = false;
const mapped = content.map((block) => {
if (!isTextContentBlock(block)) {
return block;
}
const nextText = transform(block);
if (nextText === null || nextText === block.text) {
return block;
}
changed = true;
return { ...block, text: nextText };
});
return { changed, mapped };
}
@@ -0,0 +1,162 @@
import { splitLeadingEnvAssignments } from "./shell-env-prefix.js";
import { advanceQuoteEscapeState, readShellChars, type QuoteEscapeState } from "./shell-quote-state.js";
interface ParsedPipeline {
segments: string[];
separators: string[];
suffix: string;
}
interface ProducerRewritePlan {
command: string;
captureStderr: boolean;
}
interface ShellSafetyTarget {
environmentPrelude: string;
command: string;
}
const SINGLE_QUOTED_SHELL_VALUE_PATTERN = "'(?:'\\\\''|[^'])*'";
const SHELL_ENV_VALUE_PATTERN = `(?:"(?:\\\\.|[^"])*"|${SINGLE_QUOTED_SHELL_VALUE_PATTERN}|[^\\s;]+)`;
const LEADING_RTK_DB_PATH_EXPORT_PRELUDE_PATTERN = new RegExp(
`^(\\s*export\\s+RTK_DB_PATH=${SHELL_ENV_VALUE_PATTERN}\\s*;\\s*)([\\s\\S]*)$`,
"u",
);
function splitLeadingRtkDbPathExportPrelude(command: string): ShellSafetyTarget {
const match = command.match(LEADING_RTK_DB_PATH_EXPORT_PRELUDE_PATTERN);
if (!match) {
return { environmentPrelude: "", command };
}
return {
environmentPrelude: match[1] ?? "",
command: match[2] ?? "",
};
}
function parseSimpleTopLevelPipeline(command: string): ParsedPipeline | null {
const segments: string[] = [];
const separators: string[] = [];
const state: QuoteEscapeState = { quote: null, escaped: false };
let segmentStart = 0;
let suffix = "";
for (let index = 0; index < command.length; index += 1) {
const { character, nextCharacter } = readShellChars(command, index);
const previousCharacter = index > 0 ? (command[index - 1] ?? "") : "";
if (advanceQuoteEscapeState(state, character, "\"'`")) {
continue;
}
if (
(character === "|" && nextCharacter === "|") ||
(character === "&" && nextCharacter === "&") ||
character === ";"
) {
if (separators.length === 0) {
return null;
}
segments.push(command.slice(segmentStart, index));
suffix = command.slice(index);
break;
}
if (character === "|" && previousCharacter !== ">") {
const separatorLength = nextCharacter === "&" ? 2 : 1;
segments.push(command.slice(segmentStart, index));
separators.push(command.slice(index, index + separatorLength));
segmentStart = index + separatorLength;
if (separatorLength === 2) {
index += 1;
}
continue;
}
if (character === "&" && nextCharacter !== ">" && previousCharacter !== ">" && previousCharacter !== "<") {
return null;
}
}
if (separators.length === 0) {
return null;
}
if (!suffix) {
segments.push(command.slice(segmentStart));
}
return { segments, separators, suffix };
}
function extractProducerRewritePlan(segment: string, firstSeparator: string): ProducerRewritePlan | null {
const trimmed = segment.trim();
const { envPrefix, command: commandWithOptionalRedirect } = splitLeadingEnvAssignments(trimmed);
if (!/^rtk\s+/i.test(commandWithOptionalRedirect)) {
return null;
}
const stderrMergeMatch = commandWithOptionalRedirect.match(/^(.*?)(?:\s+)?2>\s*&1\s*$/u);
if (stderrMergeMatch) {
const command = stderrMergeMatch[1]?.trimEnd() ?? "";
return command ? { command: `${envPrefix}${command}`.trim(), captureStderr: true } : null;
}
return {
command: `${envPrefix}${commandWithOptionalRedirect}`.trim(),
captureStderr: firstSeparator === "|&",
};
}
function buildBufferedPipelineCommand(
producer: ProducerRewritePlan,
remainder: string,
): string {
const tempFileVariable = "__pi_rtk_pipe_tmp";
const statusVariable = "__pi_rtk_pipe_status";
const producerRedirect = producer.captureStderr ? `> "$${tempFileVariable}" 2>&1` : `> "$${tempFileVariable}"`;
const cleanupTrap = `rm -f "$${tempFileVariable}"`;
return [
"{",
`${tempFileVariable}="$(mktemp)" || exit $?;`,
`${statusVariable}=0;`,
`trap '${cleanupTrap}' EXIT HUP INT TERM;`,
`${producer.command} ${producerRedirect};`,
`${statusVariable}=$?;`,
`if [ $${statusVariable} -eq 0 ]; then (${remainder}) < "$${tempFileVariable}"; ${statusVariable}=$?; fi;`,
`exit $${statusVariable};`,
"}",
].join(" ");
}
export function applyRewrittenCommandShellSafetyFixups(command: string, platform: string = process.platform): string {
if (platform !== "win32") {
return command;
}
const target = splitLeadingRtkDbPathExportPrelude(command);
const parsedPipeline = parseSimpleTopLevelPipeline(target.command);
if (!parsedPipeline) {
return command;
}
const producer = extractProducerRewritePlan(parsedPipeline.segments[0] ?? "", parsedPipeline.separators[0] ?? "");
if (!producer) {
return command;
}
const remainder = parsedPipeline.segments
.slice(1)
.map((segment, index) => `${index === 0 ? "" : (parsedPipeline.separators[index] ?? "")}${segment}`)
.join("")
.trim();
if (!remainder) {
return command;
}
const suffix = parsedPipeline.suffix ? ` ${parsedPipeline.suffix.trimStart()}` : "";
return `${target.environmentPrelude}${buildBufferedPipelineCommand(producer, remainder)}${suffix}`;
}
@@ -0,0 +1,77 @@
import { join } from "node:path";
import { splitLeadingEnvAssignments } from "./shell-env-prefix.js";
const RTK_DB_PATH_ENV_NAME = "RTK_DB_PATH";
const SINGLE_QUOTED_SHELL_VALUE_PATTERN = "'(?:'\\\\''|[^'])*'";
const SHELL_ENV_VALUE_PATTERN = `(?:"[^"]*"|${SINGLE_QUOTED_SHELL_VALUE_PATTERN}|[^\\s;]+)`;
const RTK_DB_PATH_ASSIGNMENT_PATTERN = new RegExp(
`(?:^|\\s)RTK_DB_PATH=${SHELL_ENV_VALUE_PATTERN}(?=\\s|$)`,
);
const RTK_DB_PATH_EXPORT_PATTERN = new RegExp(`^export\\s+RTK_DB_PATH=${SHELL_ENV_VALUE_PATTERN}(?=\\s*(?:;|$))`);
function resolveTemporaryDirectory(): string {
if (process.platform === "win32") {
const windowsTempDir = process.env.TEMP ?? process.env.TMP;
if (windowsTempDir && windowsTempDir.trim()) {
return windowsTempDir;
}
const localAppData = process.env.LOCALAPPDATA;
if (localAppData && localAppData.trim()) {
return join(localAppData, "Temp");
}
const userProfile = process.env.USERPROFILE;
if (userProfile && userProfile.trim()) {
return join(userProfile, "AppData", "Local", "Temp");
}
const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
if (systemRoot && systemRoot.trim()) {
return join(systemRoot, "Temp");
}
return "C:/Windows/Temp";
}
const posixTempDir = process.env.TMPDIR ?? process.env.TMP;
if (posixTempDir && posixTempDir.trim()) {
return posixTempDir;
}
return "/tmp";
}
function getTemporaryRtkHistoryDbPath(): string {
return join(resolveTemporaryDirectory(), "pi-rtk-optimizer", "history.db");
}
function quoteForShellEnv(value: string): string {
const normalizedValue = process.platform === "win32" ? value.replace(/\\/g, "/") : value;
return `'${normalizedValue.replace(/'/g, `'\\''`)}'`;
}
function hasLeadingRtkDbPathAssignment(command: string): boolean {
const trimmed = command.trimStart();
return (
RTK_DB_PATH_ASSIGNMENT_PATTERN.test(splitLeadingEnvAssignments(trimmed).envPrefix) ||
RTK_DB_PATH_EXPORT_PATTERN.test(trimmed)
);
}
function hasInheritedRtkDbPath(): boolean {
return Boolean(process.env[RTK_DB_PATH_ENV_NAME]?.trim());
}
export function applyRtkCommandEnvironment(command: string): string {
if (!command.trim()) {
return command;
}
if (hasLeadingRtkDbPathAssignment(command) || hasInheritedRtkDbPath()) {
return command;
}
return `export ${RTK_DB_PATH_ENV_NAME}=${quoteForShellEnv(getTemporaryRtkHistoryDbPath())}; ${command}`;
}
@@ -0,0 +1,97 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export type RtkExecutableResolverName = "where" | "which";
export interface RtkExecutableResolution {
command: string;
resolvedPath?: string;
resolver: RtkExecutableResolverName;
warning?: string;
}
interface ResolverCommand {
command: RtkExecutableResolverName;
args: string[];
}
export interface ResolveRtkExecutableOptions {
platform?: typeof process.platform;
timeoutMs?: number;
}
function trimResolutionDetail(value: string | undefined): string {
return (value ?? "").replace(/\s+/g, " ").trim();
}
function stripWrappingQuotes(value: string): string {
if (value.length < 2) {
return value;
}
const first = value[0];
const last = value[value.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
return value.slice(1, -1);
}
return value;
}
export function parseRtkExecutablePath(stdout: string): string | undefined {
for (const line of stdout.split(/\r?\n/)) {
const candidate = stripWrappingQuotes(line.trim());
if (candidate) {
return candidate;
}
}
return undefined;
}
function getResolverCommand(platform: typeof process.platform): ResolverCommand {
if (platform === "win32") {
return { command: "where", args: ["rtk"] };
}
return { command: "which", args: ["rtk"] };
}
function fallbackResolution(resolver: RtkExecutableResolverName, warning: string): RtkExecutableResolution {
return {
command: "rtk",
resolver,
warning,
};
}
export async function resolveRtkExecutable(
pi: ExtensionAPI,
options: ResolveRtkExecutableOptions = {},
): Promise<RtkExecutableResolution> {
const resolver = getResolverCommand(options.platform ?? process.platform);
const timeout = options.timeoutMs ?? 1000;
try {
const result = await pi.exec(resolver.command, resolver.args, { timeout });
const resolvedPath = parseRtkExecutablePath(result.stdout ?? "");
if (result.code === 0 && resolvedPath) {
return {
command: resolvedPath,
resolvedPath,
resolver: resolver.command,
};
}
const detail = trimResolutionDetail(result.stderr || result.stdout || `exit ${result.code}`);
return fallbackResolution(
resolver.command,
`rtk executable path resolution via ${resolver.command} failed${detail ? `: ${detail}` : ""}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return fallbackResolution(
resolver.command,
`rtk executable path resolution via ${resolver.command} failed: ${trimResolutionDetail(message)}`,
);
}
}
@@ -0,0 +1,126 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { resolveRtkExecutable, type RtkExecutableResolution } from "./rtk-executable-resolver.js";
export interface RtkRewriteProviderResult {
changed: boolean;
originalCommand: string;
rewrittenCommand: string;
exitCode: number;
error?: string;
executableResolution?: RtkExecutableResolution;
}
export interface RtkRewriteProviderOptions {
timeoutMs?: number;
resolverTimeoutMs?: number;
platform?: typeof process.platform;
executableResolution?: RtkExecutableResolution;
}
function isAlreadyRtk(command: string): boolean {
const trimmed = command.trimStart();
return trimmed === "rtk" || trimmed.startsWith("rtk ");
}
function normalizeOptions(optionsOrTimeout: number | RtkRewriteProviderOptions): RtkRewriteProviderOptions {
if (typeof optionsOrTimeout === "number") {
return { timeoutMs: optionsOrTimeout };
}
return optionsOrTimeout;
}
export async function resolveRtkRewrite(
pi: ExtensionAPI,
command: string,
optionsOrTimeout: number | RtkRewriteProviderOptions = {},
): Promise<RtkRewriteProviderResult> {
const options = normalizeOptions(optionsOrTimeout);
const timeoutMs = options.timeoutMs ?? 3000;
if (!command || !command.trim()) {
return { changed: false, originalCommand: command, rewrittenCommand: command, exitCode: 1 };
}
if (isAlreadyRtk(command)) {
return { changed: false, originalCommand: command, rewrittenCommand: command, exitCode: 1 };
}
try {
const executableResolution =
options.executableResolution ??
(await resolveRtkExecutable(pi, {
platform: options.platform,
timeoutMs: options.resolverTimeoutMs,
}));
const result = await pi.exec(executableResolution.command, ["rewrite", command], { timeout: timeoutMs });
if (result.code === 1) {
return {
changed: false,
originalCommand: command,
rewrittenCommand: command,
exitCode: 1,
executableResolution,
};
}
if (result.code === 2) {
return {
changed: false,
originalCommand: command,
rewrittenCommand: command,
exitCode: 2,
error: result.stderr?.trim() || "rtk denied rewrite",
executableResolution,
};
}
if (result.code === 0 || result.code === 3) {
const rewritten = result.stdout?.trim();
if (!rewritten) {
return {
changed: false,
originalCommand: command,
rewrittenCommand: command,
exitCode: result.code,
error: "rtk returned empty output",
executableResolution,
};
}
if (rewritten === command) {
return {
changed: false,
originalCommand: command,
rewrittenCommand: command,
exitCode: result.code,
executableResolution,
};
}
return {
changed: true,
originalCommand: command,
rewrittenCommand: rewritten,
exitCode: result.code,
executableResolution,
};
}
return {
changed: false,
originalCommand: command,
rewrittenCommand: command,
exitCode: result.code,
error: `unexpected exit code ${result.code}`,
executableResolution,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
changed: false,
originalCommand: command,
rewrittenCommand: command,
exitCode: -1,
error: message,
};
}
}
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import {
shouldRequireRtkAvailabilityForCommandHandling,
shouldSkipCommandHandlingWhenRtkMissing,
} from "./runtime-guard.ts";
import { cloneDefaultConfig, runTest } from "./test-helpers.test.ts";
import type { RuntimeStatus } from "./types.ts";
function runtimeStatus(rtkAvailable: boolean): RuntimeStatus {
return { rtkAvailable };
}
runTest("rewrite mode still requires RTK availability when guard is enabled", () => {
const config = cloneDefaultConfig();
config.commandRewritingEnabled = true;
config.mode = "rewrite";
config.guardWhenRtkMissing = true;
assert.equal(shouldRequireRtkAvailabilityForCommandHandling(config), true);
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(false)), true);
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(true)), false);
});
runTest("suggest mode uses RTK availability guard to avoid repeated missing-binary rewrite probes", () => {
const config = cloneDefaultConfig();
config.commandRewritingEnabled = true;
config.mode = "suggest";
config.guardWhenRtkMissing = true;
assert.equal(shouldRequireRtkAvailabilityForCommandHandling(config), true);
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(false)), true);
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(true)), false);
});
runTest("guard disabled never blocks command handling", () => {
const config = cloneDefaultConfig();
config.commandRewritingEnabled = true;
config.mode = "rewrite";
config.guardWhenRtkMissing = false;
assert.equal(shouldRequireRtkAvailabilityForCommandHandling(config), false);
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(false)), false);
});
runTest("disabled command rewriting never requires the RTK binary", () => {
const config = cloneDefaultConfig();
config.commandRewritingEnabled = false;
config.guardWhenRtkMissing = true;
assert.equal(shouldRequireRtkAvailabilityForCommandHandling(config), false);
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(false)), false);
});
console.log("All runtime-guard tests passed.");
+14
View File
@@ -0,0 +1,14 @@
import type { RtkIntegrationConfig, RuntimeStatus } from "./types.js";
export function shouldRequireRtkAvailabilityForCommandHandling(
config: Pick<RtkIntegrationConfig, "commandRewritingEnabled" | "guardWhenRtkMissing">,
): boolean {
return config.commandRewritingEnabled && config.guardWhenRtkMissing;
}
export function shouldSkipCommandHandlingWhenRtkMissing(
config: Pick<RtkIntegrationConfig, "commandRewritingEnabled" | "guardWhenRtkMissing">,
runtimeStatus: Pick<RuntimeStatus, "rtkAvailable">,
): boolean {
return shouldRequireRtkAvailabilityForCommandHandling(config) && !runtimeStatus.rtkAvailable;
}
+18
View File
@@ -0,0 +1,18 @@
const SINGLE_QUOTED_SHELL_VALUE_PATTERN = "'(?:'\\\\''|[^'])*'";
const ENV_ASSIGNMENT_VALUE_PATTERN = `(?:"[^"]*"|${SINGLE_QUOTED_SHELL_VALUE_PATTERN}|[^\\s]+)`;
const LEADING_ENV_ASSIGNMENT_PATTERN = new RegExp(
`^((?:[A-Za-z_][A-Za-z0-9_]*=${ENV_ASSIGNMENT_VALUE_PATTERN}\\s+)*)`,
);
export interface LeadingEnvAssignmentSplit {
envPrefix: string;
command: string;
}
export function splitLeadingEnvAssignments(input: string): LeadingEnvAssignmentSplit {
const envPrefix = input.match(LEADING_ENV_ASSIGNMENT_PATTERN)?.[1] ?? "";
return {
envPrefix,
command: input.slice(envPrefix.length),
};
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Shared shell quote/escape state machine used by command-parsing helpers.
*
* Both the top-level pipeline splitter and the leading-`cd /d` parser walk a
* command string character-by-character while tracking whether the cursor is
* inside a quoted region and whether the current character is backslash-
* escaped. This helper advances that state for one character so the two parsers
* do not duplicate the transition logic.
*
* `quoteChars` selects which characters open a quote (e.g. `'"\'\`'` for the
* pipeline parser, `'"\'` for the `cd /d` parser), preserving each caller's
* exact quote semantics.
*
* Returns `true` when the character is consumed by the state machine (caller
* should `continue` to the next character); returns `false` when the character
* is a top-level, unquoted, unescaped token the caller must interpret.
*/
export interface QuoteEscapeState {
quote: string | null;
escaped: boolean;
}
export function advanceQuoteEscapeState(
state: QuoteEscapeState,
character: string,
quoteChars: string,
): boolean {
if (state.escaped) {
state.escaped = false;
return true;
}
if (state.quote !== null) {
if (character === "\\" && state.quote !== "'") {
state.escaped = true;
return true;
}
if (character === state.quote) {
state.quote = null;
}
return true;
}
if (character === "\\") {
state.escaped = true;
return true;
}
if (quoteChars.includes(character)) {
state.quote = character;
return true;
}
return false;
}
/**
* Reads the current and next character from a command string at `index`,
* returning empty strings past either end so callers can compare without
* bounds checks. Shared by the command-parsing helpers that walk a command
* character-by-character.
*/
export function readShellChars(
command: string,
index: number,
): { character: string; nextCharacter: string } {
return {
character: command[index] ?? "",
nextCharacter: command[index + 1] ?? "",
};
}
+13
View File
@@ -0,0 +1,13 @@
export function stripAnsi(text: string): string {
return text
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
.replace(/\x1b\][0-9;]*(?:\x07|\x1b\\)/g, "")
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
}
export function stripAnsiFast(text: string): string {
if (!text.includes("\x1b")) {
return text;
}
return stripAnsi(text);
}
+155
View File
@@ -0,0 +1,155 @@
import { matchesCommandPatterns } from "./command-detection.js";
interface BuildStats {
compiled: number;
errors: string[][];
warnings: string[];
}
const BUILD_COMMAND_PATTERNS = [
/^cargo\s+(build|check)\b/,
/^bun\s+build\b/,
/^npm\s+run\s+build\b/,
/^yarn\s+build\b/,
/^pnpm\s+build\b/,
/^(?:npx\s+)?tsc\b/,
/^make\b/,
/^cmake\b/,
/^gradle\b/,
/^mvn\b/,
/^go\s+(build|install)\b/,
/^python\s+setup\.py\s+build\b/,
/^pip\s+install\b/,
] as const;
const SKIP_PATTERNS = [
/^\s*Compiling\s+/,
/^\s*Checking\s+/,
/^\s*Downloading\s+/,
/^\s*Downloaded\s+/,
/^\s*Fetching\s+/,
/^\s*Fetched\s+/,
/^\s*Updating\s+/,
/^\s*Updated\s+/,
/^\s*Building\s+/,
/^\s*Generated\s+/,
/^\s*Creating\s+/,
/^\s*Running\s+/,
];
const ERROR_START_PATTERNS = [/^error\[/, /^error:/, /^\[ERROR\]/, /^FAIL/];
const WARNING_PATTERNS = [/^warning:/, /^\[WARNING\]/, /^warn:/];
function isSkipLine(line: string): boolean {
return SKIP_PATTERNS.some((pattern) => pattern.test(line));
}
function isErrorStart(line: string): boolean {
return ERROR_START_PATTERNS.some((pattern) => pattern.test(line));
}
function isWarning(line: string): boolean {
return WARNING_PATTERNS.some((pattern) => pattern.test(line));
}
export function isBuildCommand(command: string | undefined | null): boolean {
return matchesCommandPatterns(command, BUILD_COMMAND_PATTERNS);
}
export function filterBuildOutput(output: string, command: string | undefined | null): string | null {
if (!isBuildCommand(command)) {
return null;
}
const lines = output.split("\n");
const stats: BuildStats = {
compiled: 0,
errors: [],
warnings: [],
};
let inErrorBlock = false;
let currentError: string[] = [];
let blankCount = 0;
for (const line of lines) {
if (line.match(/^\s*(Compiling|Checking|Building)\s+/)) {
stats.compiled++;
continue;
}
if (isSkipLine(line)) {
continue;
}
if (isErrorStart(line)) {
if (inErrorBlock && currentError.length > 0) {
stats.errors.push([...currentError]);
}
inErrorBlock = true;
currentError = [line];
blankCount = 0;
continue;
}
if (isWarning(line)) {
stats.warnings.push(line);
continue;
}
if (!inErrorBlock) {
continue;
}
if (line.trim() === "") {
blankCount++;
if (blankCount >= 2 && currentError.length > 3) {
stats.errors.push([...currentError]);
inErrorBlock = false;
currentError = [];
} else {
currentError.push(line);
}
continue;
}
if (line.match(/^\s/) || line.match(/^-->/)) {
currentError.push(line);
blankCount = 0;
continue;
}
stats.errors.push([...currentError]);
inErrorBlock = false;
currentError = [];
}
if (inErrorBlock && currentError.length > 0) {
stats.errors.push(currentError);
}
if (stats.errors.length === 0 && stats.warnings.length === 0) {
return `[OK] Build successful (${stats.compiled} units compiled)`;
}
const result: string[] = [];
if (stats.errors.length > 0) {
result.push(`[ERROR] ${stats.errors.length} error(s):`);
for (const error of stats.errors.slice(0, 5)) {
result.push(...error.slice(0, 10));
if (error.length > 10) {
result.push(" ...");
}
}
if (stats.errors.length > 5) {
result.push(`... and ${stats.errors.length - 5} more errors`);
}
}
if (stats.warnings.length > 0) {
result.push(`\n[WARN] ${stats.warnings.length} warning(s)`);
}
return result.join("\n");
}
@@ -0,0 +1,53 @@
const ENV_PREFIX_PATTERN = /^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+)\s+)*/;
const CHAIN_OPERATORS = ["&&", "||", ";", "|"] as const;
function sliceFirstSegment(command: string): string {
let cutIndex = -1;
for (const operator of CHAIN_OPERATORS) {
const index = command.indexOf(operator);
if (index === -1) {
continue;
}
if (cutIndex === -1 || index < cutIndex) {
cutIndex = index;
}
}
if (cutIndex === -1) {
return command;
}
return command.slice(0, cutIndex);
}
export function normalizeCommandForDetection(command: string | undefined | null): string | null {
if (typeof command !== "string") {
return null;
}
const firstNonEmptyLine = command
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstNonEmptyLine) {
return null;
}
const withoutEnvPrefix = firstNonEmptyLine.replace(ENV_PREFIX_PATTERN, "").trim();
if (!withoutEnvPrefix) {
return null;
}
const firstSegment = sliceFirstSegment(withoutEnvPrefix).trim().toLowerCase();
return firstSegment || null;
}
export function matchesCommandPatterns(
command: string | undefined | null,
patterns: readonly RegExp[],
): boolean {
const normalized = normalizeCommandForDetection(command);
if (!normalized) {
return false;
}
return patterns.some((pattern) => pattern.test(normalized));
}
+231
View File
@@ -0,0 +1,231 @@
import { matchesCommandPatterns, normalizeCommandForDetection } from "./command-detection.js";
const GIT_COMMAND_PATTERNS = [/^git\s+(diff|status|log|show|stash)\b/] as const;
const RAW_GIT_DIFF_PATTERN = /^diff --git /m;
const RAW_GIT_STATUS_PATTERN = /^(?:## |(?:M|A|D|R|C|U|\?| )\S)/m;
export function isGitCommand(command: string | undefined | null): boolean {
return matchesCommandPatterns(command, GIT_COMMAND_PATTERNS);
}
export function compactDiff(output: string, maxLines = 50): string {
const lines = output.split("\n");
const result: string[] = [];
let currentFile = "";
let added = 0;
let removed = 0;
let inHunk = false;
let hunkLines = 0;
const maxHunkLines = 10;
for (const line of lines) {
if (result.length >= maxLines) {
result.push("\n... (more changes truncated)");
break;
}
if (line.startsWith("diff --git")) {
if (currentFile && (added > 0 || removed > 0)) {
result.push(` +${added} -${removed}`);
}
const match = line.match(/diff --git a\/(.+) b\/(.+)/);
currentFile = match?.[2] ?? "unknown";
result.push(`\n> ${currentFile}`);
added = 0;
removed = 0;
inHunk = false;
continue;
}
if (line.startsWith("@@")) {
inHunk = true;
hunkLines = 0;
const hunkInfo = line.match(/@@ .+ @@/)?.[0] ?? "@@";
result.push(` ${hunkInfo}`);
continue;
}
if (!inHunk) {
continue;
}
if (line.startsWith("+") && !line.startsWith("+++")) {
added++;
if (hunkLines < maxHunkLines) {
result.push(` ${line}`);
hunkLines++;
}
} else if (line.startsWith("-") && !line.startsWith("---")) {
removed++;
if (hunkLines < maxHunkLines) {
result.push(` ${line}`);
hunkLines++;
}
} else if (hunkLines < maxHunkLines && !line.startsWith("\\")) {
if (hunkLines > 0) {
result.push(` ${line}`);
hunkLines++;
}
}
if (hunkLines === maxHunkLines) {
result.push(" ... (truncated)");
hunkLines++;
}
}
if (currentFile && (added > 0 || removed > 0)) {
result.push(` +${added} -${removed}`);
}
return result.join("\n");
}
interface StatusStats {
staged: number;
modified: number;
untracked: number;
conflicts: number;
stagedFiles: string[];
modifiedFiles: string[];
untrackedFiles: string[];
}
export function compactStatus(output: string): string {
const lines = output.split("\n");
if (lines.length === 0 || (lines.length === 1 && lines[0]?.trim() === "")) {
return "Clean working tree";
}
const stats: StatusStats = {
staged: 0,
modified: 0,
untracked: 0,
conflicts: 0,
stagedFiles: [],
modifiedFiles: [],
untrackedFiles: [],
};
let branchName = "";
for (const line of lines) {
if (line.startsWith("##")) {
const match = line.match(/## (.+)/);
if (match?.[1]) {
branchName = match[1].split("...")[0] ?? match[1];
}
continue;
}
if (line.length < 3) {
continue;
}
const status = line.slice(0, 2);
const filename = line.slice(3);
const indexStatus = status[0];
const worktreeStatus = status[1];
if (["M", "A", "D", "R", "C"].includes(indexStatus)) {
stats.staged++;
stats.stagedFiles.push(filename);
}
if (indexStatus === "U") {
stats.conflicts++;
}
if (["M", "D"].includes(worktreeStatus)) {
stats.modified++;
stats.modifiedFiles.push(filename);
}
if (status === "??") {
stats.untracked++;
stats.untrackedFiles.push(filename);
}
}
let result = `Branch: ${branchName}\n`;
if (stats.staged > 0) {
result += `Staged: ${stats.staged} files\n`;
for (const file of stats.stagedFiles.slice(0, 5)) {
result += ` ${file}\n`;
}
if (stats.staged > 5) {
result += ` ... +${stats.staged - 5} more\n`;
}
}
if (stats.modified > 0) {
result += `Modified: ${stats.modified} files\n`;
for (const file of stats.modifiedFiles.slice(0, 5)) {
result += ` ${file}\n`;
}
if (stats.modified > 5) {
result += ` ... +${stats.modified - 5} more\n`;
}
}
if (stats.untracked > 0) {
result += `Untracked: ${stats.untracked} files\n`;
for (const file of stats.untrackedFiles.slice(0, 3)) {
result += ` ${file}\n`;
}
if (stats.untracked > 3) {
result += ` ... +${stats.untracked - 3} more\n`;
}
}
if (stats.conflicts > 0) {
result += `Conflicts: ${stats.conflicts} files\n`;
}
return result.trim();
}
export function compactLog(output: string, limit = 20): string {
const lines = output.split("\n");
const result: string[] = [];
for (const line of lines.slice(0, limit)) {
if (line.length > 80) {
result.push(`${line.slice(0, 77)}...`);
} else {
result.push(line);
}
}
if (lines.length > limit) {
result.push(`... and ${lines.length - limit} more commits`);
}
return result.join("\n");
}
export function compactGitOutput(output: string, command: string | undefined | null): string | null {
if (!isGitCommand(command)) {
return null;
}
const normalized = normalizeCommandForDetection(command);
if (!normalized) {
return null;
}
if (normalized.startsWith("git diff")) {
return RAW_GIT_DIFF_PATTERN.test(output) ? compactDiff(output) : null;
}
if (normalized.startsWith("git status")) {
return RAW_GIT_STATUS_PATTERN.test(output) ? compactStatus(output) : null;
}
if (normalized.startsWith("git log")) {
return compactLog(output);
}
return null;
}
+8
View File
@@ -0,0 +1,8 @@
export { stripAnsiFast } from "./ansi.js";
export { truncate } from "./truncate.js";
export { filterBuildOutput } from "./build.js";
export { aggregateTestOutput } from "./test-output.js";
export { aggregateLinterOutput } from "./linter.js";
export { detectLanguage, smartTruncate, filterSourceCode } from "./source.js";
export { compactGitOutput } from "./git.js";
export { groupSearchResults } from "./search.js";
+151
View File
@@ -0,0 +1,151 @@
import { matchesCommandPatterns, normalizeCommandForDetection } from "./command-detection.js";
import { compactPath } from "./path-utils.js";
const LINTER_COMMAND_PATTERNS = [
/^(?:pnpm\s+)?(?:npx\s+)?eslint\b/,
/^(?:npx\s+)?prettier\b/,
/^ruff\b/,
/^pylint\b/,
/^mypy\b/,
/^flake8\b/,
/^black\b/,
/^cargo\s+clippy\b/,
/^golangci-lint\b/,
] as const;
interface Issue {
severity: "ERROR" | "WARNING";
rule: string;
file: string;
line?: number;
message: string;
}
export function isLinterCommand(command: string | undefined | null): boolean {
return matchesCommandPatterns(command, LINTER_COMMAND_PATTERNS);
}
function parseLine(line: string): Issue | null {
const fileLinePattern = /^(.+):(\d+):(\d+):\s*(.+)$/;
const rustPattern = /^(error|warning):\s*(.+?)\s+at\s+(.+):(\d+):(\d+)$/;
const fileLineMatch = line.match(fileLinePattern);
if (fileLineMatch) {
const file = fileLineMatch[1] ?? "unknown";
const lineNumber = Number.parseInt(fileLineMatch[2] ?? "0", 10);
const content = fileLineMatch[4] ?? line;
const severity = /warning/i.test(content) ? "WARNING" : "ERROR";
const rule = content.match(/\[(.+?)\]$/)?.[1] ?? "unknown";
return {
severity,
rule,
file,
line: Number.isNaN(lineNumber) ? undefined : lineNumber,
message: content,
};
}
const rustMatch = line.match(rustPattern);
if (rustMatch) {
const severity = (rustMatch[1]?.toUpperCase() ?? "ERROR") as "ERROR" | "WARNING";
const message = rustMatch[2] ?? line;
const file = rustMatch[3] ?? "unknown";
const lineNumber = Number.parseInt(rustMatch[4] ?? "0", 10);
return {
severity,
rule: "unknown",
file,
line: Number.isNaN(lineNumber) ? undefined : lineNumber,
message,
};
}
return null;
}
function parseIssues(output: string): Issue[] {
const issues: Issue[] = [];
for (const line of output.split("\n")) {
const parsed = parseLine(line);
if (parsed) {
issues.push(parsed);
}
}
return issues;
}
function detectLinterType(command: string | undefined | null): string {
const normalized = normalizeCommandForDetection(command);
if (!normalized) {
return "Linter";
}
if (/(?:^|\s)eslint\b/.test(normalized)) return "ESLint";
if (/^ruff\b/.test(normalized)) return "Ruff";
if (/^pylint\b/.test(normalized)) return "Pylint";
if (/^mypy\b/.test(normalized)) return "MyPy";
if (/^flake8\b/.test(normalized)) return "Flake8";
if (/clippy\b/.test(normalized)) return "Clippy";
if (/^golangci-lint\b/.test(normalized)) return "GolangCI-Lint";
if (/prettier\b/.test(normalized)) return "Prettier";
return "Linter";
}
export function aggregateLinterOutput(output: string, command: string | undefined | null): string | null {
if (!isLinterCommand(command)) {
return null;
}
const linterType = detectLinterType(command);
const issues = parseIssues(output);
if (issues.length === 0) {
return `[OK] ${linterType}: No issues found`;
}
const errors = issues.filter((issue) => issue.severity === "ERROR").length;
const warnings = issues.filter((issue) => issue.severity === "WARNING").length;
const byRule = new Map<string, number>();
for (const issue of issues) {
byRule.set(issue.rule, (byRule.get(issue.rule) ?? 0) + 1);
}
const byFile = new Map<string, Issue[]>();
for (const issue of issues) {
const existing = byFile.get(issue.file) ?? [];
existing.push(issue);
byFile.set(issue.file, existing);
}
let result = `${linterType}: ${errors} errors, ${warnings} warnings in ${byFile.size} files\n`;
result += "═══════════════════════════════════════\n";
result += "Top rules:\n";
const sortedRules = Array.from(byRule.entries())
.sort((left, right) => right[1] - left[1])
.slice(0, 10);
for (const [rule, count] of sortedRules) {
result += ` ${rule} (${count}x)\n`;
}
result += "\nTop files:\n";
const sortedFiles = Array.from(byFile.entries())
.sort((left, right) => right[1].length - left[1].length)
.slice(0, 10);
for (const [file, fileIssues] of sortedFiles) {
result += ` ${compactPath(file, 40)} (${fileIssues.length} issues)\n`;
const fileRules = new Map<string, number>();
for (const issue of fileIssues) {
fileRules.set(issue.rule, (fileRules.get(issue.rule) ?? 0) + 1);
}
const topRules = Array.from(fileRules.entries())
.sort((left, right) => right[1] - left[1])
.slice(0, 3);
for (const [rule, count] of topRules) {
result += ` ${rule} (${count})\n`;
}
}
return result;
}
@@ -0,0 +1,67 @@
function detectPathSeparator(path: string): "/" | "\\" {
return path.includes("\\") && !path.includes("/") ? "\\" : "/";
}
function detectPathPrefix(path: string, separator: "/" | "\\"): string {
if (/^[A-Za-z]:[\\/]/.test(path)) {
return `${path.slice(0, 2)}${separator}`;
}
if (path.startsWith("\\\\") || path.startsWith("//")) {
const parts = path.split(/[\\/]+/).filter((part) => part.length > 0);
if (parts.length >= 2) {
return `${separator}${separator}${parts[0]}${separator}${parts[1]}${separator}`;
}
return `${separator}${separator}`;
}
if (path.startsWith("/") || path.startsWith("\\")) {
return separator;
}
return "";
}
function joinPathSegments(prefix: string, separator: "/" | "\\", segments: string[]): string {
if (segments.length === 0) {
return prefix || "";
}
const joined = segments.join(separator);
return prefix ? `${prefix}${joined}` : joined;
}
export function compactPath(path: string, maxLength: number): string {
if (path.length <= maxLength) {
return path;
}
if (maxLength < 2) {
return path.slice(0, maxLength);
}
const separator = detectPathSeparator(path);
const prefix = detectPathPrefix(path, separator);
const segments = path
.slice(prefix.length)
.split(/[\\/]+/)
.filter((segment) => segment.length > 0);
const lastSegment = segments[segments.length - 1] ?? path.slice(-(maxLength - 1));
const previousSegment = segments[segments.length - 2];
const candidates = [
joinPathSegments(prefix, separator, ["…", ...(previousSegment ? [previousSegment] : []), lastSegment]),
joinPathSegments("", separator, ["…", ...(previousSegment ? [previousSegment] : []), lastSegment]),
joinPathSegments("", separator, ["…", lastSegment]),
`${path.slice(-(maxLength - 1))}`,
];
for (const candidate of candidates) {
if (candidate.length <= maxLength) {
return candidate;
}
}
return `${lastSegment.slice(-(maxLength - 1))}`;
}
+67
View File
@@ -0,0 +1,67 @@
import { compactPath } from "./path-utils.js";
interface SearchResult {
file: string;
lineNumber: string;
content: string;
}
export function groupSearchResults(output: string, maxResults = 50): string | null {
const results: SearchResult[] = [];
for (const line of output.split("\n")) {
if (!line.trim()) {
continue;
}
const match = line.match(/^(.+?):(\d+)?:(.+)$/);
if (!match) {
continue;
}
results.push({
file: match[1] ?? "unknown",
lineNumber: match[2] ?? "?",
content: match[3] ?? "",
});
}
if (results.length === 0) {
return null;
}
const byFile = new Map<string, SearchResult[]>();
for (const result of results) {
const existing = byFile.get(result.file) ?? [];
existing.push(result);
byFile.set(result.file, existing);
}
let outputText = `${results.length} matches in ${byFile.size} files:\n\n`;
const sortedFiles = Array.from(byFile.entries()).sort((left, right) =>
left[0].localeCompare(right[0]),
);
let shown = 0;
for (const [file, matches] of sortedFiles) {
if (shown >= maxResults) {
break;
}
outputText += `> ${compactPath(file, 50)} (${matches.length} matches):\n`;
for (const match of matches.slice(0, 10)) {
let cleaned = match.content.trim();
if (cleaned.length > 70) {
cleaned = `${cleaned.slice(0, 67)}...`;
}
outputText += ` ${match.lineNumber}: ${cleaned}\n`;
shown++;
}
if (matches.length > 10) {
outputText += ` +${matches.length - 10} more\n`;
}
outputText += "\n";
}
if (results.length > shown) {
outputText += `... +${results.length - shown} more\n`;
}
return outputText;
}
+317
View File
@@ -0,0 +1,317 @@
export type Language =
| "typescript"
| "javascript"
| "python"
| "rust"
| "go"
| "java"
| "c"
| "cpp"
| "unknown";
const LANGUAGE_EXTENSIONS: Record<string, Language> = {
".ts": "typescript",
".tsx": "typescript",
".js": "javascript",
".jsx": "javascript",
".mjs": "javascript",
".py": "python",
".pyw": "python",
".rs": "rust",
".go": "go",
".java": "java",
".c": "c",
".h": "c",
".cpp": "cpp",
".hpp": "cpp",
".cc": "cpp",
};
interface CommentPatterns {
line?: string;
blockStart?: string;
blockEnd?: string;
docLine?: string;
docBlockStart?: string;
}
const COMMENT_PATTERNS: Record<Language, CommentPatterns> = {
typescript: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
javascript: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
python: { line: "#", blockStart: '"""', blockEnd: '"""', docBlockStart: '"""' },
rust: { line: "//", blockStart: "/*", blockEnd: "*/", docLine: "///", docBlockStart: "/**" },
go: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
java: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
c: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
cpp: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
unknown: { line: "//", blockStart: "/*", blockEnd: "*/" },
};
const IMPORT_PATTERN = /^(use\s+|import\s+|from\s+|require\(|#include)/;
const SIGNATURE_PATTERN = /^(pub\s+)?(async\s+)?(fn|def|function|func|class|struct|enum|trait|interface|type)\s+\w+/;
const CONST_PATTERN = /^(const|static|let|pub\s+const|pub\s+static)\s+/;
function getCodePortion(line: string, language: Language): string {
const patterns = COMMENT_PATTERNS[language];
let quote: '"' | "'" | "`" | null = null;
let escaped = false;
let code = "";
for (let index = 0; index < line.length; index += 1) {
const character = line[index] ?? "";
if (escaped) {
escaped = false;
continue;
}
if (quote !== null) {
if (character === "\\") {
escaped = true;
continue;
}
if (character === quote) {
quote = null;
}
continue;
}
if (patterns.line && line.startsWith(patterns.line, index)) {
break;
}
if (patterns.blockStart && patterns.blockEnd && line.startsWith(patterns.blockStart, index)) {
const blockEndIndex = line.indexOf(patterns.blockEnd, index + patterns.blockStart.length);
if (blockEndIndex === -1) {
break;
}
index = blockEndIndex + patterns.blockEnd.length - 1;
continue;
}
if (character === '"' || character === "'" || character === "`") {
quote = character;
continue;
}
code += character;
}
return code;
}
function countCodeBraces(line: string, language: Language): { open: number; close: number } {
let open = 0;
let close = 0;
for (const character of getCodePortion(line, language)) {
if (character === "{") {
open += 1;
} else if (character === "}") {
close += 1;
}
}
return { open, close };
}
export function detectLanguage(filePath: string): Language {
const lastDot = filePath.lastIndexOf(".");
if (lastDot === -1) {
return "unknown";
}
const extension = filePath.slice(lastDot).toLowerCase();
return LANGUAGE_EXTENSIONS[extension] ?? "unknown";
}
export function filterMinimal(content: string, language: Language): string {
const patterns = COMMENT_PATTERNS[language];
const lines = content.split("\n");
const result: string[] = [];
let inBlockComment = false;
let inDocstring = false;
let inUserscriptMetadataBlock = false;
const userscriptMetadataStartPattern = /^\/\/\s*==\s*userscript\s*==$/i;
const userscriptMetadataContentPattern = /^\/\/\s*@\w+/;
const userscriptMetadataEndPattern = /^\/\/\s*==\s*\/userscript\s*==$/i;
for (const line of lines) {
const trimmed = line.trim();
const isUserscriptMetadataStart = userscriptMetadataStartPattern.test(trimmed);
const isUserscriptMetadataContent = userscriptMetadataContentPattern.test(trimmed);
const isUserscriptMetadataEnd = userscriptMetadataEndPattern.test(trimmed);
if (isUserscriptMetadataStart) {
inUserscriptMetadataBlock = true;
result.push(line);
continue;
}
if (inUserscriptMetadataBlock) {
result.push(line);
if (isUserscriptMetadataEnd) {
inUserscriptMetadataBlock = false;
} else if (isUserscriptMetadataContent) {
// Preserve metadata key/value lines (e.g. // @name) within the userscript block.
}
continue;
}
if (patterns.blockStart && patterns.blockEnd) {
if (
!inDocstring &&
trimmed.includes(patterns.blockStart) &&
!(patterns.docBlockStart && trimmed.startsWith(patterns.docBlockStart))
) {
inBlockComment = true;
}
if (inBlockComment) {
if (trimmed.includes(patterns.blockEnd)) {
inBlockComment = false;
}
continue;
}
}
if (language === "python" && trimmed.startsWith('"""')) {
inDocstring = !inDocstring;
result.push(line);
continue;
}
if (inDocstring) {
result.push(line);
continue;
}
if (patterns.line && trimmed.startsWith(patterns.line)) {
if (patterns.docLine && trimmed.startsWith(patterns.docLine)) {
result.push(line);
}
continue;
}
if (trimmed.length === 0) {
result.push("");
continue;
}
result.push(line);
}
return result
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export function filterAggressive(content: string, language: Language): string {
const minimal = filterMinimal(content, language);
const lines = minimal.split("\n");
const result: string[] = [];
let braceDepth = 0;
let inImplementation = false;
for (const line of lines) {
const trimmed = line.trim();
if (IMPORT_PATTERN.test(trimmed)) {
result.push(line);
continue;
}
if (SIGNATURE_PATTERN.test(trimmed)) {
result.push(line);
inImplementation = true;
braceDepth = 0;
continue;
}
const braces = countCodeBraces(line, language);
const codeTrimmed = getCodePortion(line, language).trim();
if (inImplementation) {
braceDepth += braces.open;
braceDepth -= braces.close;
if (braceDepth <= 1 && (codeTrimmed === "{" || codeTrimmed === "}" || codeTrimmed.endsWith("{"))) {
result.push(line);
}
if (braceDepth <= 0) {
inImplementation = false;
if (trimmed.length > 0 && trimmed !== "}") {
result.push(" // ... implementation");
}
}
continue;
}
if (CONST_PATTERN.test(trimmed)) {
result.push(line);
}
}
return result.join("\n").trim();
}
export function smartTruncate(content: string, maxLines: number, _language: Language): string {
const lines = content.split("\n");
if (lines.length <= maxLines) {
return content;
}
const result: string[] = [];
let keptLines = 0;
let skippedSection = false;
for (const line of lines) {
const trimmed = line.trim();
const isImportant =
SIGNATURE_PATTERN.test(trimmed) ||
IMPORT_PATTERN.test(trimmed) ||
trimmed.startsWith("pub ") ||
trimmed.startsWith("export ") ||
trimmed === "}" ||
trimmed === "{";
if (isImportant || keptLines < maxLines / 2) {
if (skippedSection) {
result.push(` // ... ${lines.length - keptLines} lines omitted`);
skippedSection = false;
}
result.push(line);
keptLines += 1;
} else {
skippedSection = true;
}
if (keptLines >= maxLines - 1) {
break;
}
}
if (skippedSection || keptLines < lines.length) {
result.push(`// ... ${lines.length - keptLines} more lines (total: ${lines.length})`);
}
return result.join("\n");
}
export function filterSourceCode(
content: string,
language: Language,
level: "none" | "minimal" | "aggressive",
): string {
switch (level) {
case "none":
return content;
case "minimal":
return filterMinimal(content, language);
case "aggressive":
return filterAggressive(content, language);
default:
return content;
}
}
@@ -0,0 +1,174 @@
import { matchesCommandPatterns } from "./command-detection.js";
interface TestSummary {
passed: number;
failed: number;
skipped: number;
failures: string[];
}
const TEST_COMMAND_PATTERNS = [
/^npm\s+test\b/,
/^pnpm\s+test\b/,
/^yarn\s+test\b/,
/^bun\s+test\b/,
/^cargo\s+test\b/,
/^go\s+test\b/,
/^pytest\b/,
/^python\s+-m\s+pytest\b/,
/^(?:pnpm\s+)?(?:npx\s+)?vitest\b/,
/^(?:npx\s+)?jest\b/,
/^mocha\b/,
/^ava\b/,
/^tap\b/,
] as const;
const TEST_RESULT_PATTERNS = [
/test result:\s*(\w+)\.\s*(\d+)\s*passed;\s*(\d+)\s*failed;/,
/(\d+)\s*passed(?:,\s*(\d+)\s*failed)?(?:,\s*(\d+)\s*skipped)?/i,
/(\d+)\s*pass(?:,\s*(\d+)\s*fail)?(?:,\s*(\d+)\s*skip)?/i,
/tests?:\s*(\d+)\s*passed(?:,\s*(\d+)\s*failed)?(?:,\s*(\d+)\s*skipped)?/i,
];
const FAILURE_START_PATTERNS = [
/^FAIL\s+/,
/^FAILED\s+/,
/^\s*●\s+/,
/^\s*✕\s+/,
/test\s+\w+\s+\.\.\.\s*FAILED/,
/thread\s+'\w+'\s+panicked/,
];
const FALLBACK_PASS_PATTERN = /(?:\b(?:ok|PASS)\b|[✓✔])/;
const FALLBACK_FAIL_PATTERN = /(?:\b(?:FAIL|fail)\b|[✗✕])/;
function isFailureStart(line: string): boolean {
return FAILURE_START_PATTERNS.some((pattern) => pattern.test(line));
}
function extractTestStats(output: string): Partial<TestSummary> {
for (const pattern of TEST_RESULT_PATTERNS) {
const match = output.match(pattern);
if (!match) {
continue;
}
return {
passed: Number.parseInt(match[1] ?? "0", 10) || 0,
failed: Number.parseInt(match[2] ?? "0", 10) || 0,
skipped: Number.parseInt(match[3] ?? "0", 10) || 0,
};
}
return {};
}
export function isTestCommand(command: string | undefined | null): boolean {
return matchesCommandPatterns(command, TEST_COMMAND_PATTERNS);
}
export function aggregateTestOutput(output: string, command: string | undefined | null): string | null {
if (!isTestCommand(command)) {
return null;
}
const lines = output.split("\n");
const summary: TestSummary = {
passed: 0,
failed: 0,
skipped: 0,
failures: [],
};
const stats = extractTestStats(output);
summary.passed = stats.passed ?? 0;
summary.failed = stats.failed ?? 0;
summary.skipped = stats.skipped ?? 0;
if (summary.passed === 0 && summary.failed === 0) {
for (const line of lines) {
if (FALLBACK_PASS_PATTERN.test(line)) {
summary.passed++;
}
if (FALLBACK_FAIL_PATTERN.test(line)) {
summary.failed++;
}
}
}
if (summary.failed > 0) {
let inFailure = false;
let currentFailure: string[] = [];
let blankCount = 0;
for (const line of lines) {
if (isFailureStart(line)) {
if (inFailure && currentFailure.length > 0) {
summary.failures.push(currentFailure.join("\n"));
}
inFailure = true;
currentFailure = [line];
blankCount = 0;
continue;
}
if (!inFailure) {
continue;
}
if (line.trim() === "") {
blankCount++;
if (blankCount >= 2 && currentFailure.length > 3) {
summary.failures.push(currentFailure.join("\n"));
inFailure = false;
currentFailure = [];
} else {
currentFailure.push(line);
}
continue;
}
if (line.match(/^\s/) || line.match(/^-/)) {
currentFailure.push(line);
blankCount = 0;
continue;
}
summary.failures.push(currentFailure.join("\n"));
inFailure = false;
currentFailure = [];
}
if (inFailure && currentFailure.length > 0) {
summary.failures.push(currentFailure.join("\n"));
}
}
const result: string[] = ["Test Results:"];
result.push(` PASS: ${summary.passed} passed`);
if (summary.failed > 0) {
result.push(` FAIL: ${summary.failed} failed`);
}
if (summary.skipped > 0) {
result.push(` SKIP: ${summary.skipped} skipped`);
}
if (summary.failed > 0 && summary.failures.length > 0) {
result.push("\n Failures:");
for (const failure of summary.failures.slice(0, 5)) {
const failureLines = failure.split("\n");
const firstLine = failureLines[0] ?? "";
result.push(` - ${firstLine.slice(0, 70)}${firstLine.length > 70 ? "..." : ""}`);
for (const detailLine of failureLines.slice(1, 4)) {
if (detailLine.trim()) {
result.push(` ${detailLine.slice(0, 65)}${detailLine.length > 65 ? "..." : ""}`);
}
}
if (failureLines.length > 4) {
result.push(` ... (${failureLines.length - 4} more lines)`);
}
}
if (summary.failures.length > 5) {
result.push(` ... and ${summary.failures.length - 5} more failures`);
}
}
return result.join("\n");
}
@@ -0,0 +1,11 @@
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
if (maxLength < 3) {
return "...";
}
return `${text.slice(0, maxLength - 3)}...`;
}
+58
View File
@@ -0,0 +1,58 @@
import { mock as nodeTestMockRaw } from "node:test";
import { DEFAULT_RTK_INTEGRATION_CONFIG, type RtkIntegrationConfig } from "./types.ts";
type TestResult = void | Promise<void>;
type MockModuleOptions = { namedExports?: Record<string, unknown>; defaultExport?: unknown };
function isPromiseLike(value: TestResult): value is Promise<void> {
return Boolean(value && typeof (value as Promise<void>).then === "function");
}
export function runTest(name: string, testFn: () => TestResult): TestResult {
const result = testFn();
if (!isPromiseLike(result)) {
console.log(`[PASS] ${name}`);
return;
}
return result.then(() => {
console.log(`[PASS] ${name}`);
});
}
export function cloneDefaultConfig(): RtkIntegrationConfig {
return structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG);
}
// Runtime-agnostic module-mocking helper. The tests use the node:test-shaped
// API (`mock.module(specifier, { namedExports, defaultExport })`). Bun's
// implementation of `node:test` does not expose `mock.module`, but `bun:test`
// provides an equivalent that accepts a factory function. We detect the
// capability and adapt the options form to the factory form when needed.
const nodeTestMock = nodeTestMockRaw as unknown as { module?: unknown };
let mockModuleImpl: (specifier: string, options: MockModuleOptions) => void;
if (typeof nodeTestMock.module === "function") {
mockModuleImpl = nodeTestMock.module as (specifier: string, options: MockModuleOptions) => void;
} else {
const bunTest = (await import("bun:test")) as unknown as {
mock: { module: (specifier: string, factory: () => Record<string, unknown>) => void };
};
mockModuleImpl = (specifier, options) => {
bunTest.mock.module(specifier, () => {
const moduleExports: Record<string, unknown> = {};
if (options.defaultExport !== undefined) {
moduleExports.default = options.defaultExport;
}
if (options.namedExports) {
Object.assign(moduleExports, options.namedExports);
}
return moduleExports;
});
};
}
export const mock = { module: mockModuleImpl };
@@ -0,0 +1,46 @@
import { mapTextContentBlocks, toRecord } from "./record-utils.js";
import { stripAnsiFast } from "./techniques/ansi.js";
export interface StreamingBashExecutionSanitizationResult {
changed: boolean;
result: unknown;
}
function sanitizeStreamingBashText(text: string, _command: string | undefined | null): string {
return stripAnsiFast(text);
}
/**
* Returns a sanitized shallow copy of streamed bash result blocks before the
* TUI renders them so RTK self-diagnostics never flash in partial or final
* tool output. The input object is not mutated.
*/
export function sanitizeStreamingBashExecutionResult(
result: unknown,
command: string | undefined | null,
): StreamingBashExecutionSanitizationResult {
const resultRecord = toRecord(result);
const sourceContent = Array.isArray(resultRecord.content)
? (resultRecord.content as unknown[])
: null;
if (!sourceContent || sourceContent.length === 0) {
return { changed: false, result };
}
const { changed, mapped: nextContent } = mapTextContentBlocks(sourceContent, (block) => {
const sanitizedText = sanitizeStreamingBashText(block.text, command);
return sanitizedText !== block.text ? sanitizedText : null;
});
if (!changed) {
return { changed: false, result };
}
return {
changed: true,
result: {
...resultRecord,
content: nextContent,
},
};
}
+201
View File
@@ -0,0 +1,201 @@
declare module "@earendil-works/pi-tui" {
export interface SettingItem {
id: string;
label: string;
description: string;
currentValue: string;
values: string[];
}
export interface AutocompleteItem {
value: string;
label: string;
description?: string;
}
export class Box {
constructor(...args: unknown[]);
addChild(child: unknown): void;
}
export class Container {
constructor(...args: unknown[]);
addChild(child: unknown): void;
render(width: number): string[];
invalidate(): void;
}
export class SettingsList {
constructor(...args: unknown[]);
render(width: number): string[];
invalidate(): void;
handleInput(data: string): void;
updateValue(id: string, value: string): void;
}
export class Spacer {
constructor(...args: unknown[]);
}
export class Text {
constructor(...args: unknown[]);
}
export function truncateToWidth(text: string, width: number, suffix?: string, pad?: boolean): string;
export function visibleWidth(text: string): number;
}
declare module "@earendil-works/pi-coding-agent" {
interface UiLike {
notify(message: string, level: "info" | "warning" | "error"): void;
custom<T>(
renderer: (
tui: { requestRender(): void },
theme: Theme,
keybindings: unknown,
done: () => void,
) => {
render(width: number): string[];
invalidate?(): void;
handleInput(data: string): void;
},
options?: Record<string, unknown>,
): Promise<T>;
}
export interface ExtensionContext {
hasUI: boolean;
cwd?: string;
ui: UiLike;
}
export interface ExtensionCommandContext extends ExtensionContext {}
export interface ToolResultEvent {
toolName: string;
input: Record<string, unknown>;
content: Array<Record<string, unknown>>;
details?: unknown;
}
export interface BashToolCallEvent {
toolName: "bash";
input: { command: string } & Record<string, unknown>;
}
type MaybePromise<T> = T | Promise<T>;
export interface Theme {
fg(color: string, text: string): string;
bold(text: string): string;
getFgAnsi?(name: string): string;
}
export function getAgentDir(): string;
export function getSettingsListTheme(): unknown;
export interface ExtensionAPI {
exec(
command: string,
args: string[],
options?: { timeout?: number },
): Promise<{ code: number; stdout: string; stderr: string }>;
on(
eventName: "tool_call",
handler: (
event: Record<string, unknown>,
ctx: ExtensionContext,
) => MaybePromise<Record<string, unknown> | void>,
): void;
on(
eventName: "tool_result",
handler: (
event: ToolResultEvent,
ctx: ExtensionContext,
) => MaybePromise<Record<string, unknown> | void>,
): void;
on(
eventName: "before_agent_start",
handler: (
event: { systemPrompt: string },
ctx: ExtensionContext,
) => MaybePromise<{ systemPrompt: string } | Record<string, unknown> | void>,
): void;
on(
eventName: string,
handler: (event: Record<string, unknown>, ctx: ExtensionContext) => MaybePromise<Record<string, unknown> | void>,
): void;
registerCommand(
name: string,
definition: {
description: string;
getArgumentCompletions?: (argumentPrefix: string) => Array<{ value: string; label: string; description?: string }> | null;
handler: (args: string, ctx: ExtensionCommandContext) => MaybePromise<void>;
},
): void;
}
export function isToolCallEventType(
toolName: "bash",
event: Record<string, unknown>,
): event is BashToolCallEvent;
export function isToolCallEventType(
toolName: string,
event: Record<string, unknown>,
): boolean;
}
declare module "node:assert/strict" {
const assert: {
equal(actual: unknown, expected: unknown, message?: string): void;
deepEqual(actual: unknown, expected: unknown, message?: string): void;
ok(value: unknown, message?: string): void;
};
export default assert;
}
declare module "node:test" {
export const mock: {
module(specifier: string, options: { namedExports?: Record<string, unknown>; defaultExport?: unknown }): void;
};
}
declare module "bun:test" {
export const mock: {
module(specifier: string, factory: () => Record<string, unknown>): void;
};
}
declare const process: {
platform: string;
env: Record<string, string | undefined>;
cwd(): string;
};
declare module "node:os" {
export function homedir(): string;
}
declare module "node:path" {
export function join(...segments: string[]): string;
export function dirname(path: string): string;
export function resolve(...segments: string[]): string;
export const sep: string;
}
declare module "node:fs" {
export function existsSync(path: string): boolean;
export function mkdirSync(path: string, options?: { recursive?: boolean }): void;
export function readFileSync(path: string, encoding: "utf-8"): string;
export function renameSync(oldPath: string, newPath: string): void;
export function unlinkSync(path: string): void;
export function writeFileSync(path: string, data: string, encoding: "utf-8"): void;
}
+94
View File
@@ -0,0 +1,94 @@
export const RTK_MODES = ["rewrite", "suggest"] as const;
export const RTK_SOURCE_FILTER_LEVELS = ["none", "minimal", "aggressive"] as const;
export type RtkMode = (typeof RTK_MODES)[number];
export type RtkSourceFilterLevel = (typeof RTK_SOURCE_FILTER_LEVELS)[number];
export interface RtkOutputCompactionConfig {
enabled: boolean;
stripAnsi: boolean;
readCompaction: {
enabled: boolean;
};
truncate: {
enabled: boolean;
maxChars: number;
};
sourceCodeFilteringEnabled: boolean;
preserveExactSkillReads: boolean;
sourceCodeFiltering: RtkSourceFilterLevel;
smartTruncate: {
enabled: boolean;
maxLines: number;
};
aggregateTestOutput: boolean;
filterBuildOutput: boolean;
compactGitOutput: boolean;
aggregateLinterOutput: boolean;
trackSavings: boolean;
}
export interface RtkIntegrationConfig {
enabled: boolean;
commandRewritingEnabled: boolean;
mode: RtkMode;
guardWhenRtkMissing: boolean;
showRewriteNotifications: boolean;
outputCompaction: RtkOutputCompactionConfig;
}
export const DEFAULT_RTK_INTEGRATION_CONFIG: RtkIntegrationConfig = {
enabled: true,
commandRewritingEnabled: false,
mode: "rewrite",
guardWhenRtkMissing: true,
showRewriteNotifications: true,
outputCompaction: {
enabled: true,
stripAnsi: true,
readCompaction: {
enabled: false,
},
truncate: {
enabled: true,
maxChars: 12_000,
},
sourceCodeFilteringEnabled: false,
preserveExactSkillReads: false,
sourceCodeFiltering: "none",
smartTruncate: {
enabled: false,
maxLines: 220,
},
aggregateTestOutput: true,
filterBuildOutput: true,
compactGitOutput: true,
aggregateLinterOutput: true,
trackSavings: true,
},
};
export interface ConfigLoadResult {
config: RtkIntegrationConfig;
warning?: string;
}
export interface ConfigSaveResult {
success: boolean;
error?: string;
}
export interface EnsureConfigResult {
created: boolean;
error?: string;
}
export interface RuntimeStatus {
rtkAvailable: boolean;
lastCheckedAt?: number;
lastError?: string;
rtkExecutablePath?: string;
rtkExecutableCommand?: string;
rtkExecutableResolver?: string;
rtkExecutableResolutionWarning?: string;
}
@@ -0,0 +1,138 @@
import { advanceQuoteEscapeState, readShellChars, type QuoteEscapeState } from "./shell-quote-state.js";
interface WindowsBashCompatibilityResult {
command: string;
applied: string[];
}
interface LeadingCdSlashDParse {
rawPath: string;
operator: string;
tail: string;
}
const PYTHON_UTF8_ENV_PREFIX = "PYTHONIOENCODING=utf-8";
function normalizeWindowsPathForBash(rawPath: string): string {
const trimmed = rawPath.trim();
const unquoted =
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
? trimmed.slice(1, -1)
: trimmed;
return unquoted.replace(/\\/g, "/");
}
function quoteForBash(value: string): string {
const escaped = value.replace(/"/g, '\\"');
return `"${escaped}"`;
}
function parseLeadingCdSlashD(command: string): LeadingCdSlashDParse | null {
const prefixMatch = command.match(/^\s*cd\s+\/d\s+/i);
if (!prefixMatch) {
return null;
}
const pathStart = prefixMatch[0].length;
const state: QuoteEscapeState = { quote: null, escaped: false };
for (let index = pathStart; index < command.length; index += 1) {
const { character, nextCharacter } = readShellChars(command, index);
if (advanceQuoteEscapeState(state, character, "\"'")) {
continue;
}
if (character === "&" && nextCharacter === "&") {
return {
rawPath: command.slice(pathStart, index),
operator: "&&",
tail: command.slice(index + 2),
};
}
if (character === "|" && nextCharacter === "|") {
return {
rawPath: command.slice(pathStart, index),
operator: "||",
tail: command.slice(index + 2),
};
}
if (character === "|" || character === ";") {
return {
rawPath: command.slice(pathStart, index),
operator: character,
tail: command.slice(index + 1),
};
}
}
return {
rawPath: command.slice(pathStart),
operator: "",
tail: "",
};
}
function rewriteLeadingCdSlashD(command: string): { command: string; changed: boolean } {
const parsed = parseLeadingCdSlashD(command);
if (!parsed) {
return { command, changed: false };
}
const normalizedPath = quoteForBash(normalizeWindowsPathForBash(parsed.rawPath));
if (!parsed.operator) {
return {
command: `cd ${normalizedPath}`,
changed: true,
};
}
return {
command: `cd ${normalizedPath} ${parsed.operator} ${parsed.tail.trimStart()}`,
changed: true,
};
}
function ensurePythonUtf8(command: string): { command: string; changed: boolean } {
if (/\bPYTHONIOENCODING\s*=/.test(command)) {
return { command, changed: false };
}
if (!/(^|[;&|]\s*|&&\s*|\|\|\s*)python(?:3(?:\.\d+)?)?\b/i.test(command)) {
return { command, changed: false };
}
return {
command: `${PYTHON_UTF8_ENV_PREFIX} ${command}`,
changed: true,
};
}
export function applyWindowsBashCompatibilityFixes(
command: string,
platform: string = process.platform,
): WindowsBashCompatibilityResult {
if (platform !== "win32") {
return { command, applied: [] };
}
let nextCommand = command;
const applied: string[] = [];
const cdFix = rewriteLeadingCdSlashD(nextCommand);
if (cdFix.changed) {
nextCommand = cdFix.command;
applied.push("cd-/d");
}
const pythonFix = ensurePythonUtf8(nextCommand);
if (pythonFix.changed) {
nextCommand = pythonFix.command;
applied.push("python-utf8");
}
return { command: nextCommand, applied };
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"allowImportingTsExtensions": true
},
"include": ["index.ts", "src/**/*.ts", "src/**/*.d.ts"],
"exclude": ["node_modules"]
}