Feature : RTK compress

This commit is contained in:
decolua
2026-04-22 15:36:51 +07:00
parent e1a219dba6
commit 8de9aae90c
26 changed files with 1612 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
// Port of apply_filter (rtk/src/cmds/system/pipe_cmd.rs) — catch_unwind equivalent
// On panic/error: passthrough raw output + warn to stderr
export function safeApply(fn, text) {
if (typeof fn !== "function") return text;
try {
const out = fn(text);
if (typeof out !== "string") return text;
return out;
} catch (err) {
// Rust: eprintln!("[rtk] warning: filter panicked — passing through raw output")
const name = fn.filterName || fn.name || "anonymous";
console.warn(`[rtk] warning: filter '${name}' panicked — passing through raw output: ${err?.message || err}`);
return text;
}
}

104
open-sse/rtk/autodetect.js Normal file
View File

@@ -0,0 +1,104 @@
// Port of auto_detect_filter (rtk/src/cmds/system/pipe_cmd.rs:132-188) + JS extras
// Order: git-diff → git-status → grep → find → tree → ls → search-list
// → read-numbered → dedup-log → smart-truncate → null
import { DETECT_WINDOW, READ_NUMBERED_MIN_HIT_RATIO, SMART_TRUNCATE_MIN_LINES } from "./constants.js";
import { gitDiff } from "./filters/gitDiff.js";
import { gitStatus } from "./filters/gitStatus.js";
import { grep } from "./filters/grep.js";
import { find } from "./filters/find.js";
import { dedupLog } from "./filters/dedupLog.js";
import { ls } from "./filters/ls.js";
import { tree } from "./filters/tree.js";
import { smartTruncate } from "./filters/smartTruncate.js";
import { readNumbered, READ_NUMBERED_LINE_RE } from "./filters/readNumbered.js";
import { searchList, SEARCH_LIST_HEADER_RE } from "./filters/searchList.js";
const RE_GIT_DIFF = /^diff --git /m;
const RE_GIT_DIFF_HUNK = /^@@ /m;
const RE_GIT_STATUS = /^On branch |^nothing to commit|^Changes (not |to be )|^Untracked files:/m;
const RE_PORCELAIN = /^[ MADRCU?!][ MADRCU?!] \S/m;
const RE_TREE_GLYPH = /[├└]──|│ /;
const RE_LS_ROW = /^[-dlbcps][rwx-]{9}/m;
const RE_LS_TOTAL = /^total \d+$/m;
export function autoDetectFilter(text) {
// Rust: floor_char_boundary to avoid UTF-8 split — JS .slice() by char is safe
const head = text.length > DETECT_WINDOW ? text.slice(0, DETECT_WINDOW) : text;
if (RE_GIT_DIFF.test(head) || RE_GIT_DIFF_HUNK.test(head)) return gitDiff;
if (RE_GIT_STATUS.test(head) || isMostlyPorcelain(head)) return gitStatus;
const lines = head.split("\n");
const nonEmpty = lines.filter(l => l.trim().length > 0);
// Rust grep rule: first 5 non-empty lines, ANY matches "file:number:content"
const first5 = nonEmpty.slice(0, 5);
if (first5.some(isGrepLine)) return grep;
// Rust find rule: ALL non-empty lines path-like (no ':'), >=3 lines
if (nonEmpty.length >= 3 && nonEmpty.every(isPathLike)) return find;
// Tree: contains box-drawing glyphs typical of `tree` command
if (RE_TREE_GLYPH.test(head)) return tree;
// ls -la: has "total N" header or >=3 rows starting with perms string
if (RE_LS_TOTAL.test(head) || countMatches(head, RE_LS_ROW) >= 3) return ls;
// Cursor Glob search list header
if (SEARCH_LIST_HEADER_RE.test(head)) return searchList;
// Line-numbered file dump (" N|content") — fire only if many lines match
if (lines.length >= SMART_TRUNCATE_MIN_LINES && isLineNumbered(lines)) {
return readNumbered;
}
// Fallback: dedupLog for generic multi-line noise with duplicates
if (nonEmpty.length >= 5) return dedupLog;
// Last resort: big blob with no structure — smart truncate
if (text.split("\n").length >= SMART_TRUNCATE_MIN_LINES) return smartTruncate;
return null;
}
function isGrepLine(line) {
// Rust: splitn(3, ':') → parts.len()==3 && parts[1].parse::<usize>().is_ok()
const first = line.indexOf(":");
if (first === -1) return false;
const second = line.indexOf(":", first + 1);
if (second === -1) return false;
const lineno = line.slice(first + 1, second);
return /^\d+$/.test(lineno);
}
function isPathLike(line) {
const t = line.trim();
if (t.length === 0) return false;
if (t.includes(":")) return false;
return t.startsWith(".") || t.startsWith("/") || t.includes("/");
}
function isMostlyPorcelain(head) {
const lines = head.split("\n").filter(l => l.trim());
if (lines.length < 3) return false;
const hits = lines.filter(l => RE_PORCELAIN.test(l)).length;
return hits / lines.length >= 0.6;
}
function isLineNumbered(lines) {
let hits = 0;
let nonEmpty = 0;
const sample = lines.slice(0, 100);
for (const l of sample) {
if (l.length === 0) continue;
nonEmpty++;
if (READ_NUMBERED_LINE_RE.test(l)) hits++;
}
if (nonEmpty < 5) return false;
return hits / nonEmpty >= READ_NUMBERED_MIN_HIT_RATIO;
}
function countMatches(text, re) {
const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
return (text.match(g) || []).length;
}

