mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 07:23:06 +00:00
chore: initialize my-pi extension bundle
This commit is contained in:
+1
-1
@@ -6,6 +6,7 @@
|
|||||||
# Node artifact files
|
# Node artifact files
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.pi/npm/
|
||||||
|
|
||||||
# Compiled Java class files
|
# Compiled Java class files
|
||||||
*.class
|
*.class
|
||||||
@@ -47,4 +48,3 @@ Thumbs.db
|
|||||||
*.flv
|
*.flv
|
||||||
*.mov
|
*.mov
|
||||||
*.wmv
|
*.wmv
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 仓库说明
|
||||||
|
|
||||||
|
本仓库用于维护可通过一次 `pi install` 部署的个人 Pi 扩展组合包。根 `package.json` 是唯一安装入口;依赖、默认行为和必要配置必须由组合包自身声明或部署,不把安装后的手工配置作为默认流程。每个自维护扩展使用独立的顶层目录,扩展源码、测试和说明应保留在各自目录内。
|
||||||
|
|
||||||
|
## 当前扩展
|
||||||
|
|
||||||
|
- `pi-rtk-optimizer/`:默认只负责非搜索工具输出压缩;RTK 命令改写为可选能力。
|
||||||
|
- 上游来源:<https://github.com/MasuRii/pi-rtk-optimizer>
|
||||||
|
- 初始导入快照:`d155d253cb2f1358e34e717d47a82ebccb08cb8e`(2026-07-03,`0.9.0`)。
|
||||||
|
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git`。
|
||||||
|
- `extensions/fff-override.ts`:强制使用 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 FFF 的 `@` 补全;显式 CLI flag 仍遵循 FFF 官方优先级。
|
||||||
|
- `extensions/permission-system.ts`:在权限扩展注册前,将 `config/pi-permission-system.json` 同步为全局权威配置。
|
||||||
|
- 根包还固定安装 `pi-context-view`、`@firstpick/pi-extension-codex-fast-mode` 和 `@gotgenes/pi-permission-system`。
|
||||||
|
|
||||||
|
## 当前职责与默认行为
|
||||||
|
|
||||||
|
- FFF 独占字面搜索。RTK 不得处理 `grep`、`find`、`multi_grep` 的调用或结果,也不得通过默认命令改写接管 `rg`、`grep`、`find`、`fd`。
|
||||||
|
- RTK 默认只压缩非搜索输出,包括 Bash ANSI 清理、测试聚合、构建过滤、Git 压缩和 Lint 聚合,并记录压缩统计。
|
||||||
|
- `commandRewritingEnabled` 默认 `false`。默认安装不依赖系统 `rtk` CLI;只有用户主动开启命令改写时才需要 `rtk rewrite`。
|
||||||
|
- `readCompaction.enabled`、`sourceCodeFilteringEnabled` 和 `smartTruncate.enabled` 当前均默认 `false`,源码读取保持原样。未经用户明确决定,不因节省上下文而改变这些默认值。
|
||||||
|
- 若以后开启 read 压缩,优先考虑 `readCompaction + smartTruncate`,源码过滤仍独立评估;必须保留精确 `offset/limit` 读取、短文件和行锚点的完整性。
|
||||||
|
- `pi-context-view` 只观察上下文占用,不参与压缩策略。
|
||||||
|
- Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier,由 `/fast-mode` 在会话内控制。
|
||||||
|
- 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜,敏感凭据路径拒绝,外部目录、Git 写操作、破坏性系统操作、网络和安装操作询问。
|
||||||
|
|
||||||
|
## 修改边界
|
||||||
|
|
||||||
|
- 优先在目标扩展目录内完成改动;不要让一个扩展依赖另一个扩展的未公开内部实现。
|
||||||
|
- 保留原项目的 `LICENSE`、版权信息和必要的来源说明。
|
||||||
|
- 扩展运行目录中的 `config.json`、日志、构建产物、覆盖率目录和依赖目录属于本地状态,不应提交;`config/pi-permission-system.json` 是组合包的权威源配置,必须提交并维护。
|
||||||
|
- 外部 Pi 扩展依赖必须在根 `package.json` 中使用精确版本,并更新根 `package-lock.json`;不要用仓库级 `.pi/settings.json` 代替组合包依赖。
|
||||||
|
- Pi 核心包只作为宿主 peer dependencies,不得在组合包内再安装或打包一套 Pi runtime;保留根 `.npmrc` 的 peer 安装策略。
|
||||||
|
- 上游仅作为参考来源。同步上游改动时先核对本仓库已有修改,再按明确范围移植;不要直接覆盖本地实现。
|
||||||
|
- 未经明确要求,不执行发布、提交、推送或安装到用户 Pi 运行目录等外部写操作。
|
||||||
|
|
||||||
|
## `pi-rtk-optimizer` 开发约定
|
||||||
|
|
||||||
|
- 组合包运行环境为 Node.js 22.19 或更高版本;RTK 子目录开发验证还需要 npm、Bun 和项目声明的开发依赖。
|
||||||
|
- 扩展入口是 `pi-rtk-optimizer/index.ts`,主要实现位于 `pi-rtk-optimizer/src/`。
|
||||||
|
- RTK 命令改写默认关闭;若用户主动开启,其命令支持策略由已安装的 `rtk rewrite` 决定,不在扩展内重复维护规则。
|
||||||
|
- FFF override 独占搜索工具与搜索输出;RTK 不得处理 `grep` 工具结果。
|
||||||
|
- read 压缩属于有损能力。任何默认值调整都必须同时说明哪些内容可能被省略、精确读取如何恢复原文,以及对 `edit` 文本匹配和排障证据的影响。
|
||||||
|
- 修改配置结构时同步检查默认值、归一化逻辑、设置界面、类型定义、README 示例和相关测试。
|
||||||
|
- 工具输出压缩可能损失证据。排障和审计相关改动应优先保证原始输出可恢复,并覆盖锚点完整性和截断边界。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
组合包依赖或加载入口变化时,至少验证根 `npm install` 幂等、锁文件有效,以及全部扩展可在隔离的临时 Pi agent 目录加载。权限配置变化时使用当前固定版本的 `pi-permission-system` schema 校验,并验证包装入口部署后的文件与仓库源配置一致。
|
||||||
|
|
||||||
|
在 `pi-rtk-optimizer/` 内按改动范围选择最小充分验证:
|
||||||
|
|
||||||
|
- `npm run build`:TypeScript 转译检查。
|
||||||
|
- `npm run typecheck`:完整类型检查。
|
||||||
|
- `npm run test`:运行 Bun 测试。
|
||||||
|
- `npm run check`:类型、测试和打包检查的完整验证。
|
||||||
|
|
||||||
|
若环境缺少依赖或未执行某项验证,交付时明确说明,不以静态检查代替运行结果。
|
||||||
@@ -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。
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Generated
+549
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
config.json
|
||||||
|
*.tmp
|
||||||
|
.pi-rtk-optimizer-check.mjs
|
||||||
|
owned
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
config.json
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.npmignore
|
||||||
|
tsconfig.json
|
||||||
|
.pi-rtk-optimizer-check.mjs
|
||||||
@@ -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`
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# pi-rtk-optimizer
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/pi-rtk-optimizer)
|
||||||
|
[](LICENSE)
|
||||||
|
[]()
|
||||||
|
|
||||||
|
[](https://ko-fi.com/Y8Y01PSSVR)
|
||||||
|
|
||||||
|
> Optional RTK command rewriting and non-search tool output compaction for the Pi coding agent.
|
||||||
|
<img width="1360" height="752" alt="image" src="https://github.com/user-attachments/assets/f4536889-62ec-429a-984e-dc0de9f1f709" />
|
||||||
|
**pi-rtk-optimizer** compacts noisy `bash` and `read` output. RTK CLI command rewriting is available but disabled by default, and search tool output is deliberately left to FFF.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Command Rewriting
|
||||||
|
|
||||||
|
- Optional **automatic rewriting** or **suggestion-only** mode for common development workflows (disabled by default)
|
||||||
|
- Delegates bash command rewrite decisions to the installed `rtk rewrite` command, keeping RTK as the source of truth for supported commands, shell parsing, bypasses, and compound-command behavior
|
||||||
|
- Runtime guard when `rtk` binary is unavailable (raw commands run unchanged and repeated missing-binary rewrite probes are avoided)
|
||||||
|
- `/rtk show` and `/rtk verify` surface the resolved `rtk` executable path when the host can resolve it
|
||||||
|
- Pi-specific shell safety fixups for rewritten commands on Windows
|
||||||
|
|
||||||
|
### Output Compaction Pipeline
|
||||||
|
|
||||||
|
Multi-stage pipeline to reduce token consumption:
|
||||||
|
|
||||||
|
| Stage | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| ANSI Stripping | Removes terminal color/formatting codes |
|
||||||
|
| Test Aggregation | Summarizes test runner output (pass/fail counts) |
|
||||||
|
| Build Filtering | Extracts errors/warnings from build output |
|
||||||
|
| Git Compaction | Condenses `git status`, `git log`, `git diff` output |
|
||||||
|
| Linter Aggregation | Summarizes linting tool output |
|
||||||
|
| Source Code Filtering | `none`, `minimal`, or `aggressive` comment/whitespace removal with userscript metadata preservation |
|
||||||
|
| Smart Truncation | Preserves file boundaries and important lines while keeping 80-line reads exact |
|
||||||
|
| Anchor-Safe Read Compaction | Detects hashline/anchored `read` output and preserves complete edit anchors when filtering or truncating anchored lines |
|
||||||
|
| Hard Truncation | Final character limit enforcement |
|
||||||
|
|
||||||
|
### Interactive Settings
|
||||||
|
|
||||||
|
- Tabbed TUI settings modal via `/rtk` command
|
||||||
|
- Real-time configuration changes without restart
|
||||||
|
- Command completions for all subcommands
|
||||||
|
|
||||||
|
### Session Metrics
|
||||||
|
|
||||||
|
- Tracks compaction savings per tool type
|
||||||
|
- View statistics with `/rtk stats`
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Local Extension Folder
|
||||||
|
|
||||||
|
Place this folder in one of the following locations:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.pi/agent/extensions/pi-rtk-optimizer # Global default (when PI_CODING_AGENT_DIR is unset)
|
||||||
|
$PI_CODING_AGENT_DIR/extensions/pi-rtk-optimizer # Global when PI_CODING_AGENT_DIR is set
|
||||||
|
.pi/extensions/pi-rtk-optimizer # Project-specific
|
||||||
|
```
|
||||||
|
|
||||||
|
Pi auto-discovers extensions in these paths on startup.
|
||||||
|
|
||||||
|
### npm Package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi install npm:pi-rtk-optimizer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Git Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi install git:github.com/MasuRii/pi-rtk-optimizer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Settings Modal
|
||||||
|
|
||||||
|
Open the interactive settings modal:
|
||||||
|
|
||||||
|
```
|
||||||
|
/rtk
|
||||||
|
```
|
||||||
|
|
||||||
|
Use ←/→ to switch tabs, ↑/↓ to navigate settings in the active tab, type to search, Enter/Space to cycle values, and Escape to close.
|
||||||
|
|
||||||
|
### Subcommands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/rtk` | Open settings modal |
|
||||||
|
| `/rtk show` | Display current configuration and runtime status |
|
||||||
|
| `/rtk path` | Show config file path |
|
||||||
|
| `/rtk verify` | Check if `rtk` binary is available |
|
||||||
|
| `/rtk stats` | Show output compaction metrics for current session |
|
||||||
|
| `/rtk clear-stats` | Reset compaction metrics |
|
||||||
|
| `/rtk reset` | Reset all settings to defaults |
|
||||||
|
| `/rtk help` | Display usage help |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration is stored at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Default global path: ~/.pi/agent/extensions/pi-rtk-optimizer/config.json
|
||||||
|
Actual global path: $PI_CODING_AGENT_DIR/extensions/pi-rtk-optimizer/config.json when PI_CODING_AGENT_DIR is set
|
||||||
|
```
|
||||||
|
|
||||||
|
A starter template is included at `config/config.example.json`.
|
||||||
|
|
||||||
|
For audit or debugging sessions, keep `showRewriteNotifications` enabled and disable lossy `read` compaction/source filtering before gathering evidence. Existing `config.json` files are user-owned runtime state; do not overwrite local choices unless you intentionally want to change live extension behavior.
|
||||||
|
|
||||||
|
### Configuration Options
|
||||||
|
|
||||||
|
#### Top-Level Settings
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `enabled` | boolean | `true` | Master switch for all extension features |
|
||||||
|
| `commandRewritingEnabled` | boolean | `false` | Enable optional RTK CLI command rewriting |
|
||||||
|
| `mode` | string | `"rewrite"` | `"rewrite"` (auto-rewrite) or `"suggest"` (notify only) |
|
||||||
|
| `guardWhenRtkMissing` | boolean | `true` | Run original commands when rtk binary unavailable |
|
||||||
|
| `showRewriteNotifications` | boolean | `true` | Show rewrite notices in TUI |
|
||||||
|
|
||||||
|
#### Rewrite Source
|
||||||
|
|
||||||
|
Bash command support is intentionally resolved by the installed `rtk` binary through `rtk rewrite`. The extension does not maintain duplicate rewrite rules or category classifiers; update/configure RTK itself for command support policy.
|
||||||
|
|
||||||
|
> **Breaking in 0.6.0:** Rewrite category toggles (`rewriteGitGithub`, `rewriteFilesystem`, `rewriteRust`, `rewriteJavaScript`, `rewritePython`, `rewriteGo`, `rewriteContainers`, `rewriteNetwork`, and `rewritePackageManagers`) were removed from the extension config surface. Existing rewrite policy should be configured in RTK because the extension now delegates rewrite ownership to `rtk rewrite`.
|
||||||
|
|
||||||
|
#### Output Compaction Settings
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `outputCompaction.enabled` | boolean | `true` | Enable output compaction pipeline |
|
||||||
|
| `outputCompaction.stripAnsi` | boolean | `true` | Remove ANSI escape codes |
|
||||||
|
| `outputCompaction.readCompaction.enabled` | boolean | `false` | Enable lossy compaction for `read` output; defaults off so code reads stay exact |
|
||||||
|
| `outputCompaction.sourceCodeFilteringEnabled` | boolean | `false` | Enable source code filtering for `read` output when read compaction is enabled |
|
||||||
|
| `outputCompaction.preserveExactSkillReads` | boolean | `false` | Keep reads under configured Pi/global/project skill directories exact, bypassing read compaction |
|
||||||
|
| `outputCompaction.sourceCodeFiltering` | string | `"none"` | Filter level: `"none"`, `"minimal"`, `"aggressive"` |
|
||||||
|
| `outputCompaction.aggregateTestOutput` | boolean | `true` | Summarize test runner output |
|
||||||
|
| `outputCompaction.filterBuildOutput` | boolean | `true` | Filter build/compile output |
|
||||||
|
| `outputCompaction.compactGitOutput` | boolean | `true` | Compact git command output |
|
||||||
|
| `outputCompaction.aggregateLinterOutput` | boolean | `true` | Summarize linter output |
|
||||||
|
| `outputCompaction.trackSavings` | boolean | `true` | Track compaction metrics |
|
||||||
|
|
||||||
|
Skill-read preservation covers the global Pi skills directory (`~/.pi/agent/skills` by default, or `$PI_CODING_AGENT_DIR/skills` when set), `~/.agents/skills`, project `.pi/skills`, and ancestor `.agents/skills` directories.
|
||||||
|
|
||||||
|
When `read` output uses Pi hashline/anchor prefixes, the compactor treats each anchored line as an indivisible edit anchor. Source filtering and truncation may omit anchored lines, but retained lines keep their complete anchor prefixes; hard truncation inserts an anchor-safe marker instead of cutting through an anchor.
|
||||||
|
|
||||||
|
#### Truncation Settings
|
||||||
|
|
||||||
|
| Option | Type | Default | Range | Description |
|
||||||
|
|--------|------|---------|-------|-------------|
|
||||||
|
| `outputCompaction.smartTruncate.enabled` | boolean | `false` | — | Enable smart line-based truncation for read output when read compaction is enabled |
|
||||||
|
| `outputCompaction.smartTruncate.maxLines` | number | `220` | 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 <path>` → `cd "<normalized-path>"` (converts backslashes)
|
||||||
|
- Prepends `PYTHONIOENCODING=utf-8` for Python commands
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- **Peer dependencies:** `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`
|
||||||
|
- **Runtime:** Node.js ≥20, optional `rtk` binary for command rewriting
|
||||||
|
- **Development verification:** Node.js ≥24 and npm for Node/tsx test scripts using Node's experimental test module mocks
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Transpile-only TypeScript build check
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Full typecheck
|
||||||
|
npm run typecheck
|
||||||
|
|
||||||
|
# Run Node/tsx tests
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Full verification
|
||||||
|
npm run check
|
||||||
|
|
||||||
|
# Bundle sanity check
|
||||||
|
npm run build:check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Inspired by:
|
||||||
|
- [mcowger/pi-rtk](https://github.com/mcowger/pi-rtk)
|
||||||
|
- [rtk-ai/rtk](https://github.com/rtk-ai/rtk)
|
||||||
|
|
||||||
|
## Related Pi Extensions
|
||||||
|
|
||||||
|
- [pi-tool-display](https://github.com/MasuRii/pi-tool-display) — Compact tool rendering and diff visualization
|
||||||
|
- [pi-permission-system](https://github.com/MasuRii/pi-permission-system) — Permission enforcement for tool and command access
|
||||||
|
- [pi-smart-voice-notify](https://github.com/MasuRii/pi-smart-voice-notify) — Multi-channel TTS and sound notifications
|
||||||
|
- [pi-image-tools](https://github.com/MasuRii/pi-image-tools) — Image attachment and inline preview
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE) © MasuRii
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"commandRewritingEnabled": false,
|
||||||
|
"mode": "rewrite",
|
||||||
|
"guardWhenRtkMissing": true,
|
||||||
|
"showRewriteNotifications": true,
|
||||||
|
"outputCompaction": {
|
||||||
|
"enabled": true,
|
||||||
|
"stripAnsi": true,
|
||||||
|
"readCompaction": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"sourceCodeFilteringEnabled": false,
|
||||||
|
"preserveExactSkillReads": false,
|
||||||
|
"truncate": {
|
||||||
|
"enabled": true,
|
||||||
|
"maxChars": 12000
|
||||||
|
},
|
||||||
|
"sourceCodeFiltering": "none",
|
||||||
|
"smartTruncate": {
|
||||||
|
"enabled": false,
|
||||||
|
"maxLines": 220
|
||||||
|
},
|
||||||
|
"aggregateTestOutput": true,
|
||||||
|
"filterBuildOutput": true,
|
||||||
|
"compactGitOutput": true,
|
||||||
|
"aggregateLinterOutput": true,
|
||||||
|
"trackSavings": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import rtkIntegrationExtension from "./src/index.js";
|
||||||
|
|
||||||
|
export default rtkIntegrationExtension;
|
||||||
Generated
+1619
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-rtk-optimizer",
|
||||||
|
"version": "0.9.0",
|
||||||
|
"description": "Pi extension that optimizes RTK command rewriting and tool output compaction for the coding agent.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.ts"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"index.ts",
|
||||||
|
"src",
|
||||||
|
"config/config.example.json",
|
||||||
|
"README.md",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json --noCheck",
|
||||||
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
|
"test": "bun ./src/output-compactor.test.ts && bun ./src/command-rewriter.test.ts && bun ./src/runtime-guard.test.ts && bun ./src/package-lock-integrity.test.ts && bun ./src/additional-coverage.test.ts && bun ./src/config-modal.test.ts && bun ./src/index.test.ts",
|
||||||
|
"check": "npm run typecheck && npm run test && npm run build:check",
|
||||||
|
"build:check": "esbuild ./index.ts --bundle --platform=node --format=esm --outfile=./.pi-rtk-optimizer-check.mjs --external:@earendil-works/pi-coding-agent --external:@earendil-works/pi-tui && node -e \"import { unlinkSync } from 'node:fs'; unlinkSync('./.pi-rtk-optimizer-check.mjs');\"",
|
||||||
|
"postinstall": "node -e \"const fs=require('fs'),cp=require('child_process'),p=require('path');const cwd=process.cwd();const normalized=cwd.split(p.sep).join('/');if(!normalized.includes('/.pi/agent/extensions/'))process.exit(0);const s=p.resolve(cwd,'../../scripts/patch-vulnerable-deps.mjs');if(!fs.existsSync(s))process.exit(0);const r=cp.spawnSync(process.execPath,[s,'--target',cwd,'--quiet'],{stdio:'inherit'});process.exit(r.status||0)\""
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"pi-package",
|
||||||
|
"pi",
|
||||||
|
"pi-extension",
|
||||||
|
"pi-coding-agent",
|
||||||
|
"coding-agent",
|
||||||
|
"rtk",
|
||||||
|
"token-optimization",
|
||||||
|
"tool-compaction",
|
||||||
|
"output-compaction",
|
||||||
|
"command-rewrite",
|
||||||
|
"optimization"
|
||||||
|
],
|
||||||
|
"author": "MasuRii",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/MasuRii/pi-rtk-optimizer.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/MasuRii/pi-rtk-optimizer/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/MasuRii/pi-rtk-optimizer#readme",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "0.28.1",
|
||||||
|
"typescript": "6.0.3"
|
||||||
|
},
|
||||||
|
"pi": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.78.0 || ^0.79.0 || ^0.80.0",
|
||||||
|
"@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.78.0 || ^0.79.0 || ^0.80.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"protobufjs": "7.6.3",
|
||||||
|
"ws": "8.21.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { clearOutputMetrics, getOutputMetricsSummary, trackOutputSavings } from "./output-metrics.ts";
|
||||||
|
import { mock, runTest } from "./test-helpers.test.ts";
|
||||||
|
import { matchesCommandPatterns, normalizeCommandForDetection } from "./techniques/command-detection.ts";
|
||||||
|
import { compactPath } from "./techniques/path-utils.ts";
|
||||||
|
import { filterAggressive } from "./techniques/source.ts";
|
||||||
|
import { aggregateTestOutput } from "./techniques/test-output.ts";
|
||||||
|
import { applyWindowsBashCompatibilityFixes } from "./windows-command-helpers.ts";
|
||||||
|
import { applyRewrittenCommandShellSafetyFixups } from "./rewrite-pipeline-safety.ts";
|
||||||
|
import { applyRtkCommandEnvironment } from "./rtk-command-environment.ts";
|
||||||
|
import { sanitizeStreamingBashExecutionResult } from "./tool-execution-sanitizer.ts";
|
||||||
|
|
||||||
|
mock.module("@earendil-works/pi-coding-agent", {
|
||||||
|
namedExports: {
|
||||||
|
getAgentDir: () => "/tmp/.pi/agent",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
ensureConfigExists,
|
||||||
|
getRtkIntegrationConfigPath,
|
||||||
|
loadRtkIntegrationConfig,
|
||||||
|
normalizeRtkIntegrationConfig,
|
||||||
|
saveRtkIntegrationConfig,
|
||||||
|
} = await import("./config-store.ts");
|
||||||
|
|
||||||
|
function makeTempConfigPath(): string {
|
||||||
|
return `${getRtkIntegrationConfigPath()}.test-${Date.now()}-${Math.random().toString(16).slice(2)}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupFile(path: string): void {
|
||||||
|
for (const candidate of [path, `${path}.tmp`]) {
|
||||||
|
try {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
unlinkSync(candidate);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup failures in tests.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runTest("config-store normalizes invalid values and clamps numeric ranges", () => {
|
||||||
|
const normalized = normalizeRtkIntegrationConfig({
|
||||||
|
enabled: "yes",
|
||||||
|
mode: "invalid",
|
||||||
|
rewriteGitGithub: false,
|
||||||
|
outputCompaction: {
|
||||||
|
stripAnsi: false,
|
||||||
|
sourceCodeFilteringEnabled: "sometimes",
|
||||||
|
sourceCodeFiltering: "extreme",
|
||||||
|
truncate: {
|
||||||
|
enabled: true,
|
||||||
|
maxChars: 12,
|
||||||
|
},
|
||||||
|
smartTruncate: {
|
||||||
|
enabled: true,
|
||||||
|
maxLines: 999_999,
|
||||||
|
},
|
||||||
|
trackSavings: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(normalized.enabled, true);
|
||||||
|
assert.equal(normalized.mode, "rewrite");
|
||||||
|
assert.equal(Object.hasOwn(normalized, "rewriteGitGithub"), false);
|
||||||
|
assert.equal(normalized.outputCompaction.stripAnsi, false);
|
||||||
|
assert.equal(normalized.outputCompaction.readCompaction.enabled, true);
|
||||||
|
assert.equal(normalized.outputCompaction.sourceCodeFilteringEnabled, true);
|
||||||
|
assert.equal(normalized.outputCompaction.sourceCodeFiltering, "minimal");
|
||||||
|
assert.equal(normalized.outputCompaction.truncate.maxChars, 1_000);
|
||||||
|
assert.equal(normalized.outputCompaction.smartTruncate.maxLines, 4_000);
|
||||||
|
assert.equal(normalized.outputCompaction.trackSavings, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("config-store uses safer read defaults when readCompaction is explicit", () => {
|
||||||
|
const normalized = normalizeRtkIntegrationConfig({
|
||||||
|
outputCompaction: {
|
||||||
|
readCompaction: { enabled: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(normalized.outputCompaction.readCompaction.enabled, false);
|
||||||
|
assert.equal(normalized.outputCompaction.sourceCodeFilteringEnabled, false);
|
||||||
|
assert.equal(normalized.outputCompaction.sourceCodeFiltering, "none");
|
||||||
|
assert.equal(normalized.outputCompaction.smartTruncate.enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("config-store can ensure, save, and reload isolated config files", () => {
|
||||||
|
const tempPath = makeTempConfigPath();
|
||||||
|
cleanupFile(tempPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ensured = ensureConfigExists(tempPath);
|
||||||
|
assert.equal(ensured.error, undefined);
|
||||||
|
assert.equal(existsSync(tempPath), true);
|
||||||
|
|
||||||
|
const defaultLoad = loadRtkIntegrationConfig(tempPath);
|
||||||
|
assert.equal(defaultLoad.warning, undefined);
|
||||||
|
assert.equal(defaultLoad.config.mode, "rewrite");
|
||||||
|
assert.equal(defaultLoad.config.outputCompaction.readCompaction.enabled, false);
|
||||||
|
|
||||||
|
const saved = saveRtkIntegrationConfig(
|
||||||
|
{
|
||||||
|
...defaultLoad.config,
|
||||||
|
mode: "suggest",
|
||||||
|
outputCompaction: {
|
||||||
|
...defaultLoad.config.outputCompaction,
|
||||||
|
truncate: {
|
||||||
|
...defaultLoad.config.outputCompaction.truncate,
|
||||||
|
maxChars: 250_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tempPath,
|
||||||
|
);
|
||||||
|
assert.equal(saved.success, true);
|
||||||
|
|
||||||
|
const reloaded = loadRtkIntegrationConfig(tempPath);
|
||||||
|
assert.equal(reloaded.config.mode, "suggest");
|
||||||
|
assert.equal(reloaded.config.outputCompaction.truncate.maxChars, 200_000);
|
||||||
|
assert.ok(readFileSync(tempPath, "utf-8").endsWith("\n"));
|
||||||
|
} finally {
|
||||||
|
cleanupFile(tempPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("config-store falls back to defaults when JSON is invalid", () => {
|
||||||
|
const tempPath = makeTempConfigPath();
|
||||||
|
cleanupFile(tempPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(tempPath, "{not valid json", "utf-8");
|
||||||
|
const loaded = loadRtkIntegrationConfig(tempPath);
|
||||||
|
assert.equal(loaded.config.mode, "rewrite");
|
||||||
|
assert.ok((loaded.warning ?? "").includes(tempPath));
|
||||||
|
assert.ok((loaded.warning ?? "").includes("Failed to parse"));
|
||||||
|
} finally {
|
||||||
|
cleanupFile(tempPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("config-store malformed-file defaults are isolated from caller mutation", () => {
|
||||||
|
const tempPath = makeTempConfigPath();
|
||||||
|
cleanupFile(tempPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(tempPath, "{not valid json", "utf-8");
|
||||||
|
const firstLoad = loadRtkIntegrationConfig(tempPath);
|
||||||
|
firstLoad.config.outputCompaction.truncate.maxChars = 42_424;
|
||||||
|
firstLoad.config.outputCompaction.readCompaction.enabled = true;
|
||||||
|
|
||||||
|
const secondLoad = loadRtkIntegrationConfig(tempPath);
|
||||||
|
|
||||||
|
assert.equal(secondLoad.config.outputCompaction.truncate.maxChars, 12_000);
|
||||||
|
assert.equal(secondLoad.config.outputCompaction.readCompaction.enabled, false);
|
||||||
|
} finally {
|
||||||
|
cleanupFile(tempPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("output metrics summarize tracked savings and clear state", () => {
|
||||||
|
clearOutputMetrics();
|
||||||
|
assert.equal(getOutputMetricsSummary(), "RTK output compaction metrics: no data yet.");
|
||||||
|
|
||||||
|
const first = trackOutputSavings("1234567890", "12345", "bash", ["ansi", "truncate"]);
|
||||||
|
assert.equal(first.tool, "bash");
|
||||||
|
assert.equal(first.techniques, "ansi,truncate");
|
||||||
|
assert.equal(first.savingsPercent, 50);
|
||||||
|
|
||||||
|
trackOutputSavings("123456", "1234", "read", []);
|
||||||
|
const summary = getOutputMetricsSummary();
|
||||||
|
assert.ok(summary.includes("calls=2, saved=7 chars (43.8%)"));
|
||||||
|
assert.ok(summary.includes("- bash: 1 calls, saved 5 chars (50.0%)"));
|
||||||
|
assert.ok(summary.includes("- read: 1 calls, saved 2 chars (33.3%)"));
|
||||||
|
|
||||||
|
clearOutputMetrics();
|
||||||
|
assert.equal(getOutputMetricsSummary(), "RTK output compaction metrics: no data yet.");
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("aggressive source filtering ignores string and inline comment braces while tracking implementation blocks", () => {
|
||||||
|
const withLiteralBrace = [
|
||||||
|
"function first() {",
|
||||||
|
' const value = "{";',
|
||||||
|
" return value;",
|
||||||
|
"}",
|
||||||
|
"function second() {",
|
||||||
|
" return true;",
|
||||||
|
"}",
|
||||||
|
].join("\n");
|
||||||
|
const withoutLiteralBrace = [
|
||||||
|
"function first() {",
|
||||||
|
' const value = "plain";',
|
||||||
|
" return value;",
|
||||||
|
"}",
|
||||||
|
"function second() {",
|
||||||
|
" return true;",
|
||||||
|
"}",
|
||||||
|
].join("\n");
|
||||||
|
const withInlineCommentBrace = [
|
||||||
|
"function first() {",
|
||||||
|
" const value = 1; // {",
|
||||||
|
" return value;",
|
||||||
|
"}",
|
||||||
|
"function second() {",
|
||||||
|
" return true;",
|
||||||
|
"}",
|
||||||
|
].join("\n");
|
||||||
|
const withoutInlineCommentBrace = [
|
||||||
|
"function first() {",
|
||||||
|
" const value = 1; // no brace",
|
||||||
|
" return value;",
|
||||||
|
"}",
|
||||||
|
"function second() {",
|
||||||
|
" return true;",
|
||||||
|
"}",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
assert.equal(filterAggressive(withLiteralBrace, "typescript"), filterAggressive(withoutLiteralBrace, "typescript"));
|
||||||
|
assert.equal(filterAggressive(withInlineCommentBrace, "typescript"), filterAggressive(withoutInlineCommentBrace, "typescript"));
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("test output fallback counts unicode pass and fail symbols", () => {
|
||||||
|
const result = aggregateTestOutput("✓ creates user\n✔ updates user\n✕ deletes user\n✗ archives user\n", "bun test");
|
||||||
|
|
||||||
|
assert.ok(result?.includes("PASS: 2 passed"));
|
||||||
|
assert.ok(result?.includes("FAIL: 2 failed"));
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("command detection ignores env prefixes, blank lines, and chained suffixes", () => {
|
||||||
|
assert.equal(normalizeCommandForDetection("NODE_ENV=test FOO=bar npm test && echo done"), "npm test");
|
||||||
|
assert.equal(normalizeCommandForDetection("\n\n PYTHONPATH=src git status\n echo later"), "git status");
|
||||||
|
assert.equal(normalizeCommandForDetection(" "), null);
|
||||||
|
assert.equal(matchesCommandPatterns("CI=1 bun test | head -5", [/^bun test/]), true);
|
||||||
|
assert.equal(matchesCommandPatterns("echo hello", [/^bun test/]), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("RTK command environment preserves explicit leading RTK_DB_PATH overrides", () => {
|
||||||
|
const command = 'RTK_DB_PATH="/custom/history.db" rtk git diff';
|
||||||
|
assert.equal(applyRtkCommandEnvironment(command), command);
|
||||||
|
|
||||||
|
const singleQuotedCommand = "RTK_DB_PATH='/custom/it'\\''s/history.db' rtk git diff";
|
||||||
|
assert.equal(applyRtkCommandEnvironment(singleQuotedCommand), singleQuotedCommand);
|
||||||
|
|
||||||
|
const exportedCommand = 'export RTK_DB_PATH="/custom/history.db"; rtk git diff';
|
||||||
|
assert.equal(applyRtkCommandEnvironment(exportedCommand), exportedCommand);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("RTK command environment respects inherited RTK_DB_PATH values", () => {
|
||||||
|
const previousRtkDbPath = process.env.RTK_DB_PATH;
|
||||||
|
const command = "rtk git status";
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.env.RTK_DB_PATH = "/persistent/shared/history.db";
|
||||||
|
|
||||||
|
assert.equal(applyRtkCommandEnvironment(command), command);
|
||||||
|
} finally {
|
||||||
|
if (previousRtkDbPath === undefined) {
|
||||||
|
delete process.env.RTK_DB_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.RTK_DB_PATH = previousRtkDbPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("RTK command environment ignores blank inherited RTK_DB_PATH values", () => {
|
||||||
|
const previousRtkDbPath = process.env.RTK_DB_PATH;
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.env.RTK_DB_PATH = " ";
|
||||||
|
|
||||||
|
assert.match(applyRtkCommandEnvironment("rtk git status"), /^export RTK_DB_PATH=/);
|
||||||
|
} finally {
|
||||||
|
if (previousRtkDbPath === undefined) {
|
||||||
|
delete process.env.RTK_DB_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.RTK_DB_PATH = previousRtkDbPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("RTK command environment single-quotes hostile temp paths", () => {
|
||||||
|
const previousTmpDir = process.env.TMPDIR;
|
||||||
|
const previousTmp = process.env.TMP;
|
||||||
|
const previousTemp = process.env.TEMP;
|
||||||
|
const hostilePath = process.platform === "win32" ? "C:\\Temp\\$(touch owned)`bad`'dir" : "/tmp/$(touch owned)`bad`'dir";
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.env.TMPDIR = hostilePath;
|
||||||
|
process.env.TMP = hostilePath;
|
||||||
|
process.env.TEMP = hostilePath;
|
||||||
|
|
||||||
|
const rewritten = applyRtkCommandEnvironment("rtk git status");
|
||||||
|
assert.ok(rewritten.startsWith("export RTK_DB_PATH='"));
|
||||||
|
assert.ok(rewritten.includes("$(touch owned)`bad`'\\''dir"));
|
||||||
|
assert.ok(rewritten.endsWith("; rtk git status"));
|
||||||
|
assert.equal(/^export RTK_DB_PATH=\"/.test(rewritten), false);
|
||||||
|
} finally {
|
||||||
|
process.env.TMPDIR = previousTmpDir;
|
||||||
|
process.env.TMP = previousTmp;
|
||||||
|
process.env.TEMP = previousTemp;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("path compaction preserves the tail and handles Windows separators", () => {
|
||||||
|
const unixPath = "/Users/example/projects/pi-rtk-optimizer/src/techniques/path-utils.ts";
|
||||||
|
const compactUnixPath = compactPath(unixPath, 28);
|
||||||
|
assert.ok(compactUnixPath.length <= 28);
|
||||||
|
assert.ok(compactUnixPath.endsWith("path-utils.ts"));
|
||||||
|
assert.ok(compactUnixPath.includes("/"));
|
||||||
|
|
||||||
|
const windowsPath = "C:\\Users\\Administrator\\Documents\\pi-rtk-optimizer\\src\\windows-command-helpers.ts";
|
||||||
|
const compactWindowsPath = compactPath(windowsPath, 30);
|
||||||
|
assert.ok(compactWindowsPath.length <= 30);
|
||||||
|
assert.equal(compactWindowsPath.includes("\\"), true);
|
||||||
|
assert.ok(compactWindowsPath.endsWith("windows-command-helpers.ts"));
|
||||||
|
|
||||||
|
assert.equal(compactPath("src/file.ts", 40), "src/file.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("windows bash compatibility rewrites only when the runtime is Windows", () => {
|
||||||
|
const command = "cd /d C:\\Users\\Administrator\\project && python script.py";
|
||||||
|
const fixed = applyWindowsBashCompatibilityFixes(command, "win32");
|
||||||
|
assert.deepEqual(fixed.applied, ["cd-/d", "python-utf8"]);
|
||||||
|
assert.equal(
|
||||||
|
fixed.command,
|
||||||
|
'PYTHONIOENCODING=utf-8 cd "C:/Users/Administrator/project" && python script.py',
|
||||||
|
);
|
||||||
|
|
||||||
|
const unchanged = applyWindowsBashCompatibilityFixes(command, "linux");
|
||||||
|
assert.deepEqual(unchanged.applied, []);
|
||||||
|
assert.equal(unchanged.command, command);
|
||||||
|
|
||||||
|
const alreadyUtf8 = applyWindowsBashCompatibilityFixes("PYTHONIOENCODING=utf-8 python script.py", "win32");
|
||||||
|
assert.deepEqual(alreadyUtf8.applied, []);
|
||||||
|
assert.equal(alreadyUtf8.command, "PYTHONIOENCODING=utf-8 python script.py");
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("windows bash compatibility rewrites compound cd slash-d operators", () => {
|
||||||
|
assert.equal(
|
||||||
|
applyWindowsBashCompatibilityFixes("cd /d C:\\work || echo failed", "win32").command,
|
||||||
|
'cd "C:/work" || echo failed',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
applyWindowsBashCompatibilityFixes("cd /d C:\\work ; echo done", "win32").command,
|
||||||
|
'cd "C:/work" ; echo done',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
applyWindowsBashCompatibilityFixes("cd /d C:\\work | cat", "win32").command,
|
||||||
|
'cd "C:/work" | cat',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
applyWindowsBashCompatibilityFixes('cd /d "C:\\work space" || echo failed', "win32").command,
|
||||||
|
'cd "C:/work space" || echo failed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("rewrite pipeline safety buffers rewritten Windows producer commands", () => {
|
||||||
|
const rewritten = applyRewrittenCommandShellSafetyFixups("rtk git diff | grep TODO", "win32");
|
||||||
|
assert.ok(rewritten.includes('mktemp'));
|
||||||
|
assert.ok(rewritten.includes('trap'));
|
||||||
|
assert.ok(rewritten.includes('rtk git diff > "$__pi_rtk_pipe_tmp"'));
|
||||||
|
assert.ok(rewritten.includes('(grep TODO) < "$__pi_rtk_pipe_tmp"'));
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
applyRewrittenCommandShellSafetyFixups("rtk git diff | grep TODO", "linux"),
|
||||||
|
"rtk git diff | grep TODO",
|
||||||
|
);
|
||||||
|
assert.equal(applyRewrittenCommandShellSafetyFixups("git diff | grep TODO", "win32"), "git diff | grep TODO");
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("rewrite pipeline safety buffers leading pipelines before compound suffixes", () => {
|
||||||
|
const andCommand = applyRewrittenCommandShellSafetyFixups("rtk git diff | grep TODO && echo done", "win32");
|
||||||
|
assert.ok(andCommand.includes('(grep TODO) < "$__pi_rtk_pipe_tmp"'));
|
||||||
|
assert.ok(andCommand.endsWith("&& echo done"));
|
||||||
|
|
||||||
|
const orCommand = applyRewrittenCommandShellSafetyFixups("rtk git diff | grep TODO || echo none", "win32");
|
||||||
|
assert.ok(orCommand.includes('(grep TODO) < "$__pi_rtk_pipe_tmp"'));
|
||||||
|
assert.ok(orCommand.endsWith("|| echo none"));
|
||||||
|
|
||||||
|
const semicolonCommand = applyRewrittenCommandShellSafetyFixups("rtk git diff | grep TODO; echo done", "win32");
|
||||||
|
assert.ok(semicolonCommand.includes('(grep TODO) < "$__pi_rtk_pipe_tmp"'));
|
||||||
|
assert.ok(semicolonCommand.endsWith("; echo done"));
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("rewrite pipeline safety keeps exported RTK_DB_PATH on rewritten producer commands", () => {
|
||||||
|
const envScopedCommand = applyRtkCommandEnvironment("rtk git diff agent/extensions/pi-multi-auth/account-manager.ts | head -200");
|
||||||
|
const rewritten = applyRewrittenCommandShellSafetyFixups(envScopedCommand, "win32");
|
||||||
|
|
||||||
|
assert.ok(rewritten.startsWith("export RTK_DB_PATH="));
|
||||||
|
assert.equal(rewritten.startsWith("RTK_DB_PATH="), false);
|
||||||
|
assert.ok(rewritten.includes("; {"));
|
||||||
|
assert.ok(
|
||||||
|
rewritten.includes('rtk git diff agent/extensions/pi-multi-auth/account-manager.ts > "$__pi_rtk_pipe_tmp"'),
|
||||||
|
);
|
||||||
|
assert.ok(rewritten.includes('(head -200) < "$__pi_rtk_pipe_tmp"'));
|
||||||
|
|
||||||
|
assert.equal(applyRewrittenCommandShellSafetyFixups(envScopedCommand, "linux"), envScopedCommand);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("rewrite pipeline safety buffers explicit RTK_DB_PATH export preludes", () => {
|
||||||
|
const command = 'export RTK_DB_PATH="/custom/history.db"; rtk git diff | head -200';
|
||||||
|
const rewritten = applyRewrittenCommandShellSafetyFixups(command, "win32");
|
||||||
|
|
||||||
|
assert.ok(rewritten.startsWith('export RTK_DB_PATH="/custom/history.db"; {'));
|
||||||
|
assert.ok(rewritten.includes('rtk git diff > "$__pi_rtk_pipe_tmp"'));
|
||||||
|
assert.ok(rewritten.includes('(head -200) < "$__pi_rtk_pipe_tmp"'));
|
||||||
|
|
||||||
|
assert.equal(applyRewrittenCommandShellSafetyFixups(command, "linux"), command);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("RTK command environment uses export prelude for shell compound commands", () => {
|
||||||
|
const rewritten = applyRtkCommandEnvironment('for d in a b; do echo "$d"; done');
|
||||||
|
assert.ok(/^export RTK_DB_PATH=/.test(rewritten));
|
||||||
|
assert.ok(/; for d in a b; do echo "\$d"; done$/.test(rewritten));
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("streaming sanitizer strips ANSI codes and preserves non-text blocks", () => {
|
||||||
|
const ansiResult = {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "\x1B[32mworking tree clean\x1B[0m\n" },
|
||||||
|
{ type: "image", url: "ignored" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const ansiSanitization = sanitizeStreamingBashExecutionResult(ansiResult, "rtk git status");
|
||||||
|
assert.equal(ansiSanitization.changed, true);
|
||||||
|
assert.equal(
|
||||||
|
((ansiSanitization.result as typeof ansiResult).content[0] as { text: string }).text,
|
||||||
|
"working tree clean\n",
|
||||||
|
);
|
||||||
|
assert.equal((ansiResult.content[0] as { text: string }).text, "\x1B[32mworking tree clean\x1B[0m\n");
|
||||||
|
assert.deepEqual((ansiSanitization.result as typeof ansiResult).content[1], { type: "image", url: "ignored" });
|
||||||
|
|
||||||
|
const plainResult = {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "[rtk] warning: builtin filters: parse failure\n\nworking tree clean\n",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const plainSanitization = sanitizeStreamingBashExecutionResult(plainResult, "rtk git status");
|
||||||
|
assert.equal(plainSanitization.changed, false);
|
||||||
|
assert.equal(plainSanitization.result, plainResult);
|
||||||
|
assert.equal(
|
||||||
|
(plainResult.content[0] as { text: string }).text,
|
||||||
|
"[rtk] warning: builtin filters: parse failure\n\nworking tree clean\n",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("All additional coverage tests passed.");
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function toOnOff(value: boolean, truthyLabel = "on", falsyLabel = "off"): string {
|
||||||
|
return value ? truthyLabel : falsyLabel;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
||||||
|
|
||||||
|
interface CompletionDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOP_LEVEL_SUBCOMMANDS: CompletionDefinition[] = [
|
||||||
|
{ name: "show", description: "Show current RTK config + runtime summary" },
|
||||||
|
{ name: "path", description: "Show RTK config file path" },
|
||||||
|
{ name: "verify", description: "Check whether rtk binary is available" },
|
||||||
|
{ name: "stats", description: "Show output compaction metrics" },
|
||||||
|
{ name: "clear-stats", description: "Clear output compaction metrics" },
|
||||||
|
{ name: "reset", description: "Reset RTK settings to defaults" },
|
||||||
|
{ name: "help", description: "Show usage help" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function startsWithFilter(value: string, prefix: string): boolean {
|
||||||
|
if (!prefix) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return value.startsWith(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCompletions(values: CompletionDefinition[]): AutocompleteItem[] {
|
||||||
|
return values.map((entry) => ({
|
||||||
|
value: entry.name,
|
||||||
|
label: entry.name,
|
||||||
|
description: entry.description,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRtkArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
||||||
|
const normalized = argumentPrefix.trimStart().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return mapCompletions(TOP_LEVEL_SUBCOMMANDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes(" ")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = TOP_LEVEL_SUBCOMMANDS.filter((entry) => startsWithFilter(entry.name, normalized));
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapCompletions(filtered);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { getRtkArgumentCompletions } from "./command-completions.js";
|
||||||
|
import { createLazyModuleLoader } from "./lazy-module-loader.js";
|
||||||
|
import type { RtkIntegrationConfig, RuntimeStatus } from "./types.js";
|
||||||
|
|
||||||
|
export interface RtkIntegrationController {
|
||||||
|
getConfig(): RtkIntegrationConfig;
|
||||||
|
setConfig(next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void;
|
||||||
|
getConfigPath(): string;
|
||||||
|
getRuntimeStatus(): RuntimeStatus;
|
||||||
|
refreshRuntimeStatus(): Promise<RuntimeStatus>;
|
||||||
|
getMetricsSummary(): string;
|
||||||
|
clearMetrics(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCommandModalModule = createLazyModuleLoader<typeof import("./config-modal.js")>("./config-modal.js");
|
||||||
|
|
||||||
|
export function registerRtkIntegrationCommand(pi: ExtensionAPI, controller: RtkIntegrationController): void {
|
||||||
|
pi.registerCommand("rtk", {
|
||||||
|
description: "Configure RTK rewrite and output compaction integration",
|
||||||
|
getArgumentCompletions: getRtkArgumentCompletions,
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const { handleRtkIntegrationCommand } = await loadCommandModalModule();
|
||||||
|
await handleRtkIntegrationCommand(args, ctx, controller);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { computeRewriteDecision } from "./command-rewriter.ts";
|
||||||
|
import { resolveRtkRewrite } from "./rtk-rewrite-provider.ts";
|
||||||
|
import { cloneDefaultConfig, runTest } from "./test-helpers.test.ts";
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
function createMockPi(execResult: { code: number; stdout?: string; stderr?: string }): ExtensionAPI {
|
||||||
|
return {
|
||||||
|
exec: async (command: string) => {
|
||||||
|
if (command === "which" || command === "where") {
|
||||||
|
return { code: 0, stdout: "/usr/local/bin/rtk\n", stderr: "" };
|
||||||
|
}
|
||||||
|
return execResult;
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runTest("rtk rewrite uses resolved POSIX executable path", async () => {
|
||||||
|
const calls: Array<{ command: string; args: string[] }> = [];
|
||||||
|
const pi = {
|
||||||
|
exec: async (command: string, args: string[]) => {
|
||||||
|
calls.push({ command, args });
|
||||||
|
if (command === "which") {
|
||||||
|
return { code: 0, stdout: "/opt/rtk/bin/rtk\n", stderr: "" };
|
||||||
|
}
|
||||||
|
return { code: 3, stdout: "rtk git status", stderr: "" };
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
|
||||||
|
const result = await resolveRtkRewrite(pi, "git status", { platform: "linux" });
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.equal(result.rewrittenCommand, "rtk git status");
|
||||||
|
assert.equal(result.executableResolution?.resolvedPath, "/opt/rtk/bin/rtk");
|
||||||
|
assert.deepEqual(calls.map((call) => call.command), ["which", "/opt/rtk/bin/rtk"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("rtk rewrite uses resolved Windows executable path", async () => {
|
||||||
|
const calls: Array<{ command: string; args: string[] }> = [];
|
||||||
|
const pi = {
|
||||||
|
exec: async (command: string, args: string[]) => {
|
||||||
|
calls.push({ command, args });
|
||||||
|
if (command === "where") {
|
||||||
|
return { code: 0, stdout: "C:\\Tools\\rtk.exe\r\nC:\\Other\\rtk.exe\r\n", stderr: "" };
|
||||||
|
}
|
||||||
|
return { code: 3, stdout: "rtk git status", stderr: "" };
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
|
||||||
|
const result = await resolveRtkRewrite(pi, "git status", { platform: "win32" });
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.equal(result.executableResolution?.resolvedPath, "C:\\Tools\\rtk.exe");
|
||||||
|
assert.deepEqual(calls.map((call) => call.command), ["where", "C:\\Tools\\rtk.exe"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("rtk rewrite preserves behavior when executable path resolution fails", async () => {
|
||||||
|
const calls: Array<{ command: string; args: string[] }> = [];
|
||||||
|
const pi = {
|
||||||
|
exec: async (command: string, args: string[]) => {
|
||||||
|
calls.push({ command, args });
|
||||||
|
if (command === "which") {
|
||||||
|
return { code: 1, stdout: "", stderr: "not found" };
|
||||||
|
}
|
||||||
|
return { code: 3, stdout: "rtk git status", stderr: "" };
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
|
||||||
|
const result = await resolveRtkRewrite(pi, "git status", { platform: "linux" });
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.equal(result.executableResolution?.command, "rtk");
|
||||||
|
assert.ok(result.executableResolution?.warning?.includes("which failed"));
|
||||||
|
assert.deepEqual(calls.map((call) => call.command), ["which", "rtk"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("empty command unchanged", async () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
const decision = await computeRewriteDecision("", config, createMockPi({ code: 1 }));
|
||||||
|
assert.equal(decision.changed, false);
|
||||||
|
assert.equal(decision.reason, "empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("already rtk unchanged", async () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
const decision = await computeRewriteDecision("rtk status", config, createMockPi({ code: 1 }));
|
||||||
|
assert.equal(decision.changed, false);
|
||||||
|
assert.equal(decision.reason, "already_rtk");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("env-prefixed rtk command is treated as already RTK and never re-rewritten", async () => {
|
||||||
|
let execCallCount = 0;
|
||||||
|
const pi = {
|
||||||
|
exec: async () => {
|
||||||
|
execCallCount += 1;
|
||||||
|
return { code: 0, stdout: "rtk rtk status", stderr: "" };
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
|
||||||
|
const command = "CI=1 RTK_DB_PATH=/tmp/history.db rtk status";
|
||||||
|
const decision = await computeRewriteDecision(command, cloneDefaultConfig(), pi, {
|
||||||
|
executableResolution: { command: "rtk", resolver: "which" },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(decision.changed, false);
|
||||||
|
assert.equal(decision.rewrittenCommand, command);
|
||||||
|
assert.equal(decision.reason, "already_rtk");
|
||||||
|
assert.equal(execCallCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("rtk unsupported heredoc result leaves command unchanged", async () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
const decision = await computeRewriteDecision("cat <<EOF", config, createMockPi({ code: 1 }));
|
||||||
|
assert.equal(decision.changed, false);
|
||||||
|
assert.equal(decision.reason, "no_match");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("quoted heredoc marker is delegated to RTK rewrite", async () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
const command = 'echo "<<not heredoc" && git status';
|
||||||
|
const decision = await computeRewriteDecision(
|
||||||
|
command,
|
||||||
|
config,
|
||||||
|
createMockPi({ code: 3, stdout: 'echo "<<not heredoc" && rtk git status' }),
|
||||||
|
);
|
||||||
|
assert.equal(decision.changed, true);
|
||||||
|
assert.equal(decision.rewrittenCommand, 'echo "<<not heredoc" && rtk git status');
|
||||||
|
assert.equal(decision.reason, "ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("rg rewrite delegates to rtk grep proxy", async () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
const command = "cd /workspace && rg -n --glob '!node_modules/**' --glob '!dist/**' \"needle\" src";
|
||||||
|
const rewritten = "cd /workspace && rtk grep -n --glob '!node_modules/**' --glob '!dist/**' \"needle\" src";
|
||||||
|
const decision = await computeRewriteDecision(
|
||||||
|
command,
|
||||||
|
config,
|
||||||
|
createMockPi({
|
||||||
|
code: 3,
|
||||||
|
stdout: rewritten,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(decision.changed, true);
|
||||||
|
assert.equal(decision.rewrittenCommand, rewritten);
|
||||||
|
assert.equal(decision.reason, "ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("legacy category toggles do not pre-filter RTK rewrite source of truth", async () => {
|
||||||
|
const config = { ...cloneDefaultConfig(), rewriteGitGithub: false };
|
||||||
|
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 3, stdout: "rtk git status" }));
|
||||||
|
assert.equal(decision.changed, true);
|
||||||
|
assert.equal(decision.rewrittenCommand, "rtk git status");
|
||||||
|
assert.equal(decision.reason, "ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("rtk exit 0 rewrites", async () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 0, stdout: "rtk git status" }));
|
||||||
|
assert.equal(decision.changed, true);
|
||||||
|
assert.equal(decision.rewrittenCommand, "rtk git status");
|
||||||
|
assert.equal(decision.reason, "ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("rtk exit 3 rewrites", async () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 3, stdout: "rtk git status" }));
|
||||||
|
assert.equal(decision.changed, true);
|
||||||
|
assert.equal(decision.rewrittenCommand, "rtk git status");
|
||||||
|
assert.equal(decision.reason, "ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("exit 1 leaves unchanged", async () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 1 }));
|
||||||
|
assert.equal(decision.changed, false);
|
||||||
|
assert.equal(decision.reason, "no_match");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("exit 2 leaves unchanged and surfaces RTK detail", async () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 2, stderr: "denied" }));
|
||||||
|
assert.equal(decision.changed, false);
|
||||||
|
assert.equal(decision.reason, "no_match");
|
||||||
|
assert.equal(decision.warning, "denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("unknown category passes through to RTK", async () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
const pi = createMockPi({ code: 0, stdout: "rtk custom" });
|
||||||
|
const decision = await computeRewriteDecision("custom-cmd", config, pi);
|
||||||
|
assert.equal(decision.changed, true);
|
||||||
|
assert.equal(decision.rewrittenCommand, "rtk custom");
|
||||||
|
assert.equal(decision.reason, "ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("exec error/timeout leaves unchanged and surfaces error detail", async () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
const pi = {
|
||||||
|
exec: async () => {
|
||||||
|
throw new Error("timeout");
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
const decision = await computeRewriteDecision("git status", config, pi);
|
||||||
|
assert.equal(decision.changed, false);
|
||||||
|
assert.equal(decision.reason, "no_match");
|
||||||
|
assert.equal(decision.warning, "timeout");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("compound commands forwarded to RTK", async () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
let capturedArgs: string[] = [];
|
||||||
|
const pi = {
|
||||||
|
exec: async (_cmd: string, args: string[]) => {
|
||||||
|
capturedArgs = args;
|
||||||
|
return { code: 0, stdout: "rtk result" };
|
||||||
|
},
|
||||||
|
} as unknown as ExtensionAPI;
|
||||||
|
const decision = await computeRewriteDecision("git status && cargo test", config, pi);
|
||||||
|
assert.equal(decision.changed, true);
|
||||||
|
assert.deepEqual(capturedArgs, ["rewrite", "git status && cargo test"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("All command-rewriter tests passed.");
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { resolveRtkRewrite, type RtkRewriteProviderOptions } from "./rtk-rewrite-provider.js";
|
||||||
|
import { splitLeadingEnvAssignments } from "./shell-env-prefix.js";
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import type { RtkIntegrationConfig } from "./types.js";
|
||||||
|
|
||||||
|
export interface RewriteDecision {
|
||||||
|
changed: boolean;
|
||||||
|
originalCommand: string;
|
||||||
|
rewrittenCommand: string;
|
||||||
|
reason: "ok" | "empty" | "already_rtk" | "no_match";
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeRewriteDecision(
|
||||||
|
command: string,
|
||||||
|
_config: RtkIntegrationConfig,
|
||||||
|
pi: ExtensionAPI,
|
||||||
|
rewriteOptions: RtkRewriteProviderOptions = {},
|
||||||
|
): Promise<RewriteDecision> {
|
||||||
|
if (!command || !command.trim()) {
|
||||||
|
return { changed: false, originalCommand: command, rewrittenCommand: command, reason: "empty" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedStart = command.trimStart();
|
||||||
|
const effectiveCommand = splitLeadingEnvAssignments(trimmedStart).command.trimStart();
|
||||||
|
if (effectiveCommand === "rtk" || effectiveCommand.startsWith("rtk ")) {
|
||||||
|
return { changed: false, originalCommand: command, rewrittenCommand: command, reason: "already_rtk" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resolveRtkRewrite(pi, command, rewriteOptions);
|
||||||
|
|
||||||
|
if (result.changed && result.rewrittenCommand) {
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
originalCommand: command,
|
||||||
|
rewrittenCommand: result.rewrittenCommand,
|
||||||
|
reason: "ok",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
originalCommand: command,
|
||||||
|
rewrittenCommand: command,
|
||||||
|
reason: "no_match",
|
||||||
|
warning: result.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { cloneDefaultConfig, mock, runTest } from "./test-helpers.test.ts";
|
||||||
|
|
||||||
|
mock.module("@earendil-works/pi-coding-agent", {
|
||||||
|
namedExports: {
|
||||||
|
getAgentDir: () => "/tmp/.pi/agent",
|
||||||
|
getSettingsListTheme: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const settingsListInputs: string[] = [];
|
||||||
|
const settingsListUpdates: Array<{ id: string; value: string }> = [];
|
||||||
|
|
||||||
|
mock.module("@earendil-works/pi-tui", {
|
||||||
|
namedExports: {
|
||||||
|
Box: class {
|
||||||
|
addChild(): void {}
|
||||||
|
},
|
||||||
|
Container: class {
|
||||||
|
addChild(): void {}
|
||||||
|
render(): string[] {
|
||||||
|
return ["settings-content"];
|
||||||
|
}
|
||||||
|
invalidate(): void {}
|
||||||
|
},
|
||||||
|
SettingsList: class {
|
||||||
|
handleInput(data: string): void {
|
||||||
|
settingsListInputs.push(data);
|
||||||
|
}
|
||||||
|
updateValue(id: string, value: string): void {
|
||||||
|
settingsListUpdates.push({ id, value });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Spacer: class {},
|
||||||
|
Text: class {},
|
||||||
|
truncateToWidth: (text: string, width: number) => text.slice(0, width),
|
||||||
|
visibleWidth: (text: string) => text.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function stripAnsi(text: string): string {
|
||||||
|
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { registerRtkIntegrationCommand } = await import("./command-register.ts");
|
||||||
|
const { ZellijModal, ZellijSettingsModal } = await import("./zellij-modal.ts");
|
||||||
|
const { getRtkArgumentCompletions } = await import("./command-completions.ts");
|
||||||
|
|
||||||
|
type Notification = { message: string; level: "info" | "warning" | "error" };
|
||||||
|
|
||||||
|
interface CommandContextStub {
|
||||||
|
hasUI: boolean;
|
||||||
|
ui: {
|
||||||
|
notify(message: string, level: "info" | "warning" | "error"): void;
|
||||||
|
custom<T>(): Promise<T>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNotifyContext(hasUI: boolean): { ctx: CommandContextStub; notifications: Notification[] } {
|
||||||
|
const notifications: Notification[] = [];
|
||||||
|
return {
|
||||||
|
ctx: {
|
||||||
|
hasUI,
|
||||||
|
ui: {
|
||||||
|
notify(message: string, level: "info" | "warning" | "error") {
|
||||||
|
notifications.push({ message, level });
|
||||||
|
},
|
||||||
|
async custom<T>(): Promise<T> {
|
||||||
|
throw new Error("custom UI should not be invoked in config-modal tests");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notifications,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastNotification(notifications: Notification[]): Notification {
|
||||||
|
return notifications[notifications.length - 1] as Notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createThemeStub(): { fg: (_name: string, text: string) => string; bold: (text: string) => string } {
|
||||||
|
return {
|
||||||
|
fg: (_name: string, text: string) => text,
|
||||||
|
bold: (text: string) => text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
runTest("zellij settings modal renders overlay frame and delegates non-enter input", () => {
|
||||||
|
settingsListInputs.length = 0;
|
||||||
|
settingsListUpdates.length = 0;
|
||||||
|
const settingsModal = new ZellijSettingsModal(
|
||||||
|
{
|
||||||
|
title: "RTK Integration Settings",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
id: "enabled",
|
||||||
|
label: "Enabled",
|
||||||
|
description: "Enable integration",
|
||||||
|
currentValue: "on",
|
||||||
|
values: ["on", "off"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onChange: () => {},
|
||||||
|
onClose: () => {},
|
||||||
|
helpText: "Esc: close",
|
||||||
|
},
|
||||||
|
createThemeStub() as never,
|
||||||
|
);
|
||||||
|
const modal = new ZellijModal(settingsModal, {
|
||||||
|
titleBar: {
|
||||||
|
left: { text: "RTK Integration Settings", maxWidth: 30, color: "accent" },
|
||||||
|
right: { text: "pi-rtk-optimizer", maxWidth: 20, color: "dim" },
|
||||||
|
},
|
||||||
|
helpUndertitle: { text: "Esc: close", color: "dim" },
|
||||||
|
overlay: { anchor: "center", width: 86, maxHeight: "85%", margin: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rendered = modal.renderModal(86);
|
||||||
|
settingsModal.handleInput("\r");
|
||||||
|
settingsModal.handleInput("j");
|
||||||
|
settingsModal.updateValue("enabled", "off");
|
||||||
|
|
||||||
|
assert.equal(rendered.visibleWidth, 86);
|
||||||
|
assert.equal(rendered.contentWidth, 82);
|
||||||
|
assert.ok(stripAnsi(rendered.lines[0] ?? "").includes("RTK Integration Settings"));
|
||||||
|
assert.ok(stripAnsi(rendered.lines[rendered.lines.length - 1] ?? "").includes("Esc: close"));
|
||||||
|
assert.deepEqual(modal.getOverlayOptions(), {
|
||||||
|
overlay: true,
|
||||||
|
overlayOptions: { anchor: "center", width: 86, maxHeight: "85%", margin: 1 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(settingsListInputs, ["j"]);
|
||||||
|
assert.deepEqual(settingsListUpdates, [{ id: "enabled", value: "off" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("command completions return top-level and filtered RTK subcommands", () => {
|
||||||
|
const topLevel = getRtkArgumentCompletions("");
|
||||||
|
assert.ok(Array.isArray(topLevel));
|
||||||
|
assert.ok(topLevel.some((item) => item.value === "show"));
|
||||||
|
assert.ok(topLevel.some((item) => item.value === "clear-stats"));
|
||||||
|
|
||||||
|
const filtered = getRtkArgumentCompletions("st");
|
||||||
|
assert.deepEqual(
|
||||||
|
filtered?.map((item) => item.value),
|
||||||
|
["stats"],
|
||||||
|
);
|
||||||
|
assert.equal(getRtkArgumentCompletions("show extra"), null);
|
||||||
|
assert.equal(getRtkArgumentCompletions("zzz"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("config modal command handlers route RTK subcommands to controller actions", async () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
const controllerState = {
|
||||||
|
config,
|
||||||
|
cleared: 0,
|
||||||
|
refreshed: 0,
|
||||||
|
lastSavedMode: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = {
|
||||||
|
getConfig: () => controllerState.config,
|
||||||
|
setConfig: (next: typeof config, _ctx: unknown) => {
|
||||||
|
controllerState.config = next;
|
||||||
|
controllerState.lastSavedMode = next.mode;
|
||||||
|
},
|
||||||
|
getConfigPath: () => "C:/tmp/pi-rtk-optimizer/config.json",
|
||||||
|
getRuntimeStatus: () => ({ rtkAvailable: false, lastError: "not found" }),
|
||||||
|
refreshRuntimeStatus: async () => {
|
||||||
|
controllerState.refreshed += 1;
|
||||||
|
return { rtkAvailable: true, rtkExecutablePath: "C:/Tools/rtk.exe" };
|
||||||
|
},
|
||||||
|
getMetricsSummary: () => "metrics summary",
|
||||||
|
clearMetrics: () => {
|
||||||
|
controllerState.cleared += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let registeredName = "";
|
||||||
|
type RegisteredCommandDefinition = {
|
||||||
|
description: string;
|
||||||
|
getArgumentCompletions?: (argumentPrefix: string) => Array<{ value: string; label: string; description?: string }> | null;
|
||||||
|
handler: (args: string, ctx: CommandContextStub) => Promise<void>;
|
||||||
|
};
|
||||||
|
let definition: RegisteredCommandDefinition | undefined;
|
||||||
|
|
||||||
|
registerRtkIntegrationCommand(
|
||||||
|
{
|
||||||
|
registerCommand(name: string, nextDefinition: RegisteredCommandDefinition) {
|
||||||
|
registeredName = name;
|
||||||
|
definition = nextDefinition;
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
controller as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(registeredName, "rtk");
|
||||||
|
if (!definition) {
|
||||||
|
throw new Error("Expected /rtk command definition to be registered");
|
||||||
|
}
|
||||||
|
assert.ok(definition.description.includes("Configure RTK rewrite"));
|
||||||
|
assert.ok(typeof definition.getArgumentCompletions === "function");
|
||||||
|
|
||||||
|
const infoCtx = createNotifyContext(true);
|
||||||
|
await definition.handler("help", infoCtx.ctx);
|
||||||
|
assert.ok(lastNotification(infoCtx.notifications).message.includes("Usage: /rtk"));
|
||||||
|
|
||||||
|
await definition.handler("show", infoCtx.ctx);
|
||||||
|
assert.ok(lastNotification(infoCtx.notifications).message.includes("mode=rewrite"));
|
||||||
|
assert.ok(lastNotification(infoCtx.notifications).message.includes("rewriteSource=rtk"));
|
||||||
|
assert.equal(lastNotification(infoCtx.notifications).message.includes("categories="), false);
|
||||||
|
|
||||||
|
await definition.handler("path", infoCtx.ctx);
|
||||||
|
assert.equal(lastNotification(infoCtx.notifications).message, "rtk config: C:/tmp/pi-rtk-optimizer/config.json");
|
||||||
|
|
||||||
|
await definition.handler("verify", infoCtx.ctx);
|
||||||
|
assert.equal(controllerState.refreshed, 1);
|
||||||
|
assert.equal(lastNotification(infoCtx.notifications).level, "info");
|
||||||
|
assert.ok(lastNotification(infoCtx.notifications).message.includes("available at C:/Tools/rtk.exe"));
|
||||||
|
|
||||||
|
await definition.handler("stats", infoCtx.ctx);
|
||||||
|
assert.equal(lastNotification(infoCtx.notifications).message, "metrics summary");
|
||||||
|
|
||||||
|
await definition.handler("clear-stats", infoCtx.ctx);
|
||||||
|
assert.equal(controllerState.cleared, 1);
|
||||||
|
assert.equal(lastNotification(infoCtx.notifications).message, "RTK metrics cleared.");
|
||||||
|
|
||||||
|
await definition.handler("reset", infoCtx.ctx);
|
||||||
|
assert.equal(controllerState.lastSavedMode, "rewrite");
|
||||||
|
assert.equal(lastNotification(infoCtx.notifications).message, "RTK integration settings reset to defaults.");
|
||||||
|
|
||||||
|
await definition.handler("unknown", infoCtx.ctx);
|
||||||
|
assert.equal(lastNotification(infoCtx.notifications).level, "warning");
|
||||||
|
assert.ok(lastNotification(infoCtx.notifications).message.includes("Usage: /rtk"));
|
||||||
|
|
||||||
|
const headlessCtx = createNotifyContext(false);
|
||||||
|
await definition.handler("", headlessCtx.ctx);
|
||||||
|
assert.equal(lastNotification(headlessCtx.notifications).message, "/rtk requires interactive TUI mode.");
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("All config-modal tests passed.");
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import type { SettingItem } from "@earendil-works/pi-tui";
|
||||||
|
import { toOnOff } from "./boolean-format.js";
|
||||||
|
import type { RtkIntegrationController } from "./command-register.js";
|
||||||
|
import { ZellijModal, ZellijSettingsModal } from "./zellij-modal.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG,
|
||||||
|
RTK_SOURCE_FILTER_LEVELS,
|
||||||
|
type RtkIntegrationConfig,
|
||||||
|
type RuntimeStatus,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
interface SettingValueSyncTarget {
|
||||||
|
updateValue(id: string, value: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ON_OFF = ["on", "off"];
|
||||||
|
const MODE_VALUES = ["rewrite", "suggest"];
|
||||||
|
const SOURCE_FILTER_VALUES = [...RTK_SOURCE_FILTER_LEVELS];
|
||||||
|
const TRUNCATE_MAX_CHAR_VALUES = ["4000", "8000", "12000", "20000", "50000", "100000", "200000"];
|
||||||
|
const SMART_TRUNCATE_LINE_VALUES = ["40", "80", "120", "160", "220", "320", "500", "1000", "2000", "4000"];
|
||||||
|
const RTK_USAGE_TEXT =
|
||||||
|
"Usage: /rtk [show|path|verify|stats|clear-stats|reset|help] (or run /rtk with no args to open settings modal)";
|
||||||
|
const SETTINGS_TAB_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
label: "General",
|
||||||
|
settingIds: ["enabled", "mode", "showRewriteNotifications", "guardWhenRtkMissing"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Compaction",
|
||||||
|
settingIds: [
|
||||||
|
"outputCompactionEnabled",
|
||||||
|
"outputStripAnsi",
|
||||||
|
"outputAggregateTestOutput",
|
||||||
|
"outputFilterBuildOutput",
|
||||||
|
"outputCompactGitOutput",
|
||||||
|
"outputAggregateLinterOutput",
|
||||||
|
"outputGroupSearchOutput",
|
||||||
|
"outputTrackSavings",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Read & Source",
|
||||||
|
settingIds: [
|
||||||
|
"outputReadCompactionEnabled",
|
||||||
|
"outputSourceFilteringEnabled",
|
||||||
|
"outputSourceFiltering",
|
||||||
|
"outputPreserveExactSkillReads",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Truncation",
|
||||||
|
settingIds: [
|
||||||
|
"outputTruncateEnabled",
|
||||||
|
"outputTruncateMaxChars",
|
||||||
|
"outputSmartTruncate",
|
||||||
|
"outputSmartTruncateMaxLines",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function buildTabbedSettingGroups(settings: SettingItem[]): Array<{ label: string; settings: SettingItem[] }> {
|
||||||
|
const byId = new Map(settings.map((setting) => [setting.id, setting]));
|
||||||
|
const assignedIds = new Set<string>();
|
||||||
|
|
||||||
|
const tabs = SETTINGS_TAB_DEFINITIONS.map(({ label, settingIds }) => ({
|
||||||
|
label,
|
||||||
|
settings: settingIds.map((id) => {
|
||||||
|
const setting = byId.get(id);
|
||||||
|
if (!setting) {
|
||||||
|
throw new Error(`Missing setting item for tab '${label}': ${id}`);
|
||||||
|
}
|
||||||
|
if (assignedIds.has(id)) {
|
||||||
|
throw new Error(`Setting item assigned to multiple tabs: ${id}`);
|
||||||
|
}
|
||||||
|
assignedIds.add(id);
|
||||||
|
return setting;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const unassignedIds = settings.map((setting) => setting.id).filter((id) => !assignedIds.has(id));
|
||||||
|
if (unassignedIds.length > 0) {
|
||||||
|
throw new Error(`Unassigned setting items: ${unassignedIds.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSourceFilterLevel(
|
||||||
|
value: string,
|
||||||
|
): RtkIntegrationConfig["outputCompaction"]["sourceCodeFiltering"] | undefined {
|
||||||
|
return SOURCE_FILTER_VALUES.includes(value as (typeof SOURCE_FILTER_VALUES)[number])
|
||||||
|
? (value as RtkIntegrationConfig["outputCompaction"]["sourceCodeFiltering"])
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIntegerInRange(value: string, min: number, max: number): number | undefined {
|
||||||
|
if (!/^\d+$/.test(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeRuntimeStatus(runtimeStatus: RuntimeStatus): string {
|
||||||
|
const runtime = runtimeStatus.rtkAvailable
|
||||||
|
? "rtk=available"
|
||||||
|
: `rtk=missing${runtimeStatus.lastError ? ` (${runtimeStatus.lastError})` : ""}`;
|
||||||
|
const executable = runtimeStatus.rtkExecutablePath
|
||||||
|
? `, rtkPath=${runtimeStatus.rtkExecutablePath}`
|
||||||
|
: runtimeStatus.rtkExecutableResolutionWarning
|
||||||
|
? `, rtkPath=unresolved (${runtimeStatus.rtkExecutableResolutionWarning})`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `${runtime}${executable}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeConfig(config: RtkIntegrationConfig, runtimeStatus: RuntimeStatus): string {
|
||||||
|
return `enabled=${config.enabled}, commandRewriting=${config.commandRewritingEnabled}, mode=${config.mode}, rewriteSource=rtk, rewriteNotice=${config.showRewriteNotifications}, compaction=${config.outputCompaction.enabled}, readCompaction=${config.outputCompaction.readCompaction.enabled}, sourceFilterEnabled=${config.outputCompaction.sourceCodeFilteringEnabled}, preserveSkillReads=${config.outputCompaction.preserveExactSkillReads}, sourceFilter=${config.outputCompaction.sourceCodeFiltering}, ${summarizeRuntimeStatus(runtimeStatus)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSettingItems(config: RtkIntegrationConfig): SettingItem[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "enabled",
|
||||||
|
label: "RTK integration enabled",
|
||||||
|
description: "Master switch for rewrite, suggestions, and output compaction",
|
||||||
|
currentValue: toOnOff(config.enabled),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "commandRewritingEnabled",
|
||||||
|
label: "RTK command rewriting",
|
||||||
|
description: "Optional RTK CLI command rewriting; off keeps FFF in full control of search",
|
||||||
|
currentValue: toOnOff(config.commandRewritingEnabled),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mode",
|
||||||
|
label: "Rewrite mode",
|
||||||
|
description: "rewrite = auto-rewrite bash commands, suggest = notify only",
|
||||||
|
currentValue: config.mode,
|
||||||
|
values: MODE_VALUES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "showRewriteNotifications",
|
||||||
|
label: "Show rewrite notifications",
|
||||||
|
description: "Show 'RTK rewrite: old -> new' notice in TUI",
|
||||||
|
currentValue: toOnOff(config.showRewriteNotifications),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "guardWhenRtkMissing",
|
||||||
|
label: "Guard when rtk missing",
|
||||||
|
description: "If on, raw commands run unchanged when rtk binary is unavailable",
|
||||||
|
currentValue: toOnOff(config.guardWhenRtkMissing),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outputCompactionEnabled",
|
||||||
|
label: "Output compaction enabled",
|
||||||
|
description: "Compact bash/read tool results to reduce token usage; search results are untouched",
|
||||||
|
currentValue: toOnOff(config.outputCompaction.enabled),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outputStripAnsi",
|
||||||
|
label: "Strip ANSI in output",
|
||||||
|
description: "Remove color/control codes from tool output before further compaction",
|
||||||
|
currentValue: toOnOff(config.outputCompaction.stripAnsi),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outputReadCompactionEnabled",
|
||||||
|
label: "Read compaction enabled",
|
||||||
|
description: "If off, read tool output stays exact; build/test/git/grep compaction can still run",
|
||||||
|
currentValue: toOnOff(config.outputCompaction.readCompaction.enabled),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outputTruncateEnabled",
|
||||||
|
label: "Hard truncation enabled",
|
||||||
|
description: "Apply max character cap after other compaction techniques",
|
||||||
|
currentValue: toOnOff(config.outputCompaction.truncate.enabled),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outputTruncateMaxChars",
|
||||||
|
label: "Hard truncation max chars",
|
||||||
|
description: "Maximum characters kept when hard truncation is enabled",
|
||||||
|
currentValue: String(config.outputCompaction.truncate.maxChars),
|
||||||
|
values: TRUNCATE_MAX_CHAR_VALUES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outputSourceFilteringEnabled",
|
||||||
|
label: "Read source filtering enabled",
|
||||||
|
description: "If off, read output skips source-code filtering regardless of selected level",
|
||||||
|
currentValue: toOnOff(config.outputCompaction.sourceCodeFilteringEnabled),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outputPreserveExactSkillReads",
|
||||||
|
label: "Preserve exact skill reads",
|
||||||
|
description: "If on, read results under the global Pi skills directory (default: ~/.pi/agent/skills, respects PI_CODING_AGENT_DIR), ~/.agents/skills, .pi/skills, and ancestor .agents/skills skip read compaction",
|
||||||
|
currentValue: toOnOff(config.outputCompaction.preserveExactSkillReads),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outputSourceFiltering",
|
||||||
|
label: "Read source filtering",
|
||||||
|
description: "none|minimal|aggressive for read output compaction",
|
||||||
|
currentValue: config.outputCompaction.sourceCodeFiltering,
|
||||||
|
values: SOURCE_FILTER_VALUES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outputSmartTruncate",
|
||||||
|
label: "Read smart truncation",
|
||||||
|
description: "Keep signatures/imports when read output has many lines",
|
||||||
|
currentValue: toOnOff(config.outputCompaction.smartTruncate.enabled),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outputSmartTruncateMaxLines",
|
||||||
|
label: "Read smart truncation max lines",
|
||||||
|
description: "Target max lines for smart truncation in read outputs",
|
||||||
|
currentValue: String(config.outputCompaction.smartTruncate.maxLines),
|
||||||
|
values: SMART_TRUNCATE_LINE_VALUES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outputAggregateTestOutput",
|
||||||
|
label: "Aggregate test output",
|
||||||
|
description: "Summarize test command output to failures and key totals",
|
||||||
|
currentValue: toOnOff(config.outputCompaction.aggregateTestOutput),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outputFilterBuildOutput",
|
||||||
|
label: "Filter build output",
|
||||||
|
description: "Reduce build noise and keep key error/warning lines",
|
||||||
|
currentValue: toOnOff(config.outputCompaction.filterBuildOutput),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outputCompactGitOutput",
|
||||||
|
label: "Compact git output",
|
||||||
|
description: "Condense git command output for lower token usage",
|
||||||
|
currentValue: toOnOff(config.outputCompaction.compactGitOutput),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outputAggregateLinterOutput",
|
||||||
|
label: "Aggregate linter output",
|
||||||
|
description: "Summarize linter output by file and issue type",
|
||||||
|
currentValue: toOnOff(config.outputCompaction.aggregateLinterOutput),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outputTrackSavings",
|
||||||
|
label: "Track output savings",
|
||||||
|
description: "Collect in-session compaction metrics for /rtk stats",
|
||||||
|
currentValue: toOnOff(config.outputCompaction.trackSavings),
|
||||||
|
values: ON_OFF,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySetting(config: RtkIntegrationConfig, id: string, value: string): RtkIntegrationConfig {
|
||||||
|
switch (id) {
|
||||||
|
case "enabled":
|
||||||
|
return { ...config, enabled: value === "on" };
|
||||||
|
case "commandRewritingEnabled":
|
||||||
|
return { ...config, commandRewritingEnabled: value === "on" };
|
||||||
|
case "mode":
|
||||||
|
return { ...config, mode: value === "suggest" ? "suggest" : "rewrite" };
|
||||||
|
case "showRewriteNotifications":
|
||||||
|
return { ...config, showRewriteNotifications: value === "on" };
|
||||||
|
case "guardWhenRtkMissing":
|
||||||
|
return { ...config, guardWhenRtkMissing: value === "on" };
|
||||||
|
case "outputCompactionEnabled":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
outputCompaction: { ...config.outputCompaction, enabled: value === "on" },
|
||||||
|
};
|
||||||
|
case "outputStripAnsi":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
outputCompaction: { ...config.outputCompaction, stripAnsi: value === "on" },
|
||||||
|
};
|
||||||
|
case "outputReadCompactionEnabled":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
outputCompaction: {
|
||||||
|
...config.outputCompaction,
|
||||||
|
readCompaction: { enabled: value === "on" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "outputTruncateEnabled":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
outputCompaction: {
|
||||||
|
...config.outputCompaction,
|
||||||
|
truncate: {
|
||||||
|
...config.outputCompaction.truncate,
|
||||||
|
enabled: value === "on",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "outputTruncateMaxChars": {
|
||||||
|
const parsed = parseIntegerInRange(value, 1_000, 200_000);
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
outputCompaction: {
|
||||||
|
...config.outputCompaction,
|
||||||
|
truncate: {
|
||||||
|
...config.outputCompaction.truncate,
|
||||||
|
maxChars: parsed ?? config.outputCompaction.truncate.maxChars,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "outputSourceFilteringEnabled":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
outputCompaction: {
|
||||||
|
...config.outputCompaction,
|
||||||
|
sourceCodeFilteringEnabled: value === "on",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "outputPreserveExactSkillReads":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
outputCompaction: {
|
||||||
|
...config.outputCompaction,
|
||||||
|
preserveExactSkillReads: value === "on",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "outputSourceFiltering": {
|
||||||
|
const parsedValue = parseSourceFilterLevel(value);
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
outputCompaction: {
|
||||||
|
...config.outputCompaction,
|
||||||
|
sourceCodeFiltering: parsedValue ?? config.outputCompaction.sourceCodeFiltering,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "outputSmartTruncate":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
outputCompaction: {
|
||||||
|
...config.outputCompaction,
|
||||||
|
smartTruncate: {
|
||||||
|
...config.outputCompaction.smartTruncate,
|
||||||
|
enabled: value === "on",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "outputSmartTruncateMaxLines": {
|
||||||
|
const parsed = parseIntegerInRange(value, 40, 4_000);
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
outputCompaction: {
|
||||||
|
...config.outputCompaction,
|
||||||
|
smartTruncate: {
|
||||||
|
...config.outputCompaction.smartTruncate,
|
||||||
|
maxLines: parsed ?? config.outputCompaction.smartTruncate.maxLines,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "outputAggregateTestOutput":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
outputCompaction: {
|
||||||
|
...config.outputCompaction,
|
||||||
|
aggregateTestOutput: value === "on",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "outputFilterBuildOutput":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
outputCompaction: {
|
||||||
|
...config.outputCompaction,
|
||||||
|
filterBuildOutput: value === "on",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "outputCompactGitOutput":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
outputCompaction: {
|
||||||
|
...config.outputCompaction,
|
||||||
|
compactGitOutput: value === "on",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "outputAggregateLinterOutput":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
outputCompaction: {
|
||||||
|
...config.outputCompaction,
|
||||||
|
aggregateLinterOutput: value === "on",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "outputTrackSavings":
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
outputCompaction: {
|
||||||
|
...config.outputCompaction,
|
||||||
|
trackSavings: value === "on",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSettingValues(settingsList: SettingValueSyncTarget, config: RtkIntegrationConfig): void {
|
||||||
|
settingsList.updateValue("enabled", toOnOff(config.enabled));
|
||||||
|
settingsList.updateValue("commandRewritingEnabled", toOnOff(config.commandRewritingEnabled));
|
||||||
|
settingsList.updateValue("mode", config.mode);
|
||||||
|
settingsList.updateValue("showRewriteNotifications", toOnOff(config.showRewriteNotifications));
|
||||||
|
settingsList.updateValue("guardWhenRtkMissing", toOnOff(config.guardWhenRtkMissing));
|
||||||
|
settingsList.updateValue("outputCompactionEnabled", toOnOff(config.outputCompaction.enabled));
|
||||||
|
settingsList.updateValue("outputStripAnsi", toOnOff(config.outputCompaction.stripAnsi));
|
||||||
|
settingsList.updateValue("outputReadCompactionEnabled", toOnOff(config.outputCompaction.readCompaction.enabled));
|
||||||
|
settingsList.updateValue("outputTruncateEnabled", toOnOff(config.outputCompaction.truncate.enabled));
|
||||||
|
settingsList.updateValue("outputTruncateMaxChars", String(config.outputCompaction.truncate.maxChars));
|
||||||
|
settingsList.updateValue("outputSourceFilteringEnabled", toOnOff(config.outputCompaction.sourceCodeFilteringEnabled));
|
||||||
|
settingsList.updateValue("outputPreserveExactSkillReads", toOnOff(config.outputCompaction.preserveExactSkillReads));
|
||||||
|
settingsList.updateValue("outputSourceFiltering", config.outputCompaction.sourceCodeFiltering);
|
||||||
|
settingsList.updateValue("outputSmartTruncate", toOnOff(config.outputCompaction.smartTruncate.enabled));
|
||||||
|
settingsList.updateValue("outputSmartTruncateMaxLines", String(config.outputCompaction.smartTruncate.maxLines));
|
||||||
|
settingsList.updateValue("outputAggregateTestOutput", toOnOff(config.outputCompaction.aggregateTestOutput));
|
||||||
|
settingsList.updateValue("outputFilterBuildOutput", toOnOff(config.outputCompaction.filterBuildOutput));
|
||||||
|
settingsList.updateValue("outputCompactGitOutput", toOnOff(config.outputCompaction.compactGitOutput));
|
||||||
|
settingsList.updateValue("outputAggregateLinterOutput", toOnOff(config.outputCompaction.aggregateLinterOutput));
|
||||||
|
settingsList.updateValue("outputTrackSavings", toOnOff(config.outputCompaction.trackSavings));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSettingsModal(ctx: ExtensionCommandContext, controller: RtkIntegrationController): Promise<void> {
|
||||||
|
const overlayOptions = { anchor: "center" as const, width: 86, maxHeight: "85%" as const, margin: 1 };
|
||||||
|
|
||||||
|
await ctx.ui.custom<void>(
|
||||||
|
(tui, theme, _keybindings, done) => {
|
||||||
|
let current = controller.getConfig();
|
||||||
|
let settingsModal: ZellijSettingsModal | null = null;
|
||||||
|
const allSettings = buildSettingItems(current);
|
||||||
|
const tabs = buildTabbedSettingGroups(allSettings);
|
||||||
|
|
||||||
|
settingsModal = new ZellijSettingsModal(
|
||||||
|
{
|
||||||
|
title: "Pi RTK Optimizer",
|
||||||
|
tabs,
|
||||||
|
activeTabIndex: 0,
|
||||||
|
onChange: (id, newValue) => {
|
||||||
|
current = applySetting(current, id, newValue);
|
||||||
|
controller.setConfig(current, ctx);
|
||||||
|
current = controller.getConfig();
|
||||||
|
if (settingsModal) {
|
||||||
|
syncSettingValues(settingsModal, current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClose: () => done(),
|
||||||
|
helpText: `Config: ${controller.getConfigPath()}`,
|
||||||
|
enableSearch: true,
|
||||||
|
},
|
||||||
|
theme,
|
||||||
|
);
|
||||||
|
|
||||||
|
const modal = new ZellijModal(
|
||||||
|
settingsModal,
|
||||||
|
{
|
||||||
|
borderStyle: "rounded",
|
||||||
|
titleBar: {
|
||||||
|
left: "Pi RTK Optimizer",
|
||||||
|
},
|
||||||
|
helpUndertitle: {
|
||||||
|
variants: [
|
||||||
|
"←/→ tabs • Type to search • Enter/Space change • Esc close",
|
||||||
|
"←/→ tabs • Type to search • Esc close",
|
||||||
|
"←/→ tabs • Esc close",
|
||||||
|
],
|
||||||
|
color: "dim",
|
||||||
|
},
|
||||||
|
overlay: overlayOptions,
|
||||||
|
},
|
||||||
|
theme,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
render(width: number) {
|
||||||
|
return modal.renderModal(width).lines;
|
||||||
|
},
|
||||||
|
invalidate() {
|
||||||
|
modal.invalidate();
|
||||||
|
},
|
||||||
|
handleInput(data: string) {
|
||||||
|
modal.handleInput(data);
|
||||||
|
tui.requestRender();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ overlay: true, overlayOptions },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleArgs(
|
||||||
|
args: string,
|
||||||
|
ctx: ExtensionCommandContext,
|
||||||
|
controller: RtkIntegrationController,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const normalized = (args ?? "").trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "help") {
|
||||||
|
ctx.ui.notify(RTK_USAGE_TEXT, "info");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "show") {
|
||||||
|
ctx.ui.notify(`rtk: ${summarizeConfig(controller.getConfig(), controller.getRuntimeStatus())}`, "info");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "path") {
|
||||||
|
ctx.ui.notify(`rtk config: ${controller.getConfigPath()}`, "info");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "verify") {
|
||||||
|
const runtimeStatus = await controller.refreshRuntimeStatus();
|
||||||
|
if (runtimeStatus.rtkAvailable) {
|
||||||
|
const pathDetail = runtimeStatus.rtkExecutablePath ? ` at ${runtimeStatus.rtkExecutablePath}` : "";
|
||||||
|
ctx.ui.notify(`RTK binary is available${pathDetail}.`, "info");
|
||||||
|
} else {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`RTK binary is not available${runtimeStatus.lastError ? `: ${runtimeStatus.lastError}` : ""}.`,
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "stats") {
|
||||||
|
ctx.ui.notify(controller.getMetricsSummary(), "info");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "clear-stats") {
|
||||||
|
controller.clearMetrics();
|
||||||
|
ctx.ui.notify("RTK metrics cleared.", "info");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "reset") {
|
||||||
|
controller.setConfig({ ...DEFAULT_RTK_INTEGRATION_CONFIG }, ctx);
|
||||||
|
ctx.ui.notify("RTK integration settings reset to defaults.", "info");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify(RTK_USAGE_TEXT, "warning");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRtkIntegrationCommand(
|
||||||
|
args: string,
|
||||||
|
ctx: ExtensionCommandContext,
|
||||||
|
controller: RtkIntegrationController,
|
||||||
|
): Promise<void> {
|
||||||
|
if (await handleArgs(args, ctx, controller)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.hasUI) {
|
||||||
|
ctx.ui.notify("/rtk requires interactive TUI mode.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await openSettingsModal(ctx, controller);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { CONFIG_PATH } from "./constants.js";
|
||||||
|
import { toRecord } from "./record-utils.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG,
|
||||||
|
RTK_MODES,
|
||||||
|
RTK_SOURCE_FILTER_LEVELS,
|
||||||
|
type ConfigLoadResult,
|
||||||
|
type ConfigSaveResult,
|
||||||
|
type EnsureConfigResult,
|
||||||
|
type RtkIntegrationConfig,
|
||||||
|
type RtkSourceFilterLevel,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
function toBoolean(value: unknown, fallback: boolean): boolean {
|
||||||
|
return typeof value === "boolean" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInteger(value: unknown, fallback: number, min: number, max: number): number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const rounded = Math.round(value);
|
||||||
|
return Math.max(min, Math.min(max, rounded));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMode(value: unknown): RtkIntegrationConfig["mode"] {
|
||||||
|
return RTK_MODES.includes(value as RtkIntegrationConfig["mode"])
|
||||||
|
? (value as RtkIntegrationConfig["mode"])
|
||||||
|
: DEFAULT_RTK_INTEGRATION_CONFIG.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSourceFilterLevel(value: unknown, fallback: RtkSourceFilterLevel): RtkSourceFilterLevel {
|
||||||
|
return RTK_SOURCE_FILTER_LEVELS.includes(value as RtkSourceFilterLevel)
|
||||||
|
? (value as RtkSourceFilterLevel)
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOwnProperty(source: Record<string, unknown>, key: string): boolean {
|
||||||
|
return Object.prototype.hasOwnProperty.call(source, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRtkIntegrationConfig(raw: unknown): RtkIntegrationConfig {
|
||||||
|
const source = toRecord(raw);
|
||||||
|
const outputCompactionSource = toRecord(source.outputCompaction);
|
||||||
|
const readCompactionSource = toRecord(outputCompactionSource.readCompaction);
|
||||||
|
const truncateSource = toRecord(outputCompactionSource.truncate);
|
||||||
|
const smartTruncateSource = toRecord(outputCompactionSource.smartTruncate);
|
||||||
|
const hasReadCompaction = hasOwnProperty(outputCompactionSource, "readCompaction");
|
||||||
|
const legacyReadCompactionFallback = !hasReadCompaction;
|
||||||
|
const sourceFilteringFallback = legacyReadCompactionFallback
|
||||||
|
? true
|
||||||
|
: DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.sourceCodeFilteringEnabled;
|
||||||
|
const sourceFilterLevelFallback = legacyReadCompactionFallback
|
||||||
|
? "minimal"
|
||||||
|
: DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.sourceCodeFiltering;
|
||||||
|
const smartTruncateEnabledFallback = legacyReadCompactionFallback
|
||||||
|
? true
|
||||||
|
: DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.smartTruncate.enabled;
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: toBoolean(source.enabled, DEFAULT_RTK_INTEGRATION_CONFIG.enabled),
|
||||||
|
commandRewritingEnabled: toBoolean(
|
||||||
|
source.commandRewritingEnabled,
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG.commandRewritingEnabled,
|
||||||
|
),
|
||||||
|
mode: toMode(source.mode),
|
||||||
|
guardWhenRtkMissing: toBoolean(
|
||||||
|
source.guardWhenRtkMissing,
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG.guardWhenRtkMissing,
|
||||||
|
),
|
||||||
|
showRewriteNotifications: toBoolean(
|
||||||
|
source.showRewriteNotifications,
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG.showRewriteNotifications,
|
||||||
|
),
|
||||||
|
outputCompaction: {
|
||||||
|
enabled: toBoolean(
|
||||||
|
outputCompactionSource.enabled,
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.enabled,
|
||||||
|
),
|
||||||
|
stripAnsi: toBoolean(
|
||||||
|
outputCompactionSource.stripAnsi,
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.stripAnsi,
|
||||||
|
),
|
||||||
|
readCompaction: {
|
||||||
|
enabled: hasReadCompaction
|
||||||
|
? toBoolean(
|
||||||
|
readCompactionSource.enabled,
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.readCompaction.enabled,
|
||||||
|
)
|
||||||
|
: true,
|
||||||
|
},
|
||||||
|
sourceCodeFilteringEnabled: toBoolean(
|
||||||
|
outputCompactionSource.sourceCodeFilteringEnabled,
|
||||||
|
sourceFilteringFallback,
|
||||||
|
),
|
||||||
|
preserveExactSkillReads: toBoolean(
|
||||||
|
outputCompactionSource.preserveExactSkillReads,
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.preserveExactSkillReads,
|
||||||
|
),
|
||||||
|
truncate: {
|
||||||
|
enabled: toBoolean(
|
||||||
|
truncateSource.enabled,
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.truncate.enabled,
|
||||||
|
),
|
||||||
|
maxChars: toInteger(
|
||||||
|
truncateSource.maxChars,
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.truncate.maxChars,
|
||||||
|
1_000,
|
||||||
|
200_000,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
sourceCodeFiltering: toSourceFilterLevel(
|
||||||
|
outputCompactionSource.sourceCodeFiltering,
|
||||||
|
sourceFilterLevelFallback,
|
||||||
|
),
|
||||||
|
smartTruncate: {
|
||||||
|
enabled: toBoolean(
|
||||||
|
smartTruncateSource.enabled,
|
||||||
|
smartTruncateEnabledFallback,
|
||||||
|
),
|
||||||
|
maxLines: toInteger(
|
||||||
|
smartTruncateSource.maxLines,
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.smartTruncate.maxLines,
|
||||||
|
40,
|
||||||
|
4_000,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
aggregateTestOutput: toBoolean(
|
||||||
|
outputCompactionSource.aggregateTestOutput,
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.aggregateTestOutput,
|
||||||
|
),
|
||||||
|
filterBuildOutput: toBoolean(
|
||||||
|
outputCompactionSource.filterBuildOutput,
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.filterBuildOutput,
|
||||||
|
),
|
||||||
|
compactGitOutput: toBoolean(
|
||||||
|
outputCompactionSource.compactGitOutput,
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.compactGitOutput,
|
||||||
|
),
|
||||||
|
aggregateLinterOutput: toBoolean(
|
||||||
|
outputCompactionSource.aggregateLinterOutput,
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.aggregateLinterOutput,
|
||||||
|
),
|
||||||
|
trackSavings: toBoolean(
|
||||||
|
outputCompactionSource.trackSavings,
|
||||||
|
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.trackSavings,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureConfigExists(configPath = CONFIG_PATH): EnsureConfigResult {
|
||||||
|
if (existsSync(configPath)) {
|
||||||
|
return { created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(configPath), { recursive: true });
|
||||||
|
writeFileSync(configPath, `${JSON.stringify(DEFAULT_RTK_INTEGRATION_CONFIG, null, 2)}\n`, "utf-8");
|
||||||
|
return { created: true };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
created: false,
|
||||||
|
error: `Failed to create ${configPath}: ${message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadRtkIntegrationConfig(configPath = CONFIG_PATH): ConfigLoadResult {
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
return { config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG) };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawText = readFileSync(configPath, "utf-8");
|
||||||
|
const parsed = JSON.parse(rawText) as unknown;
|
||||||
|
return { config: normalizeRtkIntegrationConfig(parsed) };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG),
|
||||||
|
warning: `Failed to parse ${configPath}: ${message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveRtkIntegrationConfig(
|
||||||
|
config: RtkIntegrationConfig,
|
||||||
|
configPath = CONFIG_PATH,
|
||||||
|
): ConfigSaveResult {
|
||||||
|
const normalized = normalizeRtkIntegrationConfig(config);
|
||||||
|
const tmpPath = `${configPath}.tmp`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(configPath), { recursive: true });
|
||||||
|
writeFileSync(tmpPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf-8");
|
||||||
|
renameSync(tmpPath, configPath);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
if (existsSync(tmpPath)) {
|
||||||
|
unlinkSync(tmpPath);
|
||||||
|
}
|
||||||
|
} catch (cleanupError) {
|
||||||
|
// Best-effort cleanup: a stale tmp-file removal failure must not
|
||||||
|
// mask the original save error reported below.
|
||||||
|
void cleanupError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to save ${configPath}: ${message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRtkIntegrationConfigPath(configPath = CONFIG_PATH): string {
|
||||||
|
return configPath;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
import { mock, runTest } from "./test-helpers.test.ts";
|
||||||
|
|
||||||
|
mock.module("@earendil-works/pi-coding-agent", {
|
||||||
|
namedExports: {
|
||||||
|
getAgentDir: () => "/tmp/.pi/agent",
|
||||||
|
getSettingsListTheme: () => ({}),
|
||||||
|
isToolCallEventType: (toolName: string, event: Record<string, unknown>) => event.toolName === toolName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("@earendil-works/pi-tui", {
|
||||||
|
namedExports: {
|
||||||
|
Box: class {},
|
||||||
|
Container: class {
|
||||||
|
addChild(): void {}
|
||||||
|
render(): string[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
invalidate(): void {}
|
||||||
|
},
|
||||||
|
SettingsList: class {
|
||||||
|
handleInput(): void {}
|
||||||
|
updateValue(): void {}
|
||||||
|
},
|
||||||
|
Spacer: class {},
|
||||||
|
Text: class {},
|
||||||
|
truncateToWidth: (text: string) => text,
|
||||||
|
visibleWidth: (text: string) => text.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexModule = await import("./index.ts");
|
||||||
|
const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote, injectGuidelineIntoPrompt } = indexModule;
|
||||||
|
const rtkIntegrationExtension = indexModule.default;
|
||||||
|
const { DEFAULT_RTK_INTEGRATION_CONFIG } = await import("./types.ts");
|
||||||
|
const { CONFIG_PATH } = await import("./constants.ts");
|
||||||
|
|
||||||
|
function writeTestConfig(commandRewritingEnabled: boolean): void {
|
||||||
|
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
CONFIG_PATH,
|
||||||
|
`${JSON.stringify({ ...DEFAULT_RTK_INTEGRATION_CONFIG, commandRewritingEnabled }, null, 2)}\n`,
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Notification = { message: string; level: "info" | "warning" | "error" };
|
||||||
|
type ExtensionHandler = (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>;
|
||||||
|
|
||||||
|
function createNotificationContext(notifications: Notification[]): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
hasUI: true,
|
||||||
|
ui: {
|
||||||
|
notify(message: string, level: "info" | "warning" | "error") {
|
||||||
|
notifications.push({ message, level });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstText(content: unknown): string {
|
||||||
|
if (!Array.isArray(content) || content.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const block = content[0] as { type?: string; text?: string };
|
||||||
|
return block.type === "text" && typeof block.text === "string" ? block.text : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function configWith(overrides: {
|
||||||
|
enabled?: boolean;
|
||||||
|
compactionEnabled?: boolean;
|
||||||
|
readCompactionEnabled?: boolean;
|
||||||
|
sourceFilteringEnabled?: boolean;
|
||||||
|
sourceFilteringLevel?: "none" | "minimal" | "aggressive";
|
||||||
|
smartTruncateEnabled?: boolean;
|
||||||
|
truncateEnabled?: boolean;
|
||||||
|
}): typeof DEFAULT_RTK_INTEGRATION_CONFIG {
|
||||||
|
const base = DEFAULT_RTK_INTEGRATION_CONFIG;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
enabled: overrides.enabled ?? base.enabled,
|
||||||
|
outputCompaction: {
|
||||||
|
...base.outputCompaction,
|
||||||
|
enabled: overrides.compactionEnabled ?? base.outputCompaction.enabled,
|
||||||
|
readCompaction: {
|
||||||
|
...base.outputCompaction.readCompaction,
|
||||||
|
enabled: overrides.readCompactionEnabled ?? base.outputCompaction.readCompaction.enabled,
|
||||||
|
},
|
||||||
|
sourceCodeFilteringEnabled:
|
||||||
|
overrides.sourceFilteringEnabled ?? base.outputCompaction.sourceCodeFilteringEnabled,
|
||||||
|
sourceCodeFiltering: overrides.sourceFilteringLevel ?? base.outputCompaction.sourceCodeFiltering,
|
||||||
|
smartTruncate: {
|
||||||
|
...base.outputCompaction.smartTruncate,
|
||||||
|
enabled: overrides.smartTruncateEnabled ?? base.outputCompaction.smartTruncate.enabled,
|
||||||
|
},
|
||||||
|
truncate: {
|
||||||
|
...base.outputCompaction.truncate,
|
||||||
|
enabled: overrides.truncateEnabled ?? base.outputCompaction.truncate.enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
runTest("bounded notice tracker evicts old entries and supports reset", () => {
|
||||||
|
const tracker = createBoundedNoticeTracker(2);
|
||||||
|
|
||||||
|
assert.equal(tracker.remember("first"), true);
|
||||||
|
assert.equal(tracker.remember("second"), true);
|
||||||
|
assert.equal(tracker.remember("first"), false);
|
||||||
|
|
||||||
|
assert.equal(tracker.remember("third"), true);
|
||||||
|
assert.equal(tracker.remember("second"), false);
|
||||||
|
assert.equal(tracker.remember("first"), true);
|
||||||
|
|
||||||
|
tracker.reset();
|
||||||
|
assert.equal(tracker.remember("third"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("bounded notice tracker coerces invalid limits to a safe minimum", () => {
|
||||||
|
const tracker = createBoundedNoticeTracker(0);
|
||||||
|
assert.equal(tracker.remember("alpha"), true);
|
||||||
|
assert.equal(tracker.remember("beta"), true);
|
||||||
|
assert.equal(tracker.remember("alpha"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("source-filter note injected when source filtering is active", () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldInjectSourceFilterTroubleshootingNote(
|
||||||
|
configWith({
|
||||||
|
readCompactionEnabled: true,
|
||||||
|
sourceFilteringEnabled: true,
|
||||||
|
sourceFilteringLevel: "minimal",
|
||||||
|
smartTruncateEnabled: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldInjectSourceFilterTroubleshootingNote(
|
||||||
|
configWith({
|
||||||
|
readCompactionEnabled: true,
|
||||||
|
sourceFilteringEnabled: true,
|
||||||
|
sourceFilteringLevel: "aggressive",
|
||||||
|
smartTruncateEnabled: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("source-filter note skipped when extension is disabled", () => {
|
||||||
|
assert.equal(shouldInjectSourceFilterTroubleshootingNote(configWith({ enabled: false })), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("source-filter note skipped when compaction is disabled", () => {
|
||||||
|
assert.equal(shouldInjectSourceFilterTroubleshootingNote(configWith({ compactionEnabled: false })), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("source-filter note skipped when read compaction is disabled", () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldInjectSourceFilterTroubleshootingNote(
|
||||||
|
configWith({
|
||||||
|
readCompactionEnabled: false,
|
||||||
|
sourceFilteringEnabled: true,
|
||||||
|
sourceFilteringLevel: "minimal",
|
||||||
|
smartTruncateEnabled: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("source-filter note skipped when source filtering flag is off", () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldInjectSourceFilterTroubleshootingNote(configWith({ sourceFilteringEnabled: false })),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("source-filter note skipped when filtering level is 'none'", () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldInjectSourceFilterTroubleshootingNote(
|
||||||
|
configWith({ sourceFilteringEnabled: true, sourceFilteringLevel: "none" }),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("source-filter note skipped when all read filtering safeguards are disabled", () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldInjectSourceFilterTroubleshootingNote(
|
||||||
|
configWith({ smartTruncateEnabled: false, truncateEnabled: false }),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("injectGuidelineIntoPrompt inserts bullet inside Guidelines section when header is at index 0", () => {
|
||||||
|
const guideline = "Test guideline for RTK troubleshooting.";
|
||||||
|
|
||||||
|
const prompt = [
|
||||||
|
"Guidelines:",
|
||||||
|
"- Use mcp for MCP discovery first",
|
||||||
|
"- Be concise in your responses",
|
||||||
|
"",
|
||||||
|
"<project_context>",
|
||||||
|
"Project-specific instructions",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const result = injectGuidelineIntoPrompt(prompt, guideline);
|
||||||
|
|
||||||
|
assert.ok(result.includes(guideline), "guideline must be present");
|
||||||
|
|
||||||
|
const bullet = `- ${guideline}`;
|
||||||
|
assert.ok(result.includes(bullet), "guideline must be a bullet line");
|
||||||
|
|
||||||
|
const guidelinesStart = result.indexOf("Guidelines:\n");
|
||||||
|
const bulletIndex = result.indexOf(bullet);
|
||||||
|
const projectContextIndex = result.indexOf("\n<project_context>");
|
||||||
|
|
||||||
|
assert.equal(guidelinesStart, 0, "Guidelines header must be at index 0");
|
||||||
|
assert.ok(bulletIndex > guidelinesStart, "bullet must be after Guidelines header");
|
||||||
|
assert.ok(projectContextIndex !== -1, "project_context section must exist");
|
||||||
|
assert.ok(bulletIndex < projectContextIndex, "bullet must be before project_context section");
|
||||||
|
|
||||||
|
assert.equal(injectGuidelineIntoPrompt(result, guideline), result, "should be idempotent");
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("injectGuidelineIntoPrompt inserts bullet inside Guidelines section when header is mid-prompt", () => {
|
||||||
|
const guideline = "Test guideline for RTK troubleshooting.";
|
||||||
|
|
||||||
|
const prompt = [
|
||||||
|
"You are an expert coding assistant.",
|
||||||
|
"",
|
||||||
|
"Guidelines:",
|
||||||
|
"- Be concise in your responses",
|
||||||
|
"",
|
||||||
|
"Pi documentation:",
|
||||||
|
"- Main documentation: /path/to/readme",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const result = injectGuidelineIntoPrompt(prompt, guideline);
|
||||||
|
|
||||||
|
const bullet = `- ${guideline}`;
|
||||||
|
assert.ok(result.includes(bullet), "guideline must be a bullet line");
|
||||||
|
|
||||||
|
const guidelinesStart = result.indexOf("\nGuidelines:\n");
|
||||||
|
const bulletIndex = result.indexOf(bullet);
|
||||||
|
const piDocsIndex = result.indexOf("\nPi documentation:");
|
||||||
|
|
||||||
|
assert.ok(guidelinesStart !== -1, "Guidelines header must exist");
|
||||||
|
assert.ok(bulletIndex > guidelinesStart, "bullet must be after Guidelines header");
|
||||||
|
assert.ok(bulletIndex < piDocsIndex, "bullet must be before Pi documentation section");
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("injectGuidelineIntoPrompt falls back to appending when no Guidelines section exists", () => {
|
||||||
|
const guideline = "Test guideline for RTK troubleshooting.";
|
||||||
|
const prompt = "You are a coding assistant with no guidelines section.";
|
||||||
|
|
||||||
|
const result = injectGuidelineIntoPrompt(prompt, guideline);
|
||||||
|
|
||||||
|
assert.ok(result.includes(guideline), "guideline must be present");
|
||||||
|
assert.ok(result.endsWith(guideline), "guideline must be appended at the end");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("session_start refreshes RTK provenance and runtime guard skips missing rewrites", async () => {
|
||||||
|
writeTestConfig(true);
|
||||||
|
const handlers: Record<string, ExtensionHandler> = {};
|
||||||
|
const notifications: Notification[] = [];
|
||||||
|
const execCommands: string[] = [];
|
||||||
|
let rtkAvailable = false;
|
||||||
|
let rewriteCalls = 0;
|
||||||
|
|
||||||
|
rtkIntegrationExtension({
|
||||||
|
exec: async (command: string, args: string[]) => {
|
||||||
|
execCommands.push(command);
|
||||||
|
if (command === "which" || command === "where") {
|
||||||
|
return { code: 0, stdout: "/opt/rtk/bin/rtk\n", stderr: "" };
|
||||||
|
}
|
||||||
|
if (args[0] === "--version") {
|
||||||
|
return rtkAvailable
|
||||||
|
? { code: 0, stdout: "rtk 1.0.0", stderr: "" }
|
||||||
|
: { code: 1, stdout: "", stderr: "missing rtk" };
|
||||||
|
}
|
||||||
|
if (args[0] === "rewrite") {
|
||||||
|
rewriteCalls += 1;
|
||||||
|
return { code: 3, stdout: "rtk git status", stderr: "" };
|
||||||
|
}
|
||||||
|
return { code: 1, stdout: "", stderr: "unexpected" };
|
||||||
|
},
|
||||||
|
on(eventName: string, handler: ExtensionHandler) {
|
||||||
|
handlers[eventName] = handler;
|
||||||
|
},
|
||||||
|
registerCommand() {},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const sessionStartHandler = handlers.session_start;
|
||||||
|
const toolCallHandler = handlers.tool_call;
|
||||||
|
assert.ok(sessionStartHandler);
|
||||||
|
assert.ok(toolCallHandler);
|
||||||
|
|
||||||
|
await sessionStartHandler({}, createNotificationContext(notifications));
|
||||||
|
const skippedEvent = { toolName: "bash", input: { command: "git status" } };
|
||||||
|
await toolCallHandler(skippedEvent, createNotificationContext(notifications));
|
||||||
|
|
||||||
|
assert.equal((skippedEvent.input as { command: string }).command, "git status");
|
||||||
|
assert.equal(rewriteCalls, 0);
|
||||||
|
assert.ok(notifications.some((notice) => notice.message.includes("rtk binary unavailable")));
|
||||||
|
|
||||||
|
rtkAvailable = true;
|
||||||
|
await sessionStartHandler({}, createNotificationContext(notifications));
|
||||||
|
const rewrittenEvent = { toolName: "bash", input: { command: "git status" } };
|
||||||
|
await toolCallHandler(rewrittenEvent, createNotificationContext(notifications));
|
||||||
|
|
||||||
|
assert.equal(rewriteCalls, 1);
|
||||||
|
assert.ok((rewrittenEvent.input as { command: string }).command.includes("rtk git status"));
|
||||||
|
assert.ok(execCommands.includes("/opt/rtk/bin/rtk"));
|
||||||
|
writeTestConfig(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("tool execution lifecycle sanitizes streamed bash output", async () => {
|
||||||
|
const handlers: Record<string, ExtensionHandler> = {};
|
||||||
|
|
||||||
|
rtkIntegrationExtension({
|
||||||
|
exec: async () => ({ code: 0, stdout: "rtk 1.0.0", stderr: "" }),
|
||||||
|
on(eventName: string, handler: ExtensionHandler) {
|
||||||
|
handlers[eventName] = handler;
|
||||||
|
},
|
||||||
|
registerCommand() {},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const startHandler = handlers.tool_execution_start;
|
||||||
|
const updateHandler = handlers.tool_execution_update;
|
||||||
|
const endHandler = handlers.tool_execution_end;
|
||||||
|
assert.ok(startHandler);
|
||||||
|
assert.ok(updateHandler);
|
||||||
|
assert.ok(endHandler);
|
||||||
|
|
||||||
|
await startHandler(
|
||||||
|
{ toolName: "bash", toolCallId: "bash-1", args: { command: "rtk git status" } },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const updateEvent = {
|
||||||
|
toolName: "bash",
|
||||||
|
toolCallId: "bash-1",
|
||||||
|
args: { command: "rtk git status" },
|
||||||
|
partialResult: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "\x1B[32mworking tree clean\x1B[0m\n",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await updateHandler(updateEvent, {});
|
||||||
|
assert.equal(firstText(updateEvent.partialResult.content), "working tree clean\n");
|
||||||
|
|
||||||
|
const endEvent = {
|
||||||
|
toolName: "bash",
|
||||||
|
toolCallId: "bash-1",
|
||||||
|
result: { content: [{ type: "text", text: "\x1B[31merror: build failed\x1B[0m\n" }] },
|
||||||
|
};
|
||||||
|
await endHandler(endEvent, {});
|
||||||
|
assert.equal(firstText(endEvent.result.content), "error: build failed\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("tool_result lifecycle merges compaction metadata with existing details", async () => {
|
||||||
|
const handlers: Record<string, ExtensionHandler> = {};
|
||||||
|
const notifications: Notification[] = [];
|
||||||
|
|
||||||
|
rtkIntegrationExtension({
|
||||||
|
exec: async () => ({ code: 0, stdout: "rtk 1.0.0", stderr: "" }),
|
||||||
|
on(eventName: string, handler: ExtensionHandler) {
|
||||||
|
handlers[eventName] = handler;
|
||||||
|
},
|
||||||
|
registerCommand() {},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const toolResultHandler = handlers.tool_result;
|
||||||
|
assert.ok(toolResultHandler);
|
||||||
|
const result = await toolResultHandler(
|
||||||
|
{
|
||||||
|
toolName: "bash",
|
||||||
|
input: { command: "printf TODO" },
|
||||||
|
content: [{ type: "text", text: "\x1B[31msrc/a.ts\n 1: TODO\x1B[0m\n" }],
|
||||||
|
details: { metadata: { requestId: "abc" }, traceId: "trace-1" },
|
||||||
|
},
|
||||||
|
createNotificationContext(notifications),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result);
|
||||||
|
assert.equal(firstText(result.content), "src/a.ts\n 1: TODO\n");
|
||||||
|
assert.equal((result.details as { traceId?: string }).traceId, "trace-1");
|
||||||
|
const details = result.details as { rtkCompaction?: { applied: boolean }; metadata?: Record<string, unknown> };
|
||||||
|
assert.equal(details.rtkCompaction?.applied, true);
|
||||||
|
assert.deepEqual(details.metadata?.requestId, "abc");
|
||||||
|
assert.equal((details.metadata?.rtkCompaction as { applied?: boolean } | undefined)?.applied, true);
|
||||||
|
assert.equal(notifications.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await runTest("tool_call surfaces RTK rewrite errors through existing UI warning path", async () => {
|
||||||
|
writeTestConfig(true);
|
||||||
|
const handlers: Record<string, (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>> = {};
|
||||||
|
const notifications: Notification[] = [];
|
||||||
|
|
||||||
|
rtkIntegrationExtension({
|
||||||
|
exec: async (_command: string, args: string[]) => {
|
||||||
|
if (args[0] === "--version") {
|
||||||
|
return { code: 0, stdout: "rtk 1.0.0", stderr: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { code: 2, stdout: "", stderr: "denied unsafe rewrite" };
|
||||||
|
},
|
||||||
|
on(eventName: string, handler: (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>) {
|
||||||
|
handlers[eventName] = handler;
|
||||||
|
},
|
||||||
|
registerCommand() {},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const toolCallHandler = handlers.tool_call;
|
||||||
|
assert.ok(toolCallHandler);
|
||||||
|
const event = { toolName: "bash", input: { command: "git status" } };
|
||||||
|
await toolCallHandler(event, {
|
||||||
|
hasUI: true,
|
||||||
|
ui: {
|
||||||
|
notify(message: string, level: "info" | "warning" | "error") {
|
||||||
|
notifications.push({ message, level });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal((event.input as { command: string }).command, "git status");
|
||||||
|
assert.equal(notifications.length, 1);
|
||||||
|
assert.equal(notifications[0]?.level, "warning");
|
||||||
|
assert.ok(notifications[0]?.message.includes("rtk rewrite skipped"));
|
||||||
|
assert.ok(notifications[0]?.message.includes("denied unsafe rewrite"));
|
||||||
|
writeTestConfig(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("All index tests passed.");
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
import { isToolCallEventType, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import {
|
||||||
|
ensureConfigExists,
|
||||||
|
getRtkIntegrationConfigPath,
|
||||||
|
loadRtkIntegrationConfig,
|
||||||
|
normalizeRtkIntegrationConfig,
|
||||||
|
saveRtkIntegrationConfig,
|
||||||
|
} from "./config-store.js";
|
||||||
|
import { computeRewriteDecision } from "./command-rewriter.js";
|
||||||
|
import { registerRtkIntegrationCommand } from "./command-register.js";
|
||||||
|
import { EXTENSION_NAME } from "./constants.js";
|
||||||
|
import { createLazyModuleLoader } from "./lazy-module-loader.js";
|
||||||
|
import { clearOutputMetrics, getOutputMetricsSummary } from "./output-metrics.js";
|
||||||
|
import type { ToolResultCompactionMetadata } from "./output-compactor.js";
|
||||||
|
import { toRecord } from "./record-utils.js";
|
||||||
|
import { applyRtkCommandEnvironment } from "./rtk-command-environment.js";
|
||||||
|
import { resolveRtkExecutable, type RtkExecutableResolution } from "./rtk-executable-resolver.js";
|
||||||
|
import { applyRewrittenCommandShellSafetyFixups } from "./rewrite-pipeline-safety.js";
|
||||||
|
import { shouldRequireRtkAvailabilityForCommandHandling, shouldSkipCommandHandlingWhenRtkMissing } from "./runtime-guard.js";
|
||||||
|
import { sanitizeStreamingBashExecutionResult } from "./tool-execution-sanitizer.js";
|
||||||
|
import type { RtkIntegrationConfig, RuntimeStatus } from "./types.js";
|
||||||
|
import { applyWindowsBashCompatibilityFixes } from "./windows-command-helpers.js";
|
||||||
|
|
||||||
|
function trimMessage(raw: string, maxLength = 220): string {
|
||||||
|
const clean = raw.replace(/\s+/g, " ").trim();
|
||||||
|
if (clean.length <= maxLength) {
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
return `${clean.slice(0, maxLength - 1)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_FILTER_TROUBLESHOOTING_NOTE =
|
||||||
|
"RTK note: If file edits repeatedly fail because old text does not match, ask the user to manually run '/rtk' in the Pi TUI, disable 'Read compaction enabled', re-read the file, apply the edit, then ask the user to manually re-enable it in the Pi TUI.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject a guideline bullet into the Guidelines section of the system prompt.
|
||||||
|
*
|
||||||
|
* Locates the `Guidelines:` block and inserts the bullet after the last
|
||||||
|
* existing guideline, preserving the section structure. Falls back to
|
||||||
|
* appending at the end when the Guidelines section cannot be found.
|
||||||
|
*/
|
||||||
|
export function injectGuidelineIntoPrompt(systemPrompt: string, guideline: string): string {
|
||||||
|
if (!systemPrompt || systemPrompt.includes(guideline)) {
|
||||||
|
return systemPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bullet = `- ${guideline}`;
|
||||||
|
|
||||||
|
// "Guidelines:" may appear at the very start of the prompt (index 0) or
|
||||||
|
// after a newline. Check both cases so the header is always detected.
|
||||||
|
let guidelinesHeaderIndex = systemPrompt.indexOf("\nGuidelines:\n");
|
||||||
|
let headerLength = "\nGuidelines:\n".length;
|
||||||
|
|
||||||
|
if (guidelinesHeaderIndex === -1 && systemPrompt.startsWith("Guidelines:\n")) {
|
||||||
|
guidelinesHeaderIndex = 0;
|
||||||
|
headerLength = "Guidelines:\n".length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guidelinesHeaderIndex === -1) {
|
||||||
|
return `${systemPrompt}\n\n${guideline}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linesStart = guidelinesHeaderIndex + headerLength;
|
||||||
|
const remainder = systemPrompt.slice(linesStart);
|
||||||
|
const lines = remainder.split("\n");
|
||||||
|
|
||||||
|
let consumedChars = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line === "") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (/^[-*+\s]/.test(line)) {
|
||||||
|
consumedChars += line.length + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertAt = consumedChars === 0 ? linesStart : linesStart + consumedChars - 1;
|
||||||
|
|
||||||
|
const before = systemPrompt.slice(0, insertAt);
|
||||||
|
const after = systemPrompt.slice(insertAt);
|
||||||
|
|
||||||
|
const needsNewlineBefore = before.length > 0 && !before.endsWith("\n");
|
||||||
|
const needsNewlineAfter = after.length > 0 && !after.startsWith("\n");
|
||||||
|
|
||||||
|
return [before, needsNewlineBefore ? "\n" : "", bullet, needsNewlineAfter ? "\n" : "", after].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadOutputCompactorModule = createLazyModuleLoader<typeof import("./output-compactor.js")>("./output-compactor.js");
|
||||||
|
|
||||||
|
export function shouldInjectSourceFilterTroubleshootingNote(config: RtkIntegrationConfig): boolean {
|
||||||
|
const compaction = config.outputCompaction;
|
||||||
|
return (
|
||||||
|
config.enabled &&
|
||||||
|
compaction.enabled &&
|
||||||
|
compaction.readCompaction.enabled &&
|
||||||
|
compaction.sourceCodeFilteringEnabled &&
|
||||||
|
compaction.sourceCodeFiltering !== "none" &&
|
||||||
|
(compaction.smartTruncate.enabled || compaction.truncate.enabled)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeCompactionDetails(
|
||||||
|
existingDetails: unknown,
|
||||||
|
compaction: ToolResultCompactionMetadata,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const baseDetails = toRecord(existingDetails);
|
||||||
|
const baseMetadata = toRecord(baseDetails.metadata);
|
||||||
|
|
||||||
|
const nextDetails: Record<string, unknown> = {
|
||||||
|
...baseDetails,
|
||||||
|
rtkCompaction: compaction,
|
||||||
|
metadata: {
|
||||||
|
...baseMetadata,
|
||||||
|
rtkCompaction: compaction,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Object.keys(baseDetails).length === 0 && existingDetails !== undefined) {
|
||||||
|
nextDetails.rawDetails = existingDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoundedNoticeTracker {
|
||||||
|
remember(key: string): boolean;
|
||||||
|
reset(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBoundedNoticeTracker(maxEntries: number): BoundedNoticeTracker {
|
||||||
|
const normalizedLimit = Math.max(1, Math.floor(maxEntries));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const order: string[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
remember(key: string): boolean {
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(key);
|
||||||
|
order.push(key);
|
||||||
|
while (order.length > normalizedLimit) {
|
||||||
|
const evicted = order.shift();
|
||||||
|
if (evicted !== undefined) {
|
||||||
|
seen.delete(evicted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
reset(): void {
|
||||||
|
seen.clear();
|
||||||
|
order.length = 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
||||||
|
const initialLoad = loadRtkIntegrationConfig();
|
||||||
|
let config: RtkIntegrationConfig = initialLoad.config;
|
||||||
|
if (!config.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pendingLoadWarning = initialLoad.warning;
|
||||||
|
let runtimeStatus: RuntimeStatus = { rtkAvailable: false };
|
||||||
|
const warnedMessages = createBoundedNoticeTracker(100);
|
||||||
|
const suggestionNotices = createBoundedNoticeTracker(200);
|
||||||
|
const activeBashCommands = new Map<string, string>();
|
||||||
|
let missingRtkWarningShown = false;
|
||||||
|
|
||||||
|
const formatRewriteNotice = (originalCommand: string, rewrittenCommand: string): string => {
|
||||||
|
const original = trimMessage(originalCommand, 100);
|
||||||
|
const rewritten = trimMessage(rewrittenCommand, 120);
|
||||||
|
return `RTK rewrite: ${original} -> ${rewritten}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRewriteWarning = (command: string, warning: string): string => {
|
||||||
|
const target = trimMessage(command, 100);
|
||||||
|
const detail = trimMessage(warning, 120);
|
||||||
|
return `${EXTENSION_NAME}: rtk rewrite skipped for '${target}' (${detail}).`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const warnOnce = (
|
||||||
|
ctx: ExtensionContext | ExtensionCommandContext,
|
||||||
|
message: string,
|
||||||
|
level: "warning" | "error" = "warning",
|
||||||
|
): void => {
|
||||||
|
if (!warnedMessages.remember(message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.hasUI) {
|
||||||
|
ctx.ui.notify(message, level);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTrackedBashCommands = (): void => {
|
||||||
|
activeBashCommands.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackBashCommand = (toolCallId: unknown, args: unknown): void => {
|
||||||
|
if (typeof toolCallId !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const argsRecord = toRecord(args);
|
||||||
|
const command = typeof argsRecord.command === "string" ? argsRecord.command.trim() : "";
|
||||||
|
if (!command) {
|
||||||
|
activeBashCommands.delete(toolCallId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeBashCommands.set(toolCallId, command);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTrackedBashCommand = (toolCallId: unknown): string | undefined => {
|
||||||
|
if (typeof toolCallId !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeBashCommands.get(toolCallId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const forgetTrackedBashCommand = (toolCallId: unknown): void => {
|
||||||
|
if (typeof toolCallId !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeBashCommands.delete(toolCallId);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared guard for bash tool-execution events: skips when compaction is
|
||||||
|
* disabled, normalizes the event to a record, tracks the bash command, and
|
||||||
|
* returns the record for further handler-specific processing.
|
||||||
|
*/
|
||||||
|
const recordBashEventIfEnabled = (
|
||||||
|
event: unknown,
|
||||||
|
): Record<string, unknown> | null => {
|
||||||
|
if (!config.enabled || !config.outputCompaction.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventRecord = toRecord(event);
|
||||||
|
if (eventRecord.toolName !== "bash") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackBashCommand(eventRecord.toolCallId, eventRecord.args);
|
||||||
|
return eventRecord;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshConfig = async (ctx?: ExtensionContext | ExtensionCommandContext): Promise<void> => {
|
||||||
|
const ensured = ensureConfigExists();
|
||||||
|
if (ensured.error && ctx) {
|
||||||
|
warnOnce(ctx, ensured.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = loadRtkIntegrationConfig();
|
||||||
|
config = loaded.config;
|
||||||
|
pendingLoadWarning = loaded.warning;
|
||||||
|
await refreshRuntimeStatus();
|
||||||
|
|
||||||
|
if (pendingLoadWarning && ctx) {
|
||||||
|
warnOnce(ctx, pendingLoadWarning);
|
||||||
|
pendingLoadWarning = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setConfig = (next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void => {
|
||||||
|
config = normalizeRtkIntegrationConfig(next);
|
||||||
|
const saved = saveRtkIntegrationConfig(config);
|
||||||
|
if (!saved.success && saved.error) {
|
||||||
|
ctx.ui.notify(saved.error, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshRuntimeStatus = async (): Promise<RuntimeStatus> => {
|
||||||
|
if (!config.commandRewritingEnabled) {
|
||||||
|
runtimeStatus = { rtkAvailable: false };
|
||||||
|
return runtimeStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
let executableResolution: RtkExecutableResolution | undefined;
|
||||||
|
try {
|
||||||
|
executableResolution = await resolveRtkExecutable(pi);
|
||||||
|
const result = await pi.exec(executableResolution.command, ["--version"], { timeout: 5000 });
|
||||||
|
if (result.code === 0) {
|
||||||
|
runtimeStatus = {
|
||||||
|
rtkAvailable: true,
|
||||||
|
lastCheckedAt: Date.now(),
|
||||||
|
rtkExecutablePath: executableResolution.resolvedPath,
|
||||||
|
rtkExecutableCommand: executableResolution.command,
|
||||||
|
rtkExecutableResolver: executableResolution.resolver,
|
||||||
|
rtkExecutableResolutionWarning: executableResolution.warning,
|
||||||
|
};
|
||||||
|
missingRtkWarningShown = false;
|
||||||
|
return runtimeStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = trimMessage(
|
||||||
|
`${result.stderr || ""} ${result.stdout || ""} ${result.code ? `(exit ${result.code})` : ""}`,
|
||||||
|
);
|
||||||
|
runtimeStatus = {
|
||||||
|
rtkAvailable: false,
|
||||||
|
lastCheckedAt: Date.now(),
|
||||||
|
lastError: detail || `exit ${result.code}`,
|
||||||
|
rtkExecutablePath: executableResolution.resolvedPath,
|
||||||
|
rtkExecutableCommand: executableResolution.command,
|
||||||
|
rtkExecutableResolver: executableResolution.resolver,
|
||||||
|
rtkExecutableResolutionWarning: executableResolution.warning,
|
||||||
|
};
|
||||||
|
return runtimeStatus;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
runtimeStatus = {
|
||||||
|
rtkAvailable: false,
|
||||||
|
lastCheckedAt: Date.now(),
|
||||||
|
lastError: trimMessage(message),
|
||||||
|
rtkExecutablePath: executableResolution?.resolvedPath,
|
||||||
|
rtkExecutableCommand: executableResolution?.command,
|
||||||
|
rtkExecutableResolver: executableResolution?.resolver,
|
||||||
|
rtkExecutableResolutionWarning: executableResolution?.warning,
|
||||||
|
};
|
||||||
|
return runtimeStatus;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeWarnRtkMissing = (ctx: ExtensionContext): void => {
|
||||||
|
if (!config.enabled || !config.commandRewritingEnabled || !config.guardWhenRtkMissing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeStatus.rtkAvailable) {
|
||||||
|
missingRtkWarningShown = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingRtkWarningShown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
missingRtkWarningShown = true;
|
||||||
|
const reason = runtimeStatus.lastError ? ` (${runtimeStatus.lastError})` : "";
|
||||||
|
const handling = config.mode === "suggest" ? "rewrite suggestions" : "command rewrite";
|
||||||
|
warnOnce(ctx, `${EXTENSION_NAME}: rtk binary unavailable, ${handling} bypassed${reason}.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureRuntimeStatusFresh = async (): Promise<void> => {
|
||||||
|
if (!shouldRequireRtkAvailabilityForCommandHandling(config)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const isStale = !runtimeStatus.lastCheckedAt || now - runtimeStatus.lastCheckedAt > 30_000;
|
||||||
|
if (isStale) {
|
||||||
|
await refreshRuntimeStatus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = {
|
||||||
|
getConfig: () => config,
|
||||||
|
setConfig,
|
||||||
|
getConfigPath: getRtkIntegrationConfigPath,
|
||||||
|
getRuntimeStatus: () => runtimeStatus,
|
||||||
|
refreshRuntimeStatus,
|
||||||
|
getMetricsSummary: getOutputMetricsSummary,
|
||||||
|
clearMetrics: clearOutputMetrics,
|
||||||
|
};
|
||||||
|
|
||||||
|
registerRtkIntegrationCommand(pi, controller);
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
warnedMessages.reset();
|
||||||
|
suggestionNotices.reset();
|
||||||
|
clearTrackedBashCommands();
|
||||||
|
missingRtkWarningShown = false;
|
||||||
|
await refreshConfig(ctx);
|
||||||
|
maybeWarnRtkMissing(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
pi.on("agent_end", async () => {
|
||||||
|
clearTrackedBashCommands();
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_execution_start", async (event) => {
|
||||||
|
recordBashEventIfEnabled(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_execution_update", async (event) => {
|
||||||
|
const eventRecord = recordBashEventIfEnabled(event);
|
||||||
|
if (!eventRecord) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitization = sanitizeStreamingBashExecutionResult(
|
||||||
|
eventRecord.partialResult,
|
||||||
|
getTrackedBashCommand(eventRecord.toolCallId),
|
||||||
|
);
|
||||||
|
if (sanitization.changed) {
|
||||||
|
eventRecord.partialResult = sanitization.result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_execution_end", async (event) => {
|
||||||
|
const eventRecord = toRecord(event);
|
||||||
|
if (eventRecord.toolName !== "bash") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (config.enabled && config.outputCompaction.enabled) {
|
||||||
|
const sanitization = sanitizeStreamingBashExecutionResult(
|
||||||
|
eventRecord.result,
|
||||||
|
getTrackedBashCommand(eventRecord.toolCallId),
|
||||||
|
);
|
||||||
|
if (sanitization.changed) {
|
||||||
|
eventRecord.result = sanitization.result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
forgetTrackedBashCommand(eventRecord.toolCallId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("before_agent_start", async (event, ctx) => {
|
||||||
|
await ensureRuntimeStatusFresh();
|
||||||
|
maybeWarnRtkMissing(ctx);
|
||||||
|
|
||||||
|
if (!shouldInjectSourceFilterTroubleshootingNote(config)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.systemPrompt.includes(SOURCE_FILTER_TROUBLESHOOTING_NOTE)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPrompt = injectGuidelineIntoPrompt(event.systemPrompt, SOURCE_FILTER_TROUBLESHOOTING_NOTE);
|
||||||
|
|
||||||
|
if (updatedPrompt === event.systemPrompt) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
systemPrompt: updatedPrompt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
|
if (!config.enabled || !config.commandRewritingEnabled) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isToolCallEventType("bash", event)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.mode === "rewrite") {
|
||||||
|
const compatibility = applyWindowsBashCompatibilityFixes(event.input.command);
|
||||||
|
if (compatibility.command !== event.input.command) {
|
||||||
|
event.input.command = compatibility.command;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureRuntimeStatusFresh();
|
||||||
|
if (shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let executableResolution: RtkExecutableResolution | undefined;
|
||||||
|
if (runtimeStatus.rtkExecutableCommand) {
|
||||||
|
const resolver: RtkExecutableResolution["resolver"] =
|
||||||
|
runtimeStatus.rtkExecutableResolver === "where" ? "where" : "which";
|
||||||
|
executableResolution = {
|
||||||
|
command: runtimeStatus.rtkExecutableCommand,
|
||||||
|
resolvedPath: runtimeStatus.rtkExecutablePath,
|
||||||
|
resolver,
|
||||||
|
warning: runtimeStatus.rtkExecutableResolutionWarning,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const decision = await computeRewriteDecision(event.input.command, config, pi, { executableResolution });
|
||||||
|
if (!decision.changed) {
|
||||||
|
if (decision.warning) {
|
||||||
|
warnOnce(ctx, formatRewriteWarning(decision.originalCommand, decision.warning));
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.mode === "rewrite") {
|
||||||
|
if (config.showRewriteNotifications && ctx.hasUI) {
|
||||||
|
ctx.ui.notify(formatRewriteNotice(decision.originalCommand, decision.rewrittenCommand), "info");
|
||||||
|
}
|
||||||
|
const envScopedRewrittenCommand = applyRtkCommandEnvironment(decision.rewrittenCommand);
|
||||||
|
event.input.command = applyRewrittenCommandShellSafetyFixups(envScopedRewrittenCommand);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.mode === "suggest") {
|
||||||
|
const suggestionKey = `${decision.originalCommand}:${decision.rewrittenCommand}`;
|
||||||
|
if (suggestionNotices.remember(suggestionKey) && ctx.hasUI) {
|
||||||
|
ctx.ui.notify(`RTK suggestion: ${decision.rewrittenCommand}`, "info");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("tool_result", async (event, ctx) => {
|
||||||
|
if (!config.enabled || !config.outputCompaction.enabled) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { compactToolResult } = await loadOutputCompactorModule();
|
||||||
|
const outcome = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: event.toolName,
|
||||||
|
input: event.input,
|
||||||
|
content: event.content,
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!outcome.changed || !outcome.content) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: outcome.content,
|
||||||
|
details: outcome.metadata ? mergeCompactionDetails(event.details, outcome.metadata) : undefined,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
warnOnce(ctx, `${EXTENSION_NAME}: output compaction failed, using raw output (${trimMessage(message)}).`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Creates a memoized lazy loader for a dynamically imported module.
|
||||||
|
*
|
||||||
|
* The first call triggers `import(specifier)`; subsequent calls reuse the
|
||||||
|
* cached promise. This avoids re-importing the module on every invocation
|
||||||
|
* while keeping the heavy module out of the synchronous startup path.
|
||||||
|
*/
|
||||||
|
export function createLazyModuleLoader<T>(specifier: string): () => Promise<T> {
|
||||||
|
let cached: Promise<T> | undefined;
|
||||||
|
return (): Promise<T> => {
|
||||||
|
cached ??= import(specifier) as Promise<T>;
|
||||||
|
return cached;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { cloneDefaultConfig, mock, runTest } from "./test-helpers.test.ts";
|
||||||
|
|
||||||
|
const TEST_AGENT_DIR = "/tmp/.pi/agent";
|
||||||
|
|
||||||
|
mock.module("@earendil-works/pi-coding-agent", {
|
||||||
|
namedExports: {
|
||||||
|
getAgentDir: () => TEST_AGENT_DIR,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { compactToolResult } = await import("./output-compactor.ts");
|
||||||
|
|
||||||
|
function buildReadContent(lineCount: number): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let index = 0; index < lineCount; index += 1) {
|
||||||
|
if (index % 2 === 0) {
|
||||||
|
lines.push(`// comment ${index}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`const value${index} = ${index};`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${lines.join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setReadCompaction(config: ReturnType<typeof cloneDefaultConfig>, enabled: boolean): void {
|
||||||
|
config.outputCompaction.readCompaction = { enabled };
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstTextBlock(content: unknown[] | undefined): string {
|
||||||
|
if (!Array.isArray(content) || content.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const first = content[0] as { type?: string; text?: string };
|
||||||
|
if (first?.type !== "text" || typeof first.text !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return first.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUTPUT_EMOJI_MARKERS = ["✓", "✔", "❌", "⚠️", "⚠", "📋", "📄", "🔍", "✅", "⏭️", "📌", "📝", "❓", "•"];
|
||||||
|
|
||||||
|
function compactBashOutput(command: string, text: string): string {
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "bash",
|
||||||
|
input: { command },
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
},
|
||||||
|
cloneDefaultConfig(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
return firstTextBlock(result.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoOutputEmoji(text: string): void {
|
||||||
|
for (const marker of OUTPUT_EMOJI_MARKERS) {
|
||||||
|
assert.equal(text.includes(marker), false, `Unexpected output emoji marker: ${marker}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoPartialHashlineAnchors(text: string): void {
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
if (/^\s*\d+\s*#[A-Za-z0-9_-]{2,32}:/.test(line)) {
|
||||||
|
assert.equal(line.endsWith("..."), false, `Anchor line was partially truncated: ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runTest("precision read with offset keeps exact output (no source/smart/hard truncation)", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
config.outputCompaction.truncate.enabled = true;
|
||||||
|
config.outputCompaction.truncate.maxChars = 500;
|
||||||
|
config.outputCompaction.smartTruncate.enabled = true;
|
||||||
|
config.outputCompaction.smartTruncate.maxLines = 40;
|
||||||
|
|
||||||
|
const content = buildReadContent(220);
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: "sample.ts", offset: 1 },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, false);
|
||||||
|
assert.deepEqual(result.techniques, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("precision read with limit keeps exact output", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
config.outputCompaction.truncate.enabled = true;
|
||||||
|
config.outputCompaction.truncate.maxChars = 500;
|
||||||
|
config.outputCompaction.smartTruncate.enabled = true;
|
||||||
|
config.outputCompaction.smartTruncate.maxLines = 40;
|
||||||
|
|
||||||
|
const content = buildReadContent(220);
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: "sample.ts", limit: 200 },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, false);
|
||||||
|
assert.deepEqual(result.techniques, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("default read output stays exact when read compaction is disabled by default", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
config.outputCompaction.sourceCodeFilteringEnabled = true;
|
||||||
|
config.outputCompaction.sourceCodeFiltering = "aggressive";
|
||||||
|
config.outputCompaction.smartTruncate.enabled = true;
|
||||||
|
config.outputCompaction.smartTruncate.maxLines = 40;
|
||||||
|
config.outputCompaction.truncate.enabled = true;
|
||||||
|
config.outputCompaction.truncate.maxChars = 500;
|
||||||
|
|
||||||
|
const content = buildReadContent(220);
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: "sample.ts" },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, false);
|
||||||
|
assert.deepEqual(result.techniques, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("normal read compacts and adds banner when read compaction is enabled", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
config.outputCompaction.sourceCodeFilteringEnabled = true;
|
||||||
|
config.outputCompaction.sourceCodeFiltering = "minimal";
|
||||||
|
config.outputCompaction.smartTruncate.enabled = true;
|
||||||
|
config.outputCompaction.smartTruncate.maxLines = 40;
|
||||||
|
|
||||||
|
const content = buildReadContent(220);
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: "sample.ts" },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.ok(result.techniques.includes("source:minimal"));
|
||||||
|
|
||||||
|
const compacted = firstTextBlock(result.content);
|
||||||
|
assert.ok(compacted.startsWith("[RTK compacted output:"));
|
||||||
|
assert.ok(compacted.includes("source:minimal"));
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("line-anchor read output compacts without corrupting LINE#HASH anchors", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
config.outputCompaction.sourceCodeFilteringEnabled = true;
|
||||||
|
config.outputCompaction.sourceCodeFiltering = "minimal";
|
||||||
|
config.outputCompaction.smartTruncate.enabled = true;
|
||||||
|
config.outputCompaction.smartTruncate.maxLines = 40;
|
||||||
|
config.outputCompaction.truncate.enabled = true;
|
||||||
|
config.outputCompaction.truncate.maxChars = 5000;
|
||||||
|
|
||||||
|
const content = Array.from({ length: 120 }, (_value, index) => {
|
||||||
|
const lineNumber = index + 1;
|
||||||
|
const sourceLine = lineNumber % 2 === 0 ? `const value${lineNumber} = ${lineNumber};` : `// comment ${lineNumber}`;
|
||||||
|
return `${String(lineNumber).padStart(3, " ")}#ZP:${sourceLine}`;
|
||||||
|
}).join("\n");
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: "sample.ts" },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.ok(result.techniques.includes("source:minimal"));
|
||||||
|
|
||||||
|
const compacted = firstTextBlock(result.content);
|
||||||
|
assert.ok(compacted.startsWith("[RTK compacted output:"));
|
||||||
|
assert.ok(compacted.includes("source:minimal"));
|
||||||
|
assert.match(compacted, /\n\s*2#ZP:const value2 = 2;/);
|
||||||
|
assert.equal(compacted.includes("#ZP:// comment"), false);
|
||||||
|
assertNoPartialHashlineAnchors(compacted);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("colon-pipe anchor read output compacts without requiring hashline extension", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
config.outputCompaction.sourceCodeFilteringEnabled = true;
|
||||||
|
config.outputCompaction.sourceCodeFiltering = "minimal";
|
||||||
|
config.outputCompaction.smartTruncate.enabled = true;
|
||||||
|
config.outputCompaction.smartTruncate.maxLines = 40;
|
||||||
|
config.outputCompaction.truncate.enabled = true;
|
||||||
|
config.outputCompaction.truncate.maxChars = 5000;
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
"Read sample.ts: 120 lines",
|
||||||
|
"",
|
||||||
|
...Array.from({ length: 120 }, (_value, index) => {
|
||||||
|
const lineNumber = index + 1;
|
||||||
|
const sourceLine = lineNumber % 2 === 0 ? `const value${lineNumber} = ${lineNumber};` : `// comment ${lineNumber}`;
|
||||||
|
return `${lineNumber}:${(lineNumber % 256).toString(16).padStart(2, "0")}|${sourceLine}`;
|
||||||
|
}),
|
||||||
|
].join("\n");
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: "sample.ts" },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.ok(result.techniques.includes("source:minimal"));
|
||||||
|
|
||||||
|
const compacted = firstTextBlock(result.content);
|
||||||
|
assert.ok(compacted.includes("Read sample.ts: 120 lines"));
|
||||||
|
assert.match(compacted, /\n2:02\|const value2 = 2;/);
|
||||||
|
assert.equal(compacted.includes("|// comment"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("compact LINEHASH pipe anchors from oh-my-pi style reads", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
config.outputCompaction.sourceCodeFilteringEnabled = true;
|
||||||
|
config.outputCompaction.sourceCodeFiltering = "minimal";
|
||||||
|
config.outputCompaction.smartTruncate.enabled = true;
|
||||||
|
config.outputCompaction.smartTruncate.maxLines = 40;
|
||||||
|
config.outputCompaction.truncate.enabled = true;
|
||||||
|
config.outputCompaction.truncate.maxChars = 5000;
|
||||||
|
|
||||||
|
const content = Array.from({ length: 120 }, (_value, index) => {
|
||||||
|
const lineNumber = index + 1;
|
||||||
|
const hash = lineNumber % 2 === 0 ? "sr" : "ab";
|
||||||
|
const sourceLine = lineNumber % 2 === 0 ? `const value${lineNumber} = ${lineNumber};` : `// comment ${lineNumber}`;
|
||||||
|
return `${lineNumber}${hash}|${sourceLine}`;
|
||||||
|
}).join("\n");
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: "sample.ts" },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.ok(result.techniques.includes("source:minimal"));
|
||||||
|
|
||||||
|
const compacted = firstTextBlock(result.content);
|
||||||
|
assert.match(compacted, /\n2sr\|const value2 = 2;/);
|
||||||
|
assert.equal(compacted.includes("|// comment"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("compact hashline-tools file wrapper while preserving non-anchor wrapper lines", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
config.outputCompaction.sourceCodeFilteringEnabled = true;
|
||||||
|
config.outputCompaction.sourceCodeFiltering = "minimal";
|
||||||
|
config.outputCompaction.smartTruncate.enabled = true;
|
||||||
|
config.outputCompaction.smartTruncate.maxLines = 40;
|
||||||
|
config.outputCompaction.truncate.enabled = true;
|
||||||
|
config.outputCompaction.truncate.maxChars = 5000;
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
"<file>",
|
||||||
|
...Array.from({ length: 120 }, (_value, index) => {
|
||||||
|
const lineNumber = index + 1;
|
||||||
|
const sourceLine = lineNumber % 2 === 0 ? `const value${lineNumber} = ${lineNumber};` : `// comment ${lineNumber}`;
|
||||||
|
return `${lineNumber}#ZM:${sourceLine}`;
|
||||||
|
}),
|
||||||
|
"",
|
||||||
|
"(End of file - 120 total lines)",
|
||||||
|
"</file>",
|
||||||
|
].join("\n");
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: "sample.ts" },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.ok(result.techniques.includes("source:minimal"));
|
||||||
|
|
||||||
|
const compacted = firstTextBlock(result.content);
|
||||||
|
assert.ok(compacted.includes("<file>"));
|
||||||
|
assert.ok(compacted.includes("(End of file - 120 total lines)"));
|
||||||
|
assert.ok(compacted.includes("</file>"));
|
||||||
|
assert.match(compacted, /\n2#ZM:const value2 = 2;/);
|
||||||
|
assert.equal(compacted.includes("#ZM:// comment"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("anchor-safe read hard truncation preserves whole hashline anchors", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
config.outputCompaction.sourceCodeFilteringEnabled = false;
|
||||||
|
config.outputCompaction.smartTruncate.enabled = false;
|
||||||
|
config.outputCompaction.truncate.enabled = true;
|
||||||
|
config.outputCompaction.truncate.maxChars = 350;
|
||||||
|
|
||||||
|
const content = Array.from({ length: 120 }, (_value, index) => {
|
||||||
|
const lineNumber = index + 1;
|
||||||
|
return `${lineNumber}#ZP:const value${lineNumber} = "${"x".repeat(40)}";`;
|
||||||
|
}).join("\n");
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: "sample.ts" },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.ok(result.techniques.includes("truncate"));
|
||||||
|
|
||||||
|
const compacted = firstTextBlock(result.content);
|
||||||
|
assert.ok(compacted.includes("anchor-safe truncate"));
|
||||||
|
assertNoPartialHashlineAnchors(compacted);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("incidental single anchor-like line does not disable normal read compaction", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
config.outputCompaction.sourceCodeFilteringEnabled = true;
|
||||||
|
config.outputCompaction.sourceCodeFiltering = "minimal";
|
||||||
|
config.outputCompaction.smartTruncate.enabled = true;
|
||||||
|
config.outputCompaction.smartTruncate.maxLines = 40;
|
||||||
|
|
||||||
|
const content = [`1#ZP:not an anchored read`, buildReadContent(120)].join("\n");
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: "sample.ts" },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.ok(result.techniques.includes("source:minimal") || result.techniques.includes("smart-truncate"));
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("short read output stays exact below threshold", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
const content = buildReadContent(40);
|
||||||
|
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: "sample.ts" },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, false);
|
||||||
|
assert.deepEqual(result.techniques, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("read output stays exact at the 80-line boundary with trailing newline", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
config.outputCompaction.smartTruncate.enabled = true;
|
||||||
|
config.outputCompaction.smartTruncate.maxLines = 40;
|
||||||
|
|
||||||
|
const content = buildReadContent(80);
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: "sample.ts" },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, false);
|
||||||
|
assert.deepEqual(result.techniques, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("read output compacts once the content exceeds the 80-line exactness threshold when read compaction is enabled", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
config.outputCompaction.sourceCodeFilteringEnabled = true;
|
||||||
|
config.outputCompaction.sourceCodeFiltering = "minimal";
|
||||||
|
config.outputCompaction.smartTruncate.enabled = true;
|
||||||
|
config.outputCompaction.smartTruncate.maxLines = 40;
|
||||||
|
|
||||||
|
const content = buildReadContent(81);
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: "sample.ts" },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.ok(result.techniques.includes("source:minimal") || result.techniques.includes("smart-truncate"));
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("source file reads skip lossy source filtering when truncation safeguards are not needed", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
config.outputCompaction.sourceCodeFilteringEnabled = true;
|
||||||
|
config.outputCompaction.sourceCodeFiltering = "minimal";
|
||||||
|
config.outputCompaction.smartTruncate.enabled = false;
|
||||||
|
config.outputCompaction.truncate.enabled = false;
|
||||||
|
|
||||||
|
const content = buildReadContent(120);
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: "sample.ts" },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, false);
|
||||||
|
assert.deepEqual(result.techniques, []);
|
||||||
|
assert.equal(firstTextBlock(result.content), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("skill reads stay exact when preserveExactSkillReads is enabled for user skills", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
config.outputCompaction.preserveExactSkillReads = true;
|
||||||
|
config.outputCompaction.truncate.enabled = true;
|
||||||
|
config.outputCompaction.truncate.maxChars = 500;
|
||||||
|
config.outputCompaction.smartTruncate.enabled = true;
|
||||||
|
config.outputCompaction.smartTruncate.maxLines = 40;
|
||||||
|
|
||||||
|
const content = buildReadContent(220);
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: join(TEST_AGENT_DIR, "skills", "example", "SKILL.md") },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, false);
|
||||||
|
assert.deepEqual(result.techniques, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("project .pi skill reads stay exact when preserveExactSkillReads is enabled", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
config.outputCompaction.preserveExactSkillReads = true;
|
||||||
|
config.outputCompaction.truncate.enabled = true;
|
||||||
|
config.outputCompaction.truncate.maxChars = 500;
|
||||||
|
config.outputCompaction.smartTruncate.enabled = true;
|
||||||
|
config.outputCompaction.smartTruncate.maxLines = 40;
|
||||||
|
|
||||||
|
const content = buildReadContent(220);
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: ".pi/skills/example/SKILL.md" },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, false);
|
||||||
|
assert.deepEqual(result.techniques, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("ancestor .agents skill reads stay exact when preserveExactSkillReads is enabled", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
setReadCompaction(config, true);
|
||||||
|
config.outputCompaction.preserveExactSkillReads = true;
|
||||||
|
config.outputCompaction.truncate.enabled = true;
|
||||||
|
config.outputCompaction.truncate.maxChars = 500;
|
||||||
|
config.outputCompaction.smartTruncate.enabled = true;
|
||||||
|
config.outputCompaction.smartTruncate.maxLines = 40;
|
||||||
|
|
||||||
|
const content = buildReadContent(220);
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
input: { path: "../.agents/skills/example/SKILL.md" },
|
||||||
|
content: [{ type: "text", text: content }],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, false);
|
||||||
|
assert.deepEqual(result.techniques, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("build output uses plain-text status markers", () => {
|
||||||
|
const compacted = compactBashOutput("npm run build", "Compiling app v0.1.0\n");
|
||||||
|
|
||||||
|
assert.equal(compacted, "[OK] Build successful (1 units compiled)");
|
||||||
|
assertNoOutputEmoji(compacted);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("git status output uses plain-text labels", () => {
|
||||||
|
const compacted = compactBashOutput(
|
||||||
|
"git status --short --branch",
|
||||||
|
"## main...origin/main\nM staged.ts\n M modified.ts\n?? new.ts\nUU conflict.ts\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(compacted.startsWith("Branch: main\n"));
|
||||||
|
assert.ok(compacted.includes("Staged: 1 files\n staged.ts\n"));
|
||||||
|
assert.ok(compacted.includes("Modified: 1 files\n modified.ts\n"));
|
||||||
|
assert.ok(compacted.includes("Untracked: 1 files\n new.ts\n"));
|
||||||
|
assert.ok(compacted.includes("Conflicts: 1 files"));
|
||||||
|
assertNoOutputEmoji(compacted);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("git diff output uses plain-text file markers", () => {
|
||||||
|
const compacted = compactBashOutput(
|
||||||
|
"git diff",
|
||||||
|
"diff --git a/src/example.ts b/src/example.ts\n@@ -1 +1 @@\n-oldValue\n+newValue\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(compacted.includes("\n> src/example.ts\n"));
|
||||||
|
assertNoOutputEmoji(compacted);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("linter success output uses plain-text status markers", () => {
|
||||||
|
const compacted = compactBashOutput("npx eslint .", "");
|
||||||
|
|
||||||
|
assert.equal(compacted, "[OK] ESLint: No issues found");
|
||||||
|
assertNoOutputEmoji(compacted);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("test output uses plain-text labels and bullets", () => {
|
||||||
|
const compacted = compactBashOutput(
|
||||||
|
"bun test",
|
||||||
|
"3 passed, 1 failed, 2 skipped\nFAIL src/example.test.ts\n Expected: true\n Received: false\n\n\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(compacted.includes("Test Results:"));
|
||||||
|
assert.ok(compacted.includes("PASS: 3 passed"));
|
||||||
|
assert.ok(compacted.includes("FAIL: 1 failed"));
|
||||||
|
assert.ok(compacted.includes("SKIP: 2 skipped"));
|
||||||
|
assert.ok(compacted.includes(" - FAIL src/example.test.ts"));
|
||||||
|
assertNoOutputEmoji(compacted);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("all grep output stays intact because FFF owns search", () => {
|
||||||
|
const text = "src/a.ts [modified in git]\n 1: const match = true;\n\n[Continue with cursor=\"fff_c1\"]";
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "grep",
|
||||||
|
input: { pattern: "match" },
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
},
|
||||||
|
cloneDefaultConfig(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, false);
|
||||||
|
assert.equal(result.content, undefined);
|
||||||
|
assert.deepEqual(result.techniques, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("git diff compaction skips already-compacted RTK-shaped output", () => {
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "bash",
|
||||||
|
input: { command: "git diff -- agent/extensions/pi-mcp-adapter/package.json" },
|
||||||
|
content: [{ type: "text", text: "agent/extensions/pi-mcp-adapter/package.json | 2 +-\n\n--- Changes ---\n\n> agent/extensions/pi-mcp-adapter/package.json\n @@ -38,7 +38,7 @@\n - \"@earendil-works/pi-coding-agent\": \"^0.58.1\",\n" }],
|
||||||
|
},
|
||||||
|
cloneDefaultConfig(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, false);
|
||||||
|
assert.equal(result.content, undefined);
|
||||||
|
assert.deepEqual(result.techniques, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("non-hook RTK warnings are preserved verbatim", () => {
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "bash",
|
||||||
|
input: { command: "FOO=1 rtk git status" },
|
||||||
|
content: [{ type: "text", text: "[rtk] warning: builtin filters: parse failure\n\nworking tree clean\n" }],
|
||||||
|
},
|
||||||
|
cloneDefaultConfig(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, false);
|
||||||
|
assert.equal(result.content, undefined);
|
||||||
|
assert.deepEqual(result.techniques, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("quoted hook warning text is preserved as payload", () => {
|
||||||
|
const quotedHookText = 'const warning = "No hook installed — run `rtk init -g` for automatic token savings";\n';
|
||||||
|
const result = compactToolResult(
|
||||||
|
{
|
||||||
|
toolName: "bash",
|
||||||
|
input: { command: "echo probe" },
|
||||||
|
content: [{ type: "text", text: quotedHookText }],
|
||||||
|
},
|
||||||
|
cloneDefaultConfig(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.changed, false);
|
||||||
|
assert.equal(result.content, undefined);
|
||||||
|
assert.deepEqual(result.techniques, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("All output-compactor tests passed.");
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { dirname, join, resolve, sep } from "node:path";
|
||||||
|
import {
|
||||||
|
aggregateLinterOutput,
|
||||||
|
aggregateTestOutput,
|
||||||
|
compactGitOutput,
|
||||||
|
detectLanguage,
|
||||||
|
filterBuildOutput,
|
||||||
|
filterSourceCode,
|
||||||
|
smartTruncate,
|
||||||
|
stripAnsiFast,
|
||||||
|
truncate,
|
||||||
|
} from "./techniques/index.js";
|
||||||
|
import { trackOutputSavings } from "./output-metrics.js";
|
||||||
|
import { mapTextContentBlocks, toRecord } from "./record-utils.js";
|
||||||
|
import type { RtkIntegrationConfig } from "./types.js";
|
||||||
|
|
||||||
|
interface ToolResultLikeEvent {
|
||||||
|
toolName: string;
|
||||||
|
input?: unknown;
|
||||||
|
content?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResultCompactionMetadata {
|
||||||
|
applied: boolean;
|
||||||
|
techniques: string[];
|
||||||
|
truncated: boolean;
|
||||||
|
originalCharCount: number;
|
||||||
|
compactedCharCount: number;
|
||||||
|
originalLineCount: number;
|
||||||
|
compactedLineCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResultCompactionOutcome {
|
||||||
|
changed: boolean;
|
||||||
|
content?: unknown[];
|
||||||
|
techniques: string[];
|
||||||
|
metadata?: ToolResultCompactionMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnchoredReadLine {
|
||||||
|
lineNumber: number;
|
||||||
|
content: string;
|
||||||
|
originalLine: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnchorSafeReadLine {
|
||||||
|
text: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnchorSafeReadParts {
|
||||||
|
prefixLines: string[];
|
||||||
|
anchoredLines: AnchoredReadLine[];
|
||||||
|
suffixLines: string[];
|
||||||
|
trailingNewline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOSSY_TECHNIQUE_PREFIXES = [
|
||||||
|
"build",
|
||||||
|
"test",
|
||||||
|
"git",
|
||||||
|
"linter",
|
||||||
|
"search",
|
||||||
|
"truncate",
|
||||||
|
"smart-truncate",
|
||||||
|
"source:",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const READ_EXACT_OUTPUT_LINE_THRESHOLD = 80;
|
||||||
|
const READ_COMPACTION_BANNER_PREFIX = "[RTK compacted output:";
|
||||||
|
const ANCHORED_READ_LINE_MIN_MATCHES = 2;
|
||||||
|
const ANCHORED_READ_LINE_MIN_RATIO = 0.5;
|
||||||
|
const ANCHORED_READ_LINE_SAMPLE_LIMIT = 200;
|
||||||
|
const ANCHORED_READ_LINE_PATTERNS = [
|
||||||
|
/^\s*(?:>>>|>>|[>+\-*]+)?\s*(\d+)\s*#\s*[A-Za-z0-9_-]{2,32}:(.*)$/,
|
||||||
|
/^\s*(?:>>>|>>|[>+\-*]+)?\s*(\d+)\s*:\s*[A-Za-z0-9_-]{1,32}\|(.*)$/,
|
||||||
|
/^\s*(?:>>>|>>|[>+\-*]+)?\s*(\d+)[a-z]{2}\|(.*)$/,
|
||||||
|
] as const;
|
||||||
|
const ANCHORED_READ_INFORMATIONAL_LINE_PATTERN = /^\s*(?:$|<\/?file>|\.{3}|\[[^\]]+\]|Read\s+.+:\s+\d+\s+lines\b)/;
|
||||||
|
const USER_SKILL_ROOTS = [join(getAgentDir(), "skills"), join(homedir(), ".agents", "skills")];
|
||||||
|
|
||||||
|
function normalizePathForComparison(path: string): string {
|
||||||
|
return process.platform === "win32" ? path.toLowerCase() : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathUnderRoot(targetPath: string, rootPath: string): boolean {
|
||||||
|
const normalizedTarget = normalizePathForComparison(resolve(targetPath));
|
||||||
|
const normalizedRoot = normalizePathForComparison(resolve(rootPath));
|
||||||
|
if (normalizedTarget === normalizedRoot) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootWithSeparator = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
|
||||||
|
return normalizedTarget.startsWith(rootWithSeparator);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnderAnyAncestorAgentsSkills(targetPath: string): boolean {
|
||||||
|
let currentDir = resolve(process.cwd());
|
||||||
|
while (true) {
|
||||||
|
if (isPathUnderRoot(targetPath, join(currentDir, ".agents", "skills"))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentDir = dirname(currentDir);
|
||||||
|
if (parentDir === currentDir) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDir = parentDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSkillReadPath(filePath: string): boolean {
|
||||||
|
if (!filePath.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = resolve(filePath);
|
||||||
|
if (USER_SKILL_ROOTS.some((root) => isPathUnderRoot(resolvedPath, root))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPathUnderRoot(resolvedPath, join(process.cwd(), ".pi", "skills"))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isUnderAnyAncestorAgentsSkills(resolvedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArray(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCommand(input: Record<string, unknown>): string | undefined {
|
||||||
|
const raw = input.command;
|
||||||
|
if (typeof raw === "string" && raw.trim()) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(input: Record<string, unknown>): string {
|
||||||
|
const raw = input.path;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExplicitReadRange(input: Record<string, unknown>): boolean {
|
||||||
|
return input.offset !== undefined || input.limit !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitReadLines(text: string): { lines: string[]; trailingNewline: boolean } {
|
||||||
|
if (!text) {
|
||||||
|
return { lines: [], trailingNewline: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trailingNewline = text.endsWith("\n");
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
if (trailingNewline) {
|
||||||
|
lines.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lines, trailingNewline };
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinReadLines(lines: string[], trailingNewline: boolean): string {
|
||||||
|
const joined = lines.join("\n");
|
||||||
|
return trailingNewline && joined ? `${joined}\n` : joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnchoredReadLine(line: string): AnchoredReadLine | undefined {
|
||||||
|
for (const pattern of ANCHORED_READ_LINE_PATTERNS) {
|
||||||
|
const match = line.match(pattern);
|
||||||
|
if (!match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineNumber = Number.parseInt(match[1] ?? "", 10);
|
||||||
|
if (!Number.isSafeInteger(lineNumber) || lineNumber <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = match[2] ?? "";
|
||||||
|
return {
|
||||||
|
lineNumber,
|
||||||
|
content,
|
||||||
|
originalLine: line,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnchoredReadLineNumber(line: string): number | undefined {
|
||||||
|
return parseAnchoredReadLine(line)?.lineNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeAnchoredLineOutput(text: string, parseLineNumber: (line: string) => number | undefined): boolean {
|
||||||
|
let matchCount = 0;
|
||||||
|
let relevantLineCount = 0;
|
||||||
|
let previousMatchedLineNumber: number | undefined;
|
||||||
|
let hasIncreasingAnchors = false;
|
||||||
|
|
||||||
|
for (const line of splitReadLines(text).lines.slice(0, ANCHORED_READ_LINE_SAMPLE_LIMIT)) {
|
||||||
|
if (!ANCHORED_READ_INFORMATIONAL_LINE_PATTERN.test(line)) {
|
||||||
|
relevantLineCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineNumber = parseLineNumber(line);
|
||||||
|
if (lineNumber === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchCount += 1;
|
||||||
|
if (previousMatchedLineNumber !== undefined && lineNumber > previousMatchedLineNumber) {
|
||||||
|
hasIncreasingAnchors = true;
|
||||||
|
}
|
||||||
|
previousMatchedLineNumber = lineNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchCount < ANCHORED_READ_LINE_MIN_MATCHES || !hasIncreasingAnchors) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratioBase = Math.max(relevantLineCount, matchCount);
|
||||||
|
return matchCount / ratioBase >= ANCHORED_READ_LINE_MIN_RATIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeAnchoredReadOutput(text: string): boolean {
|
||||||
|
return looksLikeAnchoredLineOutput(text, parseAnchoredReadLineNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPreserveExactReadOutput(
|
||||||
|
text: string,
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
config: RtkIntegrationConfig,
|
||||||
|
): boolean {
|
||||||
|
if (!config.outputCompaction.readCompaction.enabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasExplicitReadRange(input)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.outputCompaction.preserveExactSkillReads && isSkillReadPath(normalizePath(input))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return countLines(text) <= READ_EXACT_OUTPUT_LINE_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldApplyReadSourceFiltering(text: string, config: RtkIntegrationConfig): boolean {
|
||||||
|
const compaction = config.outputCompaction;
|
||||||
|
const lineCount = countLines(text);
|
||||||
|
|
||||||
|
return (
|
||||||
|
(compaction.smartTruncate.enabled && lineCount > compaction.smartTruncate.maxLines) ||
|
||||||
|
(compaction.truncate.enabled && text.length > compaction.truncate.maxChars)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAnchoredReadParts(text: string): AnchorSafeReadParts | undefined {
|
||||||
|
if (!looksLikeAnchoredReadOutput(text)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lines, trailingNewline } = splitReadLines(text);
|
||||||
|
const parsedLines = lines.map((line) => parseAnchoredReadLine(line));
|
||||||
|
const firstAnchorIndex = parsedLines.findIndex((line) => line !== undefined);
|
||||||
|
if (firstAnchorIndex === -1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastAnchorIndex = firstAnchorIndex;
|
||||||
|
for (let index = parsedLines.length - 1; index >= firstAnchorIndex; index -= 1) {
|
||||||
|
if (parsedLines[index] !== undefined) {
|
||||||
|
lastAnchorIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchoredLines: AnchoredReadLine[] = [];
|
||||||
|
for (let index = firstAnchorIndex; index <= lastAnchorIndex; index += 1) {
|
||||||
|
const anchoredLine = parsedLines[index];
|
||||||
|
if (!anchoredLine) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
anchoredLines.push(anchoredLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
prefixLines: lines.slice(0, firstAnchorIndex),
|
||||||
|
anchoredLines,
|
||||||
|
suffixLines: lines.slice(lastAnchorIndex + 1),
|
||||||
|
trailingNewline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAnchorSafeReadLines(anchoredLines: AnchoredReadLine[]): AnchorSafeReadLine[] {
|
||||||
|
return anchoredLines.map((line) => ({
|
||||||
|
text: line.originalLine,
|
||||||
|
content: line.content,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAnchorSafeReadBody(lines: AnchorSafeReadLine[]): string {
|
||||||
|
return lines.map((line) => line.text).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAnchorSafeReadText(parts: AnchorSafeReadParts, lines: AnchorSafeReadLine[]): string {
|
||||||
|
return joinReadLines(
|
||||||
|
[...parts.prefixLines, ...lines.map((line) => line.text), ...parts.suffixLines],
|
||||||
|
parts.trailingNewline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remapTransformedContentToAnchorSafeLines(
|
||||||
|
sourceLines: AnchorSafeReadLine[],
|
||||||
|
transformedContent: string,
|
||||||
|
): AnchorSafeReadLine[] {
|
||||||
|
const transformedLines = splitReadLines(transformedContent).lines;
|
||||||
|
const remappedLines: AnchorSafeReadLine[] = [];
|
||||||
|
let searchStartIndex = 0;
|
||||||
|
|
||||||
|
for (const transformedLine of transformedLines) {
|
||||||
|
let matchedIndex = -1;
|
||||||
|
for (let index = searchStartIndex; index < sourceLines.length; index += 1) {
|
||||||
|
if (sourceLines[index]?.content === transformedLine) {
|
||||||
|
matchedIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedIndex === -1) {
|
||||||
|
remappedLines.push({
|
||||||
|
text: transformedLine,
|
||||||
|
content: transformedLine,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
remappedLines.push(sourceLines[matchedIndex]!);
|
||||||
|
searchStartIndex = matchedIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return remappedLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateAnchorSafeReadLines(lines: AnchorSafeReadLine[], maxChars: number): AnchorSafeReadLine[] {
|
||||||
|
if (renderAnchorSafeReadBody(lines).length <= maxChars) {
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
const marker = "[RTK anchor-safe truncate: remaining anchored read lines omitted to preserve complete anchors]";
|
||||||
|
const truncatedLines: AnchorSafeReadLine[] = [];
|
||||||
|
let charCount = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < lines.length; index += 1) {
|
||||||
|
const line = lines[index]!;
|
||||||
|
const separatorLength = truncatedLines.length > 0 ? 1 : 0;
|
||||||
|
const nextCharCount = charCount + separatorLength + line.text.length;
|
||||||
|
const remainingAfter = lines.length - index - 1;
|
||||||
|
const markerLength = remainingAfter > 0 ? (nextCharCount > 0 ? 1 : 0) + marker.length : 0;
|
||||||
|
|
||||||
|
if (nextCharCount + markerLength > maxChars) {
|
||||||
|
const markerLine = { text: marker, content: marker };
|
||||||
|
return truncatedLines.length > 0 ? [...truncatedLines, markerLine] : [markerLine];
|
||||||
|
}
|
||||||
|
|
||||||
|
truncatedLines.push(line);
|
||||||
|
charCount = nextCharCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return truncatedLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactAnchoredReadText(
|
||||||
|
text: string,
|
||||||
|
filePath: string,
|
||||||
|
config: RtkIntegrationConfig,
|
||||||
|
): { text: string; techniques: string[] } {
|
||||||
|
const parts = extractAnchoredReadParts(text);
|
||||||
|
if (!parts) {
|
||||||
|
return { text, techniques: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let lines = toAnchorSafeReadLines(parts.anchoredLines);
|
||||||
|
const techniques: string[] = [];
|
||||||
|
const compaction = config.outputCompaction;
|
||||||
|
const language = detectLanguage(filePath);
|
||||||
|
|
||||||
|
if (
|
||||||
|
compaction.sourceCodeFilteringEnabled &&
|
||||||
|
compaction.sourceCodeFiltering !== "none" &&
|
||||||
|
shouldApplyReadSourceFiltering(text, config)
|
||||||
|
) {
|
||||||
|
const currentSource = lines.map((line) => line.content).join("\n");
|
||||||
|
const filtered = normalizeTechniqueResult(
|
||||||
|
filterSourceCode(currentSource, language, compaction.sourceCodeFiltering),
|
||||||
|
currentSource,
|
||||||
|
);
|
||||||
|
const filteredLines = remapTransformedContentToAnchorSafeLines(lines, filtered);
|
||||||
|
if (renderAnchorSafeReadBody(filteredLines) !== renderAnchorSafeReadBody(lines)) {
|
||||||
|
lines = filteredLines;
|
||||||
|
techniques.push(`source:${compaction.sourceCodeFiltering}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compaction.smartTruncate.enabled && lines.length > compaction.smartTruncate.maxLines) {
|
||||||
|
const currentSource = lines.map((line) => line.content).join("\n");
|
||||||
|
const compacted = smartTruncate(currentSource, compaction.smartTruncate.maxLines, language);
|
||||||
|
const compactedLines = remapTransformedContentToAnchorSafeLines(lines, compacted);
|
||||||
|
if (renderAnchorSafeReadBody(compactedLines) !== renderAnchorSafeReadBody(lines)) {
|
||||||
|
lines = compactedLines;
|
||||||
|
techniques.push("smart-truncate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compaction.truncate.enabled && renderAnchorSafeReadText(parts, lines).length > compaction.truncate.maxChars) {
|
||||||
|
const nonBodyOverhead = renderAnchorSafeReadText(parts, []).length;
|
||||||
|
const bodyMaxChars = Math.max(1, compaction.truncate.maxChars - nonBodyOverhead);
|
||||||
|
const truncatedLines = truncateAnchorSafeReadLines(lines, bodyMaxChars);
|
||||||
|
if (renderAnchorSafeReadBody(truncatedLines) !== renderAnchorSafeReadBody(lines)) {
|
||||||
|
lines = truncatedLines;
|
||||||
|
techniques.push("truncate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: renderAnchorSafeReadText(parts, lines),
|
||||||
|
techniques,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReadCompactionBanner(techniques: string[]): string {
|
||||||
|
return `${READ_COMPACTION_BANNER_PREFIX} ${techniques.join(", ")}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLines(text: string): number {
|
||||||
|
if (!text) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = text.endsWith("\n") ? text.slice(0, -1) : text;
|
||||||
|
if (!normalized) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.split("\n").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLossyCompaction(techniques: string[]): boolean {
|
||||||
|
return techniques.some((technique) =>
|
||||||
|
LOSSY_TECHNIQUE_PREFIXES.some((prefix) =>
|
||||||
|
prefix.endsWith(":") ? technique.startsWith(prefix) : technique === prefix,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTechniqueResult(result: string | null, currentText: string): string {
|
||||||
|
return result === null ? currentText : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompactionState {
|
||||||
|
text: string;
|
||||||
|
techniques: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips ANSI escape codes when enabled, recording the "ansi" technique on change. */
|
||||||
|
function applyAnsiStripping(state: CompactionState, compaction: RtkIntegrationConfig["outputCompaction"]): void {
|
||||||
|
if (!compaction.stripAnsi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stripped = stripAnsiFast(state.text);
|
||||||
|
if (stripped !== state.text) {
|
||||||
|
state.text = stripped;
|
||||||
|
state.techniques.push("ansi");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies hard character truncation when enabled and the threshold is exceeded. */
|
||||||
|
function applyTruncation(state: CompactionState, compaction: RtkIntegrationConfig["outputCompaction"]): void {
|
||||||
|
if (compaction.truncate.enabled && state.text.length > compaction.truncate.maxChars) {
|
||||||
|
state.text = truncate(state.text, compaction.truncate.maxChars);
|
||||||
|
state.techniques.push("truncate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a single nullable-result compaction technique: runs `transform`, keeps
|
||||||
|
* its result when it differs from the current text, and records `technique`.
|
||||||
|
* Mirrors the `normalizeTechniqueResult(...) !== current → push` idiom shared
|
||||||
|
* across the bash/read compactors.
|
||||||
|
*/
|
||||||
|
function applyNullableTechnique(
|
||||||
|
state: CompactionState,
|
||||||
|
transform: (text: string) => string | null,
|
||||||
|
technique: string,
|
||||||
|
): void {
|
||||||
|
const compacted = normalizeTechniqueResult(transform(state.text), state.text);
|
||||||
|
if (compacted !== state.text) {
|
||||||
|
state.text = compacted;
|
||||||
|
state.techniques.push(technique);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyConditionalTechnique(
|
||||||
|
state: CompactionState,
|
||||||
|
enabled: boolean,
|
||||||
|
transform: (text: string) => string | null,
|
||||||
|
technique: string,
|
||||||
|
): void {
|
||||||
|
if (enabled) {
|
||||||
|
applyNullableTechnique(state, transform, technique);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginCompaction(
|
||||||
|
text: string,
|
||||||
|
config: RtkIntegrationConfig,
|
||||||
|
): { state: CompactionState; compaction: RtkIntegrationConfig["outputCompaction"] } {
|
||||||
|
const state: CompactionState = { text, techniques: [] };
|
||||||
|
const compaction = config.outputCompaction;
|
||||||
|
applyAnsiStripping(state, compaction);
|
||||||
|
return { state, compaction };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReadCompactionBanner(state: CompactionState): void {
|
||||||
|
if (state.techniques.length > 0 && !state.text.startsWith(READ_COMPACTION_BANNER_PREFIX)) {
|
||||||
|
state.text = `${formatReadCompactionBanner(state.techniques)}\n${state.text}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactBashText(
|
||||||
|
text: string,
|
||||||
|
command: string | undefined,
|
||||||
|
config: RtkIntegrationConfig,
|
||||||
|
): { text: string; techniques: string[] } {
|
||||||
|
const { state, compaction } = beginCompaction(text, config);
|
||||||
|
|
||||||
|
applyConditionalTechnique(state, compaction.filterBuildOutput, (t) => filterBuildOutput(t, command), "build");
|
||||||
|
applyConditionalTechnique(state, compaction.aggregateTestOutput, (t) => aggregateTestOutput(t, command), "test");
|
||||||
|
applyConditionalTechnique(state, compaction.compactGitOutput, (t) => compactGitOutput(t, command), "git");
|
||||||
|
applyConditionalTechnique(state, compaction.aggregateLinterOutput, (t) => aggregateLinterOutput(t, command), "linter");
|
||||||
|
|
||||||
|
applyTruncation(state, compaction);
|
||||||
|
|
||||||
|
return { text: state.text, techniques: state.techniques };
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactReadText(
|
||||||
|
text: string,
|
||||||
|
filePath: string,
|
||||||
|
config: RtkIntegrationConfig,
|
||||||
|
preserveExactReadOutput: boolean,
|
||||||
|
): { text: string; techniques: string[] } {
|
||||||
|
if (preserveExactReadOutput) {
|
||||||
|
return { text, techniques: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { state, compaction } = beginCompaction(text, config);
|
||||||
|
|
||||||
|
if (looksLikeAnchoredReadOutput(state.text)) {
|
||||||
|
const anchored = compactAnchoredReadText(state.text, filePath, config);
|
||||||
|
state.text = anchored.text;
|
||||||
|
state.techniques.push(...anchored.techniques);
|
||||||
|
|
||||||
|
applyReadCompactionBanner(state);
|
||||||
|
|
||||||
|
return { text: state.text, techniques: state.techniques };
|
||||||
|
}
|
||||||
|
|
||||||
|
const language = detectLanguage(filePath);
|
||||||
|
// Only apply lossy source filtering when a downstream line/char safeguard would otherwise trigger.
|
||||||
|
if (
|
||||||
|
compaction.sourceCodeFilteringEnabled &&
|
||||||
|
compaction.sourceCodeFiltering !== "none" &&
|
||||||
|
shouldApplyReadSourceFiltering(text, config)
|
||||||
|
) {
|
||||||
|
applyNullableTechnique(
|
||||||
|
state,
|
||||||
|
(t) => filterSourceCode(t, language, compaction.sourceCodeFiltering),
|
||||||
|
`source:${compaction.sourceCodeFiltering}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compaction.smartTruncate.enabled) {
|
||||||
|
const lineCount = state.text.split("\n").length;
|
||||||
|
if (lineCount > compaction.smartTruncate.maxLines) {
|
||||||
|
const compacted = smartTruncate(state.text, compaction.smartTruncate.maxLines, language);
|
||||||
|
if (compacted !== state.text) {
|
||||||
|
state.text = compacted;
|
||||||
|
state.techniques.push("smart-truncate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTruncation(state, compaction);
|
||||||
|
|
||||||
|
applyReadCompactionBanner(state);
|
||||||
|
|
||||||
|
return { text: state.text, techniques: state.techniques };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compactToolResult(
|
||||||
|
event: ToolResultLikeEvent,
|
||||||
|
config: RtkIntegrationConfig,
|
||||||
|
): ToolResultCompactionOutcome {
|
||||||
|
if (!config.outputCompaction.enabled) {
|
||||||
|
return { changed: false, techniques: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = toRecord(event.input);
|
||||||
|
const sourceContent = toArray(event.content);
|
||||||
|
if (sourceContent.length === 0) {
|
||||||
|
return { changed: false, techniques: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTechniques = new Set<string>();
|
||||||
|
const originalChunks: string[] = [];
|
||||||
|
const filteredChunks: string[] = [];
|
||||||
|
|
||||||
|
const { changed, mapped: nextContent } = mapTextContentBlocks(sourceContent, (contentBlock) => {
|
||||||
|
let transformed = { text: contentBlock.text, techniques: [] as string[] };
|
||||||
|
if (event.toolName === "bash") {
|
||||||
|
transformed = compactBashText(contentBlock.text, normalizeCommand(input), config);
|
||||||
|
} else if (event.toolName === "read") {
|
||||||
|
const normalizedPath = normalizePath(input);
|
||||||
|
transformed = compactReadText(
|
||||||
|
contentBlock.text,
|
||||||
|
normalizedPath,
|
||||||
|
config,
|
||||||
|
shouldPreserveExactReadOutput(contentBlock.text, input, config),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const technique of transformed.techniques) {
|
||||||
|
allTechniques.add(technique);
|
||||||
|
}
|
||||||
|
|
||||||
|
originalChunks.push(contentBlock.text);
|
||||||
|
filteredChunks.push(transformed.text);
|
||||||
|
|
||||||
|
return transformed.text !== contentBlock.text ? transformed.text : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return { changed: false, techniques: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const techniques = Array.from(allTechniques);
|
||||||
|
const originalText = originalChunks.join("\n");
|
||||||
|
const compactedText = filteredChunks.join("\n");
|
||||||
|
|
||||||
|
if (config.outputCompaction.trackSavings) {
|
||||||
|
trackOutputSavings(originalText, compactedText, event.toolName, techniques);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: ToolResultCompactionMetadata = {
|
||||||
|
applied: true,
|
||||||
|
techniques,
|
||||||
|
truncated: hasLossyCompaction(techniques),
|
||||||
|
originalCharCount: originalText.length,
|
||||||
|
compactedCharCount: compactedText.length,
|
||||||
|
originalLineCount: countLines(originalText),
|
||||||
|
compactedLineCount: countLines(compactedText),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
content: nextContent,
|
||||||
|
techniques,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export interface OutputMetricRecord {
|
||||||
|
timestamp: string;
|
||||||
|
tool: string;
|
||||||
|
techniques: string;
|
||||||
|
originalChars: number;
|
||||||
|
filteredChars: number;
|
||||||
|
savingsPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputMetrics: OutputMetricRecord[] = [];
|
||||||
|
|
||||||
|
export function trackOutputSavings(
|
||||||
|
original: string,
|
||||||
|
filtered: string,
|
||||||
|
tool: string,
|
||||||
|
techniques: string[],
|
||||||
|
): OutputMetricRecord {
|
||||||
|
const originalChars = original.length;
|
||||||
|
const filteredChars = filtered.length;
|
||||||
|
const savingsPercent =
|
||||||
|
originalChars > 0 ? Math.round((((originalChars - filteredChars) / originalChars) * 100) * 100) / 100 : 0;
|
||||||
|
|
||||||
|
const record: OutputMetricRecord = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
tool,
|
||||||
|
techniques: techniques.join(",") || "none",
|
||||||
|
originalChars,
|
||||||
|
filteredChars,
|
||||||
|
savingsPercent,
|
||||||
|
};
|
||||||
|
|
||||||
|
outputMetrics.push(record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearOutputMetrics(): void {
|
||||||
|
outputMetrics.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOutputMetricsSummary(): string {
|
||||||
|
if (outputMetrics.length === 0) {
|
||||||
|
return "RTK output compaction metrics: no data yet.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalOriginal = outputMetrics.reduce((sum, metric) => sum + metric.originalChars, 0);
|
||||||
|
const totalFiltered = outputMetrics.reduce((sum, metric) => sum + metric.filteredChars, 0);
|
||||||
|
const totalSaved = totalOriginal - totalFiltered;
|
||||||
|
const savingsPercent = totalOriginal > 0 ? (totalSaved / totalOriginal) * 100 : 0;
|
||||||
|
|
||||||
|
const byTool = new Map<string, { count: number; originalChars: number; filteredChars: number }>();
|
||||||
|
for (const metric of outputMetrics) {
|
||||||
|
const existing = byTool.get(metric.tool) ?? { count: 0, originalChars: 0, filteredChars: 0 };
|
||||||
|
existing.count += 1;
|
||||||
|
existing.originalChars += metric.originalChars;
|
||||||
|
existing.filteredChars += metric.filteredChars;
|
||||||
|
byTool.set(metric.tool, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = "RTK output compaction metrics\n";
|
||||||
|
result += `calls=${outputMetrics.length}, saved=${totalSaved.toLocaleString()} chars (${savingsPercent.toFixed(1)}%)\n`;
|
||||||
|
|
||||||
|
for (const [tool, stats] of byTool.entries()) {
|
||||||
|
const toolSaved = stats.originalChars - stats.filteredChars;
|
||||||
|
const toolSavingsPercent = stats.originalChars > 0 ? (toolSaved / stats.originalChars) * 100 : 0;
|
||||||
|
result += `- ${tool}: ${stats.count} calls, saved ${toolSaved.toLocaleString()} chars (${toolSavingsPercent.toFixed(1)}%)\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.trimEnd();
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { runTest } from "./test-helpers.test.ts";
|
||||||
|
|
||||||
|
type PackageLockPackage = {
|
||||||
|
version?: unknown;
|
||||||
|
bin?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PackageLock = {
|
||||||
|
packages?: Record<string, PackageLockPackage>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const packageLockPath = fileURLToPath(new URL("../package-lock.json", import.meta.url));
|
||||||
|
|
||||||
|
function loadPackageLock(): PackageLock {
|
||||||
|
return JSON.parse(readFileSync(packageLockPath, "utf-8")) as PackageLock;
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageEntries(): Array<[string, PackageLockPackage]> {
|
||||||
|
return Object.entries(loadPackageLock().packages ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
runTest("package-lock is install-safe and npm-idempotent", () => {
|
||||||
|
const entries = packageEntries();
|
||||||
|
const missingVersions = entries
|
||||||
|
.filter(([packagePath, metadata]) => packagePath !== "" && typeof metadata.version !== "string")
|
||||||
|
.map(([packagePath]) => packagePath);
|
||||||
|
const unnormalizedBinPaths = entries.flatMap(([packagePath, metadata]) =>
|
||||||
|
Object.entries(metadata.bin ?? {})
|
||||||
|
.filter(([, binPath]) => typeof binPath === "string" && binPath.startsWith("./"))
|
||||||
|
.map(([binName, binPath]) => `${packagePath}:${binName}=${binPath}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
{ missingVersions, unnormalizedBinPaths },
|
||||||
|
{ missingVersions: [], unnormalizedBinPaths: [] },
|
||||||
|
[
|
||||||
|
"package-lock.json must be safe for npm install --omit=dev and stay unchanged after install.",
|
||||||
|
"Missing versions reproduce npm's Invalid Version failure.",
|
||||||
|
"Leading ./ bin paths reproduce npm lockfile normalization diffs after install.",
|
||||||
|
].join(" "),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("All package-lock integrity tests passed.");
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export function toRecord(value: unknown): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextContentBlock {
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrows an unknown tool-result content block to a text block carrying a
|
||||||
|
* string `text` payload. Shared by the compactor and the streaming sanitizer
|
||||||
|
* so both walk content blocks with one consistent guard.
|
||||||
|
*/
|
||||||
|
export function isTextContentBlock(block: unknown): block is TextContentBlock & { text: string } {
|
||||||
|
if (!block || typeof block !== "object" || Array.isArray(block)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const contentBlock = block as TextContentBlock;
|
||||||
|
return contentBlock.type === "text" && typeof contentBlock.text === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks tool-result content blocks, invoking `transform` for each text block.
|
||||||
|
* Returns the mapped content and whether any text block changed. Non-text
|
||||||
|
* blocks pass through untouched. Shared by the compactor and the streaming
|
||||||
|
* sanitizer so both walk content with one consistent loop.
|
||||||
|
*/
|
||||||
|
export function mapTextContentBlocks(
|
||||||
|
content: unknown[],
|
||||||
|
transform: (block: TextContentBlock & { text: string }) => string | null,
|
||||||
|
): { changed: boolean; mapped: unknown[] } {
|
||||||
|
let changed = false;
|
||||||
|
const mapped = content.map((block) => {
|
||||||
|
if (!isTextContentBlock(block)) {
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
const nextText = transform(block);
|
||||||
|
if (nextText === null || nextText === block.text) {
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return { ...block, text: nextText };
|
||||||
|
});
|
||||||
|
return { changed, mapped };
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { splitLeadingEnvAssignments } from "./shell-env-prefix.js";
|
||||||
|
import { advanceQuoteEscapeState, readShellChars, type QuoteEscapeState } from "./shell-quote-state.js";
|
||||||
|
|
||||||
|
interface ParsedPipeline {
|
||||||
|
segments: string[];
|
||||||
|
separators: string[];
|
||||||
|
suffix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProducerRewritePlan {
|
||||||
|
command: string;
|
||||||
|
captureStderr: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShellSafetyTarget {
|
||||||
|
environmentPrelude: string;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SINGLE_QUOTED_SHELL_VALUE_PATTERN = "'(?:'\\\\''|[^'])*'";
|
||||||
|
const SHELL_ENV_VALUE_PATTERN = `(?:"(?:\\\\.|[^"])*"|${SINGLE_QUOTED_SHELL_VALUE_PATTERN}|[^\\s;]+)`;
|
||||||
|
const LEADING_RTK_DB_PATH_EXPORT_PRELUDE_PATTERN = new RegExp(
|
||||||
|
`^(\\s*export\\s+RTK_DB_PATH=${SHELL_ENV_VALUE_PATTERN}\\s*;\\s*)([\\s\\S]*)$`,
|
||||||
|
"u",
|
||||||
|
);
|
||||||
|
|
||||||
|
function splitLeadingRtkDbPathExportPrelude(command: string): ShellSafetyTarget {
|
||||||
|
const match = command.match(LEADING_RTK_DB_PATH_EXPORT_PRELUDE_PATTERN);
|
||||||
|
if (!match) {
|
||||||
|
return { environmentPrelude: "", command };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
environmentPrelude: match[1] ?? "",
|
||||||
|
command: match[2] ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSimpleTopLevelPipeline(command: string): ParsedPipeline | null {
|
||||||
|
const segments: string[] = [];
|
||||||
|
const separators: string[] = [];
|
||||||
|
const state: QuoteEscapeState = { quote: null, escaped: false };
|
||||||
|
let segmentStart = 0;
|
||||||
|
let suffix = "";
|
||||||
|
|
||||||
|
for (let index = 0; index < command.length; index += 1) {
|
||||||
|
const { character, nextCharacter } = readShellChars(command, index);
|
||||||
|
const previousCharacter = index > 0 ? (command[index - 1] ?? "") : "";
|
||||||
|
|
||||||
|
if (advanceQuoteEscapeState(state, character, "\"'`")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(character === "|" && nextCharacter === "|") ||
|
||||||
|
(character === "&" && nextCharacter === "&") ||
|
||||||
|
character === ";"
|
||||||
|
) {
|
||||||
|
if (separators.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
segments.push(command.slice(segmentStart, index));
|
||||||
|
suffix = command.slice(index);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === "|" && previousCharacter !== ">") {
|
||||||
|
const separatorLength = nextCharacter === "&" ? 2 : 1;
|
||||||
|
segments.push(command.slice(segmentStart, index));
|
||||||
|
separators.push(command.slice(index, index + separatorLength));
|
||||||
|
segmentStart = index + separatorLength;
|
||||||
|
if (separatorLength === 2) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === "&" && nextCharacter !== ">" && previousCharacter !== ">" && previousCharacter !== "<") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (separators.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!suffix) {
|
||||||
|
segments.push(command.slice(segmentStart));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { segments, separators, suffix };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractProducerRewritePlan(segment: string, firstSeparator: string): ProducerRewritePlan | null {
|
||||||
|
const trimmed = segment.trim();
|
||||||
|
const { envPrefix, command: commandWithOptionalRedirect } = splitLeadingEnvAssignments(trimmed);
|
||||||
|
if (!/^rtk\s+/i.test(commandWithOptionalRedirect)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stderrMergeMatch = commandWithOptionalRedirect.match(/^(.*?)(?:\s+)?2>\s*&1\s*$/u);
|
||||||
|
if (stderrMergeMatch) {
|
||||||
|
const command = stderrMergeMatch[1]?.trimEnd() ?? "";
|
||||||
|
return command ? { command: `${envPrefix}${command}`.trim(), captureStderr: true } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: `${envPrefix}${commandWithOptionalRedirect}`.trim(),
|
||||||
|
captureStderr: firstSeparator === "|&",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBufferedPipelineCommand(
|
||||||
|
producer: ProducerRewritePlan,
|
||||||
|
remainder: string,
|
||||||
|
): string {
|
||||||
|
const tempFileVariable = "__pi_rtk_pipe_tmp";
|
||||||
|
const statusVariable = "__pi_rtk_pipe_status";
|
||||||
|
const producerRedirect = producer.captureStderr ? `> "$${tempFileVariable}" 2>&1` : `> "$${tempFileVariable}"`;
|
||||||
|
const cleanupTrap = `rm -f "$${tempFileVariable}"`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
"{",
|
||||||
|
`${tempFileVariable}="$(mktemp)" || exit $?;`,
|
||||||
|
`${statusVariable}=0;`,
|
||||||
|
`trap '${cleanupTrap}' EXIT HUP INT TERM;`,
|
||||||
|
`${producer.command} ${producerRedirect};`,
|
||||||
|
`${statusVariable}=$?;`,
|
||||||
|
`if [ $${statusVariable} -eq 0 ]; then (${remainder}) < "$${tempFileVariable}"; ${statusVariable}=$?; fi;`,
|
||||||
|
`exit $${statusVariable};`,
|
||||||
|
"}",
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyRewrittenCommandShellSafetyFixups(command: string, platform: string = process.platform): string {
|
||||||
|
if (platform !== "win32") {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = splitLeadingRtkDbPathExportPrelude(command);
|
||||||
|
const parsedPipeline = parseSimpleTopLevelPipeline(target.command);
|
||||||
|
if (!parsedPipeline) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
const producer = extractProducerRewritePlan(parsedPipeline.segments[0] ?? "", parsedPipeline.separators[0] ?? "");
|
||||||
|
if (!producer) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainder = parsedPipeline.segments
|
||||||
|
.slice(1)
|
||||||
|
.map((segment, index) => `${index === 0 ? "" : (parsedPipeline.separators[index] ?? "")}${segment}`)
|
||||||
|
.join("")
|
||||||
|
.trim();
|
||||||
|
if (!remainder) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = parsedPipeline.suffix ? ` ${parsedPipeline.suffix.trimStart()}` : "";
|
||||||
|
return `${target.environmentPrelude}${buildBufferedPipelineCommand(producer, remainder)}${suffix}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { splitLeadingEnvAssignments } from "./shell-env-prefix.js";
|
||||||
|
|
||||||
|
const RTK_DB_PATH_ENV_NAME = "RTK_DB_PATH";
|
||||||
|
const SINGLE_QUOTED_SHELL_VALUE_PATTERN = "'(?:'\\\\''|[^'])*'";
|
||||||
|
const SHELL_ENV_VALUE_PATTERN = `(?:"[^"]*"|${SINGLE_QUOTED_SHELL_VALUE_PATTERN}|[^\\s;]+)`;
|
||||||
|
const RTK_DB_PATH_ASSIGNMENT_PATTERN = new RegExp(
|
||||||
|
`(?:^|\\s)RTK_DB_PATH=${SHELL_ENV_VALUE_PATTERN}(?=\\s|$)`,
|
||||||
|
);
|
||||||
|
const RTK_DB_PATH_EXPORT_PATTERN = new RegExp(`^export\\s+RTK_DB_PATH=${SHELL_ENV_VALUE_PATTERN}(?=\\s*(?:;|$))`);
|
||||||
|
|
||||||
|
function resolveTemporaryDirectory(): string {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const windowsTempDir = process.env.TEMP ?? process.env.TMP;
|
||||||
|
if (windowsTempDir && windowsTempDir.trim()) {
|
||||||
|
return windowsTempDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localAppData = process.env.LOCALAPPDATA;
|
||||||
|
if (localAppData && localAppData.trim()) {
|
||||||
|
return join(localAppData, "Temp");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userProfile = process.env.USERPROFILE;
|
||||||
|
if (userProfile && userProfile.trim()) {
|
||||||
|
return join(userProfile, "AppData", "Local", "Temp");
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
|
||||||
|
if (systemRoot && systemRoot.trim()) {
|
||||||
|
return join(systemRoot, "Temp");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "C:/Windows/Temp";
|
||||||
|
}
|
||||||
|
|
||||||
|
const posixTempDir = process.env.TMPDIR ?? process.env.TMP;
|
||||||
|
if (posixTempDir && posixTempDir.trim()) {
|
||||||
|
return posixTempDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/tmp";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTemporaryRtkHistoryDbPath(): string {
|
||||||
|
return join(resolveTemporaryDirectory(), "pi-rtk-optimizer", "history.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteForShellEnv(value: string): string {
|
||||||
|
const normalizedValue = process.platform === "win32" ? value.replace(/\\/g, "/") : value;
|
||||||
|
return `'${normalizedValue.replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLeadingRtkDbPathAssignment(command: string): boolean {
|
||||||
|
const trimmed = command.trimStart();
|
||||||
|
return (
|
||||||
|
RTK_DB_PATH_ASSIGNMENT_PATTERN.test(splitLeadingEnvAssignments(trimmed).envPrefix) ||
|
||||||
|
RTK_DB_PATH_EXPORT_PATTERN.test(trimmed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasInheritedRtkDbPath(): boolean {
|
||||||
|
return Boolean(process.env[RTK_DB_PATH_ENV_NAME]?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyRtkCommandEnvironment(command: string): string {
|
||||||
|
if (!command.trim()) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasLeadingRtkDbPathAssignment(command) || hasInheritedRtkDbPath()) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `export ${RTK_DB_PATH_ENV_NAME}=${quoteForShellEnv(getTemporaryRtkHistoryDbPath())}; ${command}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
|
||||||
|
export type RtkExecutableResolverName = "where" | "which";
|
||||||
|
|
||||||
|
export interface RtkExecutableResolution {
|
||||||
|
command: string;
|
||||||
|
resolvedPath?: string;
|
||||||
|
resolver: RtkExecutableResolverName;
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolverCommand {
|
||||||
|
command: RtkExecutableResolverName;
|
||||||
|
args: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveRtkExecutableOptions {
|
||||||
|
platform?: typeof process.platform;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimResolutionDetail(value: string | undefined): string {
|
||||||
|
return (value ?? "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripWrappingQuotes(value: string): string {
|
||||||
|
if (value.length < 2) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = value[0];
|
||||||
|
const last = value[value.length - 1];
|
||||||
|
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||||
|
return value.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRtkExecutablePath(stdout: string): string | undefined {
|
||||||
|
for (const line of stdout.split(/\r?\n/)) {
|
||||||
|
const candidate = stripWrappingQuotes(line.trim());
|
||||||
|
if (candidate) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResolverCommand(platform: typeof process.platform): ResolverCommand {
|
||||||
|
if (platform === "win32") {
|
||||||
|
return { command: "where", args: ["rtk"] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command: "which", args: ["rtk"] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackResolution(resolver: RtkExecutableResolverName, warning: string): RtkExecutableResolution {
|
||||||
|
return {
|
||||||
|
command: "rtk",
|
||||||
|
resolver,
|
||||||
|
warning,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveRtkExecutable(
|
||||||
|
pi: ExtensionAPI,
|
||||||
|
options: ResolveRtkExecutableOptions = {},
|
||||||
|
): Promise<RtkExecutableResolution> {
|
||||||
|
const resolver = getResolverCommand(options.platform ?? process.platform);
|
||||||
|
const timeout = options.timeoutMs ?? 1000;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pi.exec(resolver.command, resolver.args, { timeout });
|
||||||
|
const resolvedPath = parseRtkExecutablePath(result.stdout ?? "");
|
||||||
|
if (result.code === 0 && resolvedPath) {
|
||||||
|
return {
|
||||||
|
command: resolvedPath,
|
||||||
|
resolvedPath,
|
||||||
|
resolver: resolver.command,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = trimResolutionDetail(result.stderr || result.stdout || `exit ${result.code}`);
|
||||||
|
return fallbackResolution(
|
||||||
|
resolver.command,
|
||||||
|
`rtk executable path resolution via ${resolver.command} failed${detail ? `: ${detail}` : ""}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return fallbackResolution(
|
||||||
|
resolver.command,
|
||||||
|
`rtk executable path resolution via ${resolver.command} failed: ${trimResolutionDetail(message)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { resolveRtkExecutable, type RtkExecutableResolution } from "./rtk-executable-resolver.js";
|
||||||
|
|
||||||
|
export interface RtkRewriteProviderResult {
|
||||||
|
changed: boolean;
|
||||||
|
originalCommand: string;
|
||||||
|
rewrittenCommand: string;
|
||||||
|
exitCode: number;
|
||||||
|
error?: string;
|
||||||
|
executableResolution?: RtkExecutableResolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RtkRewriteProviderOptions {
|
||||||
|
timeoutMs?: number;
|
||||||
|
resolverTimeoutMs?: number;
|
||||||
|
platform?: typeof process.platform;
|
||||||
|
executableResolution?: RtkExecutableResolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAlreadyRtk(command: string): boolean {
|
||||||
|
const trimmed = command.trimStart();
|
||||||
|
return trimmed === "rtk" || trimmed.startsWith("rtk ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptions(optionsOrTimeout: number | RtkRewriteProviderOptions): RtkRewriteProviderOptions {
|
||||||
|
if (typeof optionsOrTimeout === "number") {
|
||||||
|
return { timeoutMs: optionsOrTimeout };
|
||||||
|
}
|
||||||
|
return optionsOrTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveRtkRewrite(
|
||||||
|
pi: ExtensionAPI,
|
||||||
|
command: string,
|
||||||
|
optionsOrTimeout: number | RtkRewriteProviderOptions = {},
|
||||||
|
): Promise<RtkRewriteProviderResult> {
|
||||||
|
const options = normalizeOptions(optionsOrTimeout);
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3000;
|
||||||
|
|
||||||
|
if (!command || !command.trim()) {
|
||||||
|
return { changed: false, originalCommand: command, rewrittenCommand: command, exitCode: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAlreadyRtk(command)) {
|
||||||
|
return { changed: false, originalCommand: command, rewrittenCommand: command, exitCode: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const executableResolution =
|
||||||
|
options.executableResolution ??
|
||||||
|
(await resolveRtkExecutable(pi, {
|
||||||
|
platform: options.platform,
|
||||||
|
timeoutMs: options.resolverTimeoutMs,
|
||||||
|
}));
|
||||||
|
const result = await pi.exec(executableResolution.command, ["rewrite", command], { timeout: timeoutMs });
|
||||||
|
|
||||||
|
if (result.code === 1) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
originalCommand: command,
|
||||||
|
rewrittenCommand: command,
|
||||||
|
exitCode: 1,
|
||||||
|
executableResolution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.code === 2) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
originalCommand: command,
|
||||||
|
rewrittenCommand: command,
|
||||||
|
exitCode: 2,
|
||||||
|
error: result.stderr?.trim() || "rtk denied rewrite",
|
||||||
|
executableResolution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.code === 0 || result.code === 3) {
|
||||||
|
const rewritten = result.stdout?.trim();
|
||||||
|
if (!rewritten) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
originalCommand: command,
|
||||||
|
rewrittenCommand: command,
|
||||||
|
exitCode: result.code,
|
||||||
|
error: "rtk returned empty output",
|
||||||
|
executableResolution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (rewritten === command) {
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
originalCommand: command,
|
||||||
|
rewrittenCommand: command,
|
||||||
|
exitCode: result.code,
|
||||||
|
executableResolution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
originalCommand: command,
|
||||||
|
rewrittenCommand: rewritten,
|
||||||
|
exitCode: result.code,
|
||||||
|
executableResolution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
originalCommand: command,
|
||||||
|
rewrittenCommand: command,
|
||||||
|
exitCode: result.code,
|
||||||
|
error: `unexpected exit code ${result.code}`,
|
||||||
|
executableResolution,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
originalCommand: command,
|
||||||
|
rewrittenCommand: command,
|
||||||
|
exitCode: -1,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import {
|
||||||
|
shouldRequireRtkAvailabilityForCommandHandling,
|
||||||
|
shouldSkipCommandHandlingWhenRtkMissing,
|
||||||
|
} from "./runtime-guard.ts";
|
||||||
|
import { cloneDefaultConfig, runTest } from "./test-helpers.test.ts";
|
||||||
|
import type { RuntimeStatus } from "./types.ts";
|
||||||
|
|
||||||
|
function runtimeStatus(rtkAvailable: boolean): RuntimeStatus {
|
||||||
|
return { rtkAvailable };
|
||||||
|
}
|
||||||
|
|
||||||
|
runTest("rewrite mode still requires RTK availability when guard is enabled", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
config.commandRewritingEnabled = true;
|
||||||
|
config.mode = "rewrite";
|
||||||
|
config.guardWhenRtkMissing = true;
|
||||||
|
|
||||||
|
assert.equal(shouldRequireRtkAvailabilityForCommandHandling(config), true);
|
||||||
|
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(false)), true);
|
||||||
|
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(true)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("suggest mode uses RTK availability guard to avoid repeated missing-binary rewrite probes", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
config.commandRewritingEnabled = true;
|
||||||
|
config.mode = "suggest";
|
||||||
|
config.guardWhenRtkMissing = true;
|
||||||
|
|
||||||
|
assert.equal(shouldRequireRtkAvailabilityForCommandHandling(config), true);
|
||||||
|
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(false)), true);
|
||||||
|
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(true)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("guard disabled never blocks command handling", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
config.commandRewritingEnabled = true;
|
||||||
|
config.mode = "rewrite";
|
||||||
|
config.guardWhenRtkMissing = false;
|
||||||
|
|
||||||
|
assert.equal(shouldRequireRtkAvailabilityForCommandHandling(config), false);
|
||||||
|
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(false)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTest("disabled command rewriting never requires the RTK binary", () => {
|
||||||
|
const config = cloneDefaultConfig();
|
||||||
|
config.commandRewritingEnabled = false;
|
||||||
|
config.guardWhenRtkMissing = true;
|
||||||
|
|
||||||
|
assert.equal(shouldRequireRtkAvailabilityForCommandHandling(config), false);
|
||||||
|
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(false)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("All runtime-guard tests passed.");
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { RtkIntegrationConfig, RuntimeStatus } from "./types.js";
|
||||||
|
|
||||||
|
export function shouldRequireRtkAvailabilityForCommandHandling(
|
||||||
|
config: Pick<RtkIntegrationConfig, "commandRewritingEnabled" | "guardWhenRtkMissing">,
|
||||||
|
): boolean {
|
||||||
|
return config.commandRewritingEnabled && config.guardWhenRtkMissing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSkipCommandHandlingWhenRtkMissing(
|
||||||
|
config: Pick<RtkIntegrationConfig, "commandRewritingEnabled" | "guardWhenRtkMissing">,
|
||||||
|
runtimeStatus: Pick<RuntimeStatus, "rtkAvailable">,
|
||||||
|
): boolean {
|
||||||
|
return shouldRequireRtkAvailabilityForCommandHandling(config) && !runtimeStatus.rtkAvailable;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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] ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { matchesCommandPatterns } from "./command-detection.js";
|
||||||
|
|
||||||
|
interface BuildStats {
|
||||||
|
compiled: number;
|
||||||
|
errors: string[][];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILD_COMMAND_PATTERNS = [
|
||||||
|
/^cargo\s+(build|check)\b/,
|
||||||
|
/^bun\s+build\b/,
|
||||||
|
/^npm\s+run\s+build\b/,
|
||||||
|
/^yarn\s+build\b/,
|
||||||
|
/^pnpm\s+build\b/,
|
||||||
|
/^(?:npx\s+)?tsc\b/,
|
||||||
|
/^make\b/,
|
||||||
|
/^cmake\b/,
|
||||||
|
/^gradle\b/,
|
||||||
|
/^mvn\b/,
|
||||||
|
/^go\s+(build|install)\b/,
|
||||||
|
/^python\s+setup\.py\s+build\b/,
|
||||||
|
/^pip\s+install\b/,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SKIP_PATTERNS = [
|
||||||
|
/^\s*Compiling\s+/,
|
||||||
|
/^\s*Checking\s+/,
|
||||||
|
/^\s*Downloading\s+/,
|
||||||
|
/^\s*Downloaded\s+/,
|
||||||
|
/^\s*Fetching\s+/,
|
||||||
|
/^\s*Fetched\s+/,
|
||||||
|
/^\s*Updating\s+/,
|
||||||
|
/^\s*Updated\s+/,
|
||||||
|
/^\s*Building\s+/,
|
||||||
|
/^\s*Generated\s+/,
|
||||||
|
/^\s*Creating\s+/,
|
||||||
|
/^\s*Running\s+/,
|
||||||
|
];
|
||||||
|
|
||||||
|
const ERROR_START_PATTERNS = [/^error\[/, /^error:/, /^\[ERROR\]/, /^FAIL/];
|
||||||
|
const WARNING_PATTERNS = [/^warning:/, /^\[WARNING\]/, /^warn:/];
|
||||||
|
|
||||||
|
function isSkipLine(line: string): boolean {
|
||||||
|
return SKIP_PATTERNS.some((pattern) => pattern.test(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isErrorStart(line: string): boolean {
|
||||||
|
return ERROR_START_PATTERNS.some((pattern) => pattern.test(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWarning(line: string): boolean {
|
||||||
|
return WARNING_PATTERNS.some((pattern) => pattern.test(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBuildCommand(command: string | undefined | null): boolean {
|
||||||
|
return matchesCommandPatterns(command, BUILD_COMMAND_PATTERNS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterBuildOutput(output: string, command: string | undefined | null): string | null {
|
||||||
|
if (!isBuildCommand(command)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = output.split("\n");
|
||||||
|
const stats: BuildStats = {
|
||||||
|
compiled: 0,
|
||||||
|
errors: [],
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let inErrorBlock = false;
|
||||||
|
let currentError: string[] = [];
|
||||||
|
let blankCount = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.match(/^\s*(Compiling|Checking|Building)\s+/)) {
|
||||||
|
stats.compiled++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSkipLine(line)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorStart(line)) {
|
||||||
|
if (inErrorBlock && currentError.length > 0) {
|
||||||
|
stats.errors.push([...currentError]);
|
||||||
|
}
|
||||||
|
inErrorBlock = true;
|
||||||
|
currentError = [line];
|
||||||
|
blankCount = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWarning(line)) {
|
||||||
|
stats.warnings.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inErrorBlock) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.trim() === "") {
|
||||||
|
blankCount++;
|
||||||
|
if (blankCount >= 2 && currentError.length > 3) {
|
||||||
|
stats.errors.push([...currentError]);
|
||||||
|
inErrorBlock = false;
|
||||||
|
currentError = [];
|
||||||
|
} else {
|
||||||
|
currentError.push(line);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.match(/^\s/) || line.match(/^-->/)) {
|
||||||
|
currentError.push(line);
|
||||||
|
blankCount = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.errors.push([...currentError]);
|
||||||
|
inErrorBlock = false;
|
||||||
|
currentError = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inErrorBlock && currentError.length > 0) {
|
||||||
|
stats.errors.push(currentError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.errors.length === 0 && stats.warnings.length === 0) {
|
||||||
|
return `[OK] Build successful (${stats.compiled} units compiled)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: string[] = [];
|
||||||
|
|
||||||
|
if (stats.errors.length > 0) {
|
||||||
|
result.push(`[ERROR] ${stats.errors.length} error(s):`);
|
||||||
|
for (const error of stats.errors.slice(0, 5)) {
|
||||||
|
result.push(...error.slice(0, 10));
|
||||||
|
if (error.length > 10) {
|
||||||
|
result.push(" ...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stats.errors.length > 5) {
|
||||||
|
result.push(`... and ${stats.errors.length - 5} more errors`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.warnings.length > 0) {
|
||||||
|
result.push(`\n[WARN] ${stats.warnings.length} warning(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
const ENV_PREFIX_PATTERN = /^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+)\s+)*/;
|
||||||
|
const CHAIN_OPERATORS = ["&&", "||", ";", "|"] as const;
|
||||||
|
|
||||||
|
function sliceFirstSegment(command: string): string {
|
||||||
|
let cutIndex = -1;
|
||||||
|
for (const operator of CHAIN_OPERATORS) {
|
||||||
|
const index = command.indexOf(operator);
|
||||||
|
if (index === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (cutIndex === -1 || index < cutIndex) {
|
||||||
|
cutIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cutIndex === -1) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
return command.slice(0, cutIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCommandForDetection(command: string | undefined | null): string | null {
|
||||||
|
if (typeof command !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstNonEmptyLine = command
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line.length > 0);
|
||||||
|
if (!firstNonEmptyLine) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withoutEnvPrefix = firstNonEmptyLine.replace(ENV_PREFIX_PATTERN, "").trim();
|
||||||
|
if (!withoutEnvPrefix) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstSegment = sliceFirstSegment(withoutEnvPrefix).trim().toLowerCase();
|
||||||
|
return firstSegment || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesCommandPatterns(
|
||||||
|
command: string | undefined | null,
|
||||||
|
patterns: readonly RegExp[],
|
||||||
|
): boolean {
|
||||||
|
const normalized = normalizeCommandForDetection(command);
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return patterns.some((pattern) => pattern.test(normalized));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { matchesCommandPatterns, normalizeCommandForDetection } from "./command-detection.js";
|
||||||
|
import { compactPath } from "./path-utils.js";
|
||||||
|
|
||||||
|
const LINTER_COMMAND_PATTERNS = [
|
||||||
|
/^(?:pnpm\s+)?(?:npx\s+)?eslint\b/,
|
||||||
|
/^(?:npx\s+)?prettier\b/,
|
||||||
|
/^ruff\b/,
|
||||||
|
/^pylint\b/,
|
||||||
|
/^mypy\b/,
|
||||||
|
/^flake8\b/,
|
||||||
|
/^black\b/,
|
||||||
|
/^cargo\s+clippy\b/,
|
||||||
|
/^golangci-lint\b/,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface Issue {
|
||||||
|
severity: "ERROR" | "WARNING";
|
||||||
|
rule: string;
|
||||||
|
file: string;
|
||||||
|
line?: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLinterCommand(command: string | undefined | null): boolean {
|
||||||
|
return matchesCommandPatterns(command, LINTER_COMMAND_PATTERNS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLine(line: string): Issue | null {
|
||||||
|
const fileLinePattern = /^(.+):(\d+):(\d+):\s*(.+)$/;
|
||||||
|
const rustPattern = /^(error|warning):\s*(.+?)\s+at\s+(.+):(\d+):(\d+)$/;
|
||||||
|
|
||||||
|
const fileLineMatch = line.match(fileLinePattern);
|
||||||
|
if (fileLineMatch) {
|
||||||
|
const file = fileLineMatch[1] ?? "unknown";
|
||||||
|
const lineNumber = Number.parseInt(fileLineMatch[2] ?? "0", 10);
|
||||||
|
const content = fileLineMatch[4] ?? line;
|
||||||
|
const severity = /warning/i.test(content) ? "WARNING" : "ERROR";
|
||||||
|
const rule = content.match(/\[(.+?)\]$/)?.[1] ?? "unknown";
|
||||||
|
return {
|
||||||
|
severity,
|
||||||
|
rule,
|
||||||
|
file,
|
||||||
|
line: Number.isNaN(lineNumber) ? undefined : lineNumber,
|
||||||
|
message: content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rustMatch = line.match(rustPattern);
|
||||||
|
if (rustMatch) {
|
||||||
|
const severity = (rustMatch[1]?.toUpperCase() ?? "ERROR") as "ERROR" | "WARNING";
|
||||||
|
const message = rustMatch[2] ?? line;
|
||||||
|
const file = rustMatch[3] ?? "unknown";
|
||||||
|
const lineNumber = Number.parseInt(rustMatch[4] ?? "0", 10);
|
||||||
|
return {
|
||||||
|
severity,
|
||||||
|
rule: "unknown",
|
||||||
|
file,
|
||||||
|
line: Number.isNaN(lineNumber) ? undefined : lineNumber,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIssues(output: string): Issue[] {
|
||||||
|
const issues: Issue[] = [];
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
const parsed = parseLine(line);
|
||||||
|
if (parsed) {
|
||||||
|
issues.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectLinterType(command: string | undefined | null): string {
|
||||||
|
const normalized = normalizeCommandForDetection(command);
|
||||||
|
if (!normalized) {
|
||||||
|
return "Linter";
|
||||||
|
}
|
||||||
|
if (/(?:^|\s)eslint\b/.test(normalized)) return "ESLint";
|
||||||
|
if (/^ruff\b/.test(normalized)) return "Ruff";
|
||||||
|
if (/^pylint\b/.test(normalized)) return "Pylint";
|
||||||
|
if (/^mypy\b/.test(normalized)) return "MyPy";
|
||||||
|
if (/^flake8\b/.test(normalized)) return "Flake8";
|
||||||
|
if (/clippy\b/.test(normalized)) return "Clippy";
|
||||||
|
if (/^golangci-lint\b/.test(normalized)) return "GolangCI-Lint";
|
||||||
|
if (/prettier\b/.test(normalized)) return "Prettier";
|
||||||
|
return "Linter";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateLinterOutput(output: string, command: string | undefined | null): string | null {
|
||||||
|
if (!isLinterCommand(command)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linterType = detectLinterType(command);
|
||||||
|
const issues = parseIssues(output);
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
return `[OK] ${linterType}: No issues found`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = issues.filter((issue) => issue.severity === "ERROR").length;
|
||||||
|
const warnings = issues.filter((issue) => issue.severity === "WARNING").length;
|
||||||
|
|
||||||
|
const byRule = new Map<string, number>();
|
||||||
|
for (const issue of issues) {
|
||||||
|
byRule.set(issue.rule, (byRule.get(issue.rule) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const byFile = new Map<string, Issue[]>();
|
||||||
|
for (const issue of issues) {
|
||||||
|
const existing = byFile.get(issue.file) ?? [];
|
||||||
|
existing.push(issue);
|
||||||
|
byFile.set(issue.file, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = `${linterType}: ${errors} errors, ${warnings} warnings in ${byFile.size} files\n`;
|
||||||
|
result += "═══════════════════════════════════════\n";
|
||||||
|
|
||||||
|
result += "Top rules:\n";
|
||||||
|
const sortedRules = Array.from(byRule.entries())
|
||||||
|
.sort((left, right) => right[1] - left[1])
|
||||||
|
.slice(0, 10);
|
||||||
|
for (const [rule, count] of sortedRules) {
|
||||||
|
result += ` ${rule} (${count}x)\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += "\nTop files:\n";
|
||||||
|
const sortedFiles = Array.from(byFile.entries())
|
||||||
|
.sort((left, right) => right[1].length - left[1].length)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
for (const [file, fileIssues] of sortedFiles) {
|
||||||
|
result += ` ${compactPath(file, 40)} (${fileIssues.length} issues)\n`;
|
||||||
|
const fileRules = new Map<string, number>();
|
||||||
|
for (const issue of fileIssues) {
|
||||||
|
fileRules.set(issue.rule, (fileRules.get(issue.rule) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const topRules = Array.from(fileRules.entries())
|
||||||
|
.sort((left, right) => right[1] - left[1])
|
||||||
|
.slice(0, 3);
|
||||||
|
for (const [rule, count] of topRules) {
|
||||||
|
result += ` ${rule} (${count})\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
function detectPathSeparator(path: string): "/" | "\\" {
|
||||||
|
return path.includes("\\") && !path.includes("/") ? "\\" : "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPathPrefix(path: string, separator: "/" | "\\"): string {
|
||||||
|
if (/^[A-Za-z]:[\\/]/.test(path)) {
|
||||||
|
return `${path.slice(0, 2)}${separator}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("\\\\") || path.startsWith("//")) {
|
||||||
|
const parts = path.split(/[\\/]+/).filter((part) => part.length > 0);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return `${separator}${separator}${parts[0]}${separator}${parts[1]}${separator}`;
|
||||||
|
}
|
||||||
|
return `${separator}${separator}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("/") || path.startsWith("\\")) {
|
||||||
|
return separator;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPathSegments(prefix: string, separator: "/" | "\\", segments: string[]): string {
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return prefix || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const joined = segments.join(separator);
|
||||||
|
return prefix ? `${prefix}${joined}` : joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compactPath(path: string, maxLength: number): string {
|
||||||
|
if (path.length <= maxLength) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxLength < 2) {
|
||||||
|
return path.slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = detectPathSeparator(path);
|
||||||
|
const prefix = detectPathPrefix(path, separator);
|
||||||
|
const segments = path
|
||||||
|
.slice(prefix.length)
|
||||||
|
.split(/[\\/]+/)
|
||||||
|
.filter((segment) => segment.length > 0);
|
||||||
|
|
||||||
|
const lastSegment = segments[segments.length - 1] ?? path.slice(-(maxLength - 1));
|
||||||
|
const previousSegment = segments[segments.length - 2];
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
joinPathSegments(prefix, separator, ["…", ...(previousSegment ? [previousSegment] : []), lastSegment]),
|
||||||
|
joinPathSegments("", separator, ["…", ...(previousSegment ? [previousSegment] : []), lastSegment]),
|
||||||
|
joinPathSegments("", separator, ["…", lastSegment]),
|
||||||
|
`…${path.slice(-(maxLength - 1))}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (candidate.length <= maxLength) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `…${lastSegment.slice(-(maxLength - 1))}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { compactPath } from "./path-utils.js";
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
file: string;
|
||||||
|
lineNumber: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupSearchResults(output: string, maxResults = 50): string | null {
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
if (!line.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match = line.match(/^(.+?):(\d+)?:(.+)$/);
|
||||||
|
if (!match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.push({
|
||||||
|
file: match[1] ?? "unknown",
|
||||||
|
lineNumber: match[2] ?? "?",
|
||||||
|
content: match[3] ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byFile = new Map<string, SearchResult[]>();
|
||||||
|
for (const result of results) {
|
||||||
|
const existing = byFile.get(result.file) ?? [];
|
||||||
|
existing.push(result);
|
||||||
|
byFile.set(result.file, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputText = `${results.length} matches in ${byFile.size} files:\n\n`;
|
||||||
|
const sortedFiles = Array.from(byFile.entries()).sort((left, right) =>
|
||||||
|
left[0].localeCompare(right[0]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let shown = 0;
|
||||||
|
for (const [file, matches] of sortedFiles) {
|
||||||
|
if (shown >= maxResults) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
outputText += `> ${compactPath(file, 50)} (${matches.length} matches):\n`;
|
||||||
|
for (const match of matches.slice(0, 10)) {
|
||||||
|
let cleaned = match.content.trim();
|
||||||
|
if (cleaned.length > 70) {
|
||||||
|
cleaned = `${cleaned.slice(0, 67)}...`;
|
||||||
|
}
|
||||||
|
outputText += ` ${match.lineNumber}: ${cleaned}\n`;
|
||||||
|
shown++;
|
||||||
|
}
|
||||||
|
if (matches.length > 10) {
|
||||||
|
outputText += ` +${matches.length - 10} more\n`;
|
||||||
|
}
|
||||||
|
outputText += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length > shown) {
|
||||||
|
outputText += `... +${results.length - shown} more\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputText;
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
export type Language =
|
||||||
|
| "typescript"
|
||||||
|
| "javascript"
|
||||||
|
| "python"
|
||||||
|
| "rust"
|
||||||
|
| "go"
|
||||||
|
| "java"
|
||||||
|
| "c"
|
||||||
|
| "cpp"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
const LANGUAGE_EXTENSIONS: Record<string, Language> = {
|
||||||
|
".ts": "typescript",
|
||||||
|
".tsx": "typescript",
|
||||||
|
".js": "javascript",
|
||||||
|
".jsx": "javascript",
|
||||||
|
".mjs": "javascript",
|
||||||
|
".py": "python",
|
||||||
|
".pyw": "python",
|
||||||
|
".rs": "rust",
|
||||||
|
".go": "go",
|
||||||
|
".java": "java",
|
||||||
|
".c": "c",
|
||||||
|
".h": "c",
|
||||||
|
".cpp": "cpp",
|
||||||
|
".hpp": "cpp",
|
||||||
|
".cc": "cpp",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CommentPatterns {
|
||||||
|
line?: string;
|
||||||
|
blockStart?: string;
|
||||||
|
blockEnd?: string;
|
||||||
|
docLine?: string;
|
||||||
|
docBlockStart?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMENT_PATTERNS: Record<Language, CommentPatterns> = {
|
||||||
|
typescript: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
|
||||||
|
javascript: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
|
||||||
|
python: { line: "#", blockStart: '"""', blockEnd: '"""', docBlockStart: '"""' },
|
||||||
|
rust: { line: "//", blockStart: "/*", blockEnd: "*/", docLine: "///", docBlockStart: "/**" },
|
||||||
|
go: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
|
||||||
|
java: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
|
||||||
|
c: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
|
||||||
|
cpp: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
|
||||||
|
unknown: { line: "//", blockStart: "/*", blockEnd: "*/" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const IMPORT_PATTERN = /^(use\s+|import\s+|from\s+|require\(|#include)/;
|
||||||
|
const SIGNATURE_PATTERN = /^(pub\s+)?(async\s+)?(fn|def|function|func|class|struct|enum|trait|interface|type)\s+\w+/;
|
||||||
|
const CONST_PATTERN = /^(const|static|let|pub\s+const|pub\s+static)\s+/;
|
||||||
|
|
||||||
|
function getCodePortion(line: string, language: Language): string {
|
||||||
|
const patterns = COMMENT_PATTERNS[language];
|
||||||
|
let quote: '"' | "'" | "`" | null = null;
|
||||||
|
let escaped = false;
|
||||||
|
let code = "";
|
||||||
|
|
||||||
|
for (let index = 0; index < line.length; index += 1) {
|
||||||
|
const character = line[index] ?? "";
|
||||||
|
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote !== null) {
|
||||||
|
if (character === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (character === quote) {
|
||||||
|
quote = null;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patterns.line && line.startsWith(patterns.line, index)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patterns.blockStart && patterns.blockEnd && line.startsWith(patterns.blockStart, index)) {
|
||||||
|
const blockEndIndex = line.indexOf(patterns.blockEnd, index + patterns.blockStart.length);
|
||||||
|
if (blockEndIndex === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
index = blockEndIndex + patterns.blockEnd.length - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === '"' || character === "'" || character === "`") {
|
||||||
|
quote = character;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
code += character;
|
||||||
|
}
|
||||||
|
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countCodeBraces(line: string, language: Language): { open: number; close: number } {
|
||||||
|
let open = 0;
|
||||||
|
let close = 0;
|
||||||
|
|
||||||
|
for (const character of getCodePortion(line, language)) {
|
||||||
|
if (character === "{") {
|
||||||
|
open += 1;
|
||||||
|
} else if (character === "}") {
|
||||||
|
close += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { open, close };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectLanguage(filePath: string): Language {
|
||||||
|
const lastDot = filePath.lastIndexOf(".");
|
||||||
|
if (lastDot === -1) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
const extension = filePath.slice(lastDot).toLowerCase();
|
||||||
|
return LANGUAGE_EXTENSIONS[extension] ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterMinimal(content: string, language: Language): string {
|
||||||
|
const patterns = COMMENT_PATTERNS[language];
|
||||||
|
const lines = content.split("\n");
|
||||||
|
const result: string[] = [];
|
||||||
|
let inBlockComment = false;
|
||||||
|
let inDocstring = false;
|
||||||
|
let inUserscriptMetadataBlock = false;
|
||||||
|
const userscriptMetadataStartPattern = /^\/\/\s*==\s*userscript\s*==$/i;
|
||||||
|
const userscriptMetadataContentPattern = /^\/\/\s*@\w+/;
|
||||||
|
const userscriptMetadataEndPattern = /^\/\/\s*==\s*\/userscript\s*==$/i;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
const isUserscriptMetadataStart = userscriptMetadataStartPattern.test(trimmed);
|
||||||
|
const isUserscriptMetadataContent = userscriptMetadataContentPattern.test(trimmed);
|
||||||
|
const isUserscriptMetadataEnd = userscriptMetadataEndPattern.test(trimmed);
|
||||||
|
|
||||||
|
if (isUserscriptMetadataStart) {
|
||||||
|
inUserscriptMetadataBlock = true;
|
||||||
|
result.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inUserscriptMetadataBlock) {
|
||||||
|
result.push(line);
|
||||||
|
if (isUserscriptMetadataEnd) {
|
||||||
|
inUserscriptMetadataBlock = false;
|
||||||
|
} else if (isUserscriptMetadataContent) {
|
||||||
|
// Preserve metadata key/value lines (e.g. // @name) within the userscript block.
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patterns.blockStart && patterns.blockEnd) {
|
||||||
|
if (
|
||||||
|
!inDocstring &&
|
||||||
|
trimmed.includes(patterns.blockStart) &&
|
||||||
|
!(patterns.docBlockStart && trimmed.startsWith(patterns.docBlockStart))
|
||||||
|
) {
|
||||||
|
inBlockComment = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inBlockComment) {
|
||||||
|
if (trimmed.includes(patterns.blockEnd)) {
|
||||||
|
inBlockComment = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (language === "python" && trimmed.startsWith('"""')) {
|
||||||
|
inDocstring = !inDocstring;
|
||||||
|
result.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inDocstring) {
|
||||||
|
result.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patterns.line && trimmed.startsWith(patterns.line)) {
|
||||||
|
if (patterns.docLine && trimmed.startsWith(patterns.docLine)) {
|
||||||
|
result.push(line);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
result.push("");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
.join("\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterAggressive(content: string, language: Language): string {
|
||||||
|
const minimal = filterMinimal(content, language);
|
||||||
|
const lines = minimal.split("\n");
|
||||||
|
const result: string[] = [];
|
||||||
|
let braceDepth = 0;
|
||||||
|
let inImplementation = false;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (IMPORT_PATTERN.test(trimmed)) {
|
||||||
|
result.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SIGNATURE_PATTERN.test(trimmed)) {
|
||||||
|
result.push(line);
|
||||||
|
inImplementation = true;
|
||||||
|
braceDepth = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const braces = countCodeBraces(line, language);
|
||||||
|
const codeTrimmed = getCodePortion(line, language).trim();
|
||||||
|
|
||||||
|
if (inImplementation) {
|
||||||
|
braceDepth += braces.open;
|
||||||
|
braceDepth -= braces.close;
|
||||||
|
|
||||||
|
if (braceDepth <= 1 && (codeTrimmed === "{" || codeTrimmed === "}" || codeTrimmed.endsWith("{"))) {
|
||||||
|
result.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (braceDepth <= 0) {
|
||||||
|
inImplementation = false;
|
||||||
|
if (trimmed.length > 0 && trimmed !== "}") {
|
||||||
|
result.push(" // ... implementation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CONST_PATTERN.test(trimmed)) {
|
||||||
|
result.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join("\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function smartTruncate(content: string, maxLines: number, _language: Language): string {
|
||||||
|
const lines = content.split("\n");
|
||||||
|
if (lines.length <= maxLines) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: string[] = [];
|
||||||
|
let keptLines = 0;
|
||||||
|
let skippedSection = false;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
const isImportant =
|
||||||
|
SIGNATURE_PATTERN.test(trimmed) ||
|
||||||
|
IMPORT_PATTERN.test(trimmed) ||
|
||||||
|
trimmed.startsWith("pub ") ||
|
||||||
|
trimmed.startsWith("export ") ||
|
||||||
|
trimmed === "}" ||
|
||||||
|
trimmed === "{";
|
||||||
|
|
||||||
|
if (isImportant || keptLines < maxLines / 2) {
|
||||||
|
if (skippedSection) {
|
||||||
|
result.push(` // ... ${lines.length - keptLines} lines omitted`);
|
||||||
|
skippedSection = false;
|
||||||
|
}
|
||||||
|
result.push(line);
|
||||||
|
keptLines += 1;
|
||||||
|
} else {
|
||||||
|
skippedSection = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keptLines >= maxLines - 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skippedSection || keptLines < lines.length) {
|
||||||
|
result.push(`// ... ${lines.length - keptLines} more lines (total: ${lines.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterSourceCode(
|
||||||
|
content: string,
|
||||||
|
language: Language,
|
||||||
|
level: "none" | "minimal" | "aggressive",
|
||||||
|
): string {
|
||||||
|
switch (level) {
|
||||||
|
case "none":
|
||||||
|
return content;
|
||||||
|
case "minimal":
|
||||||
|
return filterMinimal(content, language);
|
||||||
|
case "aggressive":
|
||||||
|
return filterAggressive(content, language);
|
||||||
|
default:
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { matchesCommandPatterns } from "./command-detection.js";
|
||||||
|
|
||||||
|
interface TestSummary {
|
||||||
|
passed: number;
|
||||||
|
failed: number;
|
||||||
|
skipped: number;
|
||||||
|
failures: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEST_COMMAND_PATTERNS = [
|
||||||
|
/^npm\s+test\b/,
|
||||||
|
/^pnpm\s+test\b/,
|
||||||
|
/^yarn\s+test\b/,
|
||||||
|
/^bun\s+test\b/,
|
||||||
|
/^cargo\s+test\b/,
|
||||||
|
/^go\s+test\b/,
|
||||||
|
/^pytest\b/,
|
||||||
|
/^python\s+-m\s+pytest\b/,
|
||||||
|
/^(?:pnpm\s+)?(?:npx\s+)?vitest\b/,
|
||||||
|
/^(?:npx\s+)?jest\b/,
|
||||||
|
/^mocha\b/,
|
||||||
|
/^ava\b/,
|
||||||
|
/^tap\b/,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TEST_RESULT_PATTERNS = [
|
||||||
|
/test result:\s*(\w+)\.\s*(\d+)\s*passed;\s*(\d+)\s*failed;/,
|
||||||
|
/(\d+)\s*passed(?:,\s*(\d+)\s*failed)?(?:,\s*(\d+)\s*skipped)?/i,
|
||||||
|
/(\d+)\s*pass(?:,\s*(\d+)\s*fail)?(?:,\s*(\d+)\s*skip)?/i,
|
||||||
|
/tests?:\s*(\d+)\s*passed(?:,\s*(\d+)\s*failed)?(?:,\s*(\d+)\s*skipped)?/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const FAILURE_START_PATTERNS = [
|
||||||
|
/^FAIL\s+/,
|
||||||
|
/^FAILED\s+/,
|
||||||
|
/^\s*●\s+/,
|
||||||
|
/^\s*✕\s+/,
|
||||||
|
/test\s+\w+\s+\.\.\.\s*FAILED/,
|
||||||
|
/thread\s+'\w+'\s+panicked/,
|
||||||
|
];
|
||||||
|
const FALLBACK_PASS_PATTERN = /(?:\b(?:ok|PASS)\b|[✓✔])/;
|
||||||
|
const FALLBACK_FAIL_PATTERN = /(?:\b(?:FAIL|fail)\b|[✗✕])/;
|
||||||
|
|
||||||
|
function isFailureStart(line: string): boolean {
|
||||||
|
return FAILURE_START_PATTERNS.some((pattern) => pattern.test(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTestStats(output: string): Partial<TestSummary> {
|
||||||
|
for (const pattern of TEST_RESULT_PATTERNS) {
|
||||||
|
const match = output.match(pattern);
|
||||||
|
if (!match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
passed: Number.parseInt(match[1] ?? "0", 10) || 0,
|
||||||
|
failed: Number.parseInt(match[2] ?? "0", 10) || 0,
|
||||||
|
skipped: Number.parseInt(match[3] ?? "0", 10) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTestCommand(command: string | undefined | null): boolean {
|
||||||
|
return matchesCommandPatterns(command, TEST_COMMAND_PATTERNS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateTestOutput(output: string, command: string | undefined | null): string | null {
|
||||||
|
if (!isTestCommand(command)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = output.split("\n");
|
||||||
|
const summary: TestSummary = {
|
||||||
|
passed: 0,
|
||||||
|
failed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failures: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = extractTestStats(output);
|
||||||
|
summary.passed = stats.passed ?? 0;
|
||||||
|
summary.failed = stats.failed ?? 0;
|
||||||
|
summary.skipped = stats.skipped ?? 0;
|
||||||
|
|
||||||
|
if (summary.passed === 0 && summary.failed === 0) {
|
||||||
|
for (const line of lines) {
|
||||||
|
if (FALLBACK_PASS_PATTERN.test(line)) {
|
||||||
|
summary.passed++;
|
||||||
|
}
|
||||||
|
if (FALLBACK_FAIL_PATTERN.test(line)) {
|
||||||
|
summary.failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.failed > 0) {
|
||||||
|
let inFailure = false;
|
||||||
|
let currentFailure: string[] = [];
|
||||||
|
let blankCount = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (isFailureStart(line)) {
|
||||||
|
if (inFailure && currentFailure.length > 0) {
|
||||||
|
summary.failures.push(currentFailure.join("\n"));
|
||||||
|
}
|
||||||
|
inFailure = true;
|
||||||
|
currentFailure = [line];
|
||||||
|
blankCount = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inFailure) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.trim() === "") {
|
||||||
|
blankCount++;
|
||||||
|
if (blankCount >= 2 && currentFailure.length > 3) {
|
||||||
|
summary.failures.push(currentFailure.join("\n"));
|
||||||
|
inFailure = false;
|
||||||
|
currentFailure = [];
|
||||||
|
} else {
|
||||||
|
currentFailure.push(line);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.match(/^\s/) || line.match(/^-/)) {
|
||||||
|
currentFailure.push(line);
|
||||||
|
blankCount = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.failures.push(currentFailure.join("\n"));
|
||||||
|
inFailure = false;
|
||||||
|
currentFailure = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inFailure && currentFailure.length > 0) {
|
||||||
|
summary.failures.push(currentFailure.join("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: string[] = ["Test Results:"];
|
||||||
|
result.push(` PASS: ${summary.passed} passed`);
|
||||||
|
if (summary.failed > 0) {
|
||||||
|
result.push(` FAIL: ${summary.failed} failed`);
|
||||||
|
}
|
||||||
|
if (summary.skipped > 0) {
|
||||||
|
result.push(` SKIP: ${summary.skipped} skipped`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.failed > 0 && summary.failures.length > 0) {
|
||||||
|
result.push("\n Failures:");
|
||||||
|
for (const failure of summary.failures.slice(0, 5)) {
|
||||||
|
const failureLines = failure.split("\n");
|
||||||
|
const firstLine = failureLines[0] ?? "";
|
||||||
|
result.push(` - ${firstLine.slice(0, 70)}${firstLine.length > 70 ? "..." : ""}`);
|
||||||
|
for (const detailLine of failureLines.slice(1, 4)) {
|
||||||
|
if (detailLine.trim()) {
|
||||||
|
result.push(` ${detailLine.slice(0, 65)}${detailLine.length > 65 ? "..." : ""}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failureLines.length > 4) {
|
||||||
|
result.push(` ... (${failureLines.length - 4} more lines)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (summary.failures.length > 5) {
|
||||||
|
result.push(` ... and ${summary.failures.length - 5} more failures`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function truncate(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxLength < 3) {
|
||||||
|
return "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${text.slice(0, maxLength - 3)}...`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { mock as nodeTestMockRaw } from "node:test";
|
||||||
|
|
||||||
|
import { DEFAULT_RTK_INTEGRATION_CONFIG, type RtkIntegrationConfig } from "./types.ts";
|
||||||
|
|
||||||
|
type TestResult = void | Promise<void>;
|
||||||
|
type MockModuleOptions = { namedExports?: Record<string, unknown>; defaultExport?: unknown };
|
||||||
|
|
||||||
|
function isPromiseLike(value: TestResult): value is Promise<void> {
|
||||||
|
return Boolean(value && typeof (value as Promise<void>).then === "function");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTest(name: string, testFn: () => TestResult): TestResult {
|
||||||
|
const result = testFn();
|
||||||
|
if (!isPromiseLike(result)) {
|
||||||
|
console.log(`[PASS] ${name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.then(() => {
|
||||||
|
console.log(`[PASS] ${name}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneDefaultConfig(): RtkIntegrationConfig {
|
||||||
|
return structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime-agnostic module-mocking helper. The tests use the node:test-shaped
|
||||||
|
// API (`mock.module(specifier, { namedExports, defaultExport })`). Bun's
|
||||||
|
// implementation of `node:test` does not expose `mock.module`, but `bun:test`
|
||||||
|
// provides an equivalent that accepts a factory function. We detect the
|
||||||
|
// capability and adapt the options form to the factory form when needed.
|
||||||
|
const nodeTestMock = nodeTestMockRaw as unknown as { module?: unknown };
|
||||||
|
|
||||||
|
let mockModuleImpl: (specifier: string, options: MockModuleOptions) => void;
|
||||||
|
|
||||||
|
if (typeof nodeTestMock.module === "function") {
|
||||||
|
mockModuleImpl = nodeTestMock.module as (specifier: string, options: MockModuleOptions) => void;
|
||||||
|
} else {
|
||||||
|
const bunTest = (await import("bun:test")) as unknown as {
|
||||||
|
mock: { module: (specifier: string, factory: () => Record<string, unknown>) => void };
|
||||||
|
};
|
||||||
|
|
||||||
|
mockModuleImpl = (specifier, options) => {
|
||||||
|
bunTest.mock.module(specifier, () => {
|
||||||
|
const moduleExports: Record<string, unknown> = {};
|
||||||
|
if (options.defaultExport !== undefined) {
|
||||||
|
moduleExports.default = options.defaultExport;
|
||||||
|
}
|
||||||
|
if (options.namedExports) {
|
||||||
|
Object.assign(moduleExports, options.namedExports);
|
||||||
|
}
|
||||||
|
return moduleExports;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mock = { module: mockModuleImpl };
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { mapTextContentBlocks, toRecord } from "./record-utils.js";
|
||||||
|
import { stripAnsiFast } from "./techniques/ansi.js";
|
||||||
|
|
||||||
|
export interface StreamingBashExecutionSanitizationResult {
|
||||||
|
changed: boolean;
|
||||||
|
result: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeStreamingBashText(text: string, _command: string | undefined | null): string {
|
||||||
|
return stripAnsiFast(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a sanitized shallow copy of streamed bash result blocks before the
|
||||||
|
* TUI renders them so RTK self-diagnostics never flash in partial or final
|
||||||
|
* tool output. The input object is not mutated.
|
||||||
|
*/
|
||||||
|
export function sanitizeStreamingBashExecutionResult(
|
||||||
|
result: unknown,
|
||||||
|
command: string | undefined | null,
|
||||||
|
): StreamingBashExecutionSanitizationResult {
|
||||||
|
const resultRecord = toRecord(result);
|
||||||
|
const sourceContent = Array.isArray(resultRecord.content)
|
||||||
|
? (resultRecord.content as unknown[])
|
||||||
|
: null;
|
||||||
|
if (!sourceContent || sourceContent.length === 0) {
|
||||||
|
return { changed: false, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { changed, mapped: nextContent } = mapTextContentBlocks(sourceContent, (block) => {
|
||||||
|
const sanitizedText = sanitizeStreamingBashText(block.text, command);
|
||||||
|
return sanitizedText !== block.text ? sanitizedText : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return { changed: false, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
result: {
|
||||||
|
...resultRecord,
|
||||||
|
content: nextContent,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
declare module "@earendil-works/pi-tui" {
|
||||||
|
export interface SettingItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
currentValue: string;
|
||||||
|
values: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutocompleteItem {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Box {
|
||||||
|
constructor(...args: unknown[]);
|
||||||
|
addChild(child: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Container {
|
||||||
|
constructor(...args: unknown[]);
|
||||||
|
addChild(child: unknown): void;
|
||||||
|
render(width: number): string[];
|
||||||
|
invalidate(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SettingsList {
|
||||||
|
constructor(...args: unknown[]);
|
||||||
|
render(width: number): string[];
|
||||||
|
invalidate(): void;
|
||||||
|
handleInput(data: string): void;
|
||||||
|
updateValue(id: string, value: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Spacer {
|
||||||
|
constructor(...args: unknown[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Text {
|
||||||
|
constructor(...args: unknown[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateToWidth(text: string, width: number, suffix?: string, pad?: boolean): string;
|
||||||
|
export function visibleWidth(text: string): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@earendil-works/pi-coding-agent" {
|
||||||
|
interface UiLike {
|
||||||
|
notify(message: string, level: "info" | "warning" | "error"): void;
|
||||||
|
custom<T>(
|
||||||
|
renderer: (
|
||||||
|
tui: { requestRender(): void },
|
||||||
|
theme: Theme,
|
||||||
|
keybindings: unknown,
|
||||||
|
done: () => void,
|
||||||
|
) => {
|
||||||
|
render(width: number): string[];
|
||||||
|
invalidate?(): void;
|
||||||
|
handleInput(data: string): void;
|
||||||
|
},
|
||||||
|
options?: Record<string, unknown>,
|
||||||
|
): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionContext {
|
||||||
|
hasUI: boolean;
|
||||||
|
cwd?: string;
|
||||||
|
ui: UiLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionCommandContext extends ExtensionContext {}
|
||||||
|
|
||||||
|
export interface ToolResultEvent {
|
||||||
|
toolName: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
content: Array<Record<string, unknown>>;
|
||||||
|
details?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BashToolCallEvent {
|
||||||
|
toolName: "bash";
|
||||||
|
input: { command: string } & Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MaybePromise<T> = T | Promise<T>;
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
fg(color: string, text: string): string;
|
||||||
|
bold(text: string): string;
|
||||||
|
getFgAnsi?(name: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentDir(): string;
|
||||||
|
export function getSettingsListTheme(): unknown;
|
||||||
|
|
||||||
|
export interface ExtensionAPI {
|
||||||
|
exec(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<{ code: number; stdout: string; stderr: string }>;
|
||||||
|
|
||||||
|
on(
|
||||||
|
eventName: "tool_call",
|
||||||
|
handler: (
|
||||||
|
event: Record<string, unknown>,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
) => MaybePromise<Record<string, unknown> | void>,
|
||||||
|
): void;
|
||||||
|
|
||||||
|
on(
|
||||||
|
eventName: "tool_result",
|
||||||
|
handler: (
|
||||||
|
event: ToolResultEvent,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
) => MaybePromise<Record<string, unknown> | void>,
|
||||||
|
): void;
|
||||||
|
|
||||||
|
on(
|
||||||
|
eventName: "before_agent_start",
|
||||||
|
handler: (
|
||||||
|
event: { systemPrompt: string },
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
) => MaybePromise<{ systemPrompt: string } | Record<string, unknown> | void>,
|
||||||
|
): void;
|
||||||
|
|
||||||
|
on(
|
||||||
|
eventName: string,
|
||||||
|
handler: (event: Record<string, unknown>, ctx: ExtensionContext) => MaybePromise<Record<string, unknown> | void>,
|
||||||
|
): void;
|
||||||
|
|
||||||
|
registerCommand(
|
||||||
|
name: string,
|
||||||
|
definition: {
|
||||||
|
description: string;
|
||||||
|
getArgumentCompletions?: (argumentPrefix: string) => Array<{ value: string; label: string; description?: string }> | null;
|
||||||
|
handler: (args: string, ctx: ExtensionCommandContext) => MaybePromise<void>;
|
||||||
|
},
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isToolCallEventType(
|
||||||
|
toolName: "bash",
|
||||||
|
event: Record<string, unknown>,
|
||||||
|
): event is BashToolCallEvent;
|
||||||
|
|
||||||
|
export function isToolCallEventType(
|
||||||
|
toolName: string,
|
||||||
|
event: Record<string, unknown>,
|
||||||
|
): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
declare module "node:assert/strict" {
|
||||||
|
const assert: {
|
||||||
|
equal(actual: unknown, expected: unknown, message?: string): void;
|
||||||
|
deepEqual(actual: unknown, expected: unknown, message?: string): void;
|
||||||
|
ok(value: unknown, message?: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default assert;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "node:test" {
|
||||||
|
export const mock: {
|
||||||
|
module(specifier: string, options: { namedExports?: Record<string, unknown>; defaultExport?: unknown }): void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "bun:test" {
|
||||||
|
export const mock: {
|
||||||
|
module(specifier: string, factory: () => Record<string, unknown>): void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const process: {
|
||||||
|
platform: string;
|
||||||
|
env: Record<string, string | undefined>;
|
||||||
|
cwd(): string;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare module "node:os" {
|
||||||
|
export function homedir(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "node:path" {
|
||||||
|
export function join(...segments: string[]): string;
|
||||||
|
export function dirname(path: string): string;
|
||||||
|
export function resolve(...segments: string[]): string;
|
||||||
|
export const sep: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "node:fs" {
|
||||||
|
export function existsSync(path: string): boolean;
|
||||||
|
export function mkdirSync(path: string, options?: { recursive?: boolean }): void;
|
||||||
|
export function readFileSync(path: string, encoding: "utf-8"): string;
|
||||||
|
export function renameSync(oldPath: string, newPath: string): void;
|
||||||
|
export function unlinkSync(path: string): void;
|
||||||
|
export function writeFileSync(path: string, data: string, encoding: "utf-8"): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
export const RTK_MODES = ["rewrite", "suggest"] as const;
|
||||||
|
export const RTK_SOURCE_FILTER_LEVELS = ["none", "minimal", "aggressive"] as const;
|
||||||
|
|
||||||
|
export type RtkMode = (typeof RTK_MODES)[number];
|
||||||
|
export type RtkSourceFilterLevel = (typeof RTK_SOURCE_FILTER_LEVELS)[number];
|
||||||
|
|
||||||
|
export interface RtkOutputCompactionConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
stripAnsi: boolean;
|
||||||
|
readCompaction: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
truncate: {
|
||||||
|
enabled: boolean;
|
||||||
|
maxChars: number;
|
||||||
|
};
|
||||||
|
sourceCodeFilteringEnabled: boolean;
|
||||||
|
preserveExactSkillReads: boolean;
|
||||||
|
sourceCodeFiltering: RtkSourceFilterLevel;
|
||||||
|
smartTruncate: {
|
||||||
|
enabled: boolean;
|
||||||
|
maxLines: number;
|
||||||
|
};
|
||||||
|
aggregateTestOutput: boolean;
|
||||||
|
filterBuildOutput: boolean;
|
||||||
|
compactGitOutput: boolean;
|
||||||
|
aggregateLinterOutput: boolean;
|
||||||
|
trackSavings: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RtkIntegrationConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
commandRewritingEnabled: boolean;
|
||||||
|
mode: RtkMode;
|
||||||
|
guardWhenRtkMissing: boolean;
|
||||||
|
showRewriteNotifications: boolean;
|
||||||
|
outputCompaction: RtkOutputCompactionConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_RTK_INTEGRATION_CONFIG: RtkIntegrationConfig = {
|
||||||
|
enabled: true,
|
||||||
|
commandRewritingEnabled: false,
|
||||||
|
mode: "rewrite",
|
||||||
|
guardWhenRtkMissing: true,
|
||||||
|
showRewriteNotifications: true,
|
||||||
|
outputCompaction: {
|
||||||
|
enabled: true,
|
||||||
|
stripAnsi: true,
|
||||||
|
readCompaction: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
truncate: {
|
||||||
|
enabled: true,
|
||||||
|
maxChars: 12_000,
|
||||||
|
},
|
||||||
|
sourceCodeFilteringEnabled: false,
|
||||||
|
preserveExactSkillReads: false,
|
||||||
|
sourceCodeFiltering: "none",
|
||||||
|
smartTruncate: {
|
||||||
|
enabled: false,
|
||||||
|
maxLines: 220,
|
||||||
|
},
|
||||||
|
aggregateTestOutput: true,
|
||||||
|
filterBuildOutput: true,
|
||||||
|
compactGitOutput: true,
|
||||||
|
aggregateLinterOutput: true,
|
||||||
|
trackSavings: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ConfigLoadResult {
|
||||||
|
config: RtkIntegrationConfig;
|
||||||
|
warning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigSaveResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnsureConfigResult {
|
||||||
|
created: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeStatus {
|
||||||
|
rtkAvailable: boolean;
|
||||||
|
lastCheckedAt?: number;
|
||||||
|
lastError?: string;
|
||||||
|
rtkExecutablePath?: string;
|
||||||
|
rtkExecutableCommand?: string;
|
||||||
|
rtkExecutableResolver?: string;
|
||||||
|
rtkExecutableResolutionWarning?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { advanceQuoteEscapeState, readShellChars, type QuoteEscapeState } from "./shell-quote-state.js";
|
||||||
|
|
||||||
|
interface WindowsBashCompatibilityResult {
|
||||||
|
command: string;
|
||||||
|
applied: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeadingCdSlashDParse {
|
||||||
|
rawPath: string;
|
||||||
|
operator: string;
|
||||||
|
tail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PYTHON_UTF8_ENV_PREFIX = "PYTHONIOENCODING=utf-8";
|
||||||
|
|
||||||
|
function normalizeWindowsPathForBash(rawPath: string): string {
|
||||||
|
const trimmed = rawPath.trim();
|
||||||
|
const unquoted =
|
||||||
|
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||||
|
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||||
|
? trimmed.slice(1, -1)
|
||||||
|
: trimmed;
|
||||||
|
return unquoted.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteForBash(value: string): string {
|
||||||
|
const escaped = value.replace(/"/g, '\\"');
|
||||||
|
return `"${escaped}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLeadingCdSlashD(command: string): LeadingCdSlashDParse | null {
|
||||||
|
const prefixMatch = command.match(/^\s*cd\s+\/d\s+/i);
|
||||||
|
if (!prefixMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathStart = prefixMatch[0].length;
|
||||||
|
const state: QuoteEscapeState = { quote: null, escaped: false };
|
||||||
|
|
||||||
|
for (let index = pathStart; index < command.length; index += 1) {
|
||||||
|
const { character, nextCharacter } = readShellChars(command, index);
|
||||||
|
|
||||||
|
if (advanceQuoteEscapeState(state, character, "\"'")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === "&" && nextCharacter === "&") {
|
||||||
|
return {
|
||||||
|
rawPath: command.slice(pathStart, index),
|
||||||
|
operator: "&&",
|
||||||
|
tail: command.slice(index + 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === "|" && nextCharacter === "|") {
|
||||||
|
return {
|
||||||
|
rawPath: command.slice(pathStart, index),
|
||||||
|
operator: "||",
|
||||||
|
tail: command.slice(index + 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === "|" || character === ";") {
|
||||||
|
return {
|
||||||
|
rawPath: command.slice(pathStart, index),
|
||||||
|
operator: character,
|
||||||
|
tail: command.slice(index + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawPath: command.slice(pathStart),
|
||||||
|
operator: "",
|
||||||
|
tail: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteLeadingCdSlashD(command: string): { command: string; changed: boolean } {
|
||||||
|
const parsed = parseLeadingCdSlashD(command);
|
||||||
|
if (!parsed) {
|
||||||
|
return { command, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = quoteForBash(normalizeWindowsPathForBash(parsed.rawPath));
|
||||||
|
if (!parsed.operator) {
|
||||||
|
return {
|
||||||
|
command: `cd ${normalizedPath}`,
|
||||||
|
changed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: `cd ${normalizedPath} ${parsed.operator} ${parsed.tail.trimStart()}`,
|
||||||
|
changed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePythonUtf8(command: string): { command: string; changed: boolean } {
|
||||||
|
if (/\bPYTHONIOENCODING\s*=/.test(command)) {
|
||||||
|
return { command, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/(^|[;&|]\s*|&&\s*|\|\|\s*)python(?:3(?:\.\d+)?)?\b/i.test(command)) {
|
||||||
|
return { command, changed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: `${PYTHON_UTF8_ENV_PREFIX} ${command}`,
|
||||||
|
changed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyWindowsBashCompatibilityFixes(
|
||||||
|
command: string,
|
||||||
|
platform: string = process.platform,
|
||||||
|
): WindowsBashCompatibilityResult {
|
||||||
|
if (platform !== "win32") {
|
||||||
|
return { command, applied: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextCommand = command;
|
||||||
|
const applied: string[] = [];
|
||||||
|
|
||||||
|
const cdFix = rewriteLeadingCdSlashD(nextCommand);
|
||||||
|
if (cdFix.changed) {
|
||||||
|
nextCommand = cdFix.command;
|
||||||
|
applied.push("cd-/d");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pythonFix = ensurePythonUtf8(nextCommand);
|
||||||
|
if (pythonFix.changed) {
|
||||||
|
nextCommand = pythonFix.command;
|
||||||
|
applied.push("python-utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command: nextCommand, applied };
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user