diff --git a/.gitignore b/.gitignore index b24d71e..6bc3695 100644 --- a/.gitignore +++ b/.gitignore @@ -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 - diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..91888c9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# 仓库说明 + +本仓库用于维护可通过一次 `pi install` 部署的个人 Pi 扩展组合包。根 `package.json` 是唯一安装入口;依赖、默认行为和必要配置必须由组合包自身声明或部署,不把安装后的手工配置作为默认流程。每个自维护扩展使用独立的顶层目录,扩展源码、测试和说明应保留在各自目录内。 + +## 当前扩展 + +- `pi-rtk-optimizer/`:默认只负责非搜索工具输出压缩;RTK 命令改写为可选能力。 +- 上游来源: +- 初始导入快照:`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`:类型、测试和打包检查的完整验证。 + +若环境缺少依赖或未执行某项验证,交付时明确说明,不以静态检查代替运行结果。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f2cd79 --- /dev/null +++ b/README.md @@ -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。 diff --git a/config/pi-permission-system.json b/config/pi-permission-system.json new file mode 100644 index 0000000..82e7ab1 --- /dev/null +++ b/config/pi-permission-system.json @@ -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" + } + } +} diff --git a/extensions/fff-override.ts b/extensions/fff-override.ts new file mode 100644 index 0000000..f6720f9 --- /dev/null +++ b/extensions/fff-override.ts @@ -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); +} diff --git a/extensions/permission-system.ts b/extensions/permission-system.ts new file mode 100644 index 0000000..2c73bfa --- /dev/null +++ b/extensions/permission-system.ts @@ -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); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..aa3482a --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..03d5be3 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pi-rtk-optimizer/.gitignore b/pi-rtk-optimizer/.gitignore new file mode 100644 index 0000000..b52b819 --- /dev/null +++ b/pi-rtk-optimizer/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +*.log +.DS_Store +dist/ +coverage/ +config.json +*.tmp +.pi-rtk-optimizer-check.mjs +owned diff --git a/pi-rtk-optimizer/.npmignore b/pi-rtk-optimizer/.npmignore new file mode 100644 index 0000000..4a93260 --- /dev/null +++ b/pi-rtk-optimizer/.npmignore @@ -0,0 +1,8 @@ +node_modules +config.json +*.log +.git +.gitignore +.npmignore +tsconfig.json +.pi-rtk-optimizer-check.mjs diff --git a/pi-rtk-optimizer/CHANGELOG.md b/pi-rtk-optimizer/CHANGELOG.md new file mode 100644 index 0000000..8a8902e --- /dev/null +++ b/pi-rtk-optimizer/CHANGELOG.md @@ -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` diff --git a/pi-rtk-optimizer/LICENSE b/pi-rtk-optimizer/LICENSE new file mode 100644 index 0000000..4ff8a1f --- /dev/null +++ b/pi-rtk-optimizer/LICENSE @@ -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. diff --git a/pi-rtk-optimizer/README.md b/pi-rtk-optimizer/README.md new file mode 100644 index 0000000..9ec2c08 --- /dev/null +++ b/pi-rtk-optimizer/README.md @@ -0,0 +1,311 @@ +
+ +# 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. +image +**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. + +
+ +## 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` | 40–4000 | Maximum lines after smart truncation | +| `outputCompaction.truncate.enabled` | boolean | `true` | — | Enable hard character truncation | +| `outputCompaction.truncate.maxChars` | number | `12000` | 1000–200000 | 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 ` → `cd ""` (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 diff --git a/pi-rtk-optimizer/config/config.example.json b/pi-rtk-optimizer/config/config.example.json new file mode 100644 index 0000000..f20e4e7 --- /dev/null +++ b/pi-rtk-optimizer/config/config.example.json @@ -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 + } +} diff --git a/pi-rtk-optimizer/index.ts b/pi-rtk-optimizer/index.ts new file mode 100644 index 0000000..f008aa8 --- /dev/null +++ b/pi-rtk-optimizer/index.ts @@ -0,0 +1,3 @@ +import rtkIntegrationExtension from "./src/index.js"; + +export default rtkIntegrationExtension; diff --git a/pi-rtk-optimizer/package-lock.json b/pi-rtk-optimizer/package-lock.json new file mode 100644 index 0000000..050f8fc --- /dev/null +++ b/pi-rtk-optimizer/package-lock.json @@ -0,0 +1,1619 @@ +{ + "name": "pi-rtk-optimizer", + "version": "0.9.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-rtk-optimizer", + "version": "0.9.0", + "hasInstallScript": true, + "license": "MIT", + "devDependencies": { + "esbuild": "0.28.1", + "typescript": "6.0.3" + }, + "engines": { + "node": ">=20" + }, + "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" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.79.5", + "license": "MIT", + "peer": true, + "dependencies": { + "@earendil-works/pi-agent-core": "^0.79.5", + "@earendil-works/pi-ai": "^0.79.5", + "@earendil-works/pi-tui": "^0.79.5", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.1.38", + "undici": "8.3.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.79.5", + "license": "MIT", + "peer": true, + "dependencies": { + "@earendil-works/pi-ai": "^0.79.5", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.79.5", + "license": "MIT", + "peer": true, + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { + "version": "2.2.1", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.24.3", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "license": "MIT", + "peer": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "license": "MIT", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.6", + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { + "version": "1.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "license": "MIT", + "peer": true, + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "license": "ISC", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "license": "ISC", + "peer": true, + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "license": "MIT", + "peer": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "license": "MIT", + "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "license": "ISC", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "license": "MIT", + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "license": "MIT", + "peer": true, + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "license": "MIT", + "peer": true, + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "license": "MIT", + "peer": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.26.0", + "license": "Apache-2.0", + "peer": true, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { + "version": "1.5.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "license": "ISC", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { + "version": "2.3.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.1.38", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.5.0", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "version": "0.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "license": "ISC", + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod": { + "version": "3.25.76", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": { + "version": "3.25.2", + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.79.5", + "license": "MIT", + "peer": true, + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@types/node": { + "version": "25.9.3", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/long": { + "version": "5.3.2", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/marked": { + "version": "18.0.5", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/protobufjs": { + "version": "7.6.3", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "license": "MIT", + "peer": true + }, + "node_modules/ws": { + "version": "8.21.0", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/pi-rtk-optimizer/package.json b/pi-rtk-optimizer/package.json new file mode 100644 index 0000000..6446b4e --- /dev/null +++ b/pi-rtk-optimizer/package.json @@ -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" + } +} diff --git a/pi-rtk-optimizer/src/additional-coverage.test.ts b/pi-rtk-optimizer/src/additional-coverage.test.ts new file mode 100644 index 0000000..ed7a274 --- /dev/null +++ b/pi-rtk-optimizer/src/additional-coverage.test.ts @@ -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."); diff --git a/pi-rtk-optimizer/src/boolean-format.ts b/pi-rtk-optimizer/src/boolean-format.ts new file mode 100644 index 0000000..41be7ea --- /dev/null +++ b/pi-rtk-optimizer/src/boolean-format.ts @@ -0,0 +1,3 @@ +export function toOnOff(value: boolean, truthyLabel = "on", falsyLabel = "off"): string { + return value ? truthyLabel : falsyLabel; +} diff --git a/pi-rtk-optimizer/src/command-completions.ts b/pi-rtk-optimizer/src/command-completions.ts new file mode 100644 index 0000000..bd07d5e --- /dev/null +++ b/pi-rtk-optimizer/src/command-completions.ts @@ -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); +} diff --git a/pi-rtk-optimizer/src/command-register.ts b/pi-rtk-optimizer/src/command-register.ts new file mode 100644 index 0000000..caac83c --- /dev/null +++ b/pi-rtk-optimizer/src/command-register.ts @@ -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; + getMetricsSummary(): string; + clearMetrics(): void; +} + +const loadCommandModalModule = createLazyModuleLoader("./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); + }, + }); +} diff --git a/pi-rtk-optimizer/src/command-rewriter.test.ts b/pi-rtk-optimizer/src/command-rewriter.test.ts new file mode 100644 index 0000000..c0756bd --- /dev/null +++ b/pi-rtk-optimizer/src/command-rewriter.test.ts @@ -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 < { + const config = cloneDefaultConfig(); + const command = 'echo "< { + 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."); diff --git a/pi-rtk-optimizer/src/command-rewriter.ts b/pi-rtk-optimizer/src/command-rewriter.ts new file mode 100644 index 0000000..bbf583f --- /dev/null +++ b/pi-rtk-optimizer/src/command-rewriter.ts @@ -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 { + 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, + }; +} diff --git a/pi-rtk-optimizer/src/config-modal.test.ts b/pi-rtk-optimizer/src/config-modal.test.ts new file mode 100644 index 0000000..e2b57cc --- /dev/null +++ b/pi-rtk-optimizer/src/config-modal.test.ts @@ -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(): Promise; + }; +} + +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(): Promise { + 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; + }; + 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."); diff --git a/pi-rtk-optimizer/src/config-modal.ts b/pi-rtk-optimizer/src/config-modal.ts new file mode 100644 index 0000000..8bd657c --- /dev/null +++ b/pi-rtk-optimizer/src/config-modal.ts @@ -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(); + + 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 { + const overlayOptions = { anchor: "center" as const, width: 86, maxHeight: "85%" as const, margin: 1 }; + + await ctx.ui.custom( + (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 { + 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 { + if (await handleArgs(args, ctx, controller)) { + return; + } + + if (!ctx.hasUI) { + ctx.ui.notify("/rtk requires interactive TUI mode.", "warning"); + return; + } + + await openSettingsModal(ctx, controller); +} diff --git a/pi-rtk-optimizer/src/config-store.ts b/pi-rtk-optimizer/src/config-store.ts new file mode 100644 index 0000000..325ee33 --- /dev/null +++ b/pi-rtk-optimizer/src/config-store.ts @@ -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, 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; +} diff --git a/pi-rtk-optimizer/src/constants.ts b/pi-rtk-optimizer/src/constants.ts new file mode 100644 index 0000000..499b5b3 --- /dev/null +++ b/pi-rtk-optimizer/src/constants.ts @@ -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"); diff --git a/pi-rtk-optimizer/src/index.test.ts b/pi-rtk-optimizer/src/index.test.ts new file mode 100644 index 0000000..238e39c --- /dev/null +++ b/pi-rtk-optimizer/src/index.test.ts @@ -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) => 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, ctx: Record) => Promise | void>; + +function createNotificationContext(notifications: Notification[]): Record { + 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-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"); + + 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 = {}; + 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 = {}; + + 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 = {}; + 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 }; + 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, ctx: Record) => Promise | 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, ctx: Record) => Promise | 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."); diff --git a/pi-rtk-optimizer/src/index.ts b/pi-rtk-optimizer/src/index.ts new file mode 100644 index 0000000..ec8c957 --- /dev/null +++ b/pi-rtk-optimizer/src/index.ts @@ -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("./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 { + const baseDetails = toRecord(existingDetails); + const baseMetadata = toRecord(baseDetails.metadata); + + const nextDetails: Record = { + ...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(); + 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(); + 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 | 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 => { + 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 => { + 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 => { + 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 {}; + } + }); +} diff --git a/pi-rtk-optimizer/src/lazy-module-loader.ts b/pi-rtk-optimizer/src/lazy-module-loader.ts new file mode 100644 index 0000000..f425aed --- /dev/null +++ b/pi-rtk-optimizer/src/lazy-module-loader.ts @@ -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(specifier: string): () => Promise { + let cached: Promise | undefined; + return (): Promise => { + cached ??= import(specifier) as Promise; + return cached; + }; +} diff --git a/pi-rtk-optimizer/src/output-compactor.test.ts b/pi-rtk-optimizer/src/output-compactor.test.ts new file mode 100644 index 0000000..a738e11 --- /dev/null +++ b/pi-rtk-optimizer/src/output-compactor.test.ts @@ -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, 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 = [ + "", + ...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)", + "", + ].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("")); + assert.ok(compacted.includes("(End of file - 120 total lines)")); + assert.ok(compacted.includes("")); + 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."); diff --git a/pi-rtk-optimizer/src/output-compactor.ts b/pi-rtk-optimizer/src/output-compactor.ts new file mode 100644 index 0000000..0b66c13 --- /dev/null +++ b/pi-rtk-optimizer/src/output-compactor.ts @@ -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 | undefined { + const raw = input.command; + if (typeof raw === "string" && raw.trim()) { + return raw; + } + return undefined; +} + +function normalizePath(input: Record): string { + const raw = input.path; + if (typeof raw === "string") { + return raw; + } + return ""; +} + +function hasExplicitReadRange(input: Record): 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, + 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(); + 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, + }; +} diff --git a/pi-rtk-optimizer/src/output-metrics.ts b/pi-rtk-optimizer/src/output-metrics.ts new file mode 100644 index 0000000..d77d888 --- /dev/null +++ b/pi-rtk-optimizer/src/output-metrics.ts @@ -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(); + 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(); +} diff --git a/pi-rtk-optimizer/src/package-lock-integrity.test.ts b/pi-rtk-optimizer/src/package-lock-integrity.test.ts new file mode 100644 index 0000000..03feaeb --- /dev/null +++ b/pi-rtk-optimizer/src/package-lock-integrity.test.ts @@ -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; +}; + +type PackageLock = { + packages?: Record; +}; + +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."); diff --git a/pi-rtk-optimizer/src/record-utils.ts b/pi-rtk-optimizer/src/record-utils.ts new file mode 100644 index 0000000..d6fd33b --- /dev/null +++ b/pi-rtk-optimizer/src/record-utils.ts @@ -0,0 +1,50 @@ +export function toRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return value as Record; +} + +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 }; +} diff --git a/pi-rtk-optimizer/src/rewrite-pipeline-safety.ts b/pi-rtk-optimizer/src/rewrite-pipeline-safety.ts new file mode 100644 index 0000000..547deee --- /dev/null +++ b/pi-rtk-optimizer/src/rewrite-pipeline-safety.ts @@ -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}`; +} diff --git a/pi-rtk-optimizer/src/rtk-command-environment.ts b/pi-rtk-optimizer/src/rtk-command-environment.ts new file mode 100644 index 0000000..2da0a3f --- /dev/null +++ b/pi-rtk-optimizer/src/rtk-command-environment.ts @@ -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}`; +} diff --git a/pi-rtk-optimizer/src/rtk-executable-resolver.ts b/pi-rtk-optimizer/src/rtk-executable-resolver.ts new file mode 100644 index 0000000..cade73e --- /dev/null +++ b/pi-rtk-optimizer/src/rtk-executable-resolver.ts @@ -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 { + 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)}`, + ); + } +} diff --git a/pi-rtk-optimizer/src/rtk-rewrite-provider.ts b/pi-rtk-optimizer/src/rtk-rewrite-provider.ts new file mode 100644 index 0000000..3a880bc --- /dev/null +++ b/pi-rtk-optimizer/src/rtk-rewrite-provider.ts @@ -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 { + 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, + }; + } +} diff --git a/pi-rtk-optimizer/src/runtime-guard.test.ts b/pi-rtk-optimizer/src/runtime-guard.test.ts new file mode 100644 index 0000000..fe5ee32 --- /dev/null +++ b/pi-rtk-optimizer/src/runtime-guard.test.ts @@ -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."); diff --git a/pi-rtk-optimizer/src/runtime-guard.ts b/pi-rtk-optimizer/src/runtime-guard.ts new file mode 100644 index 0000000..78a2025 --- /dev/null +++ b/pi-rtk-optimizer/src/runtime-guard.ts @@ -0,0 +1,14 @@ +import type { RtkIntegrationConfig, RuntimeStatus } from "./types.js"; + +export function shouldRequireRtkAvailabilityForCommandHandling( + config: Pick, +): boolean { + return config.commandRewritingEnabled && config.guardWhenRtkMissing; +} + +export function shouldSkipCommandHandlingWhenRtkMissing( + config: Pick, + runtimeStatus: Pick, +): boolean { + return shouldRequireRtkAvailabilityForCommandHandling(config) && !runtimeStatus.rtkAvailable; +} diff --git a/pi-rtk-optimizer/src/shell-env-prefix.ts b/pi-rtk-optimizer/src/shell-env-prefix.ts new file mode 100644 index 0000000..d69ce7f --- /dev/null +++ b/pi-rtk-optimizer/src/shell-env-prefix.ts @@ -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), + }; +} diff --git a/pi-rtk-optimizer/src/shell-quote-state.ts b/pi-rtk-optimizer/src/shell-quote-state.ts new file mode 100644 index 0000000..d8206dc --- /dev/null +++ b/pi-rtk-optimizer/src/shell-quote-state.ts @@ -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] ?? "", + }; +} diff --git a/pi-rtk-optimizer/src/techniques/ansi.ts b/pi-rtk-optimizer/src/techniques/ansi.ts new file mode 100644 index 0000000..1734d43 --- /dev/null +++ b/pi-rtk-optimizer/src/techniques/ansi.ts @@ -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); +} diff --git a/pi-rtk-optimizer/src/techniques/build.ts b/pi-rtk-optimizer/src/techniques/build.ts new file mode 100644 index 0000000..b9acc8f --- /dev/null +++ b/pi-rtk-optimizer/src/techniques/build.ts @@ -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"); +} diff --git a/pi-rtk-optimizer/src/techniques/command-detection.ts b/pi-rtk-optimizer/src/techniques/command-detection.ts new file mode 100644 index 0000000..d41391e --- /dev/null +++ b/pi-rtk-optimizer/src/techniques/command-detection.ts @@ -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)); +} diff --git a/pi-rtk-optimizer/src/techniques/git.ts b/pi-rtk-optimizer/src/techniques/git.ts new file mode 100644 index 0000000..c580ecf --- /dev/null +++ b/pi-rtk-optimizer/src/techniques/git.ts @@ -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; +} diff --git a/pi-rtk-optimizer/src/techniques/index.ts b/pi-rtk-optimizer/src/techniques/index.ts new file mode 100644 index 0000000..ec24b00 --- /dev/null +++ b/pi-rtk-optimizer/src/techniques/index.ts @@ -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"; diff --git a/pi-rtk-optimizer/src/techniques/linter.ts b/pi-rtk-optimizer/src/techniques/linter.ts new file mode 100644 index 0000000..f15d433 --- /dev/null +++ b/pi-rtk-optimizer/src/techniques/linter.ts @@ -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(); + for (const issue of issues) { + byRule.set(issue.rule, (byRule.get(issue.rule) ?? 0) + 1); + } + + const byFile = new Map(); + 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(); + 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; +} diff --git a/pi-rtk-optimizer/src/techniques/path-utils.ts b/pi-rtk-optimizer/src/techniques/path-utils.ts new file mode 100644 index 0000000..c52c4f7 --- /dev/null +++ b/pi-rtk-optimizer/src/techniques/path-utils.ts @@ -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))}`; +} diff --git a/pi-rtk-optimizer/src/techniques/search.ts b/pi-rtk-optimizer/src/techniques/search.ts new file mode 100644 index 0000000..da1483c --- /dev/null +++ b/pi-rtk-optimizer/src/techniques/search.ts @@ -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(); + 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; +} diff --git a/pi-rtk-optimizer/src/techniques/source.ts b/pi-rtk-optimizer/src/techniques/source.ts new file mode 100644 index 0000000..8d6de4e --- /dev/null +++ b/pi-rtk-optimizer/src/techniques/source.ts @@ -0,0 +1,317 @@ +export type Language = + | "typescript" + | "javascript" + | "python" + | "rust" + | "go" + | "java" + | "c" + | "cpp" + | "unknown"; + +const LANGUAGE_EXTENSIONS: Record = { + ".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 = { + 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; + } +} diff --git a/pi-rtk-optimizer/src/techniques/test-output.ts b/pi-rtk-optimizer/src/techniques/test-output.ts new file mode 100644 index 0000000..6de3bb2 --- /dev/null +++ b/pi-rtk-optimizer/src/techniques/test-output.ts @@ -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 { + 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"); +} diff --git a/pi-rtk-optimizer/src/techniques/truncate.ts b/pi-rtk-optimizer/src/techniques/truncate.ts new file mode 100644 index 0000000..a89719a --- /dev/null +++ b/pi-rtk-optimizer/src/techniques/truncate.ts @@ -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)}...`; +} diff --git a/pi-rtk-optimizer/src/test-helpers.test.ts b/pi-rtk-optimizer/src/test-helpers.test.ts new file mode 100644 index 0000000..dc3a622 --- /dev/null +++ b/pi-rtk-optimizer/src/test-helpers.test.ts @@ -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; +type MockModuleOptions = { namedExports?: Record; defaultExport?: unknown }; + +function isPromiseLike(value: TestResult): value is Promise { + return Boolean(value && typeof (value as Promise).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) => void }; + }; + + mockModuleImpl = (specifier, options) => { + bunTest.mock.module(specifier, () => { + const moduleExports: Record = {}; + if (options.defaultExport !== undefined) { + moduleExports.default = options.defaultExport; + } + if (options.namedExports) { + Object.assign(moduleExports, options.namedExports); + } + return moduleExports; + }); + }; +} + +export const mock = { module: mockModuleImpl }; diff --git a/pi-rtk-optimizer/src/tool-execution-sanitizer.ts b/pi-rtk-optimizer/src/tool-execution-sanitizer.ts new file mode 100644 index 0000000..d2033b1 --- /dev/null +++ b/pi-rtk-optimizer/src/tool-execution-sanitizer.ts @@ -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, + }, + }; +} diff --git a/pi-rtk-optimizer/src/types-shims.d.ts b/pi-rtk-optimizer/src/types-shims.d.ts new file mode 100644 index 0000000..9cb1719 --- /dev/null +++ b/pi-rtk-optimizer/src/types-shims.d.ts @@ -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( + renderer: ( + tui: { requestRender(): void }, + theme: Theme, + keybindings: unknown, + done: () => void, + ) => { + render(width: number): string[]; + invalidate?(): void; + handleInput(data: string): void; + }, + options?: Record, + ): Promise; + } + + export interface ExtensionContext { + hasUI: boolean; + cwd?: string; + ui: UiLike; + } + + export interface ExtensionCommandContext extends ExtensionContext {} + + export interface ToolResultEvent { + toolName: string; + input: Record; + content: Array>; + details?: unknown; + } + + export interface BashToolCallEvent { + toolName: "bash"; + input: { command: string } & Record; + } + + type MaybePromise = T | Promise; + + 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, + ctx: ExtensionContext, + ) => MaybePromise | void>, + ): void; + + on( + eventName: "tool_result", + handler: ( + event: ToolResultEvent, + ctx: ExtensionContext, + ) => MaybePromise | void>, + ): void; + + on( + eventName: "before_agent_start", + handler: ( + event: { systemPrompt: string }, + ctx: ExtensionContext, + ) => MaybePromise<{ systemPrompt: string } | Record | void>, + ): void; + + on( + eventName: string, + handler: (event: Record, ctx: ExtensionContext) => MaybePromise | 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; + } + + export function isToolCallEventType( + toolName: "bash", + event: Record, + ): event is BashToolCallEvent; + + export function isToolCallEventType( + toolName: string, + event: Record, + ): 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; defaultExport?: unknown }): void; + }; +} + +declare module "bun:test" { + export const mock: { + module(specifier: string, factory: () => Record): void; + }; +} + +declare const process: { + platform: string; + env: Record; + 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; +} diff --git a/pi-rtk-optimizer/src/types.ts b/pi-rtk-optimizer/src/types.ts new file mode 100644 index 0000000..9e3c7f7 --- /dev/null +++ b/pi-rtk-optimizer/src/types.ts @@ -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; +} diff --git a/pi-rtk-optimizer/src/windows-command-helpers.ts b/pi-rtk-optimizer/src/windows-command-helpers.ts new file mode 100644 index 0000000..b933b47 --- /dev/null +++ b/pi-rtk-optimizer/src/windows-command-helpers.ts @@ -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 }; +} diff --git a/pi-rtk-optimizer/src/zellij-modal.ts b/pi-rtk-optimizer/src/zellij-modal.ts new file mode 100644 index 0000000..9fc1fdb --- /dev/null +++ b/pi-rtk-optimizer/src/zellij-modal.ts @@ -0,0 +1,1131 @@ +// Vendored from ../zellij-modal/index.ts to keep pi-rtk-optimizer standalone. +// Keep this module in sync when upstream zellij-modal primitives change. +import { getSettingsListTheme, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent"; +import { + Box, + Container, + SettingsList, + Spacer, + Text, + truncateToWidth, + visibleWidth, + type SettingItem, +} from "@earendil-works/pi-tui"; + +const ANSI_RESET = "\x1b[0m"; + +/** + * Border character set used to render a modal frame. + */ +export interface BorderCharacters { + /** Top-left corner. */ + topLeft: string; + /** Top-right corner. */ + topRight: string; + /** Bottom-left corner. */ + bottomLeft: string; + /** Bottom-right corner. */ + bottomRight: string; + /** Horizontal line character. */ + horizontal: string; + /** Vertical line character. */ + vertical: string; + /** Optional left tee junction. */ + verticalLeft?: string; + /** Optional right tee junction. */ + verticalRight?: string; +} + +/** + * Predefined border character sets aligned with Zellij styles. + */ +export const BORDER_STYLES = { + rounded: { + topLeft: "╭", + topRight: "╮", + bottomLeft: "╰", + bottomRight: "╯", + horizontal: "─", + vertical: "│", + verticalLeft: "├", + verticalRight: "┤", + }, + square: { + topLeft: "┌", + topRight: "┐", + bottomLeft: "└", + bottomRight: "┘", + horizontal: "─", + vertical: "│", + verticalLeft: "├", + verticalRight: "┤", + }, + double: { + topLeft: "╔", + topRight: "╗", + bottomLeft: "╚", + bottomRight: "╝", + horizontal: "═", + vertical: "║", + }, + none: { + topLeft: " ", + topRight: " ", + bottomLeft: " ", + bottomRight: " ", + horizontal: " ", + vertical: " ", + }, +} as const satisfies Record; + +/** + * Name of a supported border style. + */ +export type BorderStyle = keyof typeof BORDER_STYLES; + +/** + * Supported palette color formats. + */ +export type PaletteColor = + | { type: "rgb"; r: number; g: number; b: number } + | { type: "8bit"; code: number } + | { type: "named"; name: string }; + +/** + * Semantic color slots for a Zellij-style modal. + */ +export interface ZellijColorPalette { + /** Primary foreground text. */ + fg: PaletteColor; + /** Modal background. */ + bg: PaletteColor; + /** Accent / selection color. */ + accent: PaletteColor; + /** Secondary text color. */ + muted: PaletteColor; + /** Tertiary text color. */ + dim: PaletteColor; + /** Success state color. */ + success: PaletteColor; + /** Error state color. */ + error: PaletteColor; + /** Warning state color. */ + warning: PaletteColor; + /** Default border color. */ + border: PaletteColor; + /** Border color when focused. */ + borderFocused: PaletteColor; + /** Border color when unfocused. */ + borderUnfocused: PaletteColor; +} + +/** + * Default Zellij-inspired palette. + */ +export const DEFAULT_ZELLIJ_PALETTE: ZellijColorPalette = { + fg: { type: "named", name: "white" }, + bg: { type: "named", name: "black" }, + accent: { type: "8bit", code: 36 }, + muted: { type: "8bit", code: 245 }, + dim: { type: "8bit", code: 238 }, + success: { type: "8bit", code: 154 }, + error: { type: "8bit", code: 124 }, + warning: { type: "8bit", code: 166 }, + border: { type: "8bit", code: 238 }, + borderFocused: { type: "8bit", code: 154 }, + borderUnfocused: { type: "8bit", code: 238 }, +}; + +/** + * A title segment in the top border. + */ +export interface TitleSegment { + /** Segment text. */ + text: string; + /** Segment foreground color slot or explicit color. */ + color: keyof ZellijColorPalette | PaletteColor; + /** Optional segment background color. */ + bgColor?: PaletteColor; + /** Enables bold style. */ + bold?: boolean; + /** Truncation strategy when segment text is too long. */ + truncate?: "start" | "middle" | "end" | "none"; + /** Maximum visible width for text content (0 means unlimited). */ + maxWidth?: number; +} + +/** + * Three-part title bar configuration. + */ +export interface TitleBarConfig { + /** Left segment (usually title). */ + left?: TitleSegment | string; + /** Center segment (usually status). */ + center?: TitleSegment | string; + /** Right segment (usually counters/actions). */ + right?: TitleSegment | string; + /** Optional textual separator (reserved for custom renderers). */ + separator?: string; +} + +/** + * Help text line rendered in the bottom border. + */ +export interface HelpUndertitleConfig { + /** Static help text. */ + text?: string; + /** Dynamic help text generator. */ + textGenerator?: (width: number) => string; + /** Progressive truncation variants from longest to shortest. */ + variants?: string[]; + /** Structured key hints for help text generation. */ + keyHints?: Array<{ + key: string; + description: string; + }>; + /** Separator between key hints. */ + keyHintSeparator?: string; + /** Palette slot for help text color. */ + color?: keyof ZellijColorPalette; +} + +/** + * Full modal configuration. + */ +export interface ZellijModalConfig { + /** Border style preset. */ + borderStyle: BorderStyle; + /** Active color palette. */ + palette: ZellijColorPalette; + /** Focus state for frame highlighting. */ + focused: boolean; + /** Internal content padding. */ + padding: number; + /** Top title bar config. */ + titleBar: TitleBarConfig; + /** Optional bottom help line config. */ + helpUndertitle?: HelpUndertitleConfig; + /** Minimum preferred modal width. */ + minWidth: number; + /** Maximum modal width (0 means no explicit max). */ + maxWidth: number; + /** Overlay options for `ctx.ui.custom()`. */ + overlay: { + anchor: "center" | "top" | "bottom"; + width: number | string; + maxHeight: number | string; + margin: number; + }; +} + +/** + * Partial modal configuration used by consumers. + */ +export type ZellijModalConfigPartial = Partial & { + /** Shorthand for `titleBar.left`. */ + title?: string; + /** Shorthand for help text. */ + helpText?: string | HelpUndertitleConfig; +}; + +/** + * Modal rendering metadata. + */ +export interface ZellijModalRenderOutput { + /** Fully rendered lines. */ + lines: string[]; + /** Visible frame width. */ + visibleWidth: number; + /** Width of content area inside borders and padding. */ + contentWidth: number; + /** Inclusive index of first content line. */ + contentStartLine: number; + /** Inclusive index of last content line. */ + contentEndLine: number; +} + +/** + * Minimal content renderer contract for modal content. + */ +export interface ZellijModalContentRenderer { + /** Render content into lines for the given width. */ + render(width: number): string[]; + /** Invalidate internal caches. */ + invalidate(): void; + /** Optional input handler. */ + handleInput?(data: string): void; +} + +/** + * Full modal component contract. + */ +export interface ZellijModalComponent extends ZellijModalContentRenderer { + /** Effective modal configuration. */ + config: ZellijModalConfig; + /** Wrapped content renderer. */ + content: ZellijModalContentRenderer; + /** Render complete modal output. */ + renderModal(width: number): ZellijModalRenderOutput; + /** Release resources. */ + dispose(): void; +} + +/** + * Theme helper for modal-specific color resolution and ANSI formatting. + */ +export interface ZellijModalTheme { + /** Active palette used by this theme helper. */ + palette: ZellijColorPalette; + /** Resolve color slot or explicit color into ANSI foreground/background codes. */ + resolveColor: (color: PaletteColor | keyof ZellijColorPalette) => { fg: string; bg: string }; + /** Apply foreground color to text. */ + colorizeForeground: (color: PaletteColor | keyof ZellijColorPalette, text: string) => string; + /** Apply background color to text. */ + colorizeBackground: (color: PaletteColor | keyof ZellijColorPalette, text: string) => string; +} + +/** + * Resolve a `PaletteColor` into ANSI foreground/background escape codes. + */ +export function resolveColor(color: PaletteColor): { fg: string; bg: string } { + if (color.type === "rgb") { + const r = clampInt(color.r, 0, 255); + const g = clampInt(color.g, 0, 255); + const b = clampInt(color.b, 0, 255); + return { + fg: `\x1b[38;2;${r};${g};${b}m`, + bg: `\x1b[48;2;${r};${g};${b}m`, + }; + } + + if (color.type === "8bit") { + const code = clampInt(color.code, 0, 255); + return { + fg: `\x1b[38;5;${code}m`, + bg: `\x1b[48;5;${code}m`, + }; + } + + const namedMap: Record = { + black: 16, + white: 255, + red: 196, + green: 46, + blue: 45, + yellow: 226, + cyan: 51, + magenta: 201, + gray: 245, + grey: 245, + orange: 166, + }; + const code = namedMap[color.name.toLowerCase()] ?? 255; + return { + fg: `\x1b[38;5;${code}m`, + bg: `\x1b[48;5;${code}m`, + }; +} + +/** + * Build a `ZellijModalTheme` helper from a palette. + */ +export function createZellijModalTheme(palette: ZellijColorPalette): ZellijModalTheme { + return { + palette, + resolveColor: (color) => resolveColor(resolvePaletteColor(color, palette)), + colorizeForeground: (color, text) => `${resolveColor(resolvePaletteColor(color, palette)).fg}${text}${ANSI_RESET}`, + colorizeBackground: (color, text) => `${resolveColor(resolvePaletteColor(color, palette)).bg}${text}${ANSI_RESET}`, + }; +} + +/** + * Convert Pi `Theme` values to a Zellij modal palette. + */ +export function themeToZellijPalette(theme: Theme): ZellijColorPalette { + const extract = (colorName: string, fallback: PaletteColor): PaletteColor => { + const provider = theme as unknown as { + getFgAnsi?: (name: string) => string; + }; + if (!provider.getFgAnsi) { + return fallback; + } + + try { + const ansi = provider.getFgAnsi(colorName); + const parsed = parseAnsiForegroundColor(ansi); + return parsed ?? fallback; + } catch { + return fallback; + } + }; + + return { + fg: extract("fg", DEFAULT_ZELLIJ_PALETTE.fg), + bg: extract("bg", DEFAULT_ZELLIJ_PALETTE.bg), + accent: extract("accent", DEFAULT_ZELLIJ_PALETTE.accent), + muted: extract("muted", DEFAULT_ZELLIJ_PALETTE.muted), + dim: extract("dim", DEFAULT_ZELLIJ_PALETTE.dim), + success: extract("success", DEFAULT_ZELLIJ_PALETTE.success), + error: extract("error", DEFAULT_ZELLIJ_PALETTE.error), + warning: extract("warning", DEFAULT_ZELLIJ_PALETTE.warning), + border: extract("borderMuted", DEFAULT_ZELLIJ_PALETTE.border), + borderFocused: extract("accent", DEFAULT_ZELLIJ_PALETTE.borderFocused), + borderUnfocused: extract("borderMuted", DEFAULT_ZELLIJ_PALETTE.borderUnfocused), + }; +} + +interface PositionedTitleSegment { + start: number; + end: number; + text: string; + color: keyof ZellijColorPalette | PaletteColor; + bold: boolean; +} + +/** + * Core frame renderer for Zellij-style borders, title bar, and undertitle. + */ +export class ZellijModalFrame { + private config: ZellijModalConfig; + private borders: BorderCharacters; + private theme: ZellijModalTheme; + + constructor(config: ZellijModalConfig, modalTheme?: ZellijModalTheme) { + this.config = config; + this.borders = BORDER_STYLES[config.borderStyle] ?? BORDER_STYLES.rounded; + this.theme = modalTheme ?? createZellijModalTheme(config.palette); + } + + /** + * Update frame configuration (used when modal config changes). + */ + setConfig(config: ZellijModalConfig): void { + this.config = config; + this.borders = BORDER_STYLES[config.borderStyle] ?? BORDER_STYLES.rounded; + this.theme = createZellijModalTheme(config.palette); + } + + /** + * Render one content line with left/right borders. + */ + renderContentLine(content: string, width: number, palette: ZellijColorPalette): string { + const frameWidth = Math.max(2, width); + const innerWidth = Math.max(0, frameWidth - 2); + const borderColor = this.config.focused ? palette.borderFocused : palette.borderUnfocused; + const vertical = this.theme.colorizeForeground(borderColor, this.borders.vertical); + const paddedContent = truncateToWidth(content, innerWidth, "", true); + return `${vertical}${paddedContent}${vertical}`; + } + + /** + * Render complete frame around provided content lines. + */ + renderFrame(contentLines: string[], width: number, palette: ZellijColorPalette): ZellijModalRenderOutput { + const frameWidth = Math.max(4, width); + const safeContent = contentLines.length > 0 ? contentLines : [""]; + const lines: string[] = []; + + lines.push(this.renderTitleBar(frameWidth, palette)); + + const contentStartLine = lines.length; + for (const line of safeContent) { + lines.push(this.renderContentLine(line, frameWidth, palette)); + } + const contentEndLine = lines.length - 1; + + lines.push(this.renderBottomLine(frameWidth, palette)); + + return { + lines, + visibleWidth: frameWidth, + contentWidth: Math.max(1, frameWidth - 2 - this.config.padding * 2), + contentStartLine, + contentEndLine, + }; + } + + private renderBorderLine( + width: number, + palette: ZellijColorPalette, + leftCorner: string, + rightCorner: string, + renderInner: (innerWidth: number, borderPaint: (text: string) => string) => string, + ): string { + const innerWidth = Math.max(0, width - 2); + const borderColor = this.config.focused ? palette.borderFocused : palette.borderUnfocused; + const borderPaint = (text: string) => this.theme.colorizeForeground(borderColor, text); + const inner = innerWidth === 0 ? "" : renderInner(innerWidth, borderPaint); + return `${borderPaint(leftCorner)}${inner}${borderPaint(rightCorner)}`; + } + + private renderTitleBar(width: number, palette: ZellijColorPalette): string { + return this.renderBorderLine(width, palette, this.borders.topLeft, this.borders.topRight, (innerWidth, borderPaint) => { + const segments = this.positionTitleSegments(innerWidth); + let inner = ""; + let cursor = 0; + + for (const segment of segments) { + if (segment.start > cursor) { + inner += borderPaint(this.borders.horizontal.repeat(segment.start - cursor)); + } + const text = segment.bold ? `\x1b[1m${segment.text}${ANSI_RESET}` : segment.text; + inner += this.theme.colorizeForeground(segment.color, text); + cursor = segment.end; + } + + if (cursor < innerWidth) { + inner += borderPaint(this.borders.horizontal.repeat(innerWidth - cursor)); + } + + return inner; + }); + } + + private renderBottomLine(width: number, palette: ZellijColorPalette): string { + return this.renderBorderLine(width, palette, this.borders.bottomLeft, this.borders.bottomRight, (innerWidth, borderPaint) => { + const helpText = this.resolveHelpText(Math.max(0, innerWidth - 3)); + if (!helpText) { + return borderPaint(this.borders.horizontal.repeat(innerWidth)); + } + + const helpSlot = this.config.helpUndertitle?.color ?? "dim"; + const safeHelp = truncateToWidth(helpText, Math.max(0, innerWidth - 3), "…"); + const helpWidth = visibleWidth(safeHelp); + const rightFill = Math.max(0, innerWidth - helpWidth - 3); + + return `${borderPaint(this.borders.horizontal)} ${this.theme.colorizeForeground(helpSlot, safeHelp)} ${borderPaint(this.borders.horizontal.repeat(rightFill))}`; + }); + } + + private positionTitleSegments(innerWidth: number): PositionedTitleSegment[] { + if (innerWidth <= 0) { + return []; + } + + const left = this.resolveTitleSegment(this.config.titleBar.left, "left"); + const center = this.resolveTitleSegment(this.config.titleBar.center, "center"); + const right = this.resolveTitleSegment(this.config.titleBar.right, "right"); + + const placements: PositionedTitleSegment[] = []; + + if (left) { + const leftText = this.fitTextToWidth(left.text, Math.min(innerWidth, left.maxWidth ?? innerWidth), left.truncate); + if (leftText) { + placements.push({ + start: 0, + end: Math.min(innerWidth, visibleWidth(leftText)), + text: leftText, + color: left.color, + bold: left.bold ?? false, + }); + } + } + + if (right) { + const reservedLeft = placements[0]?.end ?? 0; + const available = Math.max(0, innerWidth - reservedLeft); + const rightText = this.fitTextToWidth(right.text, Math.min(available, right.maxWidth ?? available), right.truncate); + const rightWidth = visibleWidth(rightText); + if (rightText && rightWidth > 0) { + placements.push({ + start: innerWidth - rightWidth, + end: innerWidth, + text: rightText, + color: right.color, + bold: right.bold ?? false, + }); + } + } + + if (center) { + const leftLimit = placements.find((placement) => placement.start === 0)?.end ?? 0; + const rightStart = placements.find((placement) => placement.end === innerWidth)?.start ?? innerWidth; + const freeWidth = Math.max(0, rightStart - leftLimit); + if (freeWidth > 0) { + const centerText = this.fitTextToWidth(center.text, Math.min(freeWidth, center.maxWidth ?? freeWidth), center.truncate); + const centerWidth = visibleWidth(centerText); + if (centerText && centerWidth > 0) { + const centeredStart = Math.floor((innerWidth - centerWidth) / 2); + const start = clampInt(centeredStart, leftLimit, Math.max(leftLimit, rightStart - centerWidth)); + placements.push({ + start, + end: start + centerWidth, + text: centerText, + color: center.color, + bold: center.bold ?? false, + }); + } + } + } + + return placements.sort((a, b) => a.start - b.start); + } + + private resolveTitleSegment( + segment: TitleSegment | string | undefined, + position: "left" | "center" | "right", + ): (TitleSegment & { text: string }) | null { + if (!segment) { + return null; + } + + if (typeof segment === "string") { + const color: keyof ZellijColorPalette = position === "left" ? "accent" : position === "center" ? "muted" : "dim"; + return { + text: ` ${segment} `, + color, + bold: position === "left", + truncate: "end", + maxWidth: 0, + }; + } + + const clean = segment.text.trim(); + if (!clean) { + return null; + } + + return { + ...segment, + text: ` ${clean} `, + truncate: segment.truncate ?? "end", + bold: segment.bold ?? false, + }; + } + + private fitTextToWidth(text: string, maxWidth: number, mode: TitleSegment["truncate"]): string { + if (maxWidth <= 0) { + return ""; + } + if (visibleWidth(text) <= maxWidth) { + return text; + } + + switch (mode) { + case "none": + return truncateToWidth(text, maxWidth, ""); + case "start": + return truncateStart(text, maxWidth); + case "middle": + return truncateMiddle(text, maxWidth); + case "end": + default: + return truncateToWidth(text, maxWidth, "…"); + } + } + + private resolveHelpText(maxWidth: number): string | null { + const config = this.config.helpUndertitle; + if (!config || maxWidth <= 0) { + return null; + } + + if (config.textGenerator) { + try { + const generated = config.textGenerator(maxWidth); + if (generated && generated.trim()) { + return generated; + } + } catch { + return config.text?.trim() ? config.text : null; + } + } + + if (config.variants && config.variants.length > 0) { + for (const variant of config.variants) { + if (visibleWidth(variant) <= maxWidth) { + return variant; + } + } + return config.variants[config.variants.length - 1] ?? null; + } + + if (config.keyHints && config.keyHints.length > 0) { + const separator = config.keyHintSeparator ?? " • "; + return config.keyHints + .map((hint) => `${hint.key} ${hint.description}`) + .join(separator); + } + + return config.text?.trim() ? config.text : null; + } +} + +/** + * Main Zellij-style modal component wrapper. + */ +export class ZellijModal implements ZellijModalComponent { + config: ZellijModalConfig; + content: ZellijModalContentRenderer; + + private frame: ZellijModalFrame; + private palette: ZellijColorPalette; + + constructor(content: ZellijModalContentRenderer, config: ZellijModalConfigPartial = {}, theme?: Theme) { + if (!content || typeof content.render !== "function") { + throw new Error("ZellijModal requires a valid content renderer."); + } + + this.config = this.buildConfig(config); + this.palette = theme ? themeToZellijPalette(theme) : this.config.palette; + this.content = content; + this.frame = new ZellijModalFrame({ ...this.config, palette: this.palette }); + } + + /** + * Render content only (without frame). + */ + render(width: number): string[] { + const contentWidth = Math.max(1, width - 2 - this.config.padding * 2); + const paddedWidth = contentWidth + this.config.padding * 2; + const sidePadding = " ".repeat(this.config.padding); + const lines: string[] = []; + + try { + const rawLines = this.content.render(contentWidth); + const normalized = rawLines.length > 0 ? rawLines : [""]; + + pushVerticalPadding(lines, this.config.padding, paddedWidth); + + for (const line of normalized) { + const fitted = truncateToWidth(line, contentWidth, "", true); + lines.push(`${sidePadding}${fitted}${sidePadding}`); + } + + pushVerticalPadding(lines, this.config.padding, paddedWidth); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const safe = truncateToWidth(` Render error: ${message} `, paddedWidth, "…", true); + lines.push(safe); + } + + return lines.length > 0 ? lines : [" ".repeat(paddedWidth)]; + } + + /** + * Render complete frame + content. + */ + renderModal(width: number): ZellijModalRenderOutput { + const frameWidth = this.resolveModalWidth(width); + const contentLines = this.render(frameWidth); + return this.frame.renderFrame(contentLines, frameWidth, this.palette); + } + + /** + * Invalidate child renderer state. + */ + invalidate(): void { + this.content.invalidate(); + } + + /** + * Delegate input to child renderer. + */ + handleInput(data: string): void { + this.content.handleInput?.(data); + } + + /** + * Get overlay options for `ctx.ui.custom()`. + */ + getOverlayOptions(): { overlay: true; overlayOptions: ZellijModalConfig["overlay"] } { + return { + overlay: true, + overlayOptions: this.config.overlay, + }; + } + + /** + * Dispose modal resources. + */ + dispose(): void { + this.content.invalidate(); + } + + private buildConfig(partial: ZellijModalConfigPartial): ZellijModalConfig { + const borderStyle = partial.borderStyle && BORDER_STYLES[partial.borderStyle] ? partial.borderStyle : "rounded"; + const padding = Math.max(0, partial.padding ?? 1); + const minWidth = Math.max(4, partial.minWidth ?? 40); + const maxWidth = Math.max(0, partial.maxWidth ?? 0); + const helpUndertitle = normalizeHelpUndertitle(partial.helpText, partial.helpUndertitle); + + return { + borderStyle, + palette: partial.palette ?? DEFAULT_ZELLIJ_PALETTE, + focused: partial.focused ?? true, + padding, + titleBar: partial.titleBar ?? { left: partial.title ?? "Modal" }, + helpUndertitle, + minWidth, + maxWidth, + overlay: { + anchor: partial.overlay?.anchor ?? "center", + width: partial.overlay?.width ?? 70, + maxHeight: partial.overlay?.maxHeight ?? "80%", + margin: Math.max(0, partial.overlay?.margin ?? 1), + }, + }; + } + + private resolveModalWidth(availableWidth: number): number { + const width = Math.max(4, availableWidth); + const boundedMax = this.config.maxWidth > 0 ? Math.min(width, this.config.maxWidth) : width; + if (boundedMax >= this.config.minWidth) { + return boundedMax; + } + return Math.max(4, boundedMax); + } +} + +/** + * Options for the pre-built settings modal content renderer. + */ +export interface SettingsTab { + label: string; + settings: SettingItem[]; +} + +export interface SettingsModalOptions { + /** Modal heading. */ + title: string; + /** Optional descriptive subtitle shown above settings. */ + description?: string; + /** Settings list items (used when tabs are not provided). */ + settings?: SettingItem[]; + /** Optional tabs for grouped settings. */ + tabs?: SettingsTab[]; + /** Initial active tab index. */ + activeTabIndex?: number; + /** Called when a setting value changes. */ + onChange: (id: string, value: string) => void; + /** Called when modal should close. */ + onClose: () => void; + /** Optional help text shown below settings. */ + helpText?: string; + /** Enables in-list search (`/` and typing behavior from SettingsList). */ + enableSearch?: boolean; +} + +/** + * Pre-built Zellij content renderer for configuration modals. + */ +export class ZellijSettingsModal implements ZellijModalContentRenderer { + private container: Container; + private contentBox: Box; + private settingsList: SettingsList; + private options: SettingsModalOptions; + private theme: Theme; + private showTabs: boolean; + private activeTabIndex: number; + private tabLists: SettingsList[]; + + constructor(options: SettingsModalOptions, theme: Theme) { + if (!options.title || !options.title.trim()) { + throw new Error("ZellijSettingsModal requires a non-empty title."); + } + + this.options = options; + this.theme = theme; + this.showTabs = options.tabs !== undefined && options.tabs.length > 0; + this.tabLists = this.showTabs ? options.tabs!.map((tab) => this.createSettingsList(tab.settings, () => this.options.onClose())) : []; + this.activeTabIndex = this.normalizeActiveTabIndex(options.activeTabIndex ?? 0); + this.container = new Container(); + this.contentBox = new Box(0, 0); + + if (this.showTabs) { + this.settingsList = this.tabLists[this.activeTabIndex] ?? this.tabLists[0]!; + } else { + this.contentBox.addChild(new Text(this.theme.fg("accent", this.theme.bold(options.title)), 0, 0)); + + if (options.description) { + this.contentBox.addChild(new Spacer(1)); + this.contentBox.addChild(new Text(this.theme.fg("muted", options.description), 0, 0)); + } + + this.contentBox.addChild(new Spacer(1)); + const fallbackSettings = options.settings ?? []; + this.settingsList = this.createSettingsList(fallbackSettings, () => this.options.onClose()); + this.contentBox.addChild(this.settingsList); + + if (options.helpText) { + this.contentBox.addChild(new Spacer(1)); + this.contentBox.addChild(new Text(this.theme.fg("dim", options.helpText), 0, 0)); + } + + this.container.addChild(this.contentBox); + } + } + + /** + * Render settings modal content. + */ + render(width: number): string[] { + const safeWidth = Math.max(1, width); + try { + if (!this.showTabs) { + return this.container.render(safeWidth); + } + + const lines: string[] = []; + + // Title + lines.push(this.theme.fg("accent", this.theme.bold(this.options.title))); + lines.push(""); + + // Tab bar + lines.push(this.renderTabBar(safeWidth)); + lines.push(""); + + // Active settings list for the selected tab. + const activeList = this.tabLists[this.activeTabIndex] ?? this.tabLists[0]; + if (activeList) { + const listRender = activeList.render(safeWidth); + lines.push(...listRender); + } + + // Separator + help text + if (this.options.helpText) { + lines.push(this.theme.fg("border", "─".repeat(Math.max(0, safeWidth)))); + lines.push(this.theme.fg("dim", this.options.helpText)); + } + + return lines; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return [this.theme.fg("error", truncateToWidth(`Settings render error: ${message}`, safeWidth, "…"))]; + } + } + + private createSettingsList(settings: SettingItem[], onCancel: () => void): SettingsList { + return new SettingsList( + settings, + Math.min(Math.max(settings.length + 2, 6), 18), + getSettingsListTheme(), + (id: string, value: string) => { + this.options.onChange(id, value); + }, + onCancel, + { enableSearch: this.options.enableSearch ?? true }, + ); + } + + private normalizeActiveTabIndex(index: number): number { + if (!this.showTabs || this.tabLists.length === 0) { + return 0; + } + + const normalized = Number.isFinite(index) ? Math.floor(index) : 0; + return ((normalized % this.tabLists.length) + this.tabLists.length) % this.tabLists.length; + } + + private renderTabBar(width: number): string { + if (!this.showTabs || !this.options.tabs || this.options.tabs.length === 0) { + return ""; + } + + const parts: string[] = []; + for (let i = 0; i < this.options.tabs.length; i++) { + const label = this.options.tabs[i].label; + if (i === this.activeTabIndex) { + parts.push(this.theme.fg("accent", `[ ${label} ]`)); + } else { + parts.push(this.theme.fg("muted", ` ${label} `)); + } + } + + return truncateToWidth(parts.join(""), width, "…"); + } + + private switchTab(direction: number): void { + if (!this.showTabs || this.tabLists.length === 0) { + return; + } + this.activeTabIndex = this.normalizeActiveTabIndex(this.activeTabIndex + direction); + this.settingsList = this.tabLists[this.activeTabIndex] ?? this.tabLists[0]!; + } + + /** + * Invalidate internal caches. + */ + invalidate(): void { + if (!this.showTabs) { + this.container.invalidate(); + return; + } + + for (const list of this.tabLists) { + list.invalidate(); + } + } + + /** + * Forward key input to SettingsList. + */ + handleInput(data: string): void { + if (isEnterActivationInput(data)) { + return; + } + if (this.showTabs && data === "\x1b[D") { + this.switchTab(-1); + return; + } + if (this.showTabs && data === "\x1b[C") { + this.switchTab(1); + return; + } + + this.settingsList.handleInput(data); + } + + /** + * Programmatically update one setting value in the list. + */ + updateValue(id: string, value: string): void { + if (!this.showTabs) { + this.settingsList.updateValue(id, value); + return; + } + + for (const list of this.tabLists) { + list.updateValue(id, value); + } + } +} + +function isEnterActivationInput(data: string): boolean { + return data === "\r" || data === "\n" || data === "\r\n"; +} + +function normalizeHelpUndertitle( + helpText: ZellijModalConfigPartial["helpText"], + helpUndertitle: HelpUndertitleConfig | undefined, +): HelpUndertitleConfig | undefined { + if (helpUndertitle) { + return helpUndertitle; + } + if (typeof helpText === "string") { + return helpText ? { text: helpText } : undefined; + } + return helpText; +} + +function resolvePaletteColor(color: PaletteColor | keyof ZellijColorPalette, palette: ZellijColorPalette): PaletteColor { + if (typeof color === "string") { + return palette[color]; + } + return color; +} + +function parseAnsiForegroundColor(ansi: string): PaletteColor | null { + const rgbMatch = /\x1b\[38;2;(\d+);(\d+);(\d+)m/.exec(ansi); + if (rgbMatch) { + const [, r, g, b] = rgbMatch; + return { + type: "rgb", + r: clampInt(Number.parseInt(r ?? "0", 10), 0, 255), + g: clampInt(Number.parseInt(g ?? "0", 10), 0, 255), + b: clampInt(Number.parseInt(b ?? "0", 10), 0, 255), + }; + } + + const bit8Match = /\x1b\[38;5;(\d+)m/.exec(ansi); + if (bit8Match) { + const [, code] = bit8Match; + return { + type: "8bit", + code: clampInt(Number.parseInt(code ?? "255", 10), 0, 255), + }; + } + + return null; +} + +/** + * Shared no-op guard for the directional truncators: returns the value to emit + * immediately when no truncation is required (text fits, or maxWidth is too + * small to hold anything but an ellipsis fragment), otherwise `null` signals + * the caller to proceed with directional truncation. + */ +function truncateNoOpGuard(text: string, maxWidth: number): string | null { + if (visibleWidth(text) <= maxWidth) { + return text; + } + if (maxWidth <= 1) { + return "…".slice(0, maxWidth); + } + return null; +} + +function truncateWithNoOpGuard( + text: string, + maxWidth: number, + compute: () => string, +): string { + const guarded = truncateNoOpGuard(text, maxWidth); + if (guarded !== null) { + return guarded; + } + return compute(); +} + +function truncateStart(text: string, maxWidth: number): string { + return truncateWithNoOpGuard(text, maxWidth, () => { + const chars = Array.from(text); + let current = ""; + for (let index = chars.length - 1; index >= 0; index--) { + const candidate = `${chars[index]}${current}`; + if (visibleWidth(candidate) >= maxWidth - 1) { + current = candidate; + break; + } + current = candidate; + } + return `…${truncateToWidth(current, Math.max(0, maxWidth - 1), "")}`; + }); +} + +function truncateMiddle(text: string, maxWidth: number): string { + return truncateWithNoOpGuard(text, maxWidth, () => { + const headTarget = Math.floor((maxWidth - 1) / 2); + const tailTarget = Math.max(0, maxWidth - 1 - headTarget); + const head = truncateToWidth(text, headTarget, ""); + + const chars = Array.from(text); + let tail = ""; + for (let index = chars.length - 1; index >= 0; index--) { + const candidate = `${chars[index]}${tail}`; + if (visibleWidth(candidate) > tailTarget) { + continue; + } + tail = candidate; + if (visibleWidth(tail) === tailTarget) { + break; + } + } + + return `${head}…${tail}`; + }); +} + +function clampInt(value: number, min: number, max: number): number { + if (Number.isNaN(value) || !Number.isFinite(value)) { + return min; + } + return Math.min(max, Math.max(min, Math.round(value))); +} + +function pushVerticalPadding(lines: string[], count: number, paddedWidth: number): void { + for (let i = 0; i < count; i++) { + lines.push(" ".repeat(paddedWidth)); + } +} + +/** + * Extension factory entrypoint for Pi extension loader. + * + * This extension intentionally registers no commands/events and only exposes + * reusable modal primitives for sibling extensions. + */ +export default function zellijModalExtension(_pi: ExtensionAPI): void { + // no-op +} diff --git a/pi-rtk-optimizer/tsconfig.json b/pi-rtk-optimizer/tsconfig.json new file mode 100644 index 0000000..bc262ee --- /dev/null +++ b/pi-rtk-optimizer/tsconfig.json @@ -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"] +}