54
open-sse/rtk/constants.js Normal file
View File

@@ -0,0 +1,54 @@
// RTK port constants (mirror Rust defaults)
export const RAW_CAP = 10 * 1024 * 1024; // 10 MiB
export const MIN_COMPRESS_SIZE = 500; // bytes; skip tiny blobs
export const DETECT_WINDOW = 1024; // autodetect peeks first N chars
export const GIT_DIFF_HUNK_MAX_LINES = 100; // per-hunk line cap
export const GIT_DIFF_CONTEXT_KEEP = 3; // context lines around changes
export const DEDUP_LINE_MAX = 2000; // dedupLog truncation cap
// Rust pipe_cmd.rs parity caps
export const GREP_PER_FILE_MAX = 10; // match rust: matches.iter().take(10)
export const FIND_PER_DIR_MAX = 10; // match rust: files.iter().take(10)
export const FIND_TOTAL_DIR_MAX = 20; // match rust: dirs.iter().take(20)
// git status caps (rust config::limits())
export const STATUS_MAX_FILES = 10; // config::limits().status_max_files
export const STATUS_MAX_UNTRACKED = 10; // config::limits().status_max_untracked
// ls compact_ls (rtk/src/cmds/system/ls.rs)
export const LS_EXT_SUMMARY_TOP = 5; // top-N extensions in summary
export const LS_NOISE_DIRS = [
"node_modules", ".git", "target", "__pycache__",
".next", "dist", "build", ".venv", "venv",
".cache", ".idea", ".vscode", ".DS_Store"
];
// tree filter_tree_output cap (no rust cap, we add one to be safe)
export const TREE_MAX_LINES = 200;
// Cursor Glob "Result of search in '...' (total N files):" list
export const SEARCH_LIST_PER_DIR_MAX = 10;
export const SEARCH_LIST_TOTAL_DIR_MAX = 20;
// Smart truncate (port of filter.rs smart_truncate fallback)
export const SMART_TRUNCATE_HEAD = 120; // lines kept from top
export const SMART_TRUNCATE_TAIL = 60; // lines kept from bottom
export const SMART_TRUNCATE_MIN_LINES = 250; // only kick in above this
// readNumbered (files with " N|content" lines, e.g. Cursor read_file)
export const READ_NUMBERED_MIN_HIT_RATIO = 0.7;
// Filter name strings (Rust parity + JS extras)
export const FILTERS = {
GIT_DIFF: "git-diff",
GIT_STATUS: "git-status",
GIT_LOG: "git-log",
GREP: "grep",
FIND: "find",
LS: "ls",
TREE: "tree",
DEDUP_LOG: "dedup-log",
SMART_TRUNCATE: "smart-truncate",
READ_NUMBERED: "read-numbered",
SEARCH_LIST: "search-list"
};

View File

