mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
roadmap #10: RTK semantic command-output renderers (opt-in). Locally validated release-green (typecheck + file-size + 22 RTK/analytics tests). Integrated into release/v3.8.40.
This commit is contained in:
committed by
GitHub
parent
bc00e32b15
commit
9dc31c7e08
@@ -11,6 +11,7 @@ _In development — bullets added per PR; finalized at release._
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(mcp): `omniroute_tool_search` tool + one-line TS signatures** — new MCP tool that does lexical keyword search over every MCP tool's name/description and returns the top matches as compact one-line TypeScript signatures (~half the JSON-schema token cost), so agents discover tools on demand instead of carrying all ~88 schemas every turn. Search is ReDoS-safe (substring scoring, never `new RegExp` on the query) and deterministic; `tools/list` stays complete (no hidden tools). Adds the `read:tools` scope. Tier-1 item of the compression feature-extraction roadmap. ([#5269](https://github.com/diegosouzapw/OmniRoute/pull/5269))
|
||||
- **feat(compression): RTK semantic command-output renderers (opt-in)** — adds a second, opt-in compaction layer to the RTK engine that rewrites structured command output into a far more compact semantic form: `git diff` → file headers + `@@` hunks + changed lines only; an all-green `pytest`/`jest`/`vitest`/`eslint` run → its one-line summary; `terraform`/`tofu plan` → `Plan: +N ~M -K` plus the resource list; `kubectl`/`aws` JSON arrays → a minimal table. Each renderer is conservative (no-op when the shape doesn't match) and the integration is fail-open; the test-green renderer never collapses output that carries any failure signal. Gated by `RtkConfig.enableRenderers` (default off → zero behavioral change). Eighth item of the compression feature-extraction roadmap. ([#5268](https://github.com/diegosouzapw/OmniRoute/pull/5268))
|
||||
- **kilocode:** anonymous (no-auth) access to Kilo Code's free models, mirroring the `opencode`/`mimocode` pattern. With no Kilo account connected, requests now fall back to the gateway's anonymous tier (`Authorization: Bearer anonymous` on `api.kilo.ai/api/openrouter`) so the free models work without signup; a connected OAuth account is still used unchanged for the paid tier (#4019 — thanks @Theadd for the reference implementation)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
117
open-sse/services/compression/engines/rtk/configSchema.ts
Normal file
117
open-sse/services/compression/engines/rtk/configSchema.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { DEFAULT_RTK_CONFIG } from "../../types.ts";
|
||||
import type { EngineConfigField, EngineValidationResult } from "../types.ts";
|
||||
|
||||
export const RTK_SCHEMA: EngineConfigField[] = [
|
||||
{
|
||||
key: "intensity",
|
||||
type: "select",
|
||||
label: "Intensity",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.intensity,
|
||||
options: [
|
||||
{ value: "minimal", label: "minimal" },
|
||||
{ value: "standard", label: "standard" },
|
||||
{ value: "aggressive", label: "aggressive" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "applyToToolResults",
|
||||
type: "boolean",
|
||||
label: "Apply to tool results",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.applyToToolResults,
|
||||
},
|
||||
{
|
||||
key: "applyToAssistantMessages",
|
||||
type: "boolean",
|
||||
label: "Apply to assistant messages",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.applyToAssistantMessages,
|
||||
},
|
||||
{
|
||||
key: "applyToCodeBlocks",
|
||||
type: "boolean",
|
||||
label: "Apply to code blocks",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.applyToCodeBlocks,
|
||||
},
|
||||
{
|
||||
key: "maxLinesPerResult",
|
||||
type: "number",
|
||||
label: "Max lines per result",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.maxLinesPerResult,
|
||||
min: 0,
|
||||
max: 5000,
|
||||
},
|
||||
{
|
||||
key: "maxCharsPerResult",
|
||||
type: "number",
|
||||
label: "Max chars per result",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.maxCharsPerResult,
|
||||
min: 0,
|
||||
max: 500000,
|
||||
},
|
||||
{
|
||||
key: "deduplicateThreshold",
|
||||
type: "number",
|
||||
label: "Deduplicate threshold",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.deduplicateThreshold,
|
||||
min: 2,
|
||||
max: 100,
|
||||
},
|
||||
{
|
||||
key: "rawOutputRetention",
|
||||
type: "select",
|
||||
label: "Raw output retention",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.rawOutputRetention,
|
||||
options: [
|
||||
{ value: "never", label: "never" },
|
||||
{ value: "failures", label: "failures" },
|
||||
{ value: "always", label: "always" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "enableRenderers",
|
||||
type: "boolean",
|
||||
label: "Semantic renderers",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.enableRenderers,
|
||||
},
|
||||
];
|
||||
|
||||
export function validateRtkEngineConfig(config: Record<string, unknown>): EngineValidationResult {
|
||||
const errors: string[] = [];
|
||||
if (
|
||||
config.intensity !== undefined &&
|
||||
config.intensity !== "minimal" &&
|
||||
config.intensity !== "standard" &&
|
||||
config.intensity !== "aggressive"
|
||||
) {
|
||||
errors.push("intensity must be minimal, standard, or aggressive");
|
||||
}
|
||||
for (const key of [
|
||||
"enabled",
|
||||
"applyToToolResults",
|
||||
"applyToAssistantMessages",
|
||||
"applyToCodeBlocks",
|
||||
]) {
|
||||
if (config[key] !== undefined && typeof config[key] !== "boolean") {
|
||||
errors.push(`${key} must be a boolean`);
|
||||
}
|
||||
}
|
||||
for (const key of ["maxLinesPerResult", "maxCharsPerResult", "deduplicateThreshold"]) {
|
||||
if (config[key] !== undefined && (typeof config[key] !== "number" || config[key] < 0)) {
|
||||
errors.push(`${key} must be a non-negative number`);
|
||||
}
|
||||
}
|
||||
if (config.enabledFilters !== undefined && !Array.isArray(config.enabledFilters)) {
|
||||
errors.push("enabledFilters must be an array");
|
||||
}
|
||||
if (config.disabledFilters !== undefined && !Array.isArray(config.disabledFilters)) {
|
||||
errors.push("disabledFilters must be an array");
|
||||
}
|
||||
if (
|
||||
config.rawOutputRetention !== undefined &&
|
||||
config.rawOutputRetention !== "never" &&
|
||||
config.rawOutputRetention !== "failures" &&
|
||||
config.rawOutputRetention !== "always"
|
||||
) {
|
||||
errors.push("rawOutputRetention must be never, failures, or always");
|
||||
}
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createCompressionStats, estimateCompressionTokens } from "../../stats.ts";
|
||||
import { DEFAULT_RTK_CONFIG, type CompressionResult, type RtkConfig } from "../../types.ts";
|
||||
import type { CompressionEngine, EngineConfigField, EngineValidationResult } from "../types.ts";
|
||||
import type { CompressionEngine } from "../types.ts";
|
||||
import { detectCommandType } from "./commandDetector.ts";
|
||||
import { RTK_SCHEMA, validateRtkEngineConfig } from "./configSchema.ts";
|
||||
import { deduplicateRepeatedLines } from "./deduplicator.ts";
|
||||
import { groupSimilarLines } from "./grouper.ts";
|
||||
import { matchRtkFilter } from "./filterLoader.ts";
|
||||
@@ -9,6 +10,7 @@ import { applyLineFilter } from "./lineFilter.ts";
|
||||
import { smartTruncate } from "./smartTruncate.ts";
|
||||
import { normalizeCodeLanguage, stripCode } from "./codeStripper.ts";
|
||||
import { maybePersistRtkRawOutput, type RtkRawOutputPointer } from "./rawOutput.ts";
|
||||
import { applyRenderer } from "./renderers/index.ts";
|
||||
import { isTextBlock } from "../../messageContent.ts";
|
||||
import { adaptBodyForCompression } from "../../bodyAdapter.ts";
|
||||
import { isAnthropicToolResultBlock } from "../../toolResultCompressor.ts";
|
||||
@@ -62,115 +64,6 @@ function resolveToolMeta(
|
||||
return { command: null, skipFilters: true };
|
||||
}
|
||||
|
||||
const RTK_SCHEMA: EngineConfigField[] = [
|
||||
{
|
||||
key: "intensity",
|
||||
type: "select",
|
||||
label: "Intensity",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.intensity,
|
||||
options: [
|
||||
{ value: "minimal", label: "minimal" },
|
||||
{ value: "standard", label: "standard" },
|
||||
{ value: "aggressive", label: "aggressive" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "applyToToolResults",
|
||||
type: "boolean",
|
||||
label: "Apply to tool results",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.applyToToolResults,
|
||||
},
|
||||
{
|
||||
key: "applyToAssistantMessages",
|
||||
type: "boolean",
|
||||
label: "Apply to assistant messages",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.applyToAssistantMessages,
|
||||
},
|
||||
{
|
||||
key: "applyToCodeBlocks",
|
||||
type: "boolean",
|
||||
label: "Apply to code blocks",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.applyToCodeBlocks,
|
||||
},
|
||||
{
|
||||
key: "maxLinesPerResult",
|
||||
type: "number",
|
||||
label: "Max lines per result",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.maxLinesPerResult,
|
||||
min: 0,
|
||||
max: 5000,
|
||||
},
|
||||
{
|
||||
key: "maxCharsPerResult",
|
||||
type: "number",
|
||||
label: "Max chars per result",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.maxCharsPerResult,
|
||||
min: 0,
|
||||
max: 500000,
|
||||
},
|
||||
{
|
||||
key: "deduplicateThreshold",
|
||||
type: "number",
|
||||
label: "Deduplicate threshold",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.deduplicateThreshold,
|
||||
min: 2,
|
||||
max: 100,
|
||||
},
|
||||
{
|
||||
key: "rawOutputRetention",
|
||||
type: "select",
|
||||
label: "Raw output retention",
|
||||
defaultValue: DEFAULT_RTK_CONFIG.rawOutputRetention,
|
||||
options: [
|
||||
{ value: "never", label: "never" },
|
||||
{ value: "failures", label: "failures" },
|
||||
{ value: "always", label: "always" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function validateRtkEngineConfig(config: Record<string, unknown>): EngineValidationResult {
|
||||
const errors: string[] = [];
|
||||
if (
|
||||
config.intensity !== undefined &&
|
||||
config.intensity !== "minimal" &&
|
||||
config.intensity !== "standard" &&
|
||||
config.intensity !== "aggressive"
|
||||
) {
|
||||
errors.push("intensity must be minimal, standard, or aggressive");
|
||||
}
|
||||
for (const key of [
|
||||
"enabled",
|
||||
"applyToToolResults",
|
||||
"applyToAssistantMessages",
|
||||
"applyToCodeBlocks",
|
||||
]) {
|
||||
if (config[key] !== undefined && typeof config[key] !== "boolean") {
|
||||
errors.push(`${key} must be a boolean`);
|
||||
}
|
||||
}
|
||||
for (const key of ["maxLinesPerResult", "maxCharsPerResult", "deduplicateThreshold"]) {
|
||||
if (config[key] !== undefined && (typeof config[key] !== "number" || config[key] < 0)) {
|
||||
errors.push(`${key} must be a non-negative number`);
|
||||
}
|
||||
}
|
||||
if (config.enabledFilters !== undefined && !Array.isArray(config.enabledFilters)) {
|
||||
errors.push("enabledFilters must be an array");
|
||||
}
|
||||
if (config.disabledFilters !== undefined && !Array.isArray(config.disabledFilters)) {
|
||||
errors.push("disabledFilters must be an array");
|
||||
}
|
||||
if (
|
||||
config.rawOutputRetention !== undefined &&
|
||||
config.rawOutputRetention !== "never" &&
|
||||
config.rawOutputRetention !== "failures" &&
|
||||
config.rawOutputRetention !== "always"
|
||||
) {
|
||||
errors.push("rawOutputRetention must be never, failures, or always");
|
||||
}
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
export interface RtkProcessResult {
|
||||
text: string;
|
||||
compressed: boolean;
|
||||
@@ -366,6 +259,20 @@ export function processRtkText(
|
||||
}
|
||||
}
|
||||
|
||||
// #10: semantic renderers — opt-in via enableRenderers flag (default OFF), fail-open
|
||||
if (config.enableRenderers) {
|
||||
try {
|
||||
const rendered = applyRenderer(result, detection, config);
|
||||
if (rendered.changed) {
|
||||
result = rendered.text;
|
||||
techniquesUsed.push(`rtk-render:${rendered.renderer}`);
|
||||
rulesApplied.push(`rtk:render:${rendered.renderer}`);
|
||||
}
|
||||
} catch {
|
||||
// fail-open: renderer never brings down the request
|
||||
}
|
||||
}
|
||||
|
||||
if (config.applyToCodeBlocks) {
|
||||
let strippedCodeBlocks = 0;
|
||||
result = result.replace(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { RenderResult, CommandDetectionResult } from "./types.ts";
|
||||
import { NO_RENDER } from "./types.ts";
|
||||
|
||||
/**
|
||||
* RTK semantic renderer for `git diff` / `git show` output.
|
||||
*
|
||||
* Keeps only:
|
||||
* - `diff --git a/... b/...` file header lines
|
||||
* - `@@ ... @@` hunk headers
|
||||
* - change lines starting with `+` or `-` (but NOT `+++`/`---`)
|
||||
*
|
||||
* Everything else (context lines, index lines, mode lines, etc.) is dropped.
|
||||
* If no hunk header is found, returns no-op (input is not a real diff).
|
||||
*/
|
||||
export function renderGitDiff(text: string, _detection: CommandDetectionResult): RenderResult {
|
||||
// Guard: must have at least one hunk header
|
||||
if (!text.includes("@@ ")) {
|
||||
return NO_RENDER(text);
|
||||
}
|
||||
|
||||
const kept: string[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
if (
|
||||
line.startsWith("diff --git ") ||
|
||||
line.startsWith("@@ ")
|
||||
) {
|
||||
kept.push(line);
|
||||
} else if (/^[+-](?![+-])/.test(line)) {
|
||||
// change line (+foo or -foo) but NOT +++ or ---
|
||||
kept.push(line);
|
||||
}
|
||||
// drop: context lines (space), index lines, --- a/, +++ b/, mode lines, etc.
|
||||
}
|
||||
|
||||
const out = kept.join("\n");
|
||||
if (out === text) return NO_RENDER(text);
|
||||
|
||||
return { text: out, changed: true, renderer: "git-diff" };
|
||||
}
|
||||
50
open-sse/services/compression/engines/rtk/renderers/index.ts
Normal file
50
open-sse/services/compression/engines/rtk/renderers/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { CommandDetectionResult } from "../commandDetector.ts";
|
||||
import type { RtkConfig } from "../../../types.ts";
|
||||
import { type RenderResult, NO_RENDER } from "./types.ts";
|
||||
import { renderGitDiff } from "./gitDiff.ts";
|
||||
import { renderTestGreen } from "./testGreen.ts";
|
||||
import { renderTerraformPlan } from "./terraformPlan.ts";
|
||||
import { renderStructuredTable } from "./structuredTable.ts";
|
||||
|
||||
// preenchido nas tasks 2–5
|
||||
const REGISTRY: Record<string, (text: string, d: CommandDetectionResult) => RenderResult> = {};
|
||||
|
||||
// Task 2: git-diff renderer
|
||||
// Note: "git-show" is not a real detection type in commandDetector.ts DETECTORS array,
|
||||
// so only "git-diff" is registered here.
|
||||
REGISTRY["git-diff"] = renderGitDiff;
|
||||
|
||||
// Task 3: test-green renderer
|
||||
// Note: "test-eslint" is not a real detection type; the real type is "build-eslint".
|
||||
REGISTRY["test-pytest"] = renderTestGreen;
|
||||
REGISTRY["test-jest"] = renderTestGreen;
|
||||
REGISTRY["test-vitest"] = renderTestGreen;
|
||||
REGISTRY["build-eslint"] = renderTestGreen;
|
||||
|
||||
// Task 4: terraform-plan renderer
|
||||
REGISTRY["terraform-plan"] = renderTerraformPlan;
|
||||
REGISTRY["tofu-plan"] = renderTerraformPlan;
|
||||
|
||||
// Task 5: structured-table renderer
|
||||
// Note: "kubectl" is not a real detection type in commandDetector.ts DETECTORS array
|
||||
// (it's in KNOWN_COMMANDS but has no DETECTOR entry with a "kubectl" type).
|
||||
// Kubectl JSON output will be detected as "json-output" or "aws" depending on content.
|
||||
// Registering "aws" and "json-output" which are the real types for this output shape.
|
||||
REGISTRY["aws"] = renderStructuredTable;
|
||||
REGISTRY["json-output"] = renderStructuredTable;
|
||||
|
||||
export function applyRenderer(
|
||||
text: string,
|
||||
detection: CommandDetectionResult,
|
||||
config: RtkConfig
|
||||
): RenderResult {
|
||||
const r = REGISTRY[detection.type];
|
||||
if (!r) return NO_RENDER(text);
|
||||
if (config.renderers && config.renderers.length > 0 && !config.renderers.includes(detection.type)) {
|
||||
return NO_RENDER(text);
|
||||
}
|
||||
return r(text, detection);
|
||||
}
|
||||
export { type RenderResult } from "./types.ts";
|
||||
|
||||
export { REGISTRY };
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { RenderResult, CommandDetectionResult } from "./types.ts";
|
||||
import { NO_RENDER } from "./types.ts";
|
||||
|
||||
const MAX_TABLE_ROWS = 200;
|
||||
const MAX_COLUMNS = 5;
|
||||
// Priority column names to prefer when choosing which columns to display
|
||||
const PRIORITY_KEYS = ["name", "id", "status", "type", "kind"];
|
||||
|
||||
/**
|
||||
* RTK semantic renderer for structured JSON array output (aws, kubectl, etc.).
|
||||
*
|
||||
* Only renders if:
|
||||
* - Input parses as JSON (or contains a JSON array substring)
|
||||
* - Result is an array of ≥2 homogeneous objects (same dominant scalar keys)
|
||||
*
|
||||
* Output: minimal TSV-like table (header + rows). Large arrays (>200 items)
|
||||
* are capped with a trailing "… (+K more)" line.
|
||||
*
|
||||
* All other shapes ⇒ no-op (conservative).
|
||||
*/
|
||||
export function renderStructuredTable(
|
||||
text: string,
|
||||
_detection: CommandDetectionResult,
|
||||
): RenderResult {
|
||||
const parsed = tryParse(text.trim());
|
||||
if (!parsed) return NO_RENDER(text);
|
||||
|
||||
// Must be an array of ≥2 objects
|
||||
if (!Array.isArray(parsed) || parsed.length < 2) return NO_RENDER(text);
|
||||
|
||||
const items = parsed as unknown[];
|
||||
// Every item must be a non-null, non-array object
|
||||
if (!items.every((item) => item !== null && typeof item === "object" && !Array.isArray(item))) {
|
||||
return NO_RENDER(text);
|
||||
}
|
||||
|
||||
const objects = items as Record<string, unknown>[];
|
||||
|
||||
// Collect scalar keys across all objects, count occurrences
|
||||
const keyCount: Record<string, number> = {};
|
||||
for (const obj of objects) {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v === null || typeof v !== "object") {
|
||||
keyCount[k] = (keyCount[k] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(keyCount).length === 0) return NO_RENDER(text);
|
||||
|
||||
// Choose columns: prioritize PRIORITY_KEYS, then by frequency, cap at MAX_COLUMNS
|
||||
const threshold = Math.floor(objects.length / 2);
|
||||
const candidateKeys = Object.entries(keyCount)
|
||||
.filter(([, count]) => count >= threshold)
|
||||
.map(([k]) => k);
|
||||
|
||||
const priorityChosen = PRIORITY_KEYS.filter((k) => candidateKeys.includes(k));
|
||||
const rest = candidateKeys
|
||||
.filter((k) => !PRIORITY_KEYS.includes(k))
|
||||
.sort((a, b) => (keyCount[b] ?? 0) - (keyCount[a] ?? 0));
|
||||
|
||||
const columns = [...priorityChosen, ...rest].slice(0, MAX_COLUMNS);
|
||||
if (columns.length === 0) return NO_RENDER(text);
|
||||
|
||||
// Build table
|
||||
const rows = objects.slice(0, MAX_TABLE_ROWS);
|
||||
const extra = objects.length > MAX_TABLE_ROWS ? objects.length - MAX_TABLE_ROWS : 0;
|
||||
|
||||
const header = columns.join("\t");
|
||||
const body = rows
|
||||
.map((obj) => columns.map((k) => String(obj[k] ?? "")).join("\t"))
|
||||
.join("\n");
|
||||
|
||||
const out = extra > 0 ? `${header}\n${body}\n… (+${extra} more)` : `${header}\n${body}`;
|
||||
|
||||
return { text: out, changed: true, renderer: "structured-table" };
|
||||
}
|
||||
|
||||
function tryParse(text: string): unknown {
|
||||
// Direct parse
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
// Try to find the largest [...] substring
|
||||
const start = text.indexOf("[");
|
||||
const end = text.lastIndexOf("]");
|
||||
if (start !== -1 && end > start) {
|
||||
try {
|
||||
return JSON.parse(text.slice(start, end + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { RenderResult, CommandDetectionResult } from "./types.ts";
|
||||
import { NO_RENDER } from "./types.ts";
|
||||
|
||||
/**
|
||||
* RTK semantic renderer for `terraform plan` / `tofu plan` output.
|
||||
*
|
||||
* Extracts:
|
||||
* - The canonical summary line `Plan: N to add, M to change, K to destroy.`
|
||||
* reformatted as `Plan: +N ~M -K`
|
||||
* - Resource lines `# <addr> will be <verb>`
|
||||
*
|
||||
* "No changes." output ⇒ no-op (already short enough).
|
||||
* If no `Plan:` line is found ⇒ no-op (conservative).
|
||||
*/
|
||||
export function renderTerraformPlan(
|
||||
text: string,
|
||||
_detection: CommandDetectionResult,
|
||||
): RenderResult {
|
||||
// "No changes" is already compact — idempotent no-op
|
||||
if (/^No changes\./m.test(text)) return NO_RENDER(text);
|
||||
|
||||
// Extract the canonical Plan summary line
|
||||
const planMatch = text.match(/Plan:\s+(\d+)\s+to add,\s+(\d+)\s+to change,\s+(\d+)\s+to destroy/);
|
||||
if (!planMatch) return NO_RENDER(text);
|
||||
|
||||
const [, add, change, destroy] = planMatch;
|
||||
const summary = `Plan: +${add} ~${change} -${destroy}`;
|
||||
|
||||
// Collect resource address lines: " # <addr> will be <verb>"
|
||||
const resources: string[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const m = line.match(/^\s+#\s+(\S+)\s+will\s+be\s+\S+/);
|
||||
if (m) {
|
||||
resources.push(` # ${m[1]} will be ${line.trim().replace(/^#\s+\S+\s+will\s+be\s+/, "")}`);
|
||||
}
|
||||
}
|
||||
|
||||
const out = [summary, ...resources].join("\n");
|
||||
return { text: out, changed: true, renderer: "terraform-plan" };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { RenderResult, CommandDetectionResult } from "./types.ts";
|
||||
import { NO_RENDER } from "./types.ts";
|
||||
|
||||
/**
|
||||
* RTK semantic renderer for test suite output (pytest, jest, vitest, eslint).
|
||||
*
|
||||
* CRITICAL safety guard: only collapses when output indicates TOTAL success.
|
||||
* ANY sign of failure forces a no-op to preserve full diagnostics.
|
||||
*
|
||||
* Failure signals (force no-op):
|
||||
* - /\bFAIL\b/ in the text
|
||||
* - /failed/i paired with a nonzero count (e.g. "1 failed")
|
||||
* - ✖ symbol
|
||||
* - "Error" anywhere
|
||||
* - "Traceback" (Python)
|
||||
* - "AssertionError"
|
||||
*
|
||||
* When green, extract the summary line and return it.
|
||||
* If no recognizable summary line is found, no-op.
|
||||
*/
|
||||
export function renderTestGreen(text: string, _detection: CommandDetectionResult): RenderResult {
|
||||
// Strip ANSI color codes before applying the failure guards. jest/vitest emit a
|
||||
// colored "FAIL" status whose preceding ANSI byte ("m" of [31m) is a word
|
||||
// character, defeating a /\bFAIL\b/ boundary on the raw string. Guards run on the
|
||||
// stripped copy; the original `text` is what we return on a no-op (lossless).
|
||||
const stripped = text.replace(/\[[0-9;]*m/g, "");
|
||||
|
||||
// Failure guard — must check FIRST; never weaken
|
||||
if (/\bFAIL\b/.test(stripped)) return NO_RENDER(text);
|
||||
if (/✖/.test(stripped)) return NO_RENDER(text);
|
||||
if (/Error/.test(stripped)) return NO_RENDER(text);
|
||||
if (/Traceback/.test(stripped)) return NO_RENDER(text);
|
||||
if (/AssertionError/.test(stripped)) return NO_RENDER(text);
|
||||
|
||||
// "failed" with a nonzero count, e.g. "1 failed" or "failed: 3"
|
||||
const failedMatch = stripped.match(/(\d+)\s+failed/i) ?? stripped.match(/failed[:\s]+(\d+)/i);
|
||||
if (failedMatch && parseInt(failedMatch[1], 10) > 0) return NO_RENDER(text);
|
||||
|
||||
// Try to extract a recognised summary line (from the stripped copy → ANSI-free)
|
||||
const summary = extractSummaryLine(stripped);
|
||||
if (!summary) return NO_RENDER(text);
|
||||
|
||||
return { text: summary, changed: true, renderer: "test-green" };
|
||||
}
|
||||
|
||||
function extractSummaryLine(text: string): string | null {
|
||||
const lines = text.split("\n");
|
||||
|
||||
// pytest: === N passed in X.Xs === (also handles variants like === N passed, M warning ===)
|
||||
for (const line of lines) {
|
||||
if (/={3,}\s+\d+\s+passed/.test(line)) return line.trim();
|
||||
}
|
||||
|
||||
// jest / vitest: "Tests: N passed, N total" or "Test Suites: ... Tests: ..."
|
||||
for (const line of lines) {
|
||||
if (/Tests:\s+\d+\s+passed/.test(line)) return line.trim();
|
||||
}
|
||||
|
||||
// vitest / jest run summary line: "✓ N tests passed"
|
||||
for (const line of lines) {
|
||||
if (/\d+\s+tests?\s+passed/i.test(line)) return line.trim();
|
||||
}
|
||||
|
||||
// eslint / build-eslint: empty output = clean; if we reach here with no failures, synthesize
|
||||
if (text.trim() === "" || text.trim().startsWith("\n")) {
|
||||
return "ESLint: 0 problems found";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
13
open-sse/services/compression/engines/rtk/renderers/types.ts
Normal file
13
open-sse/services/compression/engines/rtk/renderers/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { CommandDetectionResult } from "../commandDetector.ts";
|
||||
import type { RtkConfig } from "../../../types.ts";
|
||||
|
||||
export interface RenderResult {
|
||||
text: string;
|
||||
changed: boolean;
|
||||
renderer: string; // nome do renderer aplicado (ou "" se nenhum)
|
||||
}
|
||||
|
||||
export type Renderer = (text: string, detection: CommandDetectionResult) => RenderResult;
|
||||
|
||||
export const NO_RENDER = (text: string): RenderResult => ({ text, changed: false, renderer: "" });
|
||||
export type { RtkConfig, CommandDetectionResult };
|
||||
@@ -103,6 +103,10 @@ export interface RtkConfig {
|
||||
stripCodeComments?: boolean;
|
||||
/** R1/N3: keep JSDoc/docstring block comments when removing comments. Default: true. */
|
||||
preserveDocstrings?: boolean;
|
||||
/** #10: semantic command-output renderers (default off) */
|
||||
enableRenderers?: boolean;
|
||||
/** #10: whitelist por command-type; vazio/undefined = todos */
|
||||
renderers?: string[];
|
||||
}
|
||||
|
||||
export interface CompressionLanguageConfig {
|
||||
@@ -314,6 +318,7 @@ export const DEFAULT_RTK_CONFIG: RtkConfig = {
|
||||
groupingThreshold: 3,
|
||||
stripCodeComments: false,
|
||||
preserveDocstrings: true,
|
||||
enableRenderers: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_COMPRESSION_LANGUAGE_CONFIG: CompressionLanguageConfig = {
|
||||
|
||||
36
tests/unit/compression/rtk-render-gitdiff.test.ts
Normal file
36
tests/unit/compression/rtk-render-gitdiff.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { renderGitDiff } from "../../../open-sse/services/compression/engines/rtk/renderers/gitDiff.ts";
|
||||
|
||||
const det = {
|
||||
type: "git-diff",
|
||||
command: "git diff",
|
||||
confidence: 1,
|
||||
category: "git",
|
||||
matchedPatterns: [],
|
||||
};
|
||||
|
||||
test("keeps file headers, hunks and +/- changes; drops context", () => {
|
||||
const input = `diff --git a/x.ts b/x.ts
|
||||
index 111..222 100644
|
||||
--- a/x.ts
|
||||
+++ b/x.ts
|
||||
@@ -1,3 +1,3 @@
|
||||
-const a = 1;
|
||||
+const a = 2;
|
||||
const b = 3;`;
|
||||
const r = renderGitDiff(input, det);
|
||||
assert.equal(r.changed, true);
|
||||
assert.ok(r.text.includes("diff --git a/x.ts b/x.ts"));
|
||||
assert.ok(r.text.includes("@@ -1,3 +1,3 @@"));
|
||||
assert.ok(r.text.includes("-const a = 1;"));
|
||||
assert.ok(r.text.includes("+const a = 2;"));
|
||||
assert.ok(!r.text.includes("index 111..222"));
|
||||
assert.ok(!r.text.includes("--- a/x.ts"));
|
||||
assert.ok(!r.text.includes(" const b = 3;")); // contexto dropado
|
||||
});
|
||||
|
||||
test("no hunks ⇒ no-op", () => {
|
||||
const r = renderGitDiff("just some text\nno diff here", det);
|
||||
assert.equal(r.changed, false);
|
||||
});
|
||||
32
tests/unit/compression/rtk-render-table.test.ts
Normal file
32
tests/unit/compression/rtk-render-table.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { renderStructuredTable } from "../../../open-sse/services/compression/engines/rtk/renderers/structuredTable.ts";
|
||||
|
||||
const det = {
|
||||
type: "kubectl",
|
||||
command: "kubectl get pods -o json",
|
||||
confidence: 1,
|
||||
category: "cloud",
|
||||
matchedPatterns: [],
|
||||
};
|
||||
|
||||
test("homogeneous JSON array ⇒ minimal table", () => {
|
||||
const input = JSON.stringify([
|
||||
{ name: "pod-a", status: "Running", restarts: 0 },
|
||||
{ name: "pod-b", status: "Pending", restarts: 2 },
|
||||
]);
|
||||
const r = renderStructuredTable(input, det);
|
||||
assert.equal(r.changed, true);
|
||||
assert.ok(r.text.includes("name"));
|
||||
assert.ok(r.text.includes("pod-a"));
|
||||
assert.ok(r.text.includes("Pending"));
|
||||
assert.ok(!r.text.includes('"status":')); // não é mais JSON
|
||||
});
|
||||
|
||||
test("malformed JSON ⇒ no-op", () => {
|
||||
assert.equal(renderStructuredTable("{not json", det).changed, false);
|
||||
});
|
||||
|
||||
test("single object (not array) ⇒ no-op", () => {
|
||||
assert.equal(renderStructuredTable('{"name":"x"}', det).changed, false);
|
||||
});
|
||||
37
tests/unit/compression/rtk-render-terraform.test.ts
Normal file
37
tests/unit/compression/rtk-render-terraform.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { renderTerraformPlan } from "../../../open-sse/services/compression/engines/rtk/renderers/terraformPlan.ts";
|
||||
|
||||
const det = {
|
||||
type: "terraform-plan",
|
||||
command: "terraform plan",
|
||||
confidence: 1,
|
||||
category: "infra",
|
||||
matchedPatterns: [],
|
||||
};
|
||||
|
||||
test("summarizes plan into +N ~M -K plus resources", () => {
|
||||
const input = `Terraform will perform the following actions:
|
||||
# aws_instance.web will be created
|
||||
+ resource "aws_instance" "web" { ... many lines ... }
|
||||
# aws_s3_bucket.data will be updated in-place
|
||||
~ resource "aws_s3_bucket" "data" { ... }
|
||||
Plan: 1 to add, 1 to change, 0 to destroy.`;
|
||||
const r = renderTerraformPlan(input, det);
|
||||
assert.equal(r.changed, true);
|
||||
assert.ok(r.text.includes("Plan: +1 ~1 -0"));
|
||||
assert.ok(r.text.includes("aws_instance.web"));
|
||||
assert.ok(!r.text.includes('resource "aws_instance" "web" {'));
|
||||
// Regression (core review): the verb must not be duplicated ("will be will be created").
|
||||
assert.ok(!r.text.includes("will be will be"));
|
||||
assert.ok(r.text.includes("# aws_instance.web will be created"));
|
||||
assert.ok(r.text.includes("# aws_s3_bucket.data will be updated in-place"));
|
||||
});
|
||||
|
||||
test("no-changes ⇒ no-op", () => {
|
||||
const r = renderTerraformPlan(
|
||||
"No changes. Your infrastructure matches the configuration.",
|
||||
det,
|
||||
);
|
||||
assert.equal(r.changed, false);
|
||||
});
|
||||
40
tests/unit/compression/rtk-render-testgreen.test.ts
Normal file
40
tests/unit/compression/rtk-render-testgreen.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { renderTestGreen } from "../../../open-sse/services/compression/engines/rtk/renderers/testGreen.ts";
|
||||
|
||||
const det = (t: string) => ({
|
||||
type: t,
|
||||
command: "",
|
||||
confidence: 1,
|
||||
category: "test",
|
||||
matchedPatterns: [],
|
||||
});
|
||||
|
||||
test("pytest all-green collapses to summary", () => {
|
||||
const input = `============ test session starts ============
|
||||
collected 142 items
|
||||
tests/a.py ....................
|
||||
tests/b.py ....................
|
||||
============ 142 passed in 3.21s ============`;
|
||||
const r = renderTestGreen(input, det("test-pytest"));
|
||||
assert.equal(r.changed, true);
|
||||
assert.ok(r.text.includes("142 passed"));
|
||||
assert.ok(!r.text.includes("...................."));
|
||||
});
|
||||
|
||||
test("any failure ⇒ no-op (preserve diagnostics)", () => {
|
||||
const input = `tests/a.py ..F..
|
||||
=== 1 failed, 4 passed in 1.0s ===
|
||||
E AssertionError: nope`;
|
||||
const r = renderTestGreen(input, det("test-pytest"));
|
||||
assert.equal(r.changed, false);
|
||||
});
|
||||
|
||||
test("ANSI-colored FAIL with no numeric failed-count ⇒ no-op (regression: \\bFAIL\\b defeated by ANSI)", () => {
|
||||
// jest/vitest emit a colored FAIL header; the ESC[31m byte 'm' before 'F' kills the
|
||||
// word boundary. With the per-test failed-count line already stripped by an upstream
|
||||
// filter, the ANSI strip in the guard is the only thing preventing a collapsed failure.
|
||||
const input = "[1m[31mFAIL[39m[22m src/auth.test.ts\nTests: 3 passed, 3 total";
|
||||
const r = renderTestGreen(input, det("test-jest"));
|
||||
assert.equal(r.changed, false);
|
||||
});
|
||||
43
tests/unit/compression/rtk-renderers-integration.test.ts
Normal file
43
tests/unit/compression/rtk-renderers-integration.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { processRtkText } from "../../../open-sse/services/compression/engines/rtk/index.ts";
|
||||
import { applyRenderer } from "../../../open-sse/services/compression/engines/rtk/renderers/index.ts"; // sanity import
|
||||
|
||||
// keep unused import check quiet
|
||||
void applyRenderer;
|
||||
|
||||
const GIT_DIFF = `diff --git a/x.ts b/x.ts
|
||||
index 111..222 100644
|
||||
--- a/x.ts
|
||||
+++ b/x.ts
|
||||
@@ -1,3 +1,3 @@
|
||||
-const a = 1;
|
||||
+const a = 2;
|
||||
const b = 3;`;
|
||||
|
||||
test("enableRenderers default false ⇒ baseline unchanged", () => {
|
||||
const off = processRtkText(GIT_DIFF, { command: "git diff", config: { enabled: true } });
|
||||
const explicitOff = processRtkText(GIT_DIFF, {
|
||||
command: "git diff",
|
||||
config: { enabled: true, enableRenderers: false },
|
||||
});
|
||||
assert.equal(off.text, explicitOff.text); // renderer não roda por default
|
||||
});
|
||||
|
||||
test("enableRenderers true ⇒ git diff is rendered through processRtkText", () => {
|
||||
const on = processRtkText(GIT_DIFF, {
|
||||
command: "git diff",
|
||||
config: { enabled: true, enableRenderers: true },
|
||||
});
|
||||
assert.ok(on.techniquesUsed.includes("rtk-render:git-diff"));
|
||||
assert.ok(!on.text.includes("index ")); // contexto/metadata dropado
|
||||
});
|
||||
|
||||
test("fail-open: renderer error keeps prior result", () => {
|
||||
// entrada que detecta git-diff mas é benigna; renderer não deve lançar nem alterar incorretamente
|
||||
const r = processRtkText("not really a diff", {
|
||||
command: "git diff",
|
||||
config: { enabled: true, enableRenderers: true },
|
||||
});
|
||||
assert.ok(typeof r.text === "string"); // nunca lança
|
||||
});
|
||||
Reference in New Issue
Block a user