@@ -0,0 +1,44 @@
// Generic fallback: collapse consecutive duplicate lines + blank-line dedupe + hard line cap
import { DEDUP_LINE_MAX } from "../constants.js";
export function dedupLog(input) {
const lines = input.split("\n");
const out = [];
let prev = null;
let runCount = 0;
let blankStreak = 0;
const flushRun = () => {
if (prev !== null && runCount > 1) {
out.push(` ... (${runCount - 1} duplicate lines)`);
}
};
for (const line of lines) {
if (line.trim() === "") {
if (blankStreak < 1) out.push(line);
blankStreak += 1;
flushRun();
prev = null;
runCount = 0;
continue;
}
blankStreak = 0;
if (line === prev) {
runCount += 1;
continue;
}
flushRun();
out.push(line);
prev = line;
runCount = 1;
if (out.length >= DEDUP_LINE_MAX) {
out.push(`... (truncated at ${DEDUP_LINE_MAX} lines)`);
return out.join("\n");
}
}
flushRun();
return out.join("\n");
}
dedupLog.filterName = "dedup-log";

View File

@@ -0,0 +1,49 @@
// Port of find_wrapper (rtk/src/cmds/system/pipe_cmd.rs:89-128)
// Group by parent dir, show basenames, cap 10/dir and 20 dirs total
import { FIND_PER_DIR_MAX, FIND_TOTAL_DIR_MAX } from "../constants.js";
export function find(input) {
const lines = input.split("\n").filter(l => l.trim());
if (lines.length === 0) return input;
const byDir = new Map();
for (const path of lines) {
const lastSlash = path.lastIndexOf("/");
let dir;
let basename;
if (lastSlash === -1) {
dir = ".";
basename = path;
} else {
// Rust: PathBuf::from(path).parent().display() + file_name().display()
dir = path.slice(0, lastSlash) || "/";
basename = path.slice(lastSlash + 1);
}
if (!byDir.has(dir)) byDir.set(dir, []);
byDir.get(dir).push(basename);
}
// Rust: dirs.sort_by_key(|(d, _)| d.clone())
const dirs = Array.from(byDir.keys()).sort();
let out = `${lines.length} files in ${dirs.length} dirs:\n\n`;
const showDirs = dirs.slice(0, FIND_TOTAL_DIR_MAX);
for (const dir of showDirs) {
const files = byDir.get(dir);
out += `${dir}/ (${files.length}):\n`;
const showFiles = files.slice(0, FIND_PER_DIR_MAX);
for (const f of showFiles) out += ` ${f}\n`;
if (files.length > FIND_PER_DIR_MAX) {
out += ` +${files.length - FIND_PER_DIR_MAX}\n`;
}
out += "\n";
}
if (dirs.length > FIND_TOTAL_DIR_MAX) {
out += `+${dirs.length - FIND_TOTAL_DIR_MAX} more dirs\n`;
}
return out;
}
find.filterName = "find";

View File

@@ -0,0 +1,92 @@
// Port of Rust git::compact_diff (src/cmds/git/git.rs L325-413)
// Compacts unified diff: file headers, hunk-level truncation at 100 lines, +/-/context counting
import { GIT_DIFF_HUNK_MAX_LINES } from "../constants.js";
export function gitDiff(diff, maxLines = 500) {
const result = [];
let currentFile = "";
let added = 0;
let removed = 0;
let inHunk = false;
let hunkShown = 0;
let hunkSkipped = 0;
let wasTruncated = false;
const maxHunkLines = GIT_DIFF_HUNK_MAX_LINES;
const lines = diff.split("\n");
outer: for (const line of lines) {
if (line.startsWith("diff --git")) {
if (hunkSkipped > 0) {
result.push(` ... (${hunkSkipped} lines truncated)`);
wasTruncated = true;
hunkSkipped = 0;
}
if (currentFile && (added > 0 || removed > 0)) {
result.push(` +${added} -${removed}`);
}
const parts = line.split(" b/");
currentFile = parts.length > 1 ? parts.slice(1).join(" b/") : "unknown";
result.push(`\n${currentFile}`);
added = 0;
removed = 0;
inHunk = false;
hunkShown = 0;
} else if (line.startsWith("@@")) {
if (hunkSkipped > 0) {
result.push(` ... (${hunkSkipped} lines truncated)`);
wasTruncated = true;
hunkSkipped = 0;
}
inHunk = true;
hunkShown = 0;
result.push(` ${line}`);
} else if (inHunk) {
if (line.startsWith("+") && !line.startsWith("+++")) {
added += 1;
if (hunkShown < maxHunkLines) {
result.push(` ${line}`);
hunkShown += 1;
} else {
hunkSkipped += 1;
}
} else if (line.startsWith("-") && !line.startsWith("---")) {
removed += 1;
if (hunkShown < maxHunkLines) {
result.push(` ${line}`);
hunkShown += 1;
} else {
hunkSkipped += 1;
}
} else if (hunkShown < maxHunkLines && !line.startsWith("\\")) {
if (hunkShown > 0) {
result.push(` ${line}`);
hunkShown += 1;
}
}
}
if (result.length >= maxLines) {
result.push("\n... (more changes truncated)");
wasTruncated = true;
break outer;
}
}
if (hunkSkipped > 0) {
result.push(` ... (${hunkSkipped} lines truncated)`);
wasTruncated = true;
}
if (currentFile && (added > 0 || removed > 0)) {
result.push(` +${added} -${removed}`);
}
if (wasTruncated) {
result.push("[full diff: rtk git diff --no-compact]");
}
return result.join("\n");
}
gitDiff.filterName = "git-diff";

View File

@@ -0,0 +1,117 @@
// Port of git::format_status_output (rtk/src/cmds/git/git.rs:619-730)
// Output format:
// * <branch>
// + Staged: N files
// path1
// ... +K more
// ~ Modified: N files
// ? Untracked: N files
// conflicts: N files
// clean — nothing to commit
import { STATUS_MAX_FILES, STATUS_MAX_UNTRACKED } from "../constants.js";
export function gitStatus(input) {
const lines = input.split("\n");
if (lines.length === 0 || (lines.length === 1 && !lines[0].trim())) {
return "Clean working tree";
}
let branch = "";
const stagedFiles = [];
const modifiedFiles = [];
const untrackedFiles = [];
let staged = 0;
let modified = 0;
let untracked = 0;
let conflicts = 0;
for (const raw of lines) {
if (!raw.trim()) continue;
// Long-form branch detection (LLM usually sends this, not porcelain)
const longBranch = raw.match(/^On branch (\S+)/);
if (longBranch) { branch = longBranch[1]; continue; }
// Porcelain branch header: "## main...origin/main"
if (raw.startsWith("##")) { branch = raw.replace(/^##\s*/, ""); continue; }
// Porcelain status (2 chars + space + path)
if (raw.length >= 3 && /^[ MADRCU?!][ MADRCU?!] /.test(raw)) {
const x = raw[0];
const y = raw[1];
const file = raw.slice(3);
if (raw.slice(0, 2) === "??") {
untracked++;
untrackedFiles.push(file);
continue;
}
if ("MADRC".includes(x)) {
staged++;
stagedFiles.push(file);
} else if (x === "U") {
conflicts++;
}
if (y === "M" || y === "D") {
modified++;
modifiedFiles.push(file);
}
continue;
}
// Long form fallback ("modified: path", "new file: path", ...)
const longMatch = raw.match(/^\s*(modified|new file|deleted|renamed|both modified):\s+(.+)$/);
if (longMatch) {
const kind = longMatch[1];
const path = longMatch[2].trim();
if (kind === "both modified") { conflicts++; }
else if (kind === "modified" || kind === "deleted") { modified++; modifiedFiles.push(path); }
else if (kind === "new file" || kind === "renamed") { staged++; stagedFiles.push(path); }
continue;
}
// "Untracked files:" section — gather bare paths after this marker
// Handled implicitly: plain paths without markers are skipped (safer).
}
let out = "";
if (branch) out += `* ${branch}\n`;
if (staged > 0) {
out += `+ Staged: ${staged} files\n`;
for (const f of stagedFiles.slice(0, STATUS_MAX_FILES)) out += ` ${f}\n`;
if (stagedFiles.length > STATUS_MAX_FILES) {
out += ` ... +${stagedFiles.length - STATUS_MAX_FILES} more\n`;
}
}
if (modified > 0) {
out += `~ Modified: ${modified} files\n`;
for (const f of modifiedFiles.slice(0, STATUS_MAX_FILES)) out += ` ${f}\n`;
if (modifiedFiles.length > STATUS_MAX_FILES) {
out += ` ... +${modifiedFiles.length - STATUS_MAX_FILES} more\n`;
}
}
if (untracked > 0) {
out += `? Untracked: ${untracked} files\n`;
for (const f of untrackedFiles.slice(0, STATUS_MAX_UNTRACKED)) out += ` ${f}\n`;
if (untrackedFiles.length > STATUS_MAX_UNTRACKED) {
out += ` ... +${untrackedFiles.length - STATUS_MAX_UNTRACKED} more\n`;
}
}
if (conflicts > 0) {
out += `conflicts: ${conflicts} files\n`;
}
if (staged === 0 && modified === 0 && untracked === 0 && conflicts === 0) {
out += "clean — nothing to commit\n";
}
return out.replace(/\n+$/, "");
}
gitStatus.filterName = "git-status";

View File

@@ -0,0 +1,48 @@
// Port of grep_wrapper (rtk/src/cmds/system/pipe_cmd.rs:50-86)
// Input format: "file:lineno:content" — splitn(3, ':') in Rust
import { GREP_PER_FILE_MAX } from "../constants.js";
export function grep(input) {
const byFile = new Map();
let total = 0;
for (const line of input.split("\n")) {
// splitn(3, ':') — only split on first 2 colons
const first = line.indexOf(":");
if (first === -1) continue;
const second = line.indexOf(":", first + 1);
if (second === -1) continue;
const file = line.slice(0, first);
const lineNumStr = line.slice(first + 1, second);
const content = line.slice(second + 1);
// Rust: parts[1].parse::<usize>().is_ok()
if (!/^\d+$/.test(lineNumStr)) continue;
total++;
if (!byFile.has(file)) byFile.set(file, []);
byFile.get(file).push([lineNumStr, content]);
}
if (total === 0) return input;
// Rust: files.sort_by_key(|(f, _)| *f)
const files = Array.from(byFile.keys()).sort();
let out = `${total} matches in ${files.length}F:\n\n`;
for (const file of files) {
const matches = byFile.get(file);
out += `[file] ${file} (${matches.length}):\n`;
const show = matches.slice(0, GREP_PER_FILE_MAX);
for (const [lineNum, content] of show) {
// Rust: format!(" {:>4}: {}", line_num, content.trim())
out += ` ${lineNum.padStart(4)}: ${content.trim()}\n`;
}
if (matches.length > GREP_PER_FILE_MAX) {
out += ` +${matches.length - GREP_PER_FILE_MAX}\n`;
}
out += "\n";
}
return out;
}
grep.filterName = "grep";

View File

@@ -0,0 +1,79 @@
// Port of compact_ls (rtk/src/cmds/system/ls.rs:154-232)
// Input: `ls -la` style output. Output: compact "name/ (dirs)\nname size"
import { LS_EXT_SUMMARY_TOP, LS_NOISE_DIRS } from "../constants.js";
// Rust LS_DATE_RE: month + day + (year|HH:MM)
const LS_DATE_RE = /\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}\s+(\d{4}|\d{2}:\d{2})\s+/;
function humanSize(bytes) {
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)}M`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}K`;
return `${bytes}B`;
}
function parseLsLine(line) {
const m = LS_DATE_RE.exec(line);
if (!m) return null;
const name = line.slice(m.index + m[0].length);
const beforeDate = line.slice(0, m.index);
const beforeParts = beforeDate.split(/\s+/).filter(Boolean);
if (beforeParts.length < 4) return null;
const perms = beforeParts[0];
const fileType = perms.charAt(0);
// size = rightmost parseable number before the date
let size = 0;
for (let i = beforeParts.length - 1; i >= 0; i--) {
const n = Number(beforeParts[i]);
if (Number.isInteger(n) && String(n) === beforeParts[i]) { size = n; break; }
}
return { fileType, size, name };
}
export function ls(input) {
const dirs = [];
const files = []; // [name, sizeStr]
const byExt = new Map();
for (const line of input.split("\n")) {
if (line.startsWith("total ") || line.length === 0) continue;
const parsed = parseLsLine(line);
if (!parsed) continue;
if (parsed.name === "." || parsed.name === "..") continue;
// Rust ls.rs: show_all flag respected — for LLM context always skip noise
if (LS_NOISE_DIRS.includes(parsed.name)) continue;
if (parsed.fileType === "d") {
dirs.push(parsed.name);
} else if (parsed.fileType === "-" || parsed.fileType === "l") {
const dot = parsed.name.lastIndexOf(".");
const ext = dot > 0 ? parsed.name.slice(dot) : "no ext";
byExt.set(ext, (byExt.get(ext) || 0) + 1);
files.push([parsed.name, humanSize(parsed.size)]);
}
}
if (dirs.length === 0 && files.length === 0) return input;
let out = "";
for (const d of dirs) out += `${d}/\n`;
for (const [name, size] of files) out += `${name} ${size}\n`;
// Summary line (Rust port)
let summary = `\nSummary: ${files.length} files, ${dirs.length} dirs`;
if (byExt.size > 0) {
const ext = Array.from(byExt.entries()).sort((a, b) => b[1] - a[1]);
const parts = ext.slice(0, LS_EXT_SUMMARY_TOP).map(([e, c]) => `${c} ${e}`);
summary += ` (${parts.join(", ")}`;
if (ext.length > LS_EXT_SUMMARY_TOP) {
summary += `, +${ext.length - LS_EXT_SUMMARY_TOP} more`;
}
summary += ")";
}
return out + summary;
}
ls.filterName = "ls";

View File

@@ -0,0 +1,27 @@
// Handles Cursor/Codex read_file output: " 1|content\n 2|content".
// Strategy mirrors Rust filter::smart_truncate (filter.rs): keep head+tail, drop middle.
import { SMART_TRUNCATE_HEAD, SMART_TRUNCATE_TAIL, SMART_TRUNCATE_MIN_LINES } from "../constants.js";
const LINE_RE = /^\s*\d+\|/;
export function readNumbered(input) {
const lines = input.split("\n");
if (lines.length < SMART_TRUNCATE_MIN_LINES) return input;
// Count how many lines match "N|content" to verify shape (hit ratio check
// already done by autodetect; here we just truncate).
const head = lines.slice(0, SMART_TRUNCATE_HEAD);
const tail = lines.slice(lines.length - SMART_TRUNCATE_TAIL);
const cut = lines.length - head.length - tail.length;
return [
...head,
`... +${cut} lines truncated (file continues)`,
...tail
].join("\n");
}
readNumbered.filterName = "read-numbered";
// Exposed for autodetect
export const READ_NUMBERED_LINE_RE = LINE_RE;

View File

@@ -0,0 +1,52 @@
// Compact "Result of search in '...' (total N files):\n- path\n- path" output
// (Cursor Glob tool). Groups by parent dir like find, shows basenames.
import { SEARCH_LIST_PER_DIR_MAX, SEARCH_LIST_TOTAL_DIR_MAX } from "../constants.js";
const HEADER_RE = /^Result of search in '[^']*' \(total (\d+) files?\):/;
export function searchList(input) {
const lines = input.split("\n");
if (lines.length === 0) return input;
// First line must be the header (validated by autodetect too)
const header = lines[0] || "";
const rest = lines.slice(1);
const paths = [];
for (const raw of rest) {
const t = raw.trim();
if (!t.startsWith("- ")) continue;
paths.push(t.slice(2));
}
if (paths.length === 0) return input;
const byDir = new Map();
for (const p of paths) {
const slash = p.lastIndexOf("/");
const dir = slash === -1 ? "." : (p.slice(0, slash) || "/");
const name = slash === -1 ? p : p.slice(slash + 1);
if (!byDir.has(dir)) byDir.set(dir, []);
byDir.get(dir).push(name);
}
const dirs = Array.from(byDir.keys()).sort();
let out = `${header}\n${paths.length} files in ${dirs.length} dirs:\n\n`;
for (const dir of dirs.slice(0, SEARCH_LIST_TOTAL_DIR_MAX)) {
const names = byDir.get(dir);
out += `${dir}/ (${names.length}):\n`;
for (const n of names.slice(0, SEARCH_LIST_PER_DIR_MAX)) out += ` ${n}\n`;
if (names.length > SEARCH_LIST_PER_DIR_MAX) {
out += ` +${names.length - SEARCH_LIST_PER_DIR_MAX}\n`;
}
out += "\n";
}
if (dirs.length > SEARCH_LIST_TOTAL_DIR_MAX) {
out += `+${dirs.length - SEARCH_LIST_TOTAL_DIR_MAX} more dirs\n`;
}
return out.replace(/\n+$/, "");
}
searchList.filterName = "search-list";
export const SEARCH_LIST_HEADER_RE = HEADER_RE;

View File

@@ -0,0 +1,15 @@
// Port concept of filter::smart_truncate (rtk/src/core/filter.rs).
// Keep HEAD + TAIL lines, replace middle with "... +N lines truncated".
import { SMART_TRUNCATE_HEAD, SMART_TRUNCATE_TAIL, SMART_TRUNCATE_MIN_LINES } from "../constants.js";
export function smartTruncate(input) {
const lines = input.split("\n");
if (lines.length < SMART_TRUNCATE_MIN_LINES) return input;
const head = lines.slice(0, SMART_TRUNCATE_HEAD);
const tail = lines.slice(lines.length - SMART_TRUNCATE_TAIL);
const cut = lines.length - head.length - tail.length;
return [...head, `... +${cut} lines truncated`, ...tail].join("\n");
}
smartTruncate.filterName = "smart-truncate";

View File

@@ -0,0 +1,32 @@
// Port of filter_tree_output (rtk/src/cmds/system/tree.rs:65-94)
// Removes summary line (e.g. "5 directories, 23 files") and trailing blanks.
import { TREE_MAX_LINES } from "../constants.js";
export function tree(input) {
const lines = input.split("\n");
if (lines.length === 0) return input;
const filtered = [];
for (const line of lines) {
// Drop "X directories, Y files" summary
if (line.includes("director") && line.includes("file")) continue;
// Drop leading blanks
if (line.trim() === "" && filtered.length === 0) continue;
filtered.push(line);
}
// Drop trailing blanks
while (filtered.length > 0 && filtered[filtered.length - 1].trim() === "") {
filtered.pop();
}
// Cap overly long trees (JS-only safeguard; Rust has no cap)
if (filtered.length > TREE_MAX_LINES) {
const cut = filtered.length - TREE_MAX_LINES;
return filtered.slice(0, TREE_MAX_LINES).join("\n") + `\n... +${cut} more lines`;
}
return filtered.join("\n");
}
tree.filterName = "tree";

11
open-sse/rtk/flag.js Normal file
View File

@@ -0,0 +1,11 @@
// Synchronous RTK toggle cache. Updated by /api/settings PATCH handler
// and initialized from DB on server boot.
let enabled = false;
export function setRtkEnabled(value) {
enabled = Boolean(value);
}
export function isRtkEnabled() {
return enabled;
}

102
open-sse/rtk/index.js Normal file
View File

@@ -0,0 +1,102 @@
// RTK port: compress tool_result content in LLM request bodies
// Injected at the top of translateRequest (before any format translation)
import { RAW_CAP, MIN_COMPRESS_SIZE } from "./constants.js";
import { autoDetectFilter } from "./autodetect.js";
import { safeApply } from "./applyFilter.js";
import { isRtkEnabled } from "./flag.js";
export { isRtkEnabled, setRtkEnabled } from "./flag.js";
// Compress tool_result content in-place. Returns stats or null if disabled/failed.
export function compressMessages(body) {
if (!isRtkEnabled()) return null;
if (!body || !Array.isArray(body.messages)) return null;
const stats = { bytesBefore: 0, bytesAfter: 0, hits: [] };
try {
for (let i = 0; i < body.messages.length; i++) {
const msg = body.messages[i];
if (!msg) continue;
// Shape 1: OpenAI tool message — { role:"tool", content: "string" }
if (msg.role === "tool" && typeof msg.content === "string") {
msg.content = compressText(msg.content, stats, "openai-tool");
continue;
}
if (!Array.isArray(msg.content)) continue;
// Shape 1b: OpenAI tool message — { role:"tool", content:[{type:"text", text:"..."}] }
if (msg.role === "tool") {
for (let k = 0; k < msg.content.length; k++) {
const part = msg.content[k];
if (part && part.type === "text" && typeof part.text === "string") {
part.text = compressText(part.text, stats, "openai-tool-array");
}
}
continue;
}
// Shape 2/3: blocks array with tool_result entries
for (let j = 0; j < msg.content.length; j++) {
const block = msg.content[j];
if (!block || block.type !== "tool_result") continue;
if (block.is_error === true) continue; // preserve error traces
if (typeof block.content === "string") {
// Shape 2: claude string form
block.content = compressText(block.content, stats, "claude-string");
} else if (Array.isArray(block.content)) {
// Shape 3: claude array form — compress each text part
for (let k = 0; k < block.content.length; k++) {
const part = block.content[k];
if (part && part.type === "text" && typeof part.text === "string") {
part.text = compressText(part.text, stats, "claude-array");
}
}
}
}
}
} catch (e) {
console.warn("[RTK] compressMessages error:", e.message);
return null;
}
return stats;
}
function compressText(text, stats, shape) {
const bytesIn = text.length;
stats.bytesBefore += bytesIn;
if (bytesIn < MIN_COMPRESS_SIZE || bytesIn > RAW_CAP) {
stats.bytesAfter += bytesIn;
return text;
}
const fn = autoDetectFilter(text);
if (!fn) {
stats.bytesAfter += bytesIn;
return text;
}
const out = safeApply(fn, text);
// Safety: never return empty, never grow the input
if (!out || out.length === 0 || out.length >= bytesIn) {
stats.bytesAfter += bytesIn;
return text;
}
stats.bytesAfter += out.length;
stats.hits.push({ shape, filter: fn.filterName || fn.name, saved: bytesIn - out.length });
return out;
}
// Convenience: format a log line from stats
export function formatRtkLog(stats) {
if (!stats || !stats.hits || stats.hits.length === 0) return null;
const saved = stats.bytesBefore - stats.bytesAfter;
const pct = stats.bytesBefore > 0 ? ((saved / stats.bytesBefore) * 100).toFixed(1) : "0";
const filters = Array.from(new Set(stats.hits.map(h => h.filter))).join(",");
return `[RTK] saved ${saved}B / ${stats.bytesBefore}B (${pct}%) via [${filters}] hits=${stats.hits.length}`;
}

38
open-sse/rtk/registry.js Normal file
View File

@@ -0,0 +1,38 @@
import { FILTERS } from "./constants.js";
import { gitDiff } from "./filters/gitDiff.js";
import { gitStatus } from "./filters/gitStatus.js";
import { grep } from "./filters/grep.js";
import { find } from "./filters/find.js";
import { dedupLog } from "./filters/dedupLog.js";
import { ls } from "./filters/ls.js";
import { tree } from "./filters/tree.js";
import { smartTruncate } from "./filters/smartTruncate.js";
import { readNumbered } from "./filters/readNumbered.js";
import { searchList } from "./filters/searchList.js";
const REGISTRY = {
[FILTERS.GIT_DIFF]: gitDiff,
[FILTERS.GIT_STATUS]: gitStatus,
[FILTERS.GREP]: grep,
[FILTERS.FIND]: find,
[FILTERS.DEDUP_LOG]: dedupLog,
[FILTERS.LS]: ls,
[FILTERS.TREE]: tree,
[FILTERS.SMART_TRUNCATE]: smartTruncate,
[FILTERS.READ_NUMBERED]: readNumbered,
[FILTERS.SEARCH_LIST]: searchList
};
// Rust resolve_filter aliases (pipe_cmd.rs): grep|rg, find|fd
const ALIASES = {
rg: grep,
fd: find
};
export function resolveFilter(name) {
return REGISTRY[name] || ALIASES[name] || null;
}
export function allFilters() {
return REGISTRY;
}

View File

@@ -5,6 +5,7 @@ import { cloakClaudeTools } from "../utils/claudeCloaking.js";
import { filterToOpenAIFormat } from "./helpers/openaiHelper.js";
import { normalizeThinkingConfig } from "../services/provider.js";
import { AntigravityExecutor } from "../executors/antigravity.js";
import { compressMessages, formatRtkLog } from "../rtk/index.js";
// Registry for translators
const requestRegistry = new Map();
@@ -74,6 +75,13 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
ensureInitialized();
let result = body;
// RTK: compress tool_result content before any translation (shape-agnostic)
const rtkStats = compressMessages(result);
if (rtkStats) {
const line = formatRtkLog(rtkStats);
if (line) console.log(line);
}
// Strip explicit content types (opt-in via strip[] in PROVIDER_MODELS entry)
stripContentTypes(result, stripList);