diff --git a/.agents/workflows/deploy-vps-akamai.md b/.agents/workflows/deploy-vps-akamai.md index 50f92cab5f..b25ae3cd99 100644 --- a/.agents/workflows/deploy-vps-akamai.md +++ b/.agents/workflows/deploy-vps-akamai.md @@ -17,7 +17,7 @@ Deploy OmniRoute to the Akamai VPS using `npm pack + scp` + PM2. // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts +cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts ``` ### 2. Copy to Akamai VPS and install diff --git a/.agents/workflows/deploy-vps-both.md b/.agents/workflows/deploy-vps-both.md index 161d197bd4..e1aa4d1def 100644 --- a/.agents/workflows/deploy-vps-both.md +++ b/.agents/workflows/deploy-vps-both.md @@ -22,7 +22,7 @@ Deploy OmniRoute to the production VPSs using `npm pack + scp` + PM2. // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts +cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts ``` ### 2. Copy to both VPS and install diff --git a/.agents/workflows/deploy-vps-local.md b/.agents/workflows/deploy-vps-local.md index f348f63da5..549b1f0b2a 100644 --- a/.agents/workflows/deploy-vps-local.md +++ b/.agents/workflows/deploy-vps-local.md @@ -17,7 +17,7 @@ Deploy OmniRoute to the Local VPS using `npm pack + scp` + PM2. // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts +cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts ``` ### 2. Copy to Local VPS and install diff --git a/.agents/workflows/generate-release.md b/.agents/workflows/generate-release.md index e127c832d4..399e12d214 100644 --- a/.agents/workflows/generate-release.md +++ b/.agents/workflows/generate-release.md @@ -226,7 +226,7 @@ git checkout main git pull origin main # Build and pack locally -cd /home/diegosouzapw/dev/proxys/9router && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts +cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts # Deploy to LOCAL VPS (192.168.0.15) scp omniroute-*.tgz root@192.168.0.15:/tmp/ diff --git a/.agents/workflows/version-bump.md b/.agents/workflows/version-bump.md index 4b3b77a921..0667e0eb1d 100644 --- a/.agents/workflows/version-bump.md +++ b/.agents/workflows/version-bump.md @@ -20,7 +20,7 @@ Automatically bump the project version, generate CHANGELOG entries from git hist // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute CURRENT_VERSION=$(node -p "require('./package.json').version") LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") CURRENT_BRANCH=$(git branch --show-current) @@ -64,7 +64,7 @@ npm version "$VERSION" --no-git-tag-version // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null) echo "=== Commits since $LAST_TAG ===" git log "$LAST_TAG"..HEAD --pretty=format:"%h %s" --no-merges | head -100 @@ -133,7 +133,7 @@ The date must be today's date in `YYYY-MM-DD` format. // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute VERSION=$(node -p "require('./package.json').version") # Update docs/openapi.yaml version @@ -156,7 +156,7 @@ echo "✓ All workspace packages synced to $VERSION" // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute VERSION=$(node -p "require('./package.json').version") OLD_VERSION_PATTERN='[0-9]\+\.[0-9]\+\.[0-9]\+' @@ -174,7 +174,7 @@ echo "✓ llm.txt → $VERSION" // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute npm install echo "✓ Lock file regenerated" ``` @@ -236,7 +236,7 @@ For each file in `docs/` (excluding `docs/i18n/`), review if CHANGELOG changes a // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute npm run lint ``` @@ -245,7 +245,7 @@ npm run lint // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute npm test ``` @@ -254,7 +254,7 @@ npm test // turbo ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute VERSION=$(node -p "require('./package.json').version") echo "Expected version: $VERSION" echo "" @@ -299,7 +299,7 @@ grep "^## \[" CHANGELOG.md | head -2 // turbo-all ```bash -cd /home/diegosouzapw/dev/proxys/9router +cd /home/diegosouzapw/dev/proxys/OmniRoute git add -A VERSION=$(node -p "require('./package.json').version") git commit -m "chore(release): bump to v$VERSION — changelog, docs, version sync" diff --git a/AGENTS.md b/AGENTS.md index 3b05a3dd65..aadaf98b2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Unified AI proxy/router — route any LLM through one endpoint. Multi-provider s with **160+ providers** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks, Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra, SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more) -with **MCP Server** (29 tools), **A2A v0.3 Protocol**, and **Electron desktop app**. +with **MCP Server** (37 tools), **A2A v0.3 Protocol**, and **Electron desktop app**. ## Stack @@ -292,17 +292,28 @@ compression pipeline), and more. Modular prompt compression that runs proactively before the existing reactive context manager. -- **`strategySelector.ts`**: Selects compression mode based on config, combo overrides, auto-trigger - thresholds. Priority: combo override > auto-trigger > default mode > off. +- **`strategySelector.ts`**: Selects compression mode based on config, compression combo assignments, + combo overrides, auto-trigger thresholds, and defaults. Priority: assigned compression combo > + combo override > auto-trigger > default mode > off. - **`lite.ts`**: 5 lite-mode techniques: `collapseWhitespace`, `dedupSystemPrompt`, `compressToolResults`, `removeRedundantContent`, `replaceImageUrls`. Target: 10-15% savings at <1ms latency. +- **`caveman.ts` / `cavemanRules.ts`**: Caveman-style semantic condensation backed by built-in + rules plus file-loaded language packs under `compression/rules/`. +- **`engines/rtk/`**: Rule-based terminal/tool-output compression inspired by RTK patterns. Detects + command output classes, applies JSON filter packs, deduplicates repeated lines, strips ANSI/code + noise, and preserves errors/actionable context. The RTK JSON DSL supports replace, + match-output short-circuit, strip/keep, per-line truncation, head/tail/max-line truncation, + inline tests, trust-gated project/global custom filters, and optional redacted raw-output + retention for authenticated recovery. +- **`engines/registry.ts`**: Registers engines (`caveman`, `rtk`) and powers stacked pipelines. - **`stats.ts`**: Per-request compression stats tracking (original tokens, compressed tokens, - savings %, techniques used). -- **`types.ts`**: `CompressionMode` (off/lite/standard/aggressive/ultra), `CompressionConfig`, - `CompressionStats`, `CompressionResult`. -- DB settings in `src/lib/db/compression.ts`, API route at `src/app/api/settings/compression/`. -- Phase 1 implements lite mode only; standard/aggressive/ultra are placeholders for Phase 2. + savings %, techniques used, engine breakdown, compression combo id). +- **`types.ts`**: `CompressionMode` (off/lite/standard/aggressive/ultra/rtk/stacked), + `CompressionConfig`, `CompressionStats`, `CompressionResult`. +- DB settings in `src/lib/db/compression.ts`, compression combos in + `src/lib/db/compressionCombos.ts`, API routes under `src/app/api/settings/compression/`, + `src/app/api/context/*`, and preview/language-pack routes under `src/app/api/compression/*`. #### Combo Routing Engine (`combo.ts`) @@ -323,7 +334,7 @@ Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`, ### MCP Server (`open-sse/mcp-server/`) -29 tools, 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (10 scopes), Zod schemas. +37 tools, 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (10 scopes), Zod schemas. **Core tools** (20): get_health, list_combos, get_combo_metrics, switch_combo, check_quota, route_request, cost_report, list_models_catalog, web_search, simulate_route, set_budget_guard, @@ -332,6 +343,11 @@ best_combo_for_task, explain_route, get_session_snapshot, db_health_check, sync_ **Cache tools** (2): cache_stats, cache_flush. +**Compression tools** (5): compression_status, compression_configure, set_compression_engine, +list_compression_combos, compression_combo_stats. + +**1proxy tools** (3): oneproxy_fetch, oneproxy_rotate, oneproxy_stats. + **Memory tools** (3): memory_search, memory_add, memory_clear. **Skill tools** (4): skills_list, skills_enable, skills_execute, skills_executions. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e926f19c8..80b3a71240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,55 @@ ## [Unreleased] +## [3.7.9] — 2026-05-03 + +### ✨ New Features + +- **feat(compression):** major upgrade to Caveman and RTK compression pipelines (#1876, #1889): + - Add RTK tool-output compression, stacked Caveman + RTK pipelines, compression combo assignments, dashboard context pages, MCP management tools, and language-aware Caveman rule packs. + - Expand RTK parity with a 39-filter catalog, RTK-style JSON DSL stages, inline verify/benchmark coverage, trust-gated custom filters, expanded command detection, and redacted raw-output recovery. + - Expose rule intensities, track USD savings, unify config validation, and persist MCP savings. + - Expand Caveman parity and MCP metadata compression. +- **feat(provider):** update Jina AI model catalog to support Embeddings and Rerank natively (#1874 — thanks @backryun) +- **feat(provider):** add NanoGPT image generation provider (#1899 — thanks @Aculeasis) +- **feat(ui):** move proxy configuration to dedicated System → Proxy page (#1907 — thanks @oyi77) +- **feat(ui):** add K/M/B/T cost shortener utility (#1902 — thanks @oyi77) +- **feat(providers):** implement bulk paste for extra API keys (#1916 — thanks @0xtbug) +- **feat(analytics):** usage history API key backfill + dark mode pricing (#1896 — thanks @Gi99lin) +- **feat(logs):** show RTK and Caveman compression token savings accurately in request log UI (#1923 — thanks @emdash) + +### 🐛 Bug Fixes + +- **fix(providers):** allow local OpenAI-compatible endpoints (like Ollama) to be added without an API key (fixes #1893) +- **fix(providers):** bypass AgentRouter unauthorized_client_error by spoofing Claude CLI headers via Anthropic endpoints (fixes #1921) +- **fix(copilot):** emit compatible reasoning text deltas (#1919 — thanks @ivan-mezentsev) +- **fix(api-manager):** show validation errors inline in modals, not behind (#1920 — thanks @andrewmunsell) +- **fix(compression):** align seeded standard savings combo with stacked default, preserve stacked defaults, and secure metadata routes. +- **fix(gemini-cli):** separate Cloud Code transport from Antigravity (#1869 — thanks @dhaern) +- **fix(codex):** map prompt field to input array for Cursor compatibility (fixes #1872) +- **fix(core):** align stream parameter default to false per strict OpenAI spec (fixes #1873) +- **fix(ui):** restore Next.js CSP `unsafe-eval` in production `script-src` to fix unresponsive Onboarding button (fixes #1883) +- **fix(proxy):** globally strip `prompt_cache_retention` in `BaseExecutor` to prevent upstream 400 errors from strict endpoints like droid/gemini-2-pro (fixes #1884) +- **fix(ui):** include `isOpen` dependency in `EditConnectionModal` state sync to ensure `maxConcurrent` is properly hydrated when reopening the modal (fixes #1859) +- **fix(security):** remediate 4 polynomial-redos CodeQL alerts in compression regexes by bounding repetitions and removing overlapping quantifiers +- **fix(codex):** flatten Chat Completions tool format to Codex Responses format in `normalizeCodexTools` — prevents `Missing required parameter: tools[0].name` upstream errors (#1914 — thanks @tranduykhanh030) +- **fix(proxy):** add proxy-aware execution context to image generation route — proxy settings are now correctly applied for image providers behind restricted networks (#1904 — thanks @Aculeasis) +- **fix(translator):** inject `properties: {}` into zero-argument MCP tool schemas during Anthropic→OpenAI translation — prevents 400 errors from OpenAI strict schema validation (#1898 — thanks @bryceIT) +- **fix(codex):** sanitize raw responses input (#1895 — thanks @dhaern) +- **fix(combos):** align strategy contracts (#1892 — thanks @dhaern) +- **fix(combos):** fix combo provider breaker profile handling (#1891 — thanks @rdself) +- **fix(migrations):** duplicate-column no-op fix (#1886 — thanks @smartenok-ops) +- **fix(auth):** per-connection OAuth refresh mutex (#1885 — thanks @smartenok-ops) +- **fix(auth):** require dashboard management auth for compression preview + +### 📝 Documentation + +- **docs(compression):** document RTK+Caveman stacked savings ranges + +### 🏆 Release Attribution & Retroactive Credits + +- **@payne0420** (PR #1828 / #1839) — Implementation of the **Rate Limit Watchdog** and environment overrides. (This feature was manually backported to v3.7.8, causing the automatic GitHub Release notes to omit the author's credit). + --- ## [3.7.8] — 2026-05-01 diff --git a/README.md b/README.md index 49c38eb156..d794d02d81 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ # 🚀 OmniRoute — The Free AI Gateway -### Never stop coding. Save 15-75% tokens with prompt compression + auto-fallback to **FREE & low-cost AI models**. +### Never stop coding. Save 15-95% eligible tokens with RTK+Caveman compression + auto-fallback to **FREE & low-cost AI models**. -_The most complete open-source AI proxy — **one endpoint**, **160+ providers**, **13 routing strategies**, zero downtime. Multi-platform: **Web**, **Desktop (Electron)**, **Mobile (PWA + Termux)**. Fully extensible via **MCP Server (29 tools)**, **A2A Protocol**, and **Memory/Skills** systems. Available in **40+ languages**._ +_The most complete open-source AI proxy — **one endpoint**, **160+ providers**, **13 routing strategies**, zero downtime. Multi-platform: **Web**, **Desktop (Electron)**, **Mobile (PWA + Termux)**. Fully extensible via **MCP Server (37 tools)**, **A2A Protocol**, and **Memory/Skills** systems. Available in **40+ languages**._ **Chat Completions • Responses API • Embeddings • Image Generation • Video • Music • Audio Speech/Transcription • Reranking • Moderations • Web Search • MCP Server • A2A Protocol • 4,600+ Tests • 100% TypeScript** @@ -20,7 +20,7 @@ _The most complete open-source AI proxy — **one endpoint**, **160+ providers** diegosouzapw%2FOmniRoute | Trendshift -[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [🗜️ Compression](#%EF%B8%8F-prompt-compression--save-15-75-tokens-automatically) • [💰 Pricing](#-pricing-at-a-glance) • [🎯 Use Cases](#-use-cases--ready-made-combo-playbooks) • [🌍 Proxy](#-bypass-geographic-blocks--use-ai-from-any-country) • [❓ FAQ](#-frequently-asked-questions) • [📖 Docs](#-documentation) • [💬 WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) +[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [🗜️ Compression](#%EF%B8%8F-prompt-compression--save-15-95-eligible-tokens-automatically) • [💰 Pricing](#-pricing-at-a-glance) • [🎯 Use Cases](#-use-cases--ready-made-combo-playbooks) • [🌍 Proxy](#-bypass-geographic-blocks--use-ai-from-any-country) • [❓ FAQ](#-frequently-asked-questions) • [📖 Docs](#-documentation) • [💬 WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) @@ -219,7 +219,7 @@ _Connect any AI-powered IDE or CLI tool through OmniRoute — free API gateway f **OmniRoute solves all of this:** -✅ **Prompt Compression** — auto-compress prompts & tool outputs, save 15-75% tokens per request +✅ **Prompt Compression** — auto-compress prompts & tool outputs, save 15-95% eligible tokens per request with RTK+Caveman stacked mode ✅ **Maximize subscriptions** — track quota, use every bit before reset ✅ **Auto fallback** — Subscription → API Key → Cheap → Free, zero downtime ✅ **Multi-account** — round-robin between accounts per provider @@ -410,7 +410,7 @@ Alibaba · Amazon Q · AssemblyAI · Baidu Qianfan · Baseten · Black Forest La ↓ ┌──────────────────────────────────────────────────┐ │ OmniRoute (Smart Router) │ -│ • 🗜️ Prompt Compression (save 15-75% tokens) │ +│ • 🗜️ Prompt Compression (save 15-95% eligible) │ │ • Format translation (OpenAI ↔ Claude ↔ Gemini) │ │ • Quota tracking + Embeddings + Images │ │ • Auto token refresh + Rate limit management │ @@ -424,14 +424,14 @@ Alibaba · Amazon Q · AssemblyAI · Baidu Qianfan · Baseten · Black Forest La │ ↓ budget limit └─→ [Tier 4: FREE] Qoder, Qwen, Kiro (unlimited) -Result: Never stop coding, minimal cost + 15-75% token savings +Result: Never stop coding, minimal cost + 15-95% eligible token savings ``` --- -## 🗜️ Prompt Compression — Save 15-75% Tokens Automatically +## 🗜️ Prompt Compression — Save 15-95% Eligible Tokens Automatically -> **Why use many token when few token do trick?** OmniRoute's built-in compression pipeline reduces token usage on every request — before it even reaches the provider. Inspired by [Caveman](https://github.com/JuliusBrussee/caveman) (⭐ 51K+). +> **Why use many token when few token do trick?** OmniRoute's built-in compression pipeline reduces token usage before requests reach the provider. It combines ideas from [RTK - Rust Token Killer](https://github.com/rtk-ai/rtk) and [Caveman](https://github.com/JuliusBrussee/caveman) (⭐ 51K+). ### How It Works @@ -440,16 +440,18 @@ Every request passes through the compression pipeline **transparently** — no c ``` ┌──────────────────┐ ┌─────────────────────────────┐ ┌──────────────┐ │ Client sends │────▶│ OmniRoute Compression │────▶│ Provider │ -│ full prompt │ │ Pipeline (5 modes) │ │ receives │ +│ full prompt │ │ Pipeline (7 options) │ │ receives │ │ (10,000 tok) │ │ │ │ compressed │ -│ │ │ 🪶 Lite ........... ~15% │ │ (2,500 tok) │ +│ │ │ 🪶 Lite ........... ~15% │ │ (~1,080 tok)│ │ │ │ 🪨 Standard ....... ~30% │ │ │ -│ │ │ ⚡ Aggressive ..... ~50% │ │ 💰 75% saved │ +│ │ │ ⚡ Aggressive ..... ~50% │ │ 💰 up to 95%│ │ │ │ 🔥 Ultra .......... ~75% │ │ │ +│ │ │ 🧰 RTK ............ 60-90% │ │ │ +│ │ │ 🔗 Stacked ........ 78-95% │ │ │ └──────────────────┘ └─────────────────────────────┘ └──────────────┘ ``` -### 5 Compression Modes +### 7 Compression Options | Mode | Savings | Technique | Best For | | ------------------------- | ------- | ----------------------------------------------------------------------------------------------- | -------------------------------------- | @@ -458,6 +460,36 @@ Every request passes through the compression pipeline **transparently** — no c | **🪨 Standard (Caveman)** | ~30% | 30+ regex rules: filler removal, context condensation, structural compression, multi-turn dedup | Daily coding with Claude/Codex | | **⚡ Aggressive** | ~50% | All standard + progressive message aging + tool result summarization + LLM-based compression | Long sessions with many tool calls | | **🔥 Ultra** | ~75% | All aggressive + heuristic token pruning + stopword removal + score-based filtering | Maximum savings when tokens are scarce | +| **🧰 RTK** | 60-90% | 49 command-aware filters, RTK-style JSON DSL, verify gate, trust-gated custom filters | Shell/test/build/git output in agents | +| **🔗 Stacked** | 78-95% | RTK first, then Caveman input condensation; ~89% with upstream average math | Mixed prompts with tool logs + prose | + +### RTK + Caveman Savings Math + +These numbers are based on the upstream project READMEs under `_references/_outros`: + +| Source | Upstream claim used by OmniRoute docs | +| ------- | ------------------------------------------------------------------------------------------------------------------- | +| Caveman | `~75%` fewer output tokens; benchmark average `65%` output savings, range `22-87%`; `~46%` input compression tool | +| RTK | `60-90%` command-output token savings; sample session `~118,000 -> ~23,900` tokens, which is `79.7%` saved (`~80%`) | + +For the default stacked compression combo, OmniRoute runs: + +```txt +RTK -> Caveman +``` + +When both engines can act on the same tool/context payload, the savings compound: + +```txt +combined = 1 - (1 - RTK savings) * (1 - Caveman input savings) +average = 1 - (1 - 0.80) * (1 - 0.46) = 89.2% +range = 1 - (1 - 0.60..0.90) * (1 - 0.46) = 78.4-94.6% +``` + +Caveman output mode is separate from prompt compression. When enabled for responses, use Caveman's +own upstream output numbers: `65%` average, `~75%` headline, `22-87%` observed range. Total bill +savings depend on the prompt/output mix, but coding-agent sessions are often tool-context heavy, so +the `RTK -> Caveman` combo is the best default for maximum context savings. ### Before & After (Standard/Caveman Mode) @@ -481,6 +513,8 @@ Request Body ├─ lite.ts ─────────────── Whitespace, dedup, image URLs, redundant content ├─ caveman.ts ──────────── 30+ regex rules via cavemanRules.ts │ └─ preservation.ts ─── Protects code blocks, URLs, JSON from compression + ├─ engines/rtk/ ────────── Command detection + JSON DSL filters + raw-output recovery + ├─ engines/registry.ts ─── Shared engine registry for caveman, RTK, and stacked ├─ aggressive.ts ───────── Summarizer + tool result compressor + progressive aging │ ├─ summarizer.ts ───── Rule-based message summarization │ ├─ toolResultCompressor.ts ── file/grep/shell/JSON/error compression @@ -492,7 +526,7 @@ Request Body ### Configuration ``` -Dashboard → Settings → Compression → Pick your mode +Dashboard → Context & Cache → Caveman / RTK / Compression Combos ``` Or per-combo override: @@ -508,9 +542,11 @@ Or per-combo override: Auto-trigger: set `autoTriggerTokens` to automatically enable compression when a request exceeds a token threshold. -> 🪨 **Fun fact:** The standard/caveman mode is inspired by [Caveman](https://github.com/JuliusBrussee/caveman) — the viral project that proved "caveman speak" cuts 65% of tokens while keeping 100% technical accuracy. OmniRoute takes this further with a **5-mode pipeline** that goes from gentle whitespace cleanup all the way to aggressive heuristic pruning. +Compression combos can also assign a named compression pipeline to routing combos, so a coding combo can use RTK + Caveman while a paid subscription combo stays on lite mode. -📖 **Full compression documentation:** [`docs/COMPRESSION_GUIDE.md`](docs/COMPRESSION_GUIDE.md) +> 🪨 **Fun fact:** The standard/caveman mode is inspired by [Caveman](https://github.com/JuliusBrussee/caveman) — the viral project that reports 65% average output-token savings while keeping technical accuracy. OmniRoute takes this further with a **7-option pipeline** and a default `RTK -> Caveman` combo that can reach ~89% average savings on eligible tool/context payloads. + +📖 **Full compression documentation:** [`docs/COMPRESSION_GUIDE.md`](docs/COMPRESSION_GUIDE.md) • [`docs/RTK_COMPRESSION.md`](docs/RTK_COMPRESSION.md) • [`docs/COMPRESSION_ENGINES.md`](docs/COMPRESSION_ENGINES.md) • [`docs/COMPRESSION_RULES_FORMAT.md`](docs/COMPRESSION_RULES_FORMAT.md) • [`docs/COMPRESSION_LANGUAGE_PACKS.md`](docs/COMPRESSION_LANGUAGE_PACKS.md) --- @@ -933,8 +969,8 @@ Then in `/dashboard/media` → **Transcription** tab: upload any audio or video | ---------------------------------------------------------------------------------------------------- | -------------------------------- | | 🧠 **Smart 4-Tier Fallback** — Subscription → API → Cheap → Free | Never stop coding, zero downtime | | 🔄 **Format Translation** — OpenAI ↔ Claude ↔ Gemini ↔ Responses API | Works with ANY CLI tool | -| 🗜️ **Prompt Compression** — 5-mode pipeline (off → lite → standard → aggressive → ultra) | Save 15-75% tokens automatically | -| 🤖 **MCP Server** — 29 tools, 3 transports (stdio/SSE/HTTP), 10 scopes | IDE/agent tool integration | +| 🗜️ **Prompt Compression** — 7 options including Caveman, RTK, and stacked pipelines | Save 15-95% eligible tokens | +| 🤖 **MCP Server** — 37 tools, 3 transports (stdio/SSE/HTTP), 10 scopes | IDE/agent tool integration | | 🛡️ **Resilience Engine** — circuit breakers, cooldowns, TLS spoofing, anti-thundering herd | Auto-recovery from any failure | | 🎵 **10 Multi-Modal APIs** — chat, embed, images, video, music, TTS, STT, moderation, rerank, search | One endpoint for everything | | 🌍 **3-Level Proxy** — global, per-provider, per-key + 1proxy free marketplace | Access AI from any country | @@ -1142,7 +1178,7 @@ curl http://localhost:20128/.well-known/agent.json - [User Guide](docs/USER_GUIDE.md) — Providers, combos, CLI integration - [API Reference](docs/API_REFERENCE.md) — All endpoints with examples -- [MCP Server](open-sse/mcp-server/README.md) — 29 tools, IDE configs +- [MCP Server](open-sse/mcp-server/README.md) — 37 tools, IDE configs - [A2A Server](src/lib/a2a/README.md) — JSON-RPC, skills, streaming - [Environment Config](docs/ENVIRONMENT.md) — Complete `.env` reference - [VM Deployment](docs/VM_DEPLOYMENT_GUIDE.md) — VM + nginx + Cloudflare @@ -1325,16 +1361,20 @@ See the [Proxy Guide](docs/PROXY_GUIDE.md) for setup instructions. ### 🧠 Features & Architecture -| Document | Description | -| -------------------------------------------------------- | ---------------------------------------------------------------------- | -| [Architecture](docs/ARCHITECTURE.md) | System architecture, data flow, and internals | -| [Compression Guide](docs/COMPRESSION_GUIDE.md) | 5-mode pipeline: off / lite / standard / aggressive / ultra | -| [Resilience Guide](docs/RESILIENCE_GUIDE.md) | Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing | -| [Auto-Combo Engine](docs/AUTO-COMBO.md) | 6-factor scoring, mode packs, self-healing | -| [Proxy Guide](docs/PROXY_GUIDE.md) | 3-level proxy system, 1proxy marketplace, registry CRUD | -| [Free Tiers](docs/FREE_TIERS.md) | 25+ free API providers consolidated directory | -| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | -| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | +| Document | Description | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| [Architecture](docs/ARCHITECTURE.md) | System architecture, data flow, and internals | +| [Compression Guide](docs/COMPRESSION_GUIDE.md) | 7-option pipeline: off / lite / standard / aggressive / ultra / RTK / stacked | +| [RTK Compression](docs/RTK_COMPRESSION.md) | Command-output compression, filters, trust, verify, raw-output recovery | +| [Compression Engines](docs/COMPRESSION_ENGINES.md) | Caveman, RTK, stacked pipelines, dashboard/API/MCP surfaces | +| [Compression Rules Format](docs/COMPRESSION_RULES_FORMAT.md) | JSON rule-pack schemas for Caveman and RTK filters | +| [Compression Language Packs](docs/COMPRESSION_LANGUAGE_PACKS.md) | Language detection and Caveman rule-pack authoring | +| [Resilience Guide](docs/RESILIENCE_GUIDE.md) | Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing | +| [Auto-Combo Engine](docs/AUTO-COMBO.md) | 6-factor scoring, mode packs, self-healing | +| [Proxy Guide](docs/PROXY_GUIDE.md) | 3-level proxy system, 1proxy marketplace, registry CRUD | +| [Free Tiers](docs/FREE_TIERS.md) | 25+ free API providers consolidated directory | +| [Features Gallery](docs/FEATURES.md) | Visual dashboard tour with screenshots | +| [Codebase Documentation](docs/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough | ### 🤖 Protocols & APIs @@ -1459,10 +1499,12 @@ gh release create v2.0.0 --title "v2.0.0" --generate-notes Special thanks to **[9router](https://github.com/decolua/9router)** by **[decolua](https://github.com/decolua)** — the original project that inspired this fork. OmniRoute builds upon that incredible foundation with additional features, multi-modal APIs, and a full TypeScript rewrite. -Special thanks to **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — the original Go implementation that inspired this JavaScript port. +Special thanks to **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** by **[router-for-me](https://github.com/router-for-me)** — the original Go implementation that inspired this JavaScript port. Special thanks to **[Caveman](https://github.com/JuliusBrussee/caveman)** by **[JuliusBrussee](https://github.com/JuliusBrussee)** (⭐ 51K+) — the viral "why use many token when few token do trick" project whose caveman-speak compression philosophy inspired OmniRoute's standard compression mode and 30+ filler/condensation regex rules. +Special thanks to **[RTK - Rust Token Killer](https://github.com/rtk-ai/rtk)** by **[RTK AI](https://github.com/rtk-ai)** — the high-performance command-output compression project whose terminal, build, test, git, and tool-output filtering model inspired OmniRoute's RTK engine, JSON filter DSL, raw-output recovery, and stacked RTK → Caveman compression pipeline. + --- ## 📄 License diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 3b4c38ec29..dfec7d1198 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -216,14 +216,32 @@ Response example: ### Settings -| Endpoint | Method | Description | -| ------------------------------- | ------------- | ---------------------- | -| `/api/settings` | GET/PUT/PATCH | General settings | -| `/api/settings/proxy` | GET/PUT | Network proxy config | -| `/api/settings/proxy/test` | POST | Test proxy connection | -| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist | -| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget | -| `/api/settings/system-prompt` | GET/PUT | Global system prompt | +| Endpoint | Method | Description | +| ------------------------------- | ------------- | ------------------------- | +| `/api/settings` | GET/PUT/PATCH | General settings | +| `/api/settings/proxy` | GET/PUT | Network proxy config | +| `/api/settings/proxy/test` | POST | Test proxy connection | +| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist | +| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget | +| `/api/settings/system-prompt` | GET/PUT | Global system prompt | +| `/api/settings/compression` | GET/PUT | Global compression config | + +### Context & Compression + +| Endpoint | Method | Description | +| -------------------------------------- | -------------- | ------------------------------------------------------------------------ | +| `/api/compression/preview` | POST | Preview off/lite/standard/aggressive/ultra/RTK/stacked compression | +| `/api/compression/language-packs` | GET | List available Caveman language packs | +| `/api/compression/rules` | GET | List Caveman rule metadata | +| `/api/context/caveman/config` | GET/PUT | Caveman-specific settings alias | +| `/api/context/rtk/config` | GET/PUT | RTK-specific settings, including custom filters and raw-output retention | +| `/api/context/rtk/filters` | GET | RTK filter catalog and custom-filter diagnostics | +| `/api/context/rtk/test` | POST | Run RTK preview/test against a text payload | +| `/api/context/rtk/raw-output/[id]` | GET | Read retained redacted raw output by pointer id | +| `/api/context/combos` | GET/POST | Compression combo list/create | +| `/api/context/combos/[id]` | GET/PUT/DELETE | Compression combo detail/update/delete | +| `/api/context/combos/[id]/assignments` | GET/PUT | Assign compression combos to routing combos | +| `/api/context/analytics` | GET | Compression analytics alias | ### Monitoring @@ -451,11 +469,12 @@ Content-Type: application/json 2. Route handler calls `handleChat`, `handleEmbedding`, `handleAudioTranscription`, or `handleImageGeneration` 3. Model is resolved (direct provider/model or alias/combo) 4. Credentials selected from local DB with account availability filtering -5. For chat: `handleChatCore` — format detection, translation, cache check, idempotency check -6. Provider executor sends upstream request -7. Response translated back to client format (chat) or returned as-is (embeddings/images/audio) -8. Usage/logging recorded -9. Fallback applies on errors according to combo rules +5. For chat: `handleChatCore` checks semantic/signature cache and resolves combo compression settings +6. Proactive compression runs before provider translation when enabled (`lite`, Caveman, RTK, or stacked) +7. Provider executor sends upstream request +8. Response translated back to client format (chat) or returned as-is (embeddings/images/audio) +9. Usage, compression analytics, and request logs are recorded +10. Fallback applies on errors according to combo rules Full architecture reference: [`ARCHITECTURE.md`](ARCHITECTURE.md) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 948004b42a..cd08c672d7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ 🌐 **Languages:** 🇺🇸 [English](ARCHITECTURE.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/ARCHITECTURE.md) | 🇪🇸 [Español](i18n/es/ARCHITECTURE.md) | 🇫🇷 [Français](i18n/fr/ARCHITECTURE.md) | 🇮🇹 [Italiano](i18n/it/ARCHITECTURE.md) | 🇷🇺 [Русский](i18n/ru/ARCHITECTURE.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/ARCHITECTURE.md) | 🇩🇪 [Deutsch](i18n/de/ARCHITECTURE.md) | 🇮🇳 [हिन्दी](i18n/in/ARCHITECTURE.md) | 🇹🇭 [ไทย](i18n/th/ARCHITECTURE.md) | 🇺🇦 [Українська](i18n/uk-UA/ARCHITECTURE.md) | 🇸🇦 [العربية](i18n/ar/ARCHITECTURE.md) | 🇯🇵 [日本語](i18n/ja/ARCHITECTURE.md) | 🇻🇳 [Tiếng Việt](i18n/vi/ARCHITECTURE.md) | 🇧🇬 [Български](i18n/bg/ARCHITECTURE.md) | 🇩🇰 [Dansk](i18n/da/ARCHITECTURE.md) | 🇫🇮 [Suomi](i18n/fi/ARCHITECTURE.md) | 🇮🇱 [עברית](i18n/he/ARCHITECTURE.md) | 🇭🇺 [Magyar](i18n/hu/ARCHITECTURE.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/ARCHITECTURE.md) | 🇰🇷 [한국어](i18n/ko/ARCHITECTURE.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/ARCHITECTURE.md) | 🇳🇱 [Nederlands](i18n/nl/ARCHITECTURE.md) | 🇳🇴 [Norsk](i18n/no/ARCHITECTURE.md) | 🇵🇹 [Português (Portugal)](i18n/pt/ARCHITECTURE.md) | 🇷🇴 [Română](i18n/ro/ARCHITECTURE.md) | 🇵🇱 [Polski](i18n/pl/ARCHITECTURE.md) | 🇸🇰 [Slovenčina](i18n/sk/ARCHITECTURE.md) | 🇸🇪 [Svenska](i18n/sv/ARCHITECTURE.md) | 🇵🇭 [Filipino](i18n/phi/ARCHITECTURE.md) | 🇨🇿 [Čeština](i18n/cs/ARCHITECTURE.md) -_Last updated: 2026-04-15_ +_Last updated: 2026-05-02_ ## Executive Summary @@ -52,12 +52,13 @@ Core capabilities: - Compliance audit logging with opt-out per API key - Eval framework for LLM quality assurance - Health dashboard with real-time provider circuit breaker status -- MCP Server (29 tools) with 3 transports (stdio/SSE/Streamable HTTP) +- MCP Server (37 tools) with 3 transports (stdio/SSE/Streamable HTTP) - A2A Server (JSON-RPC 2.0 + SSE) with skills and task lifecycle - Memory system (extraction, injection, retrieval, summarization) - Skills system (registry, executor, sandbox, built-in skills) - MITM proxy with certificate management and DNS handling - Prompt injection guard middleware +- Prompt compression pipeline with Caveman, RTK, stacked pipelines, compression combos, language packs, and analytics - ACP (Agent Communication Protocol) registry - Modular OAuth providers (13 individual modules under `src/lib/oauth/providers/`) - Uninstall/full-uninstall scripts @@ -112,6 +113,9 @@ Main pages under `src/app/(dashboard)/dashboard/`: - `/dashboard/health` — uptime, circuit breakers, rate limits, quota-monitored sessions - `/dashboard/logs` — request/proxy/audit/console logs - `/dashboard/settings` — system settings tabs (general, routing, combo defaults, etc.) +- `/dashboard/context/caveman` — Caveman compression rules, language packs, preview, and output mode +- `/dashboard/context/rtk` — RTK command-output filters, preview, and runtime safety settings +- `/dashboard/context/combos` — named compression pipelines assigned to routing combos - `/dashboard/api-manager` — API key lifecycle and model permissions ## High-Level System Context @@ -203,6 +207,8 @@ Management domains: - IP filter: `src/app/api/settings/ip-filter` (GET/PUT) - Thinking budget: `src/app/api/settings/thinking-budget` (GET/PUT) - System prompt: `src/app/api/settings/system-prompt` (GET/PUT) +- Compression: `src/app/api/settings/compression`, `src/app/api/compression/*`, and + `src/app/api/context/*` - Sessions: `src/app/api/sessions` (GET) - Rate limits: `src/app/api/rate-limits` (GET) - Resilience: `src/app/api/resilience` (GET/PATCH) — request queue, connection cooldown, provider breaker, wait-for-cooldown config @@ -252,6 +258,8 @@ Services (business logic): - Rate limit management: `open-sse/services/rateLimitManager.ts` - Circuit breaker: `open-sse/services/circuitBreaker.ts` - Context handoff: `open-sse/services/contextHandoff.ts` — handoff summary generation and injection for context-relay strategy +- Compression: `open-sse/services/compression/*` — proactive compression before provider translation; + includes Caveman rules, RTK filters, stacked pipelines, compression combos, stats, and validation - Codex quota fetcher: `open-sse/services/codexQuotaFetcher.ts` — fetches Codex quota for context-relay handoff decisions - Cooldown-aware retry: `src/sse/services/cooldownAwareRetry.ts` — per-model cooldown retries with configurable `requestRetry` / `maxRetryIntervalSec` - Safe outbound fetch: `src/shared/network/safeOutboundFetch.ts` — guarded provider/model fetch with SSRF guard, private-URL blocking, retry, and timeout @@ -630,6 +638,12 @@ flowchart LR - `src/app/api/settings/ip-filter`: IP allowlist/blocklist (GET/PUT) - `src/app/api/settings/thinking-budget`: thinking token budget config (GET/PUT) - `src/app/api/settings/system-prompt`: global system prompt (GET/PUT) +- `src/app/api/settings/compression`: global compression settings (GET/PUT) +- `src/app/api/compression/*`: compression preview, rule metadata, and language packs +- `src/app/api/context/caveman/config`: Caveman settings alias (GET/PUT) +- `src/app/api/context/rtk/*`: RTK config, filter catalog, test endpoint, and raw-output recovery +- `src/app/api/context/combos*`: compression combo CRUD and routing-combo assignments +- `src/app/api/context/analytics`: compression analytics alias - `src/app/api/sessions`: active session listing (GET) - `src/app/api/rate-limits`: per-account rate limit status (GET) - `src/app/api/sync/tokens`: sync token CRUD (GET/POST) diff --git a/docs/COMPRESSION_ENGINES.md b/docs/COMPRESSION_ENGINES.md new file mode 100644 index 0000000000..81dc01c1ba --- /dev/null +++ b/docs/COMPRESSION_ENGINES.md @@ -0,0 +1,143 @@ +# Compression Engines + +OmniRoute compression is built around engine contracts. A mode can run one engine directly +(`caveman` or `rtk`) or a deterministic stacked pipeline that executes multiple engines in order. + +## Modes + +| Mode | Engine path | Intended input | +| ------------ | ---------------------------------- | -------------------------------------------- | +| `off` | none | Exact prompt preservation | +| `lite` | Caveman lite helpers | Low-risk always-on cleanup | +| `standard` | Caveman | Natural-language prompt condensation | +| `aggressive` | Caveman + history/tool summarizers | Long chat sessions | +| `ultra` | Caveman + pruning helpers | Context-limit recovery | +| `rtk` | RTK | Terminal, shell, build, test, and git output | +| `stacked` | Pipeline, default `rtk -> caveman` | Mixed tool logs and prose, max savings | + +## Engine Registry + +The registry lives in `open-sse/services/compression/engines/registry.ts`. Engines expose a shared +contract: + +- `id`: stable engine id such as `caveman` or `rtk` +- `label`: dashboard-readable name +- `supports(mode)`: whether the engine can execute a compression mode +- `compress(input)`: transforms text/messages and returns stats + +`strategySelector.ts` registers the built-in engines before compression runs. This lets preview, +runtime compression, stacked mode, tests, and future engines use the same execution path. + +## Caveman + +Caveman mode focuses on semantic condensation of normal prose: + +- preserves code blocks, URLs, JSON, paths, and structured data +- removes filler, hedging, repeated context, and verbose connective phrasing +- supports language-aware file rule packs in `open-sse/services/compression/rules/` +- remains available through the legacy `standard`, `aggressive`, and `ultra` modes + +The dashboard surface is `Dashboard -> Context & Cache -> Caveman`. + +Caveman upstream reports `~75%` fewer output tokens, `65%` average output savings in benchmarks +with a `22-87%` range, and a `~46%` input-compression tool. OmniRoute uses the Caveman input-side +number when documenting stacked prompt/context savings; Caveman output mode remains a separate +response-behavior feature. + +## RTK + +RTK mode focuses on command and tool output: + +- detects output classes such as `git status`, `git branch`, `git diff`, Vitest/Jest/Pytest, + Cargo/Go tests, TypeScript/Vite/Webpack builds, ESLint, npm audit/installs, Docker logs, + shell `find`/`grep`, stack traces, and generic logs +- applies 49 JSON filters from `open-sse/services/compression/engines/rtk/filters/` +- supports the RTK-style declarative pipeline: ANSI stripping, replace, match-output short-circuit, + strip/keep lines, per-line truncation, head/tail/max-line truncation, and on-empty fallback +- supports trust-gated project filters in `.rtk/filters.json` and global filters in + `DATA_DIR/rtk/filters.json` +- strips ANSI sequences, progress noise, repeated lines, and unhelpful boilerplate +- preserves actionable failures, warnings, summaries, changed files, and tail context +- can optionally retain redacted raw output for recovery/debugging through authenticated management + routes + +The dashboard surface is `Dashboard -> Context & Cache -> RTK`. + +Operational details for custom filters, trust, verify, and raw-output recovery live in +[`RTK_COMPRESSION.md`](RTK_COMPRESSION.md). + +RTK upstream reports `60-90%` savings for command-output compression. Its README example shows a +30-minute Claude Code session going from `~118,000` tokens to `~23,900`, or `79.7%` saved. + +## Stacked Pipelines + +Stacked mode runs pipeline steps in order. The default is: + +```txt +rtk -> caveman +``` + +Use this for coding-agent sessions where a prompt combines command output with human or assistant +prose. RTK reduces noisy tool logs first, then Caveman compresses remaining natural language. + +Pipeline steps are configured with `stackedPipeline` in compression settings or through compression +combos. + +When both engines reduce the same eligible payload, savings compound: + +```txt +combined = 1 - (1 - RTK savings) * (1 - Caveman input savings) +average = 1 - (1 - 0.80) * (1 - 0.46) = 89.2% +range = 1 - (1 - 0.60..0.90) * (1 - 0.46) = 78.4-94.6% +``` + +## Compression Combos + +Compression combos are named compression profiles that can be assigned to routing combos: + +- `compression_combos`: stores mode, pipeline, RTK config, language config, and default marker +- `compression_combo_assignments`: maps a compression combo to a routing combo +- runtime integration resolves an assigned compression combo before generic combo overrides +- analytics include `compression_combo_id` and `engine` + +Dashboard surface: `Dashboard -> Context & Cache -> Compression Combos`. + +## API Surface + +| Route | Purpose | +| -------------------------------------- | ------------------------------------------ | +| `/api/settings/compression` | Global compression settings | +| `/api/compression/preview` | Preview any compression mode | +| `/api/compression/language-packs` | List available Caveman language packs | +| `/api/context/caveman/config` | Caveman settings alias | +| `/api/context/rtk/config` | RTK defaults and settings | +| `/api/context/rtk/filters` | RTK filter catalog | +| `/api/context/rtk/test` | RTK preview/test endpoint | +| `/api/context/rtk/raw-output/[id]` | Authenticated redacted raw-output recovery | +| `/api/context/combos` | Compression combo CRUD | +| `/api/context/combos/[id]/assignments` | Routing-combo assignment CRUD | +| `/api/context/analytics` | Compression analytics alias | + +Management routes require management authentication or API-key policy checks. + +## MCP Tools + +Compression exposes five MCP tools: + +| Tool | Scope | Purpose | +| ----------------------------------- | ------------------- | -------------------------------- | +| `omniroute_compression_status` | `read:compression` | Settings, analytics, cache stats | +| `omniroute_compression_configure` | `write:compression` | Update global settings | +| `omniroute_set_compression_engine` | `write:compression` | Set mode and optional pipeline | +| `omniroute_list_compression_combos` | `read:compression` | List compression combos | +| `omniroute_compression_combo_stats` | `read:compression` | Read combo/engine analytics | + +## Validation + +The focused gates for this area are: + +```bash +node --import tsx/esm --test tests/unit/compression/rtk-*.test.ts tests/unit/compression/pipeline-integration.test.ts tests/unit/compression/context-compression-api.test.ts +node --import tsx/esm --test tests/unit/compression/*.test.ts tests/golden-set/*.test.ts tests/integration/compression-pipeline.test.ts tests/unit/api/compression/compression-api.test.ts +npm run typecheck:core +``` diff --git a/docs/COMPRESSION_GUIDE.md b/docs/COMPRESSION_GUIDE.md index a6db971396..88213bfc63 100644 --- a/docs/COMPRESSION_GUIDE.md +++ b/docs/COMPRESSION_GUIDE.md @@ -1,6 +1,6 @@ # 🗜️ Prompt Compression Guide — OmniRoute -> Save 15-75% on token costs automatically. For a quick overview, see the [README Compression section](../README.md#%EF%B8%8F-prompt-compression--save-15-75-tokens-automatically). +> Save 15-95% on eligible context automatically. For a quick overview, see the [README Compression section](../README.md#%EF%B8%8F-prompt-compression--save-15-95-eligible-tokens-automatically). ## Overview @@ -19,6 +19,8 @@ Client Request → Standard: Caveman-speak filler removal (~30%) → Aggressive: History aging + summarization (~50%) → Ultra: Heuristic pruning + code-block thinning (~75%) + → RTK: Command-aware terminal/tool-output filtering (60-90% upstream range) + → Stacked: Ordered multi-engine pipeline, usually RTK then Caveman (78-95% eligible range) → Compressed Request → Provider ``` @@ -77,6 +79,65 @@ Maximum compression for token-critical scenarios: **Best for:** When you're hitting context limits repeatedly. +### RTK Mode (60-90% upstream range) + +RTK mode is optimized for verbose tool outputs that appear in coding-agent sessions: + +- Detects command/output classes such as `git status`, `git diff`, `git log`, test runners, + TypeScript/Vite/Webpack builds, ESLint/Biome/Prettier, npm audit/installs, Docker logs, infra + output, and generic shell output +- Applies JSON filter packs from `open-sse/services/compression/engines/rtk/filters/` +- Ships 49 built-in filters with inline verify samples +- Removes ANSI control sequences, progress bars, repeated lines, and non-actionable noise +- Preserves failures, errors, warnings, changed files, summaries, and the tail of long output +- Supports trust-gated project filters, global filters, and optional redacted raw-output recovery + +**Best for:** Agent sessions with shell, build, test, git, grep, and file-output transcripts. + +### Stacked Mode (78-95% eligible range) + +Stacked mode runs multiple compression engines in a deterministic order. The default pipeline is: + +```txt +RTK -> Caveman +``` + +That order keeps terminal/tool output compact first, then applies Caveman semantic condensation to +the remaining natural-language prompt. Stacked pipelines can be configured globally or through +compression combos assigned to routing combos. + +**Best for:** Mixed context with large tool logs plus human instructions or assistant summaries. + +--- + +## Upstream Savings Math + +OmniRoute documents compression savings from two sources: upstream project benchmarks and +OmniRoute's own engine composition. + +| Source | Upstream README number used here | +| ------- | --------------------------------------------------------------------------------------------------------------------- | +| Caveman | `~75%` fewer output tokens, `65%` benchmark average output savings, `22-87%` range, and `~46%` input compression tool | +| RTK | `60-90%` command-output savings; sample session `~118,000 -> ~23,900` tokens, or `79.7%` saved (`~80%`) | + +For overlapping tool/context payloads, the default OmniRoute combo stacks the engines: + +```txt +RTK -> Caveman +``` + +The combined savings are multiplicative, not additive: + +```txt +combined = 1 - (1 - RTK savings) * (1 - Caveman input savings) +average = 1 - (1 - 0.80) * (1 - 0.46) = 89.2% +range = 1 - (1 - 0.60..0.90) * (1 - 0.46) = 78.4-94.6% +``` + +That `78-95%` number applies when both RTK and Caveman can reduce the same input/context payload. +Caveman response output mode is separate: when enabled, use Caveman's own output savings (`65%` +average, `~75%` headline, `22-87%` range). Total billing savings depend on your prompt/output mix. + --- ## Token Savings Visualization @@ -87,6 +148,8 @@ With Lite: 40K tokens sent (15% saved — safe, always-on) With Standard: 33K tokens sent (30% saved — caveman-speak rules) With Aggressive: 24K tokens sent (50% saved — aging + summarization) With Ultra: 12K tokens sent (75% saved — heuristic pruning) +With RTK: 19K-5K tokens sent (60-90% saved on command/tool output) +With Stacked: 10K-2.5K tokens sent (78-95% eligible RTK+Caveman range) ``` --- @@ -95,25 +158,29 @@ With Ultra: 12K tokens sent (75% saved — heuristic pruning) ### Dashboard -Navigate to `Dashboard → Settings → Compression`: +Navigate to `Dashboard → Context & Cache`: -- **Default Mode** — sets the system-wide compression mode +- **Caveman** — mode selection, language packs, preview, and global defaults +- **RTK** — command-filter preview, RTK safety settings, and filter catalog +- **Compression Combos** — named engine pipelines assigned to routing combos - **Auto-Trigger Threshold** — automatically engage compression when token count exceeds threshold -- **Per-Combo Override** — each combo can have its own compression mode ### Per-Combo Override -In `Dashboard → Combos → [Your Combo] → Advanced`, set compression mode per combo: +In `Dashboard → Context & Cache → Compression Combos`, assign a compression combo to a routing +combo: ```txt Combo: "free-forever" - Mode: Standard + Compression Combo: "coding-agent-stack" + Pipeline: RTK -> Caveman Targets: 1. gc/gemini-3-flash 2. if/kimi-k2-thinking ``` -This lets you use aggressive compression on free providers while keeping lite mode on paid subscriptions. +This lets you use stacked compression on free/coding providers while keeping lite mode on paid +subscriptions. ### API @@ -124,7 +191,20 @@ curl http://localhost:20128/api/settings/compression # Update compression settings curl -X PUT http://localhost:20128/api/settings/compression \ -H "Content-Type: application/json" \ - -d '{"defaultMode":"lite","autoTriggerThreshold":32000}' + -d '{"defaultMode":"stacked","autoTriggerMode":"stacked","autoTriggerTokens":32000}' + +# Preview a specific RTK/stacked payload +curl -X POST http://localhost:20128/api/compression/preview \ + -H "Content-Type: application/json" \ + -d '{"mode":"rtk","messages":[{"role":"tool","content":"npm test output here"}]}' + +# List RTK filter packs +curl http://localhost:20128/api/context/rtk/filters + +# Test RTK directly with optional command metadata +curl -X POST http://localhost:20128/api/context/rtk/test \ + -H "Content-Type: application/json" \ + -d '{"command":"npm test","text":"FAIL tests/example.test.ts\nError: boom"}' ``` --- @@ -136,11 +216,14 @@ The compression engine **always preserves:** - ✅ Code blocks (fenced and inline) - ✅ URLs and file paths - ✅ JSON structures and structured data -- ✅ API keys, tokens, and identifiers +- ✅ Identifiers and protected technical tokens - ✅ Mathematical expressions - ✅ Tool/function call definitions - ✅ System prompts (in lite mode) +RTK raw-output recovery redacts common API keys, bearer tokens, Slack tokens, AWS access keys, +passwords, tokens, and secrets before anything is persisted. + --- ## Compression Stats @@ -154,7 +237,10 @@ Every compressed request includes stats in the server logs: "savingsPercent": 15.0, "techniquesUsed": ["collapseWhitespace", "dedupSystemPrompt"], "mode": "lite", - "latencyMs": 0.8 + "engine": "caveman", + "compressionComboId": "coding-agent-stack", + "durationMs": 0.8, + "rtkRawOutputPointers": [] } ``` @@ -166,13 +252,16 @@ Every compressed request includes stats in the server logs: | ------- | ------------------------------------ | ---------- | | Phase 1 | Off, Lite | ✅ Shipped | | Phase 2 | Standard, Aggressive, Ultra | ✅ Shipped | -| Phase 3 | Per-model adaptive, ML-based pruning | 🗓️ Planned | +| Phase 3 | RTK, Stacked, Compression Combos | ✅ Shipped | +| Phase 4 | Per-model adaptive, ML-based pruning | 🗓️ Planned | --- ## Acknowledgments -Standard mode compression rules are inspired by **[Caveman](https://github.com/JuliusBrussee/caveman)** by **[JuliusBrussee](https://github.com/JuliusBrussee)** (⭐ 51K+) — the viral "why use many token when few token do trick" project. +Standard mode compression rules are inspired by **[Caveman](https://github.com/JuliusBrussee/caveman)** by **[JuliusBrussee](https://github.com/JuliusBrussee)** (⭐ 51K+) — the viral "why use many token when few token do trick" project. Caveman reports `~75%` fewer output tokens, `65%` benchmark average output savings, a `22-87%` output range, and a `~46%` input-compression tool. + +RTK mode is inspired by **[RTK - Rust Token Killer](https://github.com/rtk-ai/rtk)** by **[RTK AI](https://github.com/rtk-ai)** — the high-performance command-output compression project for terminal, build, test, git, and tool-output filtering. RTK reports `60-90%` savings, with its README sample session showing `~80%` saved. --- @@ -181,3 +270,7 @@ Standard mode compression rules are inspired by **[Caveman](https://github.com/J - [Environment Config](ENVIRONMENT.md) — Compression environment variables - [Architecture Guide](ARCHITECTURE.md) — Compression pipeline internals - [User Guide](USER_GUIDE.md) — Getting started with compression +- [RTK Compression](RTK_COMPRESSION.md) — RTK filters, trust model, verify gate, raw-output recovery +- [Compression Engines](COMPRESSION_ENGINES.md) — Caveman, RTK, stacked, APIs, MCP, dashboard +- [Compression Rules Format](COMPRESSION_RULES_FORMAT.md) — JSON rule-pack format +- [Compression Language Packs](COMPRESSION_LANGUAGE_PACKS.md) — Language-specific Caveman rules diff --git a/docs/COMPRESSION_LANGUAGE_PACKS.md b/docs/COMPRESSION_LANGUAGE_PACKS.md new file mode 100644 index 0000000000..805a7db1fc --- /dev/null +++ b/docs/COMPRESSION_LANGUAGE_PACKS.md @@ -0,0 +1,96 @@ +# Compression Language Packs + +Caveman compression can load language-specific rule packs in addition to the built-in English rules. +This keeps the core engine stable while allowing Portuguese, Spanish, German, French, Japanese, and +future language packs to evolve independently. + +## Location + +Language packs live under: + +```txt +open-sse/services/compression/rules// +``` + +Current shipped packs include: + +| Language | Directory | +| ------------------- | -------------- | +| English | `rules/en/` | +| Portuguese (Brazil) | `rules/pt-BR/` | +| Spanish | `rules/es/` | +| German | `rules/de/` | +| French | `rules/fr/` | +| Japanese | `rules/ja/` | + +## Language Detection + +`languageDetector.ts` uses lightweight heuristics to infer the language from prompt text. The +configured default language is still respected, and detection can be disabled by config when exact +control is required. + +Detection output is used only to choose rule packs. It does not change provider routing, locale +selection, or UI language. + +## Config Shape + +Compression settings can include: + +```json +{ + "languageConfig": { + "enabled": true, + "defaultLanguage": "en", + "autoDetect": true, + "enabledPacks": ["en", "pt-BR", "es", "de", "fr", "ja"] + }, + "cavemanConfig": { + "language": "en", + "autoDetectLanguage": true, + "enabledLanguagePacks": ["en", "pt-BR", "es", "de", "fr", "ja"] + } +} +``` + +`languageConfig` controls dashboard/preview defaults. `cavemanConfig` is the runtime engine config +used when Caveman compresses message text. + +## Adding a Language Pack + +1. Create `open-sse/services/compression/rules//.json`. +2. Use the Caveman rule format from `docs/COMPRESSION_RULES_FORMAT.md`. +3. Keep replacements conservative and avoid changing code, identifiers, URLs, or JSON. +4. Add or update tests for language selection and replacement behavior. +5. Expose new dashboard/i18n labels if the language appears in UI selectors. + +## API + +Available packs can be queried with: + +```bash +curl http://localhost:20128/api/compression/language-packs +``` + +The preview endpoint accepts language config overrides: + +```bash +curl -X POST http://localhost:20128/api/compression/preview \ + -H "Content-Type: application/json" \ + -d '{ + "mode": "standard", + "text": "Por favor, eu gostaria que voce basicamente resumisse isso.", + "config": { + "languageConfig": { + "defaultLanguage": "pt-BR", + "autoDetect": true + } + } + }' +``` + +## Operational Notes + +- English built-in rules remain the fallback when a language pack is missing. +- Invalid built-in JSON packs fail validation so release assets do not silently degrade. +- Rule packs are data-only and should not import code or run arbitrary logic. +- The compression analytics layer records the selected mode and engine, not full prompt text. diff --git a/docs/COMPRESSION_RULES_FORMAT.md b/docs/COMPRESSION_RULES_FORMAT.md new file mode 100644 index 0000000000..e54343968b --- /dev/null +++ b/docs/COMPRESSION_RULES_FORMAT.md @@ -0,0 +1,186 @@ +# Compression Rules Format + +Compression rules are JSON files loaded at runtime. They are intentionally data-only so new +language packs and RTK command filters can be reviewed without changing engine code. + +## Caveman Rule Packs + +Caveman rule packs live under: + +```txt +open-sse/services/compression/rules//.json +``` + +Each pack contains replacements that apply to normal prose after protected regions are isolated. + +```json +{ + "language": "en", + "category": "filler", + "rules": [ + { + "name": "question_to_directive", + "pattern": "\\b(?:Can you explain why|Could you show me how)\\b\\s*", + "replacement": "Explain why ", + "replacementMap": { + "can you explain why": "Explain why ", + "could you show me how": "Show how " + }, + "flags": "gi", + "context": "all", + "category": "context", + "minIntensity": "lite", + "description": "Convert verbose questions into direct requests." + } + ] +} +``` + +### Caveman Fields + +| Field | Required | Description | +| ------------------------ | -------- | ---------------------------------------------------------------- | +| `language` | yes | BCP-47-like language key such as `en`, `pt-BR`, `es` | +| `category` | yes | Pack category filename/category, for example `filler` or `dedup` | +| `rules` | yes | Array of regex replacement rules | +| `rules[].name` | yes | Stable rule name | +| `rules[].pattern` | yes | JavaScript regex source | +| `rules[].flags` | no | JavaScript regex flags; default `gi` | +| `rules[].replacement` | no | Replacement string or fallback when `replacementMap` misses | +| `rules[].replacementMap` | no | Match-specific replacements keyed by normalized matched text | +| `rules[].context` | no | `all`, `user`, `assistant`, or `system`; default `all` | +| `rules[].category` | no | `filler`, `context`, `structural`, `dedup`, `terse`, or `ultra` | +| `rules[].minIntensity` | no | `lite`, `full`, or `ultra`; default `lite` | +| `rules[].description` | no | Human-readable rule summary | + +Use `flags` when case-sensitive matching matters, for example article removal before lowercase prose +without stripping `the OpenAI API`. Use `replacementMap` when one regex has multiple alternatives +that need different outputs; this keeps JSON rule packs data-only while preserving the behavior of +the richer built-in TypeScript replacement functions. + +## RTK Filter Packs + +RTK filters live under: + +```txt +open-sse/services/compression/engines/rtk/filters/.json +``` + +Each filter describes how to recognize and compress a command-output family. + +```json +{ + "id": "test-vitest", + "label": "Vitest output", + "category": "test", + "priority": 92, + "match": { + "outputTypes": ["test-vitest"], + "commands": ["vitest", "npm test", "npm run test"], + "patterns": ["\\bFAIL\\b", "\\bPASS\\b", "\\bTest Files\\b"] + }, + "rules": { + "stripAnsi": true, + "replace": [{ "pattern": "\\s+\\[[0-9]+ms\\]", "replacement": "" }], + "matchOutput": [ + { "pattern": "All tests passed", "message": "vitest: ok", "unless": "FAIL|Error:" } + ], + "includePatterns": ["FAIL", "Error:", "Test Files", "Tests"], + "dropPatterns": ["^\\s*$", "Duration\\s+\\d+"], + "collapsePatterns": ["^\\s+at "], + "deduplicate": true, + "truncateLineAt": 240, + "maxLines": 160, + "headLines": 24, + "tailLines": 40, + "onEmpty": "vitest: ok", + "filterStderr": false + }, + "preserve": { + "errorPatterns": ["FAIL", "Error:", "AssertionError"], + "summaryPatterns": ["Test Files", "Tests", "Snapshots"] + }, + "tests": [ + { + "name": "keeps failing tests", + "command": "vitest", + "input": "FAIL test/a.test.ts\\nError: boom\\nTest Files 1 failed", + "expected": "FAIL test/a.test.ts\\nError: boom\\nTest Files 1 failed" + } + ] +} +``` + +### RTK Fields + +| Field | Required | Description | +| -------------------------- | -------- | ------------------------------------------------------------------------------ | +| `id` | yes | Stable filter id | +| `label` | yes | Dashboard-readable name | +| `category` | yes | Filter family: git, test, build, shell, docker, package, infra, cloud, generic | +| `priority` | no | Higher priority wins when multiple filters match | +| `match.outputTypes` | no | Detector output ids that select this filter | +| `match.commands` | no | Command tokens that select this filter | +| `match.patterns` | no | Regex patterns that select this filter from output text | +| `rules.stripAnsi` | no | Remove ANSI escape sequences before regex stages | +| `rules.replace` | no | Ordered regex substitutions applied line by line | +| `rules.matchOutput` | no | Short-circuit output rules with optional `unless` guard | +| `rules.includePatterns` | no | Lines to prefer preserving | +| `rules.dropPatterns` | no | Lines to remove as noise | +| `rules.collapsePatterns` | no | Repeated matching lines that can be collapsed | +| `rules.deduplicate` | no | Collapse duplicate normalized lines | +| `rules.truncateLineAt` | no | Unicode-safe per-line character limit | +| `rules.maxLines` | no | Maximum retained lines before tail preservation | +| `rules.headLines` | no | Head lines retained during truncation | +| `rules.tailLines` | no | Tail lines retained for recent context | +| `rules.onEmpty` | no | Fallback message when filtering removes all content | +| `rules.filterStderr` | no | Normalize common stderr prefixes before later filtering stages | +| `preserve.errorPatterns` | no | Error lines that should survive truncation | +| `preserve.summaryPatterns` | no | Summary lines that should survive truncation | +| `tests[]` | no | Inline verification samples used by the RTK verify gate | + +RTK applies declarative stages in this order: `stripAnsi`, `filterStderr`, `replace`, +`matchOutput`, `dropPatterns`/`includePatterns`, `truncateLineAt`, `headLines`/`tailLines`, +`maxLines`, and `onEmpty`. + +Custom filters can be loaded from: + +1. Project `.rtk/filters.json` files only after a matching `.rtk/trust.json` hash is present or + `trustProjectFilters` is enabled. +2. Global `DATA_DIR/rtk/filters.json`. +3. Built-in filters. + +Project/global custom files may contain one filter object or an array of filter objects. Invalid +custom filters are skipped with diagnostics; invalid built-in filters fail validation. + +Project trust file: + +```json +{ + "filtersSha256": "0123456789abcdef..." +} +``` + +The environment override `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=1` trusts project filters without a +hash and should be limited to controlled local development. + +## Safety Rules + +- Keep rules idempotent: running the same filter twice should not corrupt output. +- Preserve exact error text, file paths, line numbers, and command summaries where possible. +- Avoid rules that modify code blocks, JSON payloads, URLs, or secrets. +- Add unit coverage for new command families in detector/filter tests. +- Add `tests[]` samples to every built-in filter and to shared custom filters. + +## Validation + +Rule packs are validated before use. Built-in Caveman packs and built-in RTK filters fail fast +during validation so broken release assets are caught before shipment. Custom RTK filters are +skipped with diagnostics when parsing or trust validation fails. + +Focused validation: + +```bash +node --import tsx/esm --test tests/unit/compression/rule-loader.test.ts tests/unit/compression/language-packs.test.ts +node --import tsx/esm --test tests/unit/compression/rtk-verify.test.ts tests/unit/compression/rtk-dsl-pipeline.test.ts +``` diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 02e7c2843b..066d800705 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -497,6 +497,12 @@ The logging system writes to both stdout and rotated log files. All configuratio | `CONTEXT_LENGTH_DEFAULT` | `128000` | Global fallback max context length for models without explicit config. | | `USAGE_TOKEN_BUFFER` | `100` | Extra token headroom reserved when tracking usage quotas. | +### Compression + +| Variable | Default | Description | +| ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | unset | Trust project `.rtk/filters.json` without a `.rtk/trust.json` hash. Use only in controlled local development. | + ### Low-RAM Docker Example ```bash diff --git a/docs/FEATURES.md b/docs/FEATURES.md index d68c6a6db9..4c8e9e455f 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,6 +1,6 @@ # OmniRoute — Dashboard Features Gallery -🌐 **Languages:** 🇺🇸 [English](FEATURES.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/FEATURES.md) | 🇪🇸 [Español](i18n/es/FEATURES.md) | 🇫🇷 [Français](i18n/fr/FEATURES.md) | 🇮🇹 [Italiano](i18n/it/FEATURES.md) | 🇷🇺 [Русский](i18n/ru/FEATURES.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/FEATURES.md) | 🇩🇪 [Deutsch](i18n/de/FEATURES.md) | 🇮🇳 [हिन्दी](i18n/in/FEATURES.md) | 🇹🇭 [ไทย](i18n/th/FEATURES.md) | 🇺🇦 [Українська](i18n/uk-UA/FEATURES.md) | 🇸🇦 [العربية](i18n/ar/FEATURES.md) | 🇯🇵 [日本語](i18n/ja/FEATURES.md) | 🇻🇳 [Tiếng Việt](i18n/vi/FEATURES.md) | 🇧🇬 [Български](i18n/bg/FEATURES.md) | 🇩🇰 [Dansk](i18n/da/FEATURES.md) | 🇫🇮 [Suomi](i18n/fi/FEATURES.md) | 🇮🇱 [עברית](i18n/he/FEATURES.md) | 🇭🇺 [Magyar](i18n/hu/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/FEATURES.md) | 🇰🇷 [한국어](i18n/ko/FEATURES.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/FEATURES.md) | 🇳🇱 [Nederlands](i18n/nl/FEATURES.md) | 🇳🇴 [Norsk](i18n/no/FEATURES.md) | 🇵🇹 [Português (Portugal)](i18n/pt/FEATURES.md) | 🇷🇴 [Română](i18n/ro/FEATURES.md) | 🇵🇱 [Polski](i18n/pl/FEATURES.md) | 🇸🇰 [Slovenčina](i18n/sk/FEATURES.md) | 🇸🇪 [Svenska](i18n/sv/FEATURES.md) | 🇵🇭 [Filipino](i18n/phi/FEATURES.md) | 🇨🇿 [Čeština](i18n/cs/FEATURES.md) +🌐 **Main README translations:** 🇺🇸 [English](../README.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/README.md) | 🇪🇸 [Español](i18n/es/README.md) | 🇫🇷 [Français](i18n/fr/README.md) | 🇮🇹 [Italiano](i18n/it/README.md) | 🇷🇺 [Русский](i18n/ru/README.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/README.md) | 🇩🇪 [Deutsch](i18n/de/README.md) | 🇮🇳 [हिन्दी](i18n/in/README.md) | 🇹🇭 [ไทย](i18n/th/README.md) | 🇺🇦 [Українська](i18n/uk-UA/README.md) | 🇸🇦 [العربية](i18n/ar/README.md) | 🇯🇵 [日本語](i18n/ja/README.md) | 🇻🇳 [Tiếng Việt](i18n/vi/README.md) | 🇧🇬 [Български](i18n/bg/README.md) | 🇩🇰 [Dansk](i18n/da/README.md) | 🇫🇮 [Suomi](i18n/fi/README.md) | 🇮🇱 [עברית](i18n/he/README.md) | 🇭🇺 [Magyar](i18n/hu/README.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/README.md) | 🇰🇷 [한국어](i18n/ko/README.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/README.md) | 🇳🇱 [Nederlands](i18n/nl/README.md) | 🇳🇴 [Norsk](i18n/no/README.md) | 🇵🇹 [Português (Portugal)](i18n/pt/README.md) | 🇷🇴 [Română](i18n/ro/README.md) | 🇵🇱 [Polski](i18n/pl/README.md) | 🇸🇰 [Slovenčina](i18n/sk/README.md) | 🇸🇪 [Svenska](i18n/sv/README.md) | 🇵🇭 [Filipino](i18n/phi/README.md) | 🇨🇿 [Čeština](i18n/cs/README.md) Visual guide to every section of the OmniRoute dashboard. @@ -109,7 +109,21 @@ Configurable via combo-level or global settings: - **Max Messages For Summary** — How much recent history to condense - **Summary Model** — Optional override model for generating the handoff summary -Currently supports Codex account rotation. See [Context Relay documentation](features/context-relay.md). +Currently supports Codex account rotation. See [Context Relay documentation](ARCHITECTURE.md). + +--- + +## 🗜️ Prompt Compression _(v3.7.9+)_ + +Context & Cache now exposes dedicated pages for Caveman, RTK, and Compression Combos: + +- **Caveman** — language-aware rule packs, preview, output-mode controls, and analytics +- **RTK** — command-aware compression for shell, git, test, build, package, Docker, infra, JSON, and stack-trace output +- **Compression Combos** — named pipelines such as `rtk -> caveman` assigned to routing combos; the default stacked math reaches `~89%` average and `78-95%` eligible-context savings when both engines apply +- **Raw-output recovery** — optional redacted RTK raw-output pointers for debugging compressed failures + +See [Compression Guide](COMPRESSION_GUIDE.md), [RTK Compression](RTK_COMPRESSION.md), and +[Compression Engines](COMPRESSION_ENGINES.md). --- diff --git a/docs/MCP-SERVER.md b/docs/MCP-SERVER.md index 2f8e6bab3f..4312fb0f12 100644 --- a/docs/MCP-SERVER.md +++ b/docs/MCP-SERVER.md @@ -1,6 +1,6 @@ # OmniRoute MCP Server Documentation -> Model Context Protocol server with 16 intelligent tools +> Model Context Protocol server with 37 tools across routing, cache, compression, memory, skills, and proxy operations ## Installation @@ -19,7 +19,8 @@ omniroute --dev # MCP auto-starts on /mcp endpoint ## IDE Configuration -See [IDE Configs](integrations/ide-configs.md) for Antigravity, Cursor, Copilot, and Claude Desktop setup. +See [MCP Client Configuration](SETUP_GUIDE.md#mcp-client-configuration) for Claude Desktop, +Cursor, Cline, and compatible MCP client setup. --- @@ -49,20 +50,55 @@ See [IDE Configs](integrations/ide-configs.md) for Antigravity, Cursor, Copilot, | `omniroute_explain_route` | Explain a past routing decision | | `omniroute_get_session_snapshot` | Full session state: costs, tokens, errors | +## Cache Tools (2) + +| Tool | Description | +| :---------------------- | :-------------------------------------------------- | +| `omniroute_cache_stats` | Semantic cache, prompt-cache, and idempotency stats | +| `omniroute_cache_flush` | Flush cache globally or by signature/model | + +## Compression Tools (5) + +| Tool | Description | +| :---------------------------------- | :------------------------------------------------------------- | +| `omniroute_compression_status` | Compression settings, analytics summary, and cache-aware stats | +| `omniroute_compression_configure` | Configure compression mode, threshold, and runtime options | +| `omniroute_set_compression_engine` | Set Caveman, RTK, or stacked compression mode and pipeline | +| `omniroute_list_compression_combos` | List named compression combos and routing assignments | +| `omniroute_compression_combo_stats` | Analytics grouped by compression combo and engine | + +`omniroute_compression_status` reports MCP description compression separately under +`analytics.mcpDescriptionCompression`. Those values are metadata-size estimates for MCP listable +descriptions (`tools`, `prompts`, `resources`, and `resourceTemplates`); they are not provider usage +receipts and are marked with `source: "mcp_metadata_estimate"`. + +See [Compression Engines](COMPRESSION_ENGINES.md) and [RTK Compression](RTK_COMPRESSION.md) for +the runtime compression model behind these tools. + +## Other Tool Groups + +The remaining MCP surface includes 1proxy tools, memory tools, and skill tools. The live source of +truth is `open-sse/mcp-server/tools/` and `open-sse/mcp-server/schemas/tools.ts`. + ## Authentication MCP tools are authenticated via API key scopes. Each tool requires specific scopes: -| Scope | Tools | -| :------------- | :----------------------------------------------- | -| `read:health` | get_health, get_provider_metrics | -| `read:combos` | list_combos, get_combo_metrics | -| `write:combos` | switch_combo | -| `read:quota` | check_quota | -| `write:route` | route_request, simulate_route, test_combo | -| `read:usage` | cost_report, get_session_snapshot, explain_route | -| `write:config` | set_budget_guard, set_resilience_profile | -| `read:models` | list_models_catalog, best_combo_for_task | +| Scope | Tools | +| :-------------------- | :------------------------------------------------------------------- | +| `read:health` | get_health, get_provider_metrics | +| `read:combos` | list_combos, get_combo_metrics | +| `write:combos` | switch_combo | +| `read:quota` | check_quota | +| `write:route` | route_request, simulate_route, test_combo | +| `read:usage` | cost_report, get_session_snapshot, explain_route | +| `write:config` | set_budget_guard, set_resilience_profile | +| `read:models` | list_models_catalog, best_combo_for_task | +| `read:cache` | cache_stats | +| `write:cache` | cache_flush | +| `read:compression` | compression_status, list_compression_combos, compression_combo_stats | +| `write:compression` | compression_configure, set_compression_engine | +| `execute:completions` | route_request, test_combo | ## Audit Logging @@ -74,10 +110,10 @@ Every tool call is logged to `mcp_tool_audit` with: ## Files -| File | Purpose | -| :------------------------------------------- | :------------------------------------------ | -| `open-sse/mcp-server/server.ts` | MCP server creation + 16 tool registrations | -| `open-sse/mcp-server/transport.ts` | Stdio + HTTP transport | -| `open-sse/mcp-server/auth.ts` | API key + scope validation | -| `open-sse/mcp-server/audit.ts` | Tool call audit logging | -| `open-sse/mcp-server/tools/advancedTools.ts` | 8 advanced tool handlers | +| File | Purpose | +| :------------------------------------------- | :------------------------------------------------ | +| `open-sse/mcp-server/server.ts` | MCP server creation and scoped tool registrations | +| `open-sse/mcp-server/transport.ts` | Stdio + HTTP transport | +| `open-sse/mcp-server/auth.ts` | API key + scope validation | +| `open-sse/mcp-server/audit.ts` | Tool call audit logging | +| `open-sse/mcp-server/tools/advancedTools.ts` | 8 advanced tool handlers | diff --git a/docs/RTK_COMPRESSION.md b/docs/RTK_COMPRESSION.md new file mode 100644 index 0000000000..f675c93d25 --- /dev/null +++ b/docs/RTK_COMPRESSION.md @@ -0,0 +1,234 @@ +# RTK Compression + +RTK compression is OmniRoute's command-aware compression engine for terminal and tool output. It is +designed for coding-agent sessions where most context growth comes from test logs, build output, +package manager noise, shell transcripts, Docker output, git output, and stack traces. + +RTK can run directly with `defaultMode: "rtk"` or as the first step in a stacked pipeline, usually: + +```txt +rtk -> caveman +``` + +That order compresses noisy machine output first, then lets Caveman condense remaining prose. + +Upstream RTK reports `60-90%` command-output savings. Its README sample session goes from +`~118,000` standard tokens to `~23,900` RTK tokens, which is `79.7%` saved (`~80%`). OmniRoute uses +that upstream average for the stacked savings calculation with Caveman input compression: + +```txt +RTK average: 80% saved +Caveman input: 46% saved +Stacked: 1 - (1 - 0.80) * (1 - 0.46) = 89.2% saved +Range: 1 - (1 - 0.60..0.90) * (1 - 0.46) = 78.4-94.6% +``` + +## What It Compresses + +The built-in catalog currently ships 49 filters across these categories: + +| Category | Examples | +| --------- | ------------------------------------------------------------- | +| `git` | `git status`, `git branch`, `git diff`, `git log` | +| `test` | Vitest, Jest, Pytest, Playwright, Go tests, Cargo tests | +| `build` | TypeScript, ESLint, Biome, Prettier, Vite, Webpack, Turbo, Nx | +| `package` | `npm install`, `npm audit`, `pip`, `uv sync`, Poetry, Bundler | +| `shell` | `ls`, `find`, `grep`, generic shell logs | +| `docker` | `docker ps`, Docker logs | +| `infra` | Terraform, OpenTofu, `systemctl status` | +| `generic` | JSON output, stack traces, generic output fallback | + +The detector in `open-sse/services/compression/engines/rtk/commandDetector.ts` classifies output +before filter selection. Filters can also match by command pattern or output regex when a command +class is not enough. + +## Filter Resolution + +RTK loads filters in this order: + +1. Project filters from `.rtk/filters.json`, only when trusted. +2. Global filters from `DATA_DIR/rtk/filters.json`. +3. Built-in filters from `open-sse/services/compression/engines/rtk/filters/`. + +Project filters are intentionally trust-gated because regex filters can change how tool output is +shown to agents. A project filter file is accepted when one of these is true: + +- `rtkConfig.trustProjectFilters` is `true`. +- `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=1` is set. +- `.rtk/trust.json` contains the SHA-256 hash of `.rtk/filters.json`. + +Trust file example: + +```json +{ + "filtersSha256": "0123456789abcdef..." +} +``` + +Custom filters can be one filter object or an array of filter objects. Invalid custom filters are +skipped and reported by `/api/context/rtk/filters` diagnostics. Invalid built-in filters fail fast. + +## Filter DSL + +Filters use the JSON schema described in [Compression Rules Format](COMPRESSION_RULES_FORMAT.md). +The runtime applies these stages in order: + +```txt +stripAnsi -> filterStderr -> replace -> matchOutput -> drop/include lines + -> truncateLineAt -> head/tail/maxLines -> onEmpty +``` + +Important fields: + +| Field | Purpose | +| ---------------------------- | -------------------------------------------------------------- | +| `rules.stripAnsi` | Remove terminal color/control sequences before matching | +| `rules.filterStderr` | Normalize common stderr prefixes before matching/filtering | +| `rules.replace` | Apply ordered regex replacements | +| `rules.matchOutput` | Return a compact summary when output matches a known condition | +| `rules.matchOutput[].unless` | Skip the shortcut when an error/failure pattern is present | +| `rules.dropPatterns` | Remove noisy lines | +| `rules.includePatterns` | Prefer actionable lines | +| `rules.collapsePatterns` | Collapse repeated matching lines | +| `rules.truncateLineAt` | Unicode-safe per-line truncation | +| `rules.onEmpty` | Fallback message if all lines are filtered out | +| `tests[]` | Inline samples used by the verify gate | + +Built-in filters are expected to include inline `tests[]` samples. Custom filters should include +them too, especially when they are shared across projects. + +## Configuration + +Global settings are available through `/api/settings/compression`. RTK-specific settings are also +available through `/api/context/rtk/config`. + +```json +{ + "defaultMode": "stacked", + "autoTriggerMode": "stacked", + "autoTriggerTokens": 32000, + "stackedPipeline": [ + { "engine": "rtk", "intensity": "standard" }, + { "engine": "caveman", "intensity": "full" } + ], + "rtkConfig": { + "enabled": true, + "intensity": "standard", + "applyToToolResults": true, + "applyToCodeBlocks": false, + "applyToAssistantMessages": false, + "enabledFilters": [], + "disabledFilters": [], + "maxLinesPerResult": 120, + "maxCharsPerResult": 12000, + "deduplicateThreshold": 3, + "customFiltersEnabled": true, + "trustProjectFilters": false, + "rawOutputRetention": "never", + "rawOutputMaxBytes": 1048576 + } +} +``` + +`enabledFilters` and `disabledFilters` use filter ids, for example `test-vitest` or `git-diff`. + +## API + +| Route | Method | Purpose | +| ---------------------------------- | ------ | -------------------------------------------- | +| `/api/context/rtk/config` | GET | Read RTK config | +| `/api/context/rtk/config` | PUT | Update RTK config | +| `/api/context/rtk/filters` | GET | List filter catalog and load diagnostics | +| `/api/context/rtk/test` | POST | Preview RTK compression for one text payload | +| `/api/context/rtk/raw-output/[id]` | GET | Read retained redacted raw output | +| `/api/compression/preview` | POST | Preview any compression mode | + +RTK test payload: + +```json +{ + "command": "npm test", + "text": "FAIL tests/example.test.ts\nAssertionError: expected true\nTest Files 1 failed", + "config": { + "intensity": "standard" + } +} +``` + +Compression preview payload: + +```json +{ + "mode": "stacked", + "messages": [ + { + "role": "tool", + "content": "FAIL tests/example.test.ts\nAssertionError: expected true\nTest Files 1 failed" + } + ], + "config": { + "rtkConfig": { + "rawOutputRetention": "failures" + } + } +} +``` + +Management routes require dashboard management auth or the matching API-key policy. + +## Raw Output Recovery + +RTK normally returns only compressed text. For debugging, `rawOutputRetention` can retain redacted +raw output: + +| Value | Behavior | +| ---------- | ------------------------------------------------------- | +| `never` | Do not retain raw output | +| `failures` | Retain only likely failure output | +| `always` | Retain every compressed RTK raw output, after redaction | + +Retained files are written under: + +```txt +DATA_DIR/rtk/raw-output/ +``` + +Secrets are redacted before persistence, including common bearer tokens, API keys, Slack tokens, +AWS access keys, and assignment-style `token=...`, `secret=...`, `password=...` values. Analytics +stores only the pointer id, size, and hash metadata. + +## Verify Gate + +The focused verify gate runs built-in inline filter tests without shelling out to external commands: + +```bash +node --import tsx/esm --test tests/unit/compression/rtk-verify.test.ts +``` + +The broader RTK gate is: + +```bash +node --import tsx/esm --test \ + tests/unit/compression/rtk-*.test.ts \ + tests/unit/compression/pipeline-integration.test.ts \ + tests/unit/compression/context-compression-api.test.ts +``` + +Run the broad compression gate before release: + +```bash +node --import tsx/esm --test \ + tests/unit/compression/*.test.ts \ + tests/golden-set/*.test.ts \ + tests/integration/compression-pipeline.test.ts \ + tests/unit/api/compression/compression-api.test.ts +``` + +## Extending RTK + +1. Add or update a filter JSON file. +2. Include at least one `tests[]` sample that proves the important behavior. +3. Add a fixture under `tests/unit/compression/fixtures/rtk/` for new command families. +4. Add command detection coverage when introducing a new output class. +5. Run the verify and broad RTK gates. +6. If the filter is project-local, commit `.rtk/filters.json` and refresh `.rtk/trust.json` only after review. diff --git a/docs/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md index 9e78e203e9..26c332a5ab 100644 --- a/docs/SETUP_GUIDE.md +++ b/docs/SETUP_GUIDE.md @@ -138,7 +138,7 @@ Add to your MCP settings: } ``` -**Full MCP documentation:** [MCP Server README](../open-sse/mcp-server/README.md) — 29 tools, IDE configs, Python/TS/Go clients. +**Full MCP documentation:** [MCP Server README](../open-sse/mcp-server/README.md) — 37 tools, IDE configs, Python/TS/Go clients. ### A2A Setup (Agent-to-Agent Protocol) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 76b54c78e7..bea18f734e 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.7.8 + version: 3.7.9 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, @@ -51,6 +51,8 @@ tags: description: Routing combo management - name: Settings description: Application settings + - name: Compression + description: Prompt compression, RTK filters, Caveman rules, and compression combos - name: Usage description: Usage analytics and logs - name: Translator @@ -734,6 +736,202 @@ paths: "200": description: Updated settings + /api/settings/compression: + get: + tags: [Compression] + summary: Get global compression settings + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Current compression settings + put: + tags: [Compression] + summary: Update global compression settings + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + defaultMode: + type: string + enum: [off, lite, standard, aggressive, ultra, rtk, stacked] + autoTriggerMode: + type: string + enum: [off, lite, standard, aggressive, ultra, rtk, stacked] + autoTriggerTokens: + type: integer + minimum: 0 + rtkConfig: + type: object + additionalProperties: true + stackedPipeline: + type: array + items: + type: object + responses: + "200": + description: Updated compression settings + + /api/compression/preview: + post: + tags: [Compression] + summary: Preview compression for a message payload + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [messages, mode] + properties: + mode: + type: string + enum: [off, lite, standard, aggressive, ultra, rtk, stacked] + messages: + type: array + items: + type: object + required: [role, content] + properties: + role: + type: string + content: + oneOf: + - type: string + - type: array + items: {} + config: + type: object + additionalProperties: true + responses: + "200": + description: Compression preview with diff, validation, and stats + + /api/compression/language-packs: + get: + tags: [Compression] + summary: List Caveman compression language packs + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Available languages and rule-pack metadata + + /api/compression/rules: + get: + tags: [Compression] + summary: List Caveman compression rule metadata + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Caveman rule metadata + + /api/context/rtk/config: + get: + tags: [Compression] + summary: Get RTK compression settings + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Current RTK config + put: + tags: [Compression] + summary: Update RTK compression settings + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + intensity: + type: string + enum: [minimal, standard, aggressive] + customFiltersEnabled: + type: boolean + trustProjectFilters: + type: boolean + rawOutputRetention: + type: string + enum: [never, failures, always] + rawOutputMaxBytes: + type: integer + responses: + "200": + description: Updated RTK config + + /api/context/rtk/filters: + get: + tags: [Compression] + summary: List RTK filters and load diagnostics + security: + - ManagementSessionAuth: [] + responses: + "200": + description: RTK filter catalog and diagnostics + + /api/context/rtk/test: + post: + tags: [Compression] + summary: Run RTK compression preview for text + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [text] + properties: + text: + type: string + command: + type: string + config: + type: object + additionalProperties: true + responses: + "200": + description: Detection and RTK compression result + + /api/context/rtk/raw-output/{id}: + get: + tags: [Compression] + summary: Read retained redacted RTK raw output + security: + - ManagementSessionAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + pattern: "^[a-f0-9]{24}$" + responses: + "200": + description: Raw output text + "404": + description: Raw output not found + /api/settings/payload-rules: get: tags: [Settings] diff --git a/electron/package-lock.json b/electron/package-lock.json index fbcbba7997..abe3c9d549 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.7.8", + "version": "3.7.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.7.8", + "version": "3.7.9", "license": "MIT", "dependencies": { "better-sqlite3": "^12.8.0", diff --git a/electron/package.json b/electron/package.json index 8a182a53c8..f984a9d83f 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.7.8", + "version": "3.7.9", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/llm.txt b/llm.txt index 8b50978e3d..7702e3f58b 100644 --- a/llm.txt +++ b/llm.txt @@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.7.7 +**Current version:** 3.7.9 ## Tech Stack @@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.7.7) +## Key Features (v3.7.9) ### Core Proxy - **160+ AI providers** with automatic format translation diff --git a/next.config.mjs b/next.config.mjs index be2b7dd40c..eab10cb8e8 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -8,7 +8,7 @@ const projectRoot = dirname(fileURLToPath(import.meta.url)); const scriptSrc = process.env.NODE_ENV === "development" ? "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:" - : "script-src 'self' 'unsafe-inline' blob:"; + : "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:"; const contentSecurityPolicy = [ "default-src 'self'", "base-uri 'self'", @@ -79,9 +79,13 @@ const nextConfig = { }, outputFileTracingRoot: projectRoot, outputFileTracingIncludes: { - // Migration SQL files are read via fs.readFileSync at runtime and are NOT - // auto-traced by webpack/turbopack — include them explicitly. - "/*": ["./src/lib/db/migrations/**/*"], + // Migration SQL and compression rule/filter JSON files are read via fs at + // runtime and are NOT always auto-traced by webpack/turbopack. + "/*": [ + "./src/lib/db/migrations/**/*", + "./open-sse/services/compression/engines/rtk/filters/**/*.json", + "./open-sse/services/compression/rules/**/*.json", + ], }, outputFileTracingExcludes: { // Planning/task docs are not runtime assets and can break standalone copies @@ -136,97 +140,6 @@ const nextConfig = { images: { unoptimized: true, }, - webpack: (config, { isServer, webpack }) => { - if (isServer) { - // Webpack IgnorePlugin: skip thread-stream test files that contain - // intentionally broken syntax/imports (they cause Turbopack build errors) - config.plugins.push( - new webpack.IgnorePlugin({ - resourceRegExp: /\/test\//, - contextRegExp: /thread-stream/, - }) - ); - - // ── Turbopack / Next.js 16 module-hash patch (#394, #396, #398) ──────── - // - // Next.js 16 (with or without Turbopack) compiles the instrumentation hook - // into a separate chunk and emits hashed require() calls such as: - // require('better-sqlite3-90e2652d1716b047') - // require('zod-dcb22c6336e0bc69') - // require('pino-28069d5257187539') - // - // These hashed names don't exist in node_modules and cause a 500 at - // startup on all npm global installs (issues #394, #396, #398). - // - // We use two strategies: - // 1. Exact-name externals for all known server-side packages. - // 2. Hash-strip catch-all: any require('-<16hexchars>[/subpath]') - // strips the hash suffix and falls through to the real package name. - // - const HASH_PATTERN = /^(.+)-[0-9a-f]{16}(\/.*)?$/; - - const KNOWN_EXTERNALS = new Set([ - "better-sqlite3", - "keytar", - "@ngrok/ngrok", - "wreq-js", - "zod", - "pino", - "pino-pretty", - "pino-abstract-transport", - "child_process", - "fs", - "path", - "os", - "crypto", - "net", - "tls", - "http", - "https", - "stream", - "buffer", - "util", - "process", - ]); - - const prev = config.externals ?? []; - const prevArr = Array.isArray(prev) ? prev : [prev]; - config.externals = [ - ...prevArr, - ({ request }, callback) => { - // Case 1: Exact known package — treat as external - if (KNOWN_EXTERNALS.has(request)) { - return callback(null, `commonjs ${request}`); - } - // Case 2: Hash-suffixed name — strip hash, preserve subpath - // e.g. "better-sqlite3-90e2652d1716b047" → "better-sqlite3" - // "zod-dcb22c6336e0bc69" → "zod" - // "zod-dcb22c6336e0bc69/v3" → "zod/v3" - // "zod-dcb22c6336e0bc69/v4-mini" → "zod/v4-mini" - const hashMatch = request?.match?.(HASH_PATTERN); - if (hashMatch) { - const resolved = hashMatch[2] ? `${hashMatch[1]}${hashMatch[2]}` : hashMatch[1]; - return callback(null, `commonjs ${resolved}`); - } - callback(); - }, - ]; - } else { - // Ignore native Node.js modules in browser bundle - config.resolve.fallback = { - ...config.resolve.fallback, - fs: false, - path: false, - child_process: false, - net: false, - tls: false, - crypto: false, - process: false, - os: false, - }; - } - return config; - }, async headers() { return [ diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index 795c43c811..c2526af4a3 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -173,13 +173,59 @@ export const EMBEDDING_PROVIDERS: Record = { { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", dimensions: 3072 }, ], }, + + "jina-ai": { + id: "jina-ai", + baseUrl: "https://api.jina.ai/v1/embeddings", + authType: "apikey", + authHeader: "bearer", + models: [ + { + id: "jina-embeddings-v5-text-small", + name: "Jina Embeddings v5 Text Small", + dimensions: 1024, + }, + { id: "jina-embeddings-v5-text-nano", name: "Jina Embeddings v5 Text Nano", dimensions: 768 }, + { id: "jina-code-embeddings-1.5b", name: "Jina Code Embeddings 1.5B", dimensions: 1536 }, + { id: "jina-code-embeddings-0.5b", name: "Jina Code Embeddings 0.5B", dimensions: 896 }, + { id: "jina-embeddings-v4", name: "Jina Embeddings v4", dimensions: 2048 }, + { id: "jina-clip-v2", name: "Jina CLIP v2", dimensions: 1024 }, + { id: "jina-colbert-v2", name: "Jina ColBERT v2", dimensions: 128 }, + ], + }, }; +const EMBEDDING_PROVIDER_ALIASES: Record = { + jina: "jina-ai", + voyage: "voyage-ai", +}; + +function resolveEmbeddingProviderId(providerId: string): string { + return EMBEDDING_PROVIDER_ALIASES[providerId] || providerId; +} + +function normalizeProviderScopedModelId(providerId: string, modelId: string): string { + const resolvedProvider = resolveEmbeddingProviderId(providerId); + const provider = EMBEDDING_PROVIDERS[resolvedProvider]; + if (provider?.models.some((model) => model.id === modelId)) return modelId; + + const providerScopedModelId = `${resolvedProvider}/${modelId}`; + if (provider?.models.some((model) => model.id === providerScopedModelId)) { + return providerScopedModelId; + } + + return modelId.startsWith(`${providerId}/`) ? modelId.slice(providerId.length + 1) : modelId; +} + +function toProviderScopedModelId(providerId: string, modelId: string): string { + return modelId.startsWith(`${providerId}/`) ? modelId : `${providerId}/${modelId}`; +} + /** * Get embedding provider config by ID */ export function getEmbeddingProvider(providerId: string): EmbeddingProvider | null { - return EMBEDDING_PROVIDERS[providerId] || null; + return EMBEDDING_PROVIDERS[resolveEmbeddingProviderId(providerId)] || null; } /** @@ -195,10 +241,23 @@ export function parseEmbeddingModel( // Check for "provider/model" format const slashIdx = modelStr.indexOf("/"); if (slashIdx > 0) { + const rawProvider = modelStr.slice(0, slashIdx); + const resolvedProvider = resolveEmbeddingProviderId(rawProvider); + + if (EMBEDDING_PROVIDERS[resolvedProvider]) { + return { + provider: resolvedProvider, + model: normalizeProviderScopedModelId(resolvedProvider, modelStr.slice(slashIdx + 1)), + }; + } + // Phase 1: Try each hardcoded provider prefix for (const [providerId] of Object.entries(EMBEDDING_PROVIDERS)) { if (modelStr.startsWith(providerId + "/")) { - return { provider: providerId, model: modelStr.slice(providerId.length + 1) }; + return { + provider: providerId, + model: normalizeProviderScopedModelId(providerId, modelStr.slice(providerId.length + 1)), + }; } } // Phase 2: Try dynamic provider_nodes prefix @@ -233,7 +292,7 @@ export function getAllEmbeddingModels() { for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS)) { for (const model of config.models) { models.push({ - id: `${providerId}/${model.id}`, + id: toProviderScopedModelId(providerId, model.id), name: model.name, provider: providerId, dimensions: model.dimensions, diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index a9fc20da0e..dc8ccfaf9b 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -437,6 +437,20 @@ export const IMAGE_PROVIDERS: Record = { models: [{ id: "topaz-enhance", name: "topaz-enhance", inputModalities: ["image"] }], supportedSizes: ["1024x1024"], }, + nanogpt: { + id: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [ + { id: "qwen-image", name: "Qwen Image", inputModalities: ["text", "image"] }, + { id: "z-image-turbo", name: "Z Image Turbo" }, + { id: "chroma", name: "Chroma" }, + { id: "hidream", name: "Hidream I1 Full" }, + ], + supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], + }, }; /** diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 8265f3376f..063d047396 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -672,17 +672,39 @@ export const REGISTRY: Record = { agentrouter: { id: "agentrouter", alias: "agentrouter", - format: "openai", + format: "claude", executor: "default", - baseUrl: "https://agentrouter.org/v1/chat/completions", + baseUrl: "https://agentrouter.org/v1/messages", authType: "apikey", - authHeader: "bearer", + authHeader: "x-api-key", defaultContextLength: 128000, headers: { - "HTTP-Referer": "https://endpoint-proxy.local", - "X-Title": "OmniRoute", + "Anthropic-Version": ANTHROPIC_VERSION_HEADER, + "Anthropic-Beta": ANTHROPIC_BETA_CLAUDE_OAUTH, + "Anthropic-Dangerous-Direct-Browser-Access": "true", + "User-Agent": CLAUDE_CLI_USER_AGENT, + "X-App": "cli", + "X-Stainless-Helper-Method": "stream", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Runtime-Version": CLAUDE_CLI_STAINLESS_RUNTIME_VERSION, + "X-Stainless-Package-Version": CLAUDE_CLI_STAINLESS_PACKAGE_VERSION, + "X-Stainless-Runtime": "node", + "X-Stainless-Lang": "js", + "X-Stainless-Arch": mapStainlessArch(), + "X-Stainless-Os": mapStainlessOs(), + "X-Stainless-Timeout": "600", }, - models: [{ id: "auto", name: "Auto (Best Available)" }], + models: [ + { id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" }, + { id: "claude-opus-4-6", name: "Claude 4.6 Opus" }, + { id: "deepseek-r1-0528", name: "DeepSeek R1 0528" }, + { id: "deepseek-v3.1", name: "DeepSeek V3.1" }, + { id: "deepseek-v3.2", name: "DeepSeek V3.2" }, + { id: "glm-4.5", name: "GLM 4.5" }, + { id: "glm-4.6", name: "GLM 4.6" }, + { id: "glm-5.1", name: "GLM 5.1" }, + ], + passthroughModels: true, }, openrouter: { diff --git a/open-sse/config/rerankRegistry.ts b/open-sse/config/rerankRegistry.ts index 80f9bb5466..b075bf4357 100644 --- a/open-sse/config/rerankRegistry.ts +++ b/open-sse/config/rerankRegistry.ts @@ -69,20 +69,41 @@ export const RERANK_PROVIDERS = { models: [ { id: "jina-reranker-v3", name: "Jina Reranker v3" }, { id: "jina-reranker-m0", name: "Jina Reranker m0" }, - { - id: "jina-reranker-v2-base-multilingual", - name: "Jina Reranker v2 Base Multilingual", - }, - { id: "jina-colbert-v2", name: "Jina ColBERT v2" }, ], }, }; +const RERANK_PROVIDER_ALIASES = { + jina: "jina-ai", + voyage: "voyage-ai", +}; + +function resolveRerankProviderId(providerId) { + return RERANK_PROVIDER_ALIASES[providerId] || providerId; +} + +function normalizeProviderScopedModelId(providerId, modelId) { + const resolvedProvider = resolveRerankProviderId(providerId); + const provider = RERANK_PROVIDERS[resolvedProvider]; + if (provider?.models.some((model) => model.id === modelId)) return modelId; + + const providerScopedModelId = `${resolvedProvider}/${modelId}`; + if (provider?.models.some((model) => model.id === providerScopedModelId)) { + return providerScopedModelId; + } + + return modelId.startsWith(`${providerId}/`) ? modelId.slice(providerId.length + 1) : modelId; +} + +function toProviderScopedModelId(providerId, modelId) { + return modelId.startsWith(`${providerId}/`) ? modelId : `${providerId}/${modelId}`; +} + /** * Get rerank provider config by ID */ export function getRerankProvider(providerId) { - return RERANK_PROVIDERS[providerId] || null; + return RERANK_PROVIDERS[resolveRerankProviderId(providerId)] || null; } /** @@ -92,10 +113,25 @@ export function getRerankProvider(providerId) { export function parseRerankModel(modelStr) { if (!modelStr) return { provider: null, model: null }; + const slashIdx = modelStr.indexOf("/"); + if (slashIdx > 0) { + const rawProvider = modelStr.slice(0, slashIdx); + const resolvedProvider = resolveRerankProviderId(rawProvider); + if (RERANK_PROVIDERS[resolvedProvider]) { + return { + provider: resolvedProvider, + model: normalizeProviderScopedModelId(resolvedProvider, modelStr.slice(slashIdx + 1)), + }; + } + } + // Try each provider prefix for (const [providerId, config] of Object.entries(RERANK_PROVIDERS)) { if (modelStr.startsWith(providerId + "/")) { - return { provider: providerId, model: modelStr.slice(providerId.length + 1) }; + return { + provider: providerId, + model: normalizeProviderScopedModelId(providerId, modelStr.slice(providerId.length + 1)), + }; } } @@ -117,7 +153,7 @@ export function getAllRerankModels() { for (const [providerId, config] of Object.entries(RERANK_PROVIDERS)) { for (const model of config.models) { models.push({ - id: `${providerId}/${model.id}`, + id: toProviderScopedModelId(providerId, model.id), name: model.name, provider: providerId, }); diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 309e12aacd..00576b774c 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -293,6 +293,9 @@ export class BaseExecutor { }); } + // Fix #1884: Cursor sends prompt_cache_retention which breaks strict upstream endpoints + delete cloned.prompt_cache_retention; + // Also clean up top level optional fields that commonly cause issues when empty const optionalKeys = ["user", "stop", "seed", "response_format"]; for (const key of optionalKeys) { diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index e84be095b6..4f008623fc 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -26,6 +26,7 @@ import { getRememberedResponseConversationItems, getRememberedResponseFunctionCalls, } from "../services/responsesToolCallState.ts"; +import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; import { CORS_HEADERS } from "../utils/cors.ts"; import { createRequire } from "module"; @@ -45,6 +46,11 @@ type WreqWebSocket = { onclose: (() => void) | null; }; type WebsocketFn = (url: string, opts?: Record) => Promise; +type ResponsesMessageInput = { + role?: unknown; + phase?: unknown; + content?: unknown; +}; let _websocketFn: WebsocketFn | null = null; let _wreqChecked = false; @@ -605,6 +611,40 @@ function normalizeCodexTools(body: Record): void { return false; } + // Codex Responses API requires function tools in flat Responses format: + // { type: "function", name, description, parameters } + // Some clients/translators send Chat Completions shape: + // { type: "function", function: { name, description, parameters } } + // which upstream rejects with "Missing required parameter: tools[0].name". + // Flatten the nested `function` wrapper into top-level fields (#1914). + const functionObject = + tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) + ? (tool.function as Record) + : null; + const description = + typeof tool.description === "string" + ? tool.description + : typeof functionObject?.description === "string" + ? functionObject.description + : ""; + const parameters = + tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters) + ? tool.parameters + : functionObject?.parameters && + typeof functionObject.parameters === "object" && + !Array.isArray(functionObject.parameters) + ? functionObject.parameters + : { type: "object", properties: {} }; + + // Rewrite in-place to Responses format + for (const key of Object.keys(tool)) { + delete tool[key]; + } + tool.type = "function"; + tool.name = name; + if (description) tool.description = description; + tool.parameters = parameters; + validToolNames.add(name); return true; }); @@ -1185,19 +1225,49 @@ export class CodexExecutor extends BaseExecutor { // Issue #1832 & #1853: Map messages to input for clients like Cursor 5.5 that use responses/compact but send messages instead of input. // This MUST run before convertSystemToDeveloperRole and stripStoredItemReferences. if (!body.input && Array.isArray(body.messages)) { - body.input = body.messages.map((msg: any) => ({ + body.input = body.messages.map((msg: ResponsesMessageInput) => ({ type: "message", role: typeof msg.role === "string" ? msg.role : "user", + ...(typeof msg.phase === "string" ? { phase: msg.phase } : {}), content: typeof msg.content === "string" ? [{ type: "input_text", text: msg.content }] : Array.isArray(msg.content) - ? msg.content.map((c: any) => { - if (c && c.type === "text") return { type: "input_text", text: c.text }; - return c; + ? msg.content.map((contentPart: unknown) => { + if ( + contentPart && + typeof contentPart === "object" && + !Array.isArray(contentPart) && + (contentPart as Record).type === "text" + ) { + return { + type: "input_text", + text: (contentPart as Record).text, + }; + } + return contentPart; }) : [], })); + } else if (!body.input && typeof body.prompt === "string" && body.prompt.trim()) { + // Issue #1872: Cursor occasionally passes the request as `prompt` instead of `messages`. + body.input = [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: body.prompt }], + }, + ]; + } else if (!body.input && Array.isArray(body.prompt)) { + body.input = body.prompt.map((p: any) => ({ + type: "message", + role: "user", + content: [{ type: "input_text", text: typeof p === "string" ? p : JSON.stringify(p) }], + })); + } + + if (Array.isArray(body.input)) { + body.input = sanitizeResponsesInputItems(body.input, false); } // ── Cache-aware system prompt handling (both paths) ── @@ -1355,7 +1425,7 @@ export class CodexExecutor extends BaseExecutor { delete body.seed; // max_tokens and max_output_tokens already deleted above (before passthrough return) delete body.user; // Cursor sends this but Codex doesn't support it - delete body.prompt_cache_retention; // Cursor sends this but Codex doesn't support it + delete body.metadata; // Cursor sends this but Codex doesn't support it delete body.stream_options; // Cursor sends this but Codex doesn't support it delete body.safety_identifier; // Droid CLI sends this but Codex doesn't support it diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index dbf58ed0cf..10d2591149 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -922,6 +922,28 @@ function buildExecutorClientHeaders( return Object.keys(normalized).length > 0 ? normalized : null; } +function isCopilotClient( + headers: Headers | Record | null | undefined, + userAgent?: string | null +) { + const isMatch = (value: unknown) => + typeof value === "string" && value.toLowerCase().includes("copilot"); + + if (isMatch(userAgent)) return true; + + if (headers instanceof Headers) { + for (const [key, value] of headers) { + if (isMatch(key) || isMatch(value)) return true; + } + } else if (headers && typeof headers === "object") { + for (const [key, value] of Object.entries(headers)) { + if (isMatch(key) || isMatch(value)) return true; + } + } + + return false; +} + export async function handleChatCore({ body, modelInfo, @@ -954,6 +976,7 @@ export async function handleChatCore({ const suffix = extra ? ` ${JSON.stringify(extra)}` : ""; log?.info?.("STAGE_TRACE", `${traceId} ${label} t=${elapsed}ms${suffix}`); }; + let tokensCompressed: number | null = null; const persistFailureUsage = (statusCode: number, errorCode?: string | null) => { saveRequestUsage({ provider: provider || "unknown", @@ -1099,6 +1122,7 @@ export async function handleChatCore({ }); const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); + const copilotCompatibleReasoning = isCopilotClient(clientRawRequest?.headers, userAgent); const clientResponseFormat = sourceFormat === FORMATS.OPENAI_RESPONSES && !isResponsesEndpoint && !isDroidCLI ? FORMATS.OPENAI @@ -1178,6 +1202,23 @@ export async function handleChatCore({ const capturePipelineStreamChunks = detailedLoggingEnabled && getCallLogPipelineCaptureStreamChunks(); const skillRequestId = generateRequestId(); + let compressionAnalyticsWritePromise: Promise | null = null; + const attachCompressionUsageReceiptAfterAnalytics = ( + usage: Record, + source: "provider" | "estimated" | "stream" + ) => { + const pendingWrite = compressionAnalyticsWritePromise; + void (async () => { + try { + if (pendingWrite) await pendingWrite; + const { attachCompressionUsageReceipt } = + await import("../../src/lib/db/compressionAnalytics.ts"); + attachCompressionUsageReceipt(skillRequestId, usage, source); + } catch { + // Compression analytics are best-effort and must never affect responses. + } + })(); + }; const pipelineSessionId = (clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function" ? clientRawRequest.headers.get("x-omniroute-session-id") @@ -1285,6 +1326,7 @@ export async function handleChatCore({ comboName, comboStepId, comboExecutionKey, + tokensCompressed, cacheSource: cacheSource === "semantic" ? "semantic" : "upstream", apiKeyId: apiKeyInfo?.id || null, apiKeyName: apiKeyInfo?.name || null, @@ -1560,6 +1602,8 @@ export async function handleChatCore({ // This prevents "prompt too long" errors for large-but-not-full contexts. const allMessages = body?.messages || body?.input || body?.contents || body?.request?.contents || []; + let cavemanOutputModeApplied = false; + let cavemanOutputModeIntensity: string | null = null; if (body && Array.isArray(allMessages) && allMessages.length > 0) { let estimatedTokens = estimateTokens(JSON.stringify(allMessages)); let promptCompressionEnabled = false; @@ -1594,6 +1638,91 @@ export async function handleChatCore({ log?.debug?.("COMPRESSION", "Prompt compression disabled or unavailable"); } let compressionComboKey = comboName ?? null; + let compressionComboApplied = false; + type RuntimeCompressionCombo = { + id: string; + pipeline: NonNullable; + languagePacks: string[]; + outputMode: boolean; + outputModeIntensity: string; + }; + const isBuiltinStackedPipeline = ( + pipeline: CompressionConfig["stackedPipeline"] | undefined + ): boolean => { + if (!Array.isArray(pipeline) || pipeline.length !== 2) return false; + const [first, second] = pipeline; + return ( + first?.engine === "rtk" && + (first.intensity === undefined || first.intensity === "standard") && + !first.config && + second?.engine === "caveman" && + (second.intensity === undefined || second.intensity === "full") && + !second.config + ); + }; + const applyCompressionComboConfig = ( + compressionCombo: RuntimeCompressionCombo | null, + routingOverrideIds: string[] = [] + ): boolean => { + if (!compressionCombo || compressionCombo.pipeline.length === 0) return false; + const comboLanguagePacks = [ + ...new Set( + compressionCombo.languagePacks + .map((pack) => pack.trim()) + .filter((pack) => pack.length > 0) + ), + ]; + const comboOutputIntensity = ( + ["lite", "full", "ultra"].includes(compressionCombo.outputModeIntensity) + ? compressionCombo.outputModeIntensity + : (config.cavemanOutputMode?.intensity ?? "full") + ) as "lite" | "full" | "ultra"; + const comboDefaultLanguage = + comboLanguagePacks.find((pack) => pack === config.languageConfig?.defaultLanguage) ?? + comboLanguagePacks[0] ?? + config.languageConfig?.defaultLanguage ?? + "en"; + const comboOverrides = { ...(config.comboOverrides ?? {}) }; + for (const id of routingOverrideIds) { + if (id) comboOverrides[id] = "stacked"; + } + config = { + ...config, + compressionComboId: compressionCombo.id, + stackedPipeline: compressionCombo.pipeline, + languageConfig: { + ...(config.languageConfig ?? { + enabled: false, + defaultLanguage: "en", + autoDetect: true, + enabledPacks: ["en"], + }), + enabled: true, + defaultLanguage: comboDefaultLanguage, + enabledPacks: + comboLanguagePacks.length > 0 + ? comboLanguagePacks + : (config.languageConfig?.enabledPacks ?? ["en"]), + }, + cavemanOutputMode: { + ...(config.cavemanOutputMode ?? { + enabled: false, + intensity: "full", + autoClarity: true, + }), + enabled: compressionCombo.outputMode, + intensity: comboOutputIntensity, + }, + comboOverrides, + }; + compressionComboApplied = true; + return true; + }; + const isStackedCompressionCombo = ( + compressionCombo: RuntimeCompressionCombo | null + ): compressionCombo is RuntimeCompressionCombo => { + return Boolean(compressionCombo && compressionCombo.pipeline.length > 1); + }; if (isCombo && comboName) { try { const { getComboByName } = await import("../../src/lib/localDb"); @@ -1616,7 +1745,9 @@ export async function handleChatCore({ comboMode === "lite" || comboMode === "standard" || comboMode === "aggressive" || - comboMode === "ultra" + comboMode === "ultra" || + comboMode === "rtk" || + comboMode === "stacked" ) { config = { ...config, @@ -1628,6 +1759,27 @@ export async function handleChatCore({ }; compressionComboKey = comboName; } + const routingComboIds = [ + comboConfig?.id, + comboName, + comboName.startsWith("combo/") ? comboName.substring(6) : null, + ].filter((id): id is string => typeof id === "string" && id.length > 0); + if (routingComboIds.length > 0) { + const { getCompressionComboForRoutingCombo } = + await import("../../src/lib/db/compressionCombos.ts"); + const assignedCompressionCombo = + routingComboIds + .map((id) => getCompressionComboForRoutingCombo(id)) + .find((combo) => combo !== null) ?? null; + if ( + applyCompressionComboConfig( + assignedCompressionCombo as RuntimeCompressionCombo | null, + routingComboIds + ) + ) { + compressionComboKey = comboName; + } + } } catch (err) { log?.debug?.( "COMPRESSION", @@ -1636,6 +1788,66 @@ export async function handleChatCore({ ); } } + const modeBeforeOutputTransform = selectCompressionStrategy( + config, + compressionComboKey, + estimatedTokens, + body as Record, + { provider, targetFormat, model: effectiveModel } + ); + if ( + modeBeforeOutputTransform === "stacked" && + !compressionComboApplied && + !config.compressionComboId && + isBuiltinStackedPipeline(config.stackedPipeline) + ) { + try { + const { getDefaultCompressionCombo } = + await import("../../src/lib/db/compressionCombos.ts"); + const defaultCompressionCombo = getDefaultCompressionCombo(); + if ( + isStackedCompressionCombo(defaultCompressionCombo as RuntimeCompressionCombo | null) && + applyCompressionComboConfig(defaultCompressionCombo as RuntimeCompressionCombo | null) + ) { + log?.debug?.( + "COMPRESSION", + `Default compression combo applied: ${defaultCompressionCombo?.id}` + ); + } + } catch (err) { + log?.debug?.( + "COMPRESSION", + "Default compression combo lookup skipped: " + + (err instanceof Error ? err.message : String(err)) + ); + } + } + if (config.cavemanOutputMode?.enabled) { + try { + const { applyCavemanOutputMode } = await import("../services/compression/outputMode.ts"); + const outputModeLanguage = + config.languageConfig?.enabled === true ? config.languageConfig.defaultLanguage : "en"; + const outputMode = applyCavemanOutputMode( + body as Parameters[0], + config.cavemanOutputMode, + outputModeLanguage + ); + if (outputMode.applied) { + body = outputMode.body as typeof body; + cavemanOutputModeApplied = true; + cavemanOutputModeIntensity = config.cavemanOutputMode.intensity; + estimatedTokens = estimateTokens(JSON.stringify(body?.messages ?? body?.input ?? [])); + log?.debug?.("COMPRESSION", "Caveman output mode instruction applied"); + } else if (outputMode.skippedReason && outputMode.skippedReason !== "disabled") { + log?.debug?.("COMPRESSION", `Caveman output mode skipped: ${outputMode.skippedReason}`); + } + } catch (err) { + log?.debug?.( + "COMPRESSION", + "Caveman output mode skipped: " + (err instanceof Error ? err.message : String(err)) + ); + } + } const compressionInputBody = body as Record; const mode = selectCompressionStrategy( config, @@ -1644,80 +1856,138 @@ export async function handleChatCore({ compressionInputBody, { provider, targetFormat, model: effectiveModel } ); + let compressionAnalyticsRecorded = false; if (mode !== "off") { const result = applyCompression(compressionInputBody, mode, { model: effectiveModel, config, }); - if (result.compressed && result.stats) { - body = result.body as typeof body; - estimatedTokens = result.stats.compressedTokens; - trackCompressionStats(result.stats); - void (async () => { - try { - const { insertCompressionAnalyticsRow } = - await import("../../src/lib/db/compressionAnalytics.ts"); - insertCompressionAnalyticsRow({ - timestamp: new Date().toISOString(), - combo_id: comboName ?? null, - provider: provider ?? null, - mode, - original_tokens: result.stats.originalTokens, - compressed_tokens: result.stats.compressedTokens, - tokens_saved: Math.max( + if (result.stats) { + if (result.compressed) { + body = result.body as typeof body; + estimatedTokens = result.stats.compressedTokens; + tokensCompressed = Math.max( + 0, + result.stats.originalTokens - result.stats.compressedTokens + ); + } + + if (result.compressed || result.stats.fallbackApplied || cavemanOutputModeApplied) { + trackCompressionStats(result.stats); + compressionAnalyticsRecorded = true; + compressionAnalyticsWritePromise = (async () => { + try { + const { insertCompressionAnalyticsRow } = + await import("../../src/lib/db/compressionAnalytics.ts"); + const { calculateCost } = await import("../../src/lib/usage/costCalculator.ts"); + const tokensSaved = Math.max( 0, result.stats.originalTokens - result.stats.compressedTokens - ), - duration_ms: result.stats.durationMs ?? null, - request_id: skillRequestId, - }); - } catch (err) { - log?.debug?.( - "COMPRESSION", - "Compression analytics write skipped: " + - (err instanceof Error ? err.message : String(err)) - ); - } - })(); - void (async () => { - try { - const { detectCachingContext } = - await import("../services/compression/cachingAware.ts"); - const { recordCacheStats } = - await import("../../src/lib/db/compressionCacheStats.ts"); - const cacheContext = detectCachingContext(compressionInputBody, { - provider, - targetFormat, - model: effectiveModel, - }); - const tokensSavedCompression = Math.max( - 0, - result.stats.originalTokens - result.stats.compressedTokens - ); - recordCacheStats({ - provider: cacheContext.provider ?? provider ?? "unknown", - model: effectiveModel ?? "", - compressionMode: mode, - cacheControlPresent: cacheContext.hasCacheControl, - estimatedCacheHit: cacheContext.hasCacheControl && cacheContext.isCachingProvider, - tokensSavedCompression, - tokensSavedCaching: 0, - netSavings: tokensSavedCompression, - }); - } catch (err) { - log?.debug?.( - "COMPRESSION", - "Compression cache stats write skipped: " + - (err instanceof Error ? err.message : String(err)) - ); - } - })(); - log?.info?.( - "COMPRESSION", - `Prompt compressed (${mode}): ${result.stats.originalTokens} -> ${result.stats.compressedTokens} tokens (${result.stats.savingsPercent}% saved, techniques: ${result.stats.techniquesUsed.join(",")})` - ); + ); + const estimatedUsdSaved = await calculateCost( + provider ?? "", + effectiveModel ?? "", + { + input: tokensSaved, + } + ); + insertCompressionAnalyticsRow({ + timestamp: new Date().toISOString(), + combo_id: comboName ?? null, + provider: provider ?? null, + mode, + engine: result.stats.engine ?? mode, + compression_combo_id: + result.stats.compressionComboId ?? config.compressionComboId ?? null, + original_tokens: result.stats.originalTokens, + compressed_tokens: result.stats.compressedTokens, + tokens_saved: tokensSaved, + duration_ms: result.stats.durationMs ?? null, + request_id: skillRequestId, + estimated_usd_saved: estimatedUsdSaved || null, + validation_fallback: result.stats.fallbackApplied ? 1 : 0, + output_mode: cavemanOutputModeApplied ? cavemanOutputModeIntensity : null, + rtk_raw_output_pointer: result.stats.rtkRawOutputPointers?.[0]?.id ?? null, + rtk_raw_output_bytes: result.stats.rtkRawOutputPointers?.[0]?.bytes ?? null, + }); + } catch (err) { + log?.debug?.( + "COMPRESSION", + "Compression analytics write skipped: " + + (err instanceof Error ? err.message : String(err)) + ); + } + })(); + } + + if (result.compressed) { + void (async () => { + try { + const { detectCachingContext } = + await import("../services/compression/cachingAware.ts"); + const { recordCacheStats } = + await import("../../src/lib/db/compressionCacheStats.ts"); + const cacheContext = detectCachingContext(compressionInputBody, { + provider, + targetFormat, + model: effectiveModel, + }); + const tokensSavedCompression = Math.max( + 0, + result.stats.originalTokens - result.stats.compressedTokens + ); + recordCacheStats({ + provider: cacheContext.provider ?? provider ?? "unknown", + model: effectiveModel ?? "", + compressionMode: mode, + cacheControlPresent: cacheContext.hasCacheControl, + estimatedCacheHit: cacheContext.hasCacheControl && cacheContext.isCachingProvider, + tokensSavedCompression, + tokensSavedCaching: 0, + netSavings: tokensSavedCompression, + }); + } catch (err) { + log?.debug?.( + "COMPRESSION", + "Compression cache stats write skipped: " + + (err instanceof Error ? err.message : String(err)) + ); + } + })(); + log?.info?.( + "COMPRESSION", + `Prompt compressed (${mode}): ${result.stats.originalTokens} -> ${result.stats.compressedTokens} tokens (${result.stats.savingsPercent}% saved, techniques: ${result.stats.techniquesUsed.join(",")})` + ); + } } } + if (cavemanOutputModeApplied && !compressionAnalyticsRecorded) { + compressionAnalyticsWritePromise = (async () => { + try { + const { insertCompressionAnalyticsRow } = + await import("../../src/lib/db/compressionAnalytics.ts"); + insertCompressionAnalyticsRow({ + timestamp: new Date().toISOString(), + combo_id: comboName ?? null, + provider: provider ?? null, + mode: "output-caveman", + engine: "caveman-output", + compression_combo_id: config.compressionComboId ?? null, + original_tokens: estimatedTokens, + compressed_tokens: estimatedTokens, + tokens_saved: 0, + request_id: skillRequestId, + output_mode: cavemanOutputModeIntensity, + }); + } catch (err) { + log?.debug?.( + "COMPRESSION", + "Caveman output analytics write skipped: " + + (err instanceof Error ? err.message : String(err)) + ); + } + })(); + } } catch (err) { log?.warn?.( "COMPRESSION", @@ -1793,6 +2063,7 @@ export async function handleChatCore({ if (compressionResult.compressed) { body = compressionResult.body; const stats = compressionResult.stats; + tokensCompressed = Math.max(0, (stats?.original ?? 0) - (stats?.final ?? 0)); const layersInfo = stats && "layers" in stats && Array.isArray(stats.layers) ? ` (layers: ${stats.layers.map((l: { name: string }) => l.name).join(", ")})` @@ -3264,6 +3535,9 @@ export async function handleChatCore({ // Log usage for non-streaming responses const usage = extractUsageFromResponse(responseBody, provider); + if (usage && typeof usage === "object") { + attachCompressionUsageReceiptAfterAnalytics(usage as Record, "provider"); + } appendRequestLog({ model, provider, connectionId, tokens: usage, status: "200 OK" }).catch( () => {} ); @@ -3636,6 +3910,7 @@ export async function handleChatCore({ // Track cache token metrics for streaming responses if (streamUsage && typeof streamUsage === "object") { + attachCompressionUsageReceiptAfterAnalytics(streamUsage as Record, "stream"); const inputTokens = streamUsage.prompt_tokens || 0; const cachedTokens = toPositiveNumber( streamUsage.cache_read_input_tokens ?? @@ -3777,7 +4052,8 @@ export async function handleChatCore({ streamStateBody, onStreamComplete, apiKeyInfo, - handleStreamFailure + handleStreamFailure, + copilotCompatibleReasoning ); } else if (needsTranslation(targetFormat, clientResponseFormat)) { // Standard translation for other providers @@ -3793,7 +4069,8 @@ export async function handleChatCore({ streamStateBody, onStreamComplete, apiKeyInfo, - handleStreamFailure + handleStreamFailure, + copilotCompatibleReasoning ); } else { log?.debug?.("STREAM", `Standard passthrough mode`); diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index f030375346..09777ece75 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -45,6 +45,7 @@ const OPENAI_IMAGE_TO_IMAGE_MODELS = new Set([ "flux-kontext-max", "flux-kontext", "flux-kontext-pro", + "qwen-image", ]); const BFL_MODEL_ENDPOINTS = { diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index aa2c0f1e9d..4b85cb0119 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -691,6 +691,9 @@ export function sanitizeStreamingChunk(parsed: unknown): unknown { } if (deltaRecord.reasoning_content !== undefined) { delta.reasoning_content = deltaRecord.reasoning_content; + } + if (deltaRecord.reasoning_text !== undefined) { + delta.reasoning_text = deltaRecord.reasoning_text; } else if (typeof deltaRecord.reasoning === "string" && deltaRecord.reasoning) { // Alias: some providers use 'reasoning' instead of 'reasoning_content' delta.reasoning_content = deltaRecord.reasoning; diff --git a/open-sse/mcp-server/README.md b/open-sse/mcp-server/README.md index 43b9852940..f70c86014f 100644 --- a/open-sse/mcp-server/README.md +++ b/open-sse/mcp-server/README.md @@ -145,12 +145,20 @@ omniroute --mcp ### Cache and Compression Tools -| # | Tool | Scopes | Description | -| --- | --------------------------------- | ------------------- | ---------------------------------------------------------------------------- | -| 21 | `omniroute_cache_stats` | `read:cache` | Semantic cache, prompt-cache, and idempotency statistics | -| 22 | `omniroute_cache_flush` | `write:cache` | Flush cache entries globally or by signature/model | -| 23 | `omniroute_compression_status` | `read:compression` | Compression settings, analytics summary, and provider-aware cache statistics | -| 24 | `omniroute_compression_configure` | `write:compression` | Configure compression mode and trigger thresholds at runtime | +| # | Tool | Scopes | Description | +| --- | ----------------------------------- | ------------------- | ---------------------------------------------------------------------------- | +| 21 | `omniroute_cache_stats` | `read:cache` | Semantic cache, prompt-cache, and idempotency statistics | +| 22 | `omniroute_cache_flush` | `write:cache` | Flush cache entries globally or by signature/model | +| 23 | `omniroute_compression_status` | `read:compression` | Compression settings, analytics summary, and provider-aware cache statistics | +| 24 | `omniroute_compression_configure` | `write:compression` | Configure compression mode and trigger thresholds at runtime | +| 25 | `omniroute_set_compression_engine` | `write:compression` | Set Caveman, RTK, or stacked compression mode and pipeline | +| 26 | `omniroute_list_compression_combos` | `read:compression` | List named compression combos and routing assignments | +| 27 | `omniroute_compression_combo_stats` | `read:compression` | Read analytics grouped by compression combo and engine | + +MCP listable metadata descriptions are compressed at registration/list time when description +compression is enabled. `omniroute_compression_status` exposes those savings separately as +`analytics.mcpDescriptionCompression` with `source: "mcp_metadata_estimate"`, so clients do not +mistake metadata shrink estimates for provider token receipts. --- @@ -540,12 +548,12 @@ The MCP server supports **fine-grained scope enforcement** for multi-tenant envi | `read:usage` | `cost_report`, `explain_route`, `get_session_snapshot` | | `read:models` | `list_models_catalog` | | `read:cache` | `cache_stats` | -| `read:compression` | `compression_status` | +| `read:compression` | `compression_status`, `list_compression_combos`, `compression_combo_stats` | | `write:combos` | `switch_combo` | | `write:budget` | `set_budget_guard` | | `write:resilience` | `set_resilience_profile` | | `write:cache` | `cache_flush` | -| `write:compression` | `compression_configure` | +| `write:compression` | `compression_configure`, `set_compression_engine` | | `execute:completions` | `route_request`, `test_combo` | **Wildcard scopes:** Use `read:*` to grant all read scopes, or `*` for full access. @@ -581,7 +589,7 @@ mcp-server/ ├── audit.ts # SQLite audit logger (SHA-256 input hashing) ├── scopeEnforcement.ts # Fine-grained scope enforcement ├── schemas/ -│ ├── tools.ts # Zod schemas for all 16 tools (input/output/scopes) +│ ├── tools.ts # Zod schemas for core, cache, compression, and proxy tools │ ├── a2a.ts # A2A protocol types (Agent Card, Task, JSON-RPC) │ ├── audit.ts # Audit & routing decision types + hash helpers │ └── index.ts # Schema barrel export diff --git a/open-sse/mcp-server/descriptionCompressor.ts b/open-sse/mcp-server/descriptionCompressor.ts new file mode 100644 index 0000000000..e69a86ee4d --- /dev/null +++ b/open-sse/mcp-server/descriptionCompressor.ts @@ -0,0 +1,243 @@ +import { applyRulesToText } from "../services/compression/caveman.ts"; +import { getRulesForContext } from "../services/compression/cavemanRules.ts"; +import { + extractPreservedBlocks, + restorePreservedBlocks, +} from "../services/compression/preservation.ts"; + +export interface DescriptionCompressionResult { + compressed: string; + before: number; + after: number; + changed: boolean; +} + +export interface DescriptionCompressionOptions { + enabled?: boolean; +} + +export interface McpDescriptionCompressionStats { + descriptionsCompressed: number; + charsBefore: number; + charsAfter: number; + charsSaved: number; + estimatedTokensSaved: number; +} + +const descriptionCompressionStats: McpDescriptionCompressionStats = { + descriptionsCompressed: 0, + charsBefore: 0, + charsAfter: 0, + charsSaved: 0, + estimatedTokensSaved: 0, +}; + +const persistedDescriptionCompressionStats: McpDescriptionCompressionStats = { + descriptionsCompressed: 0, + charsBefore: 0, + charsAfter: 0, + charsSaved: 0, + estimatedTokensSaved: 0, +}; + +const MCP_LIST_CONTAINER_KEYS = new Set(["tools", "prompts", "resources", "resourceTemplates"]); +const MCP_METADATA_DESCRIPTION_FIELDS = ["description"]; + +function isDisabledEnvValue(value: string | undefined): boolean { + return !!value && ["0", "false", "off", "no"].includes(value.trim().toLowerCase()); +} + +export function isMcpDescriptionCompressionEnabled( + options: DescriptionCompressionOptions = {} +): boolean { + if (isDisabledEnvValue(process.env.OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS)) return false; + if (isDisabledEnvValue(process.env.OMNIROUTE_MCP_DESCRIPTION_COMPRESSION)) return false; + return options.enabled !== false; +} + +export function compressMcpDescription(description: string): DescriptionCompressionResult { + if (!description) { + return { compressed: description, before: 0, after: 0, changed: false }; + } + + const { text, blocks } = extractPreservedBlocks(description); + const rules = getRulesForContext("all", "full"); + const applied = applyRulesToText(text, rules).text; + const normalized = applied + .replace(/[ \t]{2,}/g, " ") + .replace(/[ \t]+([,.;:!?])/g, "$1") + .replace(/\n{3,}/g, "\n\n") + .replace(/(^|[.!?][ \t]+|\n+[ \t]*)([a-z])/g, (_match, prefix: string, char: string) => { + return `${prefix}${char.toUpperCase()}`; + }) + .trim(); + const compressed = restorePreservedBlocks(normalized, blocks); + + return { + compressed, + before: description.length, + after: compressed.length, + changed: compressed !== description, + }; +} + +export function maybeCompressMcpDescription( + description: string, + options: DescriptionCompressionOptions = {} +): string { + if (!isMcpDescriptionCompressionEnabled(options)) return description; + const result = compressMcpDescription(description); + if (result.changed && result.after < result.before) { + descriptionCompressionStats.descriptionsCompressed += 1; + descriptionCompressionStats.charsBefore += result.before; + descriptionCompressionStats.charsAfter += result.after; + descriptionCompressionStats.charsSaved += result.before - result.after; + descriptionCompressionStats.estimatedTokensSaved += Math.ceil( + (result.before - result.after) / 4 + ); + return result.compressed; + } + return description; +} + +export function compressDescriptionsInPlace( + value: unknown, + fieldNames: string[] = ["description"], + options: DescriptionCompressionOptions = {} +): void { + if (!value || typeof value !== "object") return; + const fields = new Set(fieldNames); + if (Array.isArray(value)) { + for (const item of value) compressDescriptionsInPlace(item, fieldNames, options); + return; + } + + for (const [key, nested] of Object.entries(value as Record)) { + if (fields.has(key) && typeof nested === "string") { + (value as Record)[key] = maybeCompressMcpDescription(nested, options); + } else if (nested && typeof nested === "object") { + compressDescriptionsInPlace(nested, fieldNames, options); + } + } +} + +function clonePlainMetadata(value: T): T { + if (!value || typeof value !== "object") return value; + return JSON.parse(JSON.stringify(value)) as T; +} + +function compressMcpListContainersInPlace( + value: unknown, + options: DescriptionCompressionOptions = {} +): void { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const item of value) compressMcpListContainersInPlace(item, options); + return; + } + + for (const [key, nested] of Object.entries(value as Record)) { + if (MCP_LIST_CONTAINER_KEYS.has(key) && Array.isArray(nested)) { + compressDescriptionsInPlace(nested, MCP_METADATA_DESCRIPTION_FIELDS, options); + } else if (nested && typeof nested === "object") { + compressMcpListContainersInPlace(nested, options); + } + } +} + +export function compressMcpListMetadata( + value: T, + options: DescriptionCompressionOptions = {} +): T { + if (!isMcpDescriptionCompressionEnabled(options)) return value; + const clone = clonePlainMetadata(value); + compressMcpListContainersInPlace(clone, options); + return clone; +} + +export function compressMcpRegistryMetadata>( + metadata: T, + options: DescriptionCompressionOptions = {} +): T { + if (!isMcpDescriptionCompressionEnabled(options)) return metadata; + const clone: Record = { ...metadata }; + if (typeof clone.description === "string") { + clone.description = maybeCompressMcpDescription(clone.description, options); + } + return clone as T; +} + +export function getMcpDescriptionCompressionStats(): McpDescriptionCompressionStats { + return { ...descriptionCompressionStats }; +} + +function getUnpersistedMcpDescriptionCompressionStats(): McpDescriptionCompressionStats { + return { + descriptionsCompressed: + descriptionCompressionStats.descriptionsCompressed - + persistedDescriptionCompressionStats.descriptionsCompressed, + charsBefore: + descriptionCompressionStats.charsBefore - persistedDescriptionCompressionStats.charsBefore, + charsAfter: + descriptionCompressionStats.charsAfter - persistedDescriptionCompressionStats.charsAfter, + charsSaved: + descriptionCompressionStats.charsSaved - persistedDescriptionCompressionStats.charsSaved, + estimatedTokensSaved: + descriptionCompressionStats.estimatedTokensSaved - + persistedDescriptionCompressionStats.estimatedTokensSaved, + }; +} + +export async function snapshotMcpDescriptionCompressionStats(): Promise { + const delta = getUnpersistedMcpDescriptionCompressionStats(); + if ( + delta.descriptionsCompressed <= 0 || + delta.charsSaved <= 0 || + delta.estimatedTokensSaved <= 0 + ) { + return { + descriptionsCompressed: 0, + charsBefore: 0, + charsAfter: 0, + charsSaved: 0, + estimatedTokensSaved: 0, + }; + } + + const originalTokens = Math.max(delta.estimatedTokensSaved, Math.ceil(delta.charsBefore / 4)); + const compressedTokens = Math.max(0, originalTokens - delta.estimatedTokensSaved); + const { insertCompressionAnalyticsRow } = + await import("../../src/lib/db/compressionAnalytics.ts"); + insertCompressionAnalyticsRow({ + timestamp: new Date().toISOString(), + mode: "mcp-description", + engine: "mcp-description", + original_tokens: originalTokens, + compressed_tokens: compressedTokens, + tokens_saved: delta.estimatedTokensSaved, + mcp_description_tokens_saved: delta.estimatedTokensSaved, + }); + + persistedDescriptionCompressionStats.descriptionsCompressed = + descriptionCompressionStats.descriptionsCompressed; + persistedDescriptionCompressionStats.charsBefore = descriptionCompressionStats.charsBefore; + persistedDescriptionCompressionStats.charsAfter = descriptionCompressionStats.charsAfter; + persistedDescriptionCompressionStats.charsSaved = descriptionCompressionStats.charsSaved; + persistedDescriptionCompressionStats.estimatedTokensSaved = + descriptionCompressionStats.estimatedTokensSaved; + + return delta; +} + +export function resetMcpDescriptionCompressionStats(): void { + descriptionCompressionStats.descriptionsCompressed = 0; + descriptionCompressionStats.charsBefore = 0; + descriptionCompressionStats.charsAfter = 0; + descriptionCompressionStats.charsSaved = 0; + descriptionCompressionStats.estimatedTokensSaved = 0; + persistedDescriptionCompressionStats.descriptionsCompressed = 0; + persistedDescriptionCompressionStats.charsBefore = 0; + persistedDescriptionCompressionStats.charsAfter = 0; + persistedDescriptionCompressionStats.charsSaved = 0; + persistedDescriptionCompressionStats.estimatedTokensSaved = 0; +} diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 401f39d3d5..7f829b1529 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -10,6 +10,10 @@ */ import { z } from "zod"; +import { + AUTO_ROUTING_STRATEGY_VALUES, + ROUTING_STRATEGY_VALUES, +} from "../../../src/shared/constants/routingStrategies.ts"; // ============ Shared Types ============ @@ -109,17 +113,7 @@ export const listCombosOutput = z.object({ priority: z.number(), }) ), - strategy: z.enum([ - "priority", - "weighted", - "round-robin", - "context-relay", - "strict-random", - "random", - "least-used", - "cost-optimized", - "auto", - ]), + strategy: z.enum(ROUTING_STRATEGY_VALUES), enabled: z.boolean(), metrics: z .object({ @@ -544,21 +538,9 @@ export const setBudgetGuardTool: McpToolDefinition< // --- Tool 11: omniroute_set_routing_strategy --- export const setRoutingStrategyInput = z.object({ comboId: z.string().describe("Combo ID or name to update"), - strategy: z - .enum([ - "priority", - "weighted", - "round-robin", - "context-relay", - "strict-random", - "random", - "least-used", - "cost-optimized", - "auto", - ]) - .describe("Routing strategy to apply"), + strategy: z.enum(ROUTING_STRATEGY_VALUES).describe("Routing strategy to apply"), autoRoutingStrategy: z - .enum(["rules", "cost", "eco", "latency", "fast"]) + .enum(AUTO_ROUTING_STRATEGY_VALUES) .optional() .describe("Optional strategy used by auto mode (only used when strategy='auto')"), }); @@ -1000,14 +982,41 @@ export const compressionStatusOutput = z.object({ strategy: z.string(), settings: z.object({ maxTokens: z.number(), + autoTriggerMode: z.string(), targetRatio: z.number(), - aggressiveness: z.string(), + preserveSystemPrompt: z.boolean(), + mcpDescriptionCompressionEnabled: z.boolean(), }), analytics: z.object({ totalRequests: z.number(), compressedRequests: z.number(), tokensSaved: z.number(), avgCompressionRatio: z.number(), + byMode: z.record( + z.string(), + z.object({ + count: z.number(), + tokensSaved: z.number(), + avgSavingsPct: z.number(), + }) + ), + validationFallbacks: z.number(), + requestsWithReceipts: z.number(), + realUsage: z.object({ + requestsWithReceipts: z.number(), + promptTokens: z.number(), + completionTokens: z.number(), + totalTokens: z.number(), + cacheReadTokens: z.number(), + cacheWriteTokens: z.number(), + estimatedUsdSaved: z.number(), + bySource: z.record(z.string(), z.number()), + }), + mcpDescriptionCompression: z.object({ + descriptionsCompressed: z.number(), + charsSaved: z.number(), + estimatedTokensSaved: z.number(), + }), }), cacheStats: z .object({ @@ -1037,12 +1046,21 @@ export const compressionStatusTool: McpToolDefinition< export const compressionConfigureInput = z.object({ enabled: z.boolean().optional(), strategy: z - .string() + .enum(["off", "lite", "standard", "aggressive", "ultra", "rtk", "stacked"]) .optional() - .describe("Compression strategy: 'none' | 'standard' | 'aggressive' | 'ultra'"), - maxTokens: z.number().optional().describe("Maximum tokens before compression triggers"), + .describe("Compression mode"), + autoTriggerMode: z + .enum(["off", "lite", "standard", "aggressive", "ultra", "rtk", "stacked"]) + .optional(), + maxTokens: z + .number() + .int() + .min(0) + .optional() + .describe("Maximum tokens before compression triggers"), targetRatio: z.number().optional().describe("Target compression ratio (0.0–1.0)"), - aggressiveness: z.string().optional().describe("Aggressiveness level: 'low' | 'medium' | 'high'"), + preserveSystemPrompt: z.boolean().optional(), + mcpDescriptionCompressionEnabled: z.boolean().optional(), }); export const compressionConfigureOutput = z.object({ @@ -1051,9 +1069,11 @@ export const compressionConfigureOutput = z.object({ settings: z.object({ enabled: z.boolean(), strategy: z.string(), + autoTriggerMode: z.string(), maxTokens: z.number(), targetRatio: z.number(), - aggressiveness: z.string(), + preserveSystemPrompt: z.boolean(), + mcpDescriptionCompressionEnabled: z.boolean(), }), }); @@ -1063,7 +1083,7 @@ export const compressionConfigureTool: McpToolDefinition< > = { name: "omniroute_compression_configure", description: - "Configure compression settings at runtime. Supports enabling/disabling compression, changing strategy (none/standard/aggressive/ultra), adjusting maxTokens threshold, targetRatio, and aggressiveness level.", + "Configure compression settings at runtime. Supports enabling/disabling compression, changing strategy (off/lite/standard/aggressive/ultra/rtk/stacked), adjusting maxTokens threshold, targetRatio, auto-trigger mode, system prompt preservation, and MCP description compression.", inputSchema: compressionConfigureInput, outputSchema: compressionConfigureOutput, scopes: ["write:compression"], @@ -1072,6 +1092,72 @@ export const compressionConfigureTool: McpToolDefinition< sourceEndpoints: ["/api/compression/configure"], }; +export const setCompressionEngineInput = z.object({ + engine: z.enum(["off", "caveman", "rtk", "stacked"]).optional(), + cavemanIntensity: z.enum(["lite", "full", "ultra"]).optional(), + rtkIntensity: z.enum(["minimal", "standard", "aggressive"]).optional(), + outputMode: z.boolean().optional(), +}); + +export const setCompressionEngineOutput = z.object({ + success: z.boolean(), + settings: z.record(z.string(), z.unknown()), +}); + +export const setCompressionEngineTool: McpToolDefinition< + typeof setCompressionEngineInput, + typeof setCompressionEngineOutput +> = { + name: "omniroute_set_compression_engine", + description: "Set the active compression engine and Caveman/RTK runtime options.", + inputSchema: setCompressionEngineInput, + outputSchema: setCompressionEngineOutput, + scopes: ["write:compression"], + auditLevel: "full", + phase: 2, + sourceEndpoints: ["/api/settings/compression", "/api/context/rtk/config"], +}; + +export const listCompressionCombosInput = z.object({}); +export const listCompressionCombosOutput = z.object({ + combos: z.array(z.record(z.string(), z.unknown())), +}); + +export const listCompressionCombosTool: McpToolDefinition< + typeof listCompressionCombosInput, + typeof listCompressionCombosOutput +> = { + name: "omniroute_list_compression_combos", + description: "List compression combos and their engine pipelines.", + inputSchema: listCompressionCombosInput, + outputSchema: listCompressionCombosOutput, + scopes: ["read:compression"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: ["/api/context/combos"], +}; + +export const compressionComboStatsInput = z.object({ + comboId: z.string().optional(), + since: z.enum(["24h", "7d", "30d", "all"]).optional(), +}); + +export const compressionComboStatsOutput = z.record(z.string(), z.unknown()); + +export const compressionComboStatsTool: McpToolDefinition< + typeof compressionComboStatsInput, + typeof compressionComboStatsOutput +> = { + name: "omniroute_compression_combo_stats", + description: "Get compression analytics grouped by engine and compression combo.", + inputSchema: compressionComboStatsInput, + outputSchema: compressionComboStatsOutput, + scopes: ["read:compression"], + auditLevel: "basic", + phase: 2, + sourceEndpoints: ["/api/context/analytics"], +}; + // ============ 1proxy Tools ============ export const oneproxyFetchInput = z.object({ @@ -1209,6 +1295,9 @@ export const MCP_TOOLS = [ cacheFlushTool, compressionStatusTool, compressionConfigureTool, + setCompressionEngineTool, + listCompressionCombosTool, + compressionComboStatsTool, oneproxyFetchTool, oneproxyRotateTool, oneproxyStatsTool, diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 7b7d7d9c7f..d2b5e58c95 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -76,6 +76,8 @@ import { import { memoryTools } from "./tools/memoryTools.ts"; import { skillTools } from "./tools/skillTools.ts"; import { compressionTools } from "./tools/compressionTools.ts"; +import { compressMcpRegistryMetadata } from "./descriptionCompressor.ts"; +import { getDbInstance } from "../../src/lib/db/core.ts"; import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts"; import { resolveOmniRouteBaseUrl } from "../../src/shared/utils/resolveOmniRouteBaseUrl.ts"; @@ -95,6 +97,18 @@ const TOTAL_MCP_TOOL_COUNT = type JsonRecord = Record; +function readMcpDescriptionCompressionEnabled(): boolean { + try { + const row = getDbInstance() + .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") + .get("compression", "mcpDescriptionCompressionEnabled") as { value?: string } | undefined; + if (!row?.value) return true; + return JSON.parse(row.value) !== false; + } catch { + return true; + } +} + type TextToolResult = { content: Array<{ type: "text"; text: string }>; isError?: boolean; @@ -577,6 +591,33 @@ export function createMcpServer(): McpServer { name: "omniroute", version: process.env.npm_package_version || "1.8.1", }); + const mcpDescriptionCompressionEnabled = readMcpDescriptionCompressionEnabled(); + const registerTool = server.registerTool.bind(server); + server.registerTool = ((name: string, config: Record, handler: unknown) => { + const metadata = compressMcpRegistryMetadata(config, { + enabled: mcpDescriptionCompressionEnabled, + }); + return registerTool(name, metadata, handler as never); + }) as typeof server.registerTool; + const registerPrompt = server.registerPrompt.bind(server); + server.registerPrompt = ((name: string, config: Record, handler: unknown) => { + const metadata = compressMcpRegistryMetadata(config, { + enabled: mcpDescriptionCompressionEnabled, + }); + return registerPrompt(name, metadata as never, handler as never); + }) as typeof server.registerPrompt; + const registerResource = server.registerResource.bind(server); + server.registerResource = (( + name: string, + uriOrTemplate: unknown, + config: Record, + readCallback: unknown + ) => { + const metadata = compressMcpRegistryMetadata(config, { + enabled: mcpDescriptionCompressionEnabled, + }); + return registerResource(name, uriOrTemplate as never, metadata as never, readCallback as never); + }) as typeof server.registerResource; // Register essential tools server.registerTool( diff --git a/open-sse/mcp-server/tools/advancedTools.ts b/open-sse/mcp-server/tools/advancedTools.ts index 2275990db2..723a061925 100644 --- a/open-sse/mcp-server/tools/advancedTools.ts +++ b/open-sse/mcp-server/tools/advancedTools.ts @@ -26,6 +26,11 @@ import { getComboModelString, getComboStepTarget, } from "../../../src/lib/combos/steps.ts"; +import type { + AutoRoutingStrategyValue, + RoutingStrategyValue, +} from "../../../src/shared/constants/routingStrategies.ts"; +import { normalizeRoutingStrategy } from "../../../src/shared/constants/routingStrategies.ts"; const OMNIROUTE_BASE_URL = resolveOmniRouteBaseUrl(); const OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY || ""; @@ -372,17 +377,8 @@ export async function handleSetBudgetGuard(args: { export async function handleSetRoutingStrategy(args: { comboId: string; - strategy: - | "priority" - | "weighted" - | "round-robin" - | "context-relay" - | "strict-random" - | "random" - | "least-used" - | "cost-optimized" - | "auto"; - autoRoutingStrategy?: "rules" | "cost" | "eco" | "latency" | "fast"; + strategy: RoutingStrategyValue; + autoRoutingStrategy?: AutoRoutingStrategyValue; }) { const start = Date.now(); try { @@ -424,8 +420,9 @@ export async function handleSetRoutingStrategy(args: { Object.keys(toRecord(combo.config)).length > 0 ? combo.config : comboData.config ); + const normalizedStrategy = normalizeRoutingStrategy(args.strategy); let nextConfig: JsonRecord | undefined = undefined; - if (args.strategy === "auto" && args.autoRoutingStrategy) { + if (normalizedStrategy === "auto" && args.autoRoutingStrategy) { const currentAutoConfig = toRecord(currentConfig.auto); nextConfig = { ...currentConfig, @@ -436,7 +433,7 @@ export async function handleSetRoutingStrategy(args: { }; } - const payload: JsonRecord = { strategy: args.strategy }; + const payload: JsonRecord = { strategy: normalizedStrategy }; if (nextConfig && Object.keys(nextConfig).length > 0) { payload.config = nextConfig; } @@ -451,16 +448,18 @@ export async function handleSetRoutingStrategy(args: { const updatedConfig = toRecord(updatedCombo.config); const resolvedAutoStrategy = toString(toRecord(updatedConfig.auto).routingStrategy) || - (args.strategy === "auto" ? (args.autoRoutingStrategy ?? "rules") : ""); + (normalizedStrategy === "auto" ? (args.autoRoutingStrategy ?? "rules") : ""); const result = { success: true, combo: { id: toString(updatedCombo.id, comboId), name: toString(updatedCombo.name, toString(combo.name, comboId)), - strategy: toString(updatedCombo.strategy, args.strategy), + strategy: toString(updatedCombo.strategy, normalizedStrategy), autoRoutingStrategy: - toString(updatedCombo.strategy, args.strategy) === "auto" ? resolvedAutoStrategy : null, + toString(updatedCombo.strategy, normalizedStrategy) === "auto" + ? resolvedAutoStrategy + : null, }, }; diff --git a/open-sse/mcp-server/tools/compressionTools.ts b/open-sse/mcp-server/tools/compressionTools.ts index 795b053ef7..382a977634 100644 --- a/open-sse/mcp-server/tools/compressionTools.ts +++ b/open-sse/mcp-server/tools/compressionTools.ts @@ -13,7 +13,12 @@ import { } from "../../../src/lib/db/compression.ts"; import { getCompressionAnalyticsSummary } from "../../../src/lib/db/compressionAnalytics.ts"; import { getCacheStatsSummary } from "../../../src/lib/db/compressionCacheStats.ts"; +import { listCompressionCombos } from "../../../src/lib/db/compressionCombos.ts"; import type { McpToolExtraLike } from "../scopeEnforcement.ts"; +import { + getMcpDescriptionCompressionStats, + snapshotMcpDescriptionCompressionStats, +} from "../descriptionCompressor.ts"; /** * Handle compression_status tool: return current compression config, analytics, and cache stats @@ -26,14 +31,42 @@ export async function handleCompressionStatus( strategy: string; settings: { maxTokens: number; + autoTriggerMode: string; targetRatio: number; - aggressiveness: string; + preserveSystemPrompt: boolean; + mcpDescriptionCompressionEnabled: boolean; }; analytics: { totalRequests: number; compressedRequests: number; tokensSaved: number; avgCompressionRatio: number; + byMode: Record; + byEngine: Record; + byCompressionCombo: Record; + validationFallbacks: number; + requestsWithReceipts: number; + realUsage: { + requestsWithReceipts: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + estimatedUsdSaved: number; + bySource: Record; + }; + mcpDescriptionCompression: { + descriptionsCompressed: number; + charsBefore: number; + charsAfter: number; + charsSaved: number; + estimatedTokensSaved: number; + persistedEstimatedTokensSaved: number; + persistedSnapshots: number; + source: "mcp_metadata_estimate"; + notProviderUsage: true; + }; }; cacheStats: { hits: number; @@ -45,7 +78,9 @@ export async function handleCompressionStatus( const start = Date.now(); try { const settings = await getCompressionSettings(); + await snapshotMcpDescriptionCompressionStats(); const analyticsSummary = getCompressionAnalyticsSummary(); + const mcpDescriptionStats = getMcpDescriptionCompressionStats(); const cacheStats = getCacheStatsSummary(); const result = { @@ -53,14 +88,37 @@ export async function handleCompressionStatus( strategy: settings.defaultMode || "standard", settings: { maxTokens: settings.autoTriggerTokens, + autoTriggerMode: settings.autoTriggerMode ?? "lite", targetRatio: 0.7, // Default target ratio - aggressiveness: settings.defaultMode || "standard", + preserveSystemPrompt: settings.preserveSystemPrompt, + mcpDescriptionCompressionEnabled: settings.mcpDescriptionCompressionEnabled !== false, }, analytics: { totalRequests: analyticsSummary.totalRequests, - compressedRequests: analyticsSummary.byMode?.standard?.count || 0, + compressedRequests: Object.values(analyticsSummary.byMode ?? {}).reduce( + (sum, mode) => sum + mode.count, + 0 + ), tokensSaved: analyticsSummary.totalTokensSaved, - avgCompressionRatio: analyticsSummary.byMode?.standard?.avgSavingsPct || 0, + avgCompressionRatio: analyticsSummary.avgSavingsPct, + byMode: analyticsSummary.byMode ?? {}, + byEngine: analyticsSummary.byEngine ?? {}, + byCompressionCombo: analyticsSummary.byCompressionCombo ?? {}, + validationFallbacks: analyticsSummary.validationFallbacks, + requestsWithReceipts: analyticsSummary.realUsage.requestsWithReceipts, + realUsage: analyticsSummary.realUsage, + mcpDescriptionCompression: { + descriptionsCompressed: mcpDescriptionStats.descriptionsCompressed, + charsBefore: mcpDescriptionStats.charsBefore, + charsAfter: mcpDescriptionStats.charsAfter, + charsSaved: mcpDescriptionStats.charsSaved, + estimatedTokensSaved: mcpDescriptionStats.estimatedTokensSaved, + persistedEstimatedTokensSaved: + analyticsSummary.mcpDescriptionCompression.estimatedTokensSaved, + persistedSnapshots: analyticsSummary.mcpDescriptionCompression.snapshots, + source: "mcp_metadata_estimate" as const, + notProviderUsage: true as const, + }, }, cacheStats: cacheStats ? { @@ -98,9 +156,11 @@ export async function handleCompressionConfigure( args: { enabled?: boolean; strategy?: string; + autoTriggerMode?: string; maxTokens?: number; targetRatio?: number; - aggressiveness?: string; + preserveSystemPrompt?: boolean; + mcpDescriptionCompressionEnabled?: boolean; }, extra?: McpToolExtraLike ): Promise<{ @@ -109,9 +169,11 @@ export async function handleCompressionConfigure( settings: { enabled: boolean; strategy: string; + autoTriggerMode: string; maxTokens: number; targetRatio: number; - aggressiveness: string; + preserveSystemPrompt: boolean; + mcpDescriptionCompressionEnabled: boolean; }; }> { const start = Date.now(); @@ -124,11 +186,17 @@ export async function handleCompressionConfigure( if (args.strategy !== undefined) { updates.defaultMode = args.strategy; } + if (args.autoTriggerMode !== undefined) { + updates.autoTriggerMode = args.autoTriggerMode; + } if (args.maxTokens !== undefined) { updates.autoTriggerTokens = args.maxTokens; } - if (args.aggressiveness !== undefined) { - updates.defaultMode = args.aggressiveness; + if (args.preserveSystemPrompt !== undefined) { + updates.preserveSystemPrompt = args.preserveSystemPrompt; + } + if (args.mcpDescriptionCompressionEnabled !== undefined) { + updates.mcpDescriptionCompressionEnabled = args.mcpDescriptionCompressionEnabled; } const settings = await updateCompressionSettings(updates); @@ -139,9 +207,11 @@ export async function handleCompressionConfigure( settings: { enabled: settings.enabled, strategy: settings.defaultMode || "standard", + autoTriggerMode: settings.autoTriggerMode ?? "lite", maxTokens: settings.autoTriggerTokens, targetRatio: 0.7, // Default target ratio - aggressiveness: settings.defaultMode || "standard", + preserveSystemPrompt: settings.preserveSystemPrompt, + mcpDescriptionCompressionEnabled: settings.mcpDescriptionCompressionEnabled !== false, }, }; @@ -165,7 +235,64 @@ export async function handleCompressionConfigure( } import { z } from "zod"; -import { compressionStatusInput, compressionConfigureInput } from "../schemas/tools.ts"; +import { + compressionStatusInput, + compressionConfigureInput, + setCompressionEngineInput, + listCompressionCombosInput, + compressionComboStatsInput, +} from "../schemas/tools.ts"; + +export async function handleSetCompressionEngine( + args: z.infer +): Promise<{ success: boolean; settings: Record }> { + const updates: Record = { enabled: true }; + if (args.engine) { + updates.defaultMode = args.engine === "caveman" ? "standard" : args.engine; + if (args.engine === "off") updates.enabled = false; + } + if (args.cavemanIntensity) { + const current = await getCompressionSettings(); + updates.cavemanConfig = { + ...(current.cavemanConfig ?? {}), + intensity: args.cavemanIntensity, + }; + } + if (args.rtkIntensity) { + const current = await getCompressionSettings(); + updates.rtkConfig = { + ...(current.rtkConfig ?? {}), + intensity: args.rtkIntensity, + }; + } + if (args.outputMode !== undefined) { + const current = await getCompressionSettings(); + updates.cavemanOutputMode = { + ...(current.cavemanOutputMode ?? {}), + enabled: args.outputMode, + }; + } + const settings = await updateCompressionSettings(updates); + return { success: true, settings: settings as unknown as Record }; +} + +export async function handleListCompressionCombos(): Promise<{ + combos: ReturnType; +}> { + return { combos: listCompressionCombos() }; +} + +export async function handleCompressionComboStats( + args: z.infer +): Promise> { + const summary = getCompressionAnalyticsSummary(args.since === "all" ? undefined : args.since); + if (!args.comboId) return summary as unknown as Record; + return { + comboId: args.comboId, + summary, + combo: summary.byCompressionCombo[args.comboId] ?? { count: 0, tokensSaved: 0 }, + }; +} export const compressionTools = { omniroute_compression_status: { @@ -178,8 +305,27 @@ export const compressionTools = { omniroute_compression_configure: { name: "omniroute_compression_configure", description: - "Configure compression settings at runtime. Supports enabling/disabling compression, changing strategy (none/standard/aggressive/ultra), adjusting maxTokens threshold, targetRatio, and aggressiveness level.", + "Configure compression settings at runtime. Supports enabling/disabling compression, changing strategy (off/lite/standard/aggressive/ultra/rtk/stacked), adjusting maxTokens threshold, targetRatio, auto-trigger mode, system prompt preservation, and MCP description compression.", inputSchema: compressionConfigureInput, handler: (args: z.infer) => handleCompressionConfigure(args), }, + omniroute_set_compression_engine: { + name: "omniroute_set_compression_engine", + description: "Set the active compression engine and Caveman/RTK runtime options.", + inputSchema: setCompressionEngineInput, + handler: (args: z.infer) => handleSetCompressionEngine(args), + }, + omniroute_list_compression_combos: { + name: "omniroute_list_compression_combos", + description: "List compression combos and their engine pipelines.", + inputSchema: listCompressionCombosInput, + handler: (_args: z.infer) => handleListCompressionCombos(), + }, + omniroute_compression_combo_stats: { + name: "omniroute_compression_combo_stats", + description: "Get compression analytics grouped by engine and compression combo.", + inputSchema: compressionComboStatsInput, + handler: (args: z.infer) => + handleCompressionComboStats(args), + }, }; diff --git a/open-sse/package.json b/open-sse/package.json index 8654cf7640..2d62ae8fa9 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.7.8", + "version": "3.7.9", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/AGENTS.md b/open-sse/services/AGENTS.md index b7b99308d0..d13f4b1ea4 100644 --- a/open-sse/services/AGENTS.md +++ b/open-sse/services/AGENTS.md @@ -51,12 +51,15 @@ ### Prompt Compression Pipeline - **`compression/`** — Modular prompt compression running proactively before `contextManager.ts`. - - `strategySelector.ts` — Selects mode (off/lite/standard/aggressive/ultra) with combo overrides and auto-trigger. + - `strategySelector.ts` — Selects mode (off/lite/standard/aggressive/ultra/rtk/stacked) with compression combo assignments, combo overrides, and auto-trigger. - `lite.ts` — 5 lite techniques: whitespace collapse, system prompt dedup, tool result truncation, redundant removal, image URL placeholder. + - `caveman.ts` / `cavemanRules.ts` — Caveman-style semantic condensation with file-loaded rule packs and language-aware rule selection. + - `engines/registry.ts` — Engine registry used by standalone RTK/Caveman execution and stacked pipelines. + - `engines/rtk/` — RTK tool-output compression: command detection, JSON filter packs, deduplication, smart truncation, ANSI/code noise stripping. - `stats.ts` — Per-request compression stats (original/compressed tokens, savings %, techniques). - `types.ts` — Shared types (`CompressionMode`, `CompressionConfig`, `CompressionStats`, `CompressionResult`). - `index.ts` — Barrel re-exports. - - Phase 1: lite mode only. Standard/aggressive/ultra = Phase 2. + - Dashboard/API surface: `/dashboard/context/caveman`, `/dashboard/context/rtk`, `/dashboard/context/combos`, `/api/context/*`, and `/api/compression/preview`. ### Auto-Routing & Adaptive diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 6b75933e44..3b1eb5a871 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -511,12 +511,27 @@ export function getAllModelLockouts() { // ─── Provider Breaker Compatibility Wrappers ──────────────────────────────── // Legacy helpers now delegate to the shared provider circuit breaker. +type ProviderBreakerProfile = Partial< + Pick< + ProviderProfile, + "failureThreshold" | "resetTimeoutMs" | "circuitBreakerThreshold" | "circuitBreakerReset" + > +>; + function getProviderBreaker(provider: string | null | undefined) { + return provider ? getCircuitBreaker(provider) : null; +} + +function configureProviderBreaker( + provider: string | null | undefined, + profile?: ProviderBreakerProfile | null +) { if (!provider) return null; - const profile = getProviderProfile(provider); + + const resolvedProfile = { ...getProviderProfile(provider), ...(profile ?? {}) }; return getCircuitBreaker(provider, { - failureThreshold: profile.failureThreshold ?? profile.circuitBreakerThreshold, - resetTimeout: profile.resetTimeoutMs ?? profile.circuitBreakerReset, + failureThreshold: resolvedProfile.failureThreshold ?? resolvedProfile.circuitBreakerThreshold, + resetTimeout: resolvedProfile.resetTimeoutMs ?? resolvedProfile.circuitBreakerReset, }); } @@ -551,7 +566,8 @@ export function getProviderCooldownRemainingMs(provider: string | null | undefin export function recordProviderFailure( provider: string | null | undefined, log?: { warn?: (...args: unknown[]) => void }, - connectionId?: string | null + connectionId?: string | null, + profile?: ProviderBreakerProfile | null ): void { if (!provider) return; @@ -570,7 +586,7 @@ export function recordProviderFailure( lastConnectionFailure.set(dedupKey, now); } - const breaker = getProviderBreaker(provider); + const breaker = configureProviderBreaker(provider, profile); if (!breaker) return; if (!breaker.canExecute()) return; diff --git a/open-sse/services/autoCombo/index.ts b/open-sse/services/autoCombo/index.ts index 045ffaaf8d..062dbccacf 100644 --- a/open-sse/services/autoCombo/index.ts +++ b/open-sse/services/autoCombo/index.ts @@ -15,13 +15,4 @@ export { export { getTaskFitness, getTaskTypes } from "./taskFitness"; export { SelfHealingManager, getSelfHealingManager } from "./selfHealing"; export { MODE_PACKS, getModePack, getModePackNames } from "./modePacks"; -export { - selectProvider, - createAutoCombo, - getAutoCombo, - updateAutoCombo, - deleteAutoCombo, - listAutoCombos, - type AutoComboConfig, - type SelectionResult, -} from "./engine"; +export { selectProvider, type AutoComboConfig, type SelectionResult } from "./engine"; diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 93a84f4741..8bd9efa5e9 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -48,6 +48,7 @@ import { resolveRequestRoutingTags, type RoutingTagMatchMode, } from "../../src/domain/tagRouter.ts"; +import { normalizeRoutingStrategy } from "../../src/shared/constants/routingStrategies.ts"; // Status codes that should mark round-robin target semaphores as cooling down. const TRANSIENT_FOR_SEMAPHORE = [429, 502, 503, 504]; @@ -630,17 +631,25 @@ function sortTargetsByUsage(targets: ResolvedComboTarget[], comboName: string) { */ function sortModelsByContextSize(models) { const withContext = models.map((modelStr) => { - const parsed = parseModel(modelStr); - const provider = parsed.provider || parsed.providerAlias || "unknown"; - const model = parsed.model || modelStr; - const limit = getModelContextLimit(provider, model); - return { modelStr, context: limit ?? 0 }; + return { modelStr, context: getModelContextLimitForModelString(modelStr) ?? 0 }; }); withContext.sort((a, b) => b.context - a.context); return withContext.map((e) => e.modelStr); } +function getModelContextLimitForModelString(modelStr: string) { + const parsed = parseModel(modelStr); + const provider = parsed.provider || parsed.providerAlias || "unknown"; + const model = parsed.model || modelStr; + return getModelContextLimit(provider, model); +} + function sortTargetsByContextSize(targets: ResolvedComboTarget[]) { + const hasKnownContext = targets.some( + (target) => getModelContextLimitForModelString(target.modelStr) != null + ); + if (!hasKnownContext) return targets; + const orderedModels = sortModelsByContextSize(targets.map((target) => target.modelStr)); const byModel = new Map(); for (const target of targets) { @@ -656,6 +665,38 @@ function sortTargetsByContextSize(targets: ResolvedComboTarget[]) { .filter((target): target is ResolvedComboTarget => target !== null); } +function getP2CTargetScore( + target: ResolvedComboTarget, + metrics: ReturnType +): number { + const breakerState = getCircuitBreaker(target.provider)?.getStatus?.()?.state; + if (breakerState === "OPEN") return -Infinity; + const modelMetric = metrics?.byModel?.[target.modelStr] || null; + const successRate = Number(modelMetric?.successRate); + const avgLatency = Number(modelMetric?.avgLatencyMs); + const successScore = Number.isFinite(successRate) ? successRate / 100 : 0.5; + const latencyScore = + Number.isFinite(avgLatency) && avgLatency > 0 ? 1 / Math.log10(avgLatency + 10) : 0.25; + const breakerPenalty = breakerState === "HALF_OPEN" ? 0.25 : 0; + return successScore + latencyScore - breakerPenalty; +} + +function orderTargetsByPowerOfTwoChoices(targets: ResolvedComboTarget[], comboName: string) { + if (targets.length <= 1) return targets; + const metrics = getComboMetrics(comboName); + const firstIndex = Math.floor(Math.random() * targets.length); + let secondIndex = Math.floor(Math.random() * (targets.length - 1)); + if (secondIndex >= firstIndex) secondIndex++; + + const first = targets[firstIndex]; + const second = targets[secondIndex]; + const selectedIndex = + getP2CTargetScore(second, metrics) > getP2CTargetScore(first, metrics) + ? secondIndex + : firstIndex; + return [targets[selectedIndex], ...targets.filter((_, index) => index !== selectedIndex)]; +} + function toTextContent(content) { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; @@ -1034,7 +1075,7 @@ export async function handleComboChat({ relayOptions, signal, }) { - const strategy = combo.strategy || "priority"; + const strategy = normalizeRoutingStrategy(combo.strategy || "priority"); const relayConfig = strategy === "context-relay" ? resolveContextRelayConfig(relayOptions?.config || null) : null; @@ -1427,6 +1468,14 @@ export async function handleComboChat({ } else if (strategy === "random") { orderedTargets = fisherYatesShuffle([...orderedTargets]); log.info("COMBO", `Random shuffle: ${orderedTargets.length} targets`); + } else if (strategy === "fill-first") { + log.info( + "COMBO", + `Fill-first ordering: preserving priority order (${orderedTargets.length} targets)` + ); + } else if (strategy === "p2c") { + orderedTargets = orderTargetsByPowerOfTwoChoices(orderedTargets, combo.name); + log.info("COMBO", `Power-of-two-choices ordering: selected ${orderedTargets[0]?.modelStr}`); } else if (strategy === "least-used") { orderedTargets = sortTargetsByUsage(orderedTargets, combo.name); log.info("COMBO", `Least-used ordering: ${orderedTargets[0]?.modelStr} has fewest requests`); @@ -1678,7 +1727,7 @@ export async function handleComboChat({ // Trigger shared provider circuit breaker for 5xx errors and connection failures if (isProviderFailureCode(result.status)) { - recordProviderFailure(provider, log, target.connectionId); + recordProviderFailure(provider, log, target.connectionId, profile); } // Check if this is a transient error worth retrying on same model diff --git a/open-sse/services/compression/aggressive.ts b/open-sse/services/compression/aggressive.ts index 8e2d3e85c4..a1edf3536a 100644 --- a/open-sse/services/compression/aggressive.ts +++ b/open-sse/services/compression/aggressive.ts @@ -5,39 +5,19 @@ import { applyAging } from "./progressiveAging.ts"; import { RuleBasedSummarizer } from "./summarizer.ts"; import { cavemanCompress } from "./caveman.ts"; import { applyLiteCompression } from "./lite.ts"; +import { extractTextContent, replaceTextContent, type ChatMessageLike } from "./messageContent.ts"; const COMPRESSED_MARKER_RE = /^\[COMPRESSED:/; -interface ChatMessage { - role: string; - content?: string | Array<{ type: string; text?: string }>; - [key: string]: unknown; -} +type ChatMessage = ChatMessageLike; interface AggressiveCompressionResult { messages: ChatMessage[]; stats: CompressionStats; } -function extractText(content?: string | Array<{ type: string; text?: string }>): string { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content - .filter( - (p): p is { type: string; text?: string } => - typeof p === "object" && p !== null && "text" in p - ) - .map((p) => p.text ?? "") - .join("\n"); - } - return ""; -} - function setContent(msg: ChatMessage, newContent: string): ChatMessage { - if (typeof msg.content === "string") { - return { ...msg, content: newContent }; - } - return { ...msg, content: [{ type: "text", text: newContent }] }; + return replaceTextContent(msg, newContent) as ChatMessage; } function estimateTokens(text: string): number { @@ -71,7 +51,7 @@ export function compressAggressive( }; const originalTokens = messages.reduce( - (sum, m) => sum + estimateTokens(extractText(m.content)), + (sum, m) => sum + estimateTokens(extractTextContent(m.content)), 0 ); resultStats.originalTokens = originalTokens; @@ -84,8 +64,9 @@ export function compressAggressive( // Step 1: Tool-result compression try { const afterToolResult = currentMessages.map((msg) => { + if (cfg.preserveSystemPrompt !== false && msg.role === "system") return msg; if (msg.role !== "tool" && msg.role !== "function") return msg; - const text = extractText(msg.content); + const text = extractTextContent(msg.content); if (!text || COMPRESSED_MARKER_RE.test(text)) return msg; const result = compressToolResult(text, cfg.toolStrategies); @@ -101,7 +82,12 @@ export function compressAggressive( // Step 2: Progressive aging try { - const agingResult = applyAging(currentMessages, cfg.thresholds, summarizer); + const agingResult = applyAging( + currentMessages, + cfg.thresholds, + summarizer, + cfg.preserveSystemPrompt !== false + ); agingSavings = agingResult.saved; currentMessages = agingResult.messages as ChatMessage[]; } catch (err) { @@ -112,7 +98,8 @@ export function compressAggressive( if (cfg.summarizerEnabled) { try { currentMessages = currentMessages.map((msg) => { - const text = extractText(msg.content); + if (cfg.preserveSystemPrompt !== false && msg.role === "system") return msg; + const text = extractTextContent(msg.content); if (!text || COMPRESSED_MARKER_RE.test(text)) return msg; if (text.length <= cfg.maxTokensPerMessage * 4) return msg; @@ -133,7 +120,7 @@ export function compressAggressive( // Downgrade chain: if total savings < threshold, try caveman then lite const compressedTokens = currentMessages.reduce( - (sum, m) => sum + estimateTokens(extractText(m.content)), + (sum, m) => sum + estimateTokens(extractTextContent(m.content)), 0 ); resultStats.compressedTokens = compressedTokens; @@ -157,7 +144,10 @@ export function compressAggressive( } try { - const liteResult = applyLiteCompression({ messages: currentMessages }); + const liteResult = applyLiteCompression( + { messages: currentMessages }, + { preserveSystemPrompt: cfg.preserveSystemPrompt !== false } + ); if (liteResult?.compressed && liteResult.stats) { const liteSavings = liteResult.stats.savingsPercent ?? 0; if (liteSavings > resultStats.savingsPercent) { diff --git a/open-sse/services/compression/caveman.ts b/open-sse/services/compression/caveman.ts index e0c40ecdfa..476be8d41e 100644 --- a/open-sse/services/compression/caveman.ts +++ b/open-sse/services/compression/caveman.ts @@ -1,12 +1,22 @@ -import type { CavemanConfig, CavemanRule, CompressionResult, CompressionMode } from "./types.ts"; +import type { + CavemanConfig, + CavemanRule, + CompressionResult, + CompressionMode, + CompressionStats, +} from "./types.ts"; import { DEFAULT_CAVEMAN_CONFIG } from "./types.ts"; import { CAVEMAN_RULES, getRulesForContext } from "./cavemanRules.ts"; import { extractPreservedBlocks, restorePreservedBlocks } from "./preservation.ts"; import { createCompressionStats, estimateCompressionTokens } from "./stats.ts"; +import { validateCompression } from "./validation.ts"; +import { mapTextContent } from "./messageContent.ts"; +import { detectCompressionLanguage } from "./languageDetector.ts"; interface ChatMessage { role: string; content?: string | Array<{ type: string; text?: string }>; + [key: string]: unknown; } interface ChatRequestBody { @@ -14,14 +24,165 @@ interface ChatRequestBody { [key: string]: unknown; } +const RULE_KEYWORDS: Record = { + redundant_phrasing: ["make sure", "be sure"], + redundant_because: ["due to the fact", "the reason is because"], + redundant_directive: ["it is important", "you should", "remember to"], + pleasantries: [ + "sure", + "certainly", + "of course", + "happy to", + "thanks", + "thank you", + "glad to help", + "glad to", + "no problem", + "you're welcome", + "youre welcome", + "absolutely", + ], + polite_framing: [ + "please", + "kindly", + "could you please", + "would you please", + "can you please", + "i would like you", + "i want you", + "i need you", + ], + hedging: [ + "it seems like", + "it appears that", + "i think that", + "i believe that", + "probably", + "possibly", + "maybe it", + ], + verbose_instructions: [ + "provide a detailed", + "give me a comprehensive", + "write an in-depth", + "create a thorough", + "explain in detail", + ], + filler_adverbs: ["basically", "essentially", "actually", "literally", "simply", "currently"], + filler_phrases: ["i want to", "i need to", "i'd like to", "i'm looking for"], + redundant_openers: ["hi there", "hello", "good morning", "hey"], + verbose_requests: ["i was wondering", "would it be possible"], + leader_phrases: [ + "i'll", + "i will", + "i can", + "i'd", + "let me", + "you can", + "we will", + "we can", + "let's", + ], + self_reference: ["i am trying to", "i am working on", "i have been"], + excessive_gratitude: ["thank you so much", "thanks in advance", "i really appreciate"], + qualifier_removal: ["a bit", "a little", "somewhat", "kind of", "sort of"], + softeners: ["if possible", "when you get a chance", "at your convenience", "just wondering"], + uncertainty_fillers: ["i guess", "i suppose", "more or less", "in a way"], + assistant_fillers: ["here's", "below is", "this is"], + compound_collapse: ["and any potential"], + explanatory_prefix: [ + "the function appears to be handling", + "the code seems to", + "the class is", + "this module is", + ], + question_to_directive: [ + "can you explain why", + "could you show me how", + "would you tell me", + "can you tell me", + ], + context_setup: ["i have the following code", "here is my code", "below is the code"], + intent_clarification: [ + "what i'm trying to do", + "my objective is to", + "what i need is", + "i'm aiming to", + ], + background_removal: ["as you may know", "as we discussed earlier"], + meta_commentary: ["note that", "keep in mind", "remember that"], + purpose_statement: ["for the purpose of", "with the goal of", "in an effort to", "for every"], + list_conjunction: ["and also", "as well as"], + purpose_phrases: ["in order to", "so as to"], + redundant_quantifiers: ["each and every", "any and all"], + all_quantifier: ["any and all"], + verbose_connectors: ["furthermore", "additionally", "moreover", "in addition"], + transition_removal: ["on the other hand", "in contrast", "however"], + emphasis_removal: ["very", "really", "extremely", "highly", "quite"], + passive_voice: [ + "is being used", + "is being called", + "is being generated", + "was created", + "was generated", + "was implemented", + ], + repeated_context: [ + "as we discussed earlier", + "as mentioned before", + "as previously stated", + "as i said before", + ], + repeated_question: [ + "same question as before", + "i asked this earlier", + "this is the same question", + ], + reestablished_context: ["going back to the code above", "referring back to", "returning to"], + summary_replacement: ["to summarize", "in summary of our conversation", "to recap"], + ultra_abbreviations: ["database"], + ultra_config_abbreviation: ["configuration"], + ultra_function_abbreviation: ["function"], + ultra_request_abbreviation: ["request"], + ultra_response_abbreviation: ["response"], + ultra_implementation_abbreviation: ["implementation"], + ultra_authentication_abbreviation: ["authentication"], + ultra_authorization_abbreviation: ["authorization"], + ultra_application_abbreviation: ["application"], + ultra_dependency_abbreviation: ["dependency", "dependencies"], + ultra_common_abbreviations: [ + "implementation", + "authentication", + "authorization", + "application", + "dependency", + "dependencies", + ], +}; + +const ARTICLE_HINT_RE = /\b(?:a|an|the)\b/; + +function shouldAttemptRule(ruleName: string, lowerText: string): boolean { + if (ruleName === "articles") { + ARTICLE_HINT_RE.lastIndex = 0; + return ARTICLE_HINT_RE.test(lowerText); + } + + const keywords = RULE_KEYWORDS[ruleName]; + return !keywords || keywords.some((keyword) => lowerText.includes(keyword)); +} + export function applyRulesToText( text: string, rules: CavemanRule[] ): { text: string; appliedRules: string[] } { let result = text; + const lowerResult = text.toLowerCase(); const appliedRules: string[] = []; for (const rule of rules) { + if (!shouldAttemptRule(rule.name, lowerResult)) continue; + const before = result; const { pattern, replacement } = rule; if (typeof replacement === "function") { @@ -43,14 +204,76 @@ export function applyRulesToText( function cleanupArtifacts(text: string): string { let result = text; - result = result.replace(/ +/g, " "); - result = result.replace(/ +$/gm, ""); - result = result.replace(/\n{3,}/g, "\n\n"); - result = result.replace(/^\n+/, ""); - result = result.replace(/\n+$/, ""); + if (result.includes(" ")) result = result.replace(/[ \t]{2,}/g, " "); + if (/[\t ]+[,.;:!?]/.test(result)) result = result.replace(/[ \t]+([,.;:!?])/g, "$1"); + if (/[.!?]{2,}/.test(result)) result = result.replace(/([.!?]){2,}/g, "$1"); + if (/[ \t]\n/.test(result)) result = result.replace(/[ \t]+$/gm, ""); + if (result.endsWith(" ") || result.endsWith("\t")) result = result.trimEnd(); + if (result.includes("\n\n\n")) result = result.replace(/\n{3,}/g, "\n\n"); + if (result.startsWith("\n")) result = result.replace(/^\n+/, ""); + if (result.endsWith("\n")) result = result.replace(/\n+$/, ""); return result; } +function recapitalizeSentences(text: string): string { + return text.replace( + /(^|[.!?][ \t]+|\n+[ \t]*)([a-z])/g, + (_match, prefix: string, char: string) => { + return `${prefix}${char.toUpperCase()}`; + } + ); +} + +function createCavemanStats( + originalTokens: number, + compressedTokens: number, + techniquesUsed: string[], + rulesApplied: string[] | undefined, + durationMs: number +): CompressionStats { + const savingsPercent = + originalTokens > 0 + ? Math.round(((originalTokens - compressedTokens) / originalTokens) * 10000) / 100 + : 0; + return { + originalTokens, + compressedTokens, + savingsPercent, + techniquesUsed, + mode: "standard", + timestamp: Date.now(), + ...(rulesApplied && rulesApplied.length > 0 ? { rulesApplied } : {}), + durationMs, + }; +} + +function compileUserPreservePatterns(patterns: string[]): { + patterns: RegExp[]; + warnings: string[]; +} { + const compiled: RegExp[] = []; + const warnings: string[] = []; + for (const pattern of patterns) { + try { + compiled.push(new RegExp(pattern, "g")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warnings.push(`Invalid preservePatterns regex ignored: ${pattern} (${message})`); + } + } + return { patterns: compiled, warnings }; +} + +const PROTECTED_STRUCTURE_RE = + /```|~~~|`|https?:\/\/|\[[^\]\n]{1,1000}\]\([^)[ \t\n]{1,2000}(?:[ \t]+"[^"]{0,1000}")?\)|^#{1,6}\s+|^[ \t]*\|(?:[^|\n]{0,1000}\|){1,100}[ \t]*$|\$\$|\\\[|\\begin\{|^\s*#(?:set|show|let|import|include)\b|\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b|\bprocess\.env\.[A-Za-z_][A-Za-z0-9_]*\b|\$[A-Z_][A-Z0-9_]*\b|\b\d+(?:\.\d+){1,3}(?:[-+][A-Za-z0-9.-]+)?\b|\b[a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)+\(\)?|\b[A-Za-z_$][\w$]*[ \t]*\([^()\n]{0,1000}\)|(?:^|\s)(?:\.{0,2}\/[A-Za-z0-9_@./-]+|[A-Za-z]:\\[A-Za-z0-9_.\\/-]+)|\b(?:TypeError|ReferenceError|SyntaxError|RangeError|URIError|EvalError|Error|Exception):[^\n]{0,1000}/im; +const PROTECTED_STRUCTURE_PREFILTER_RE = /[`~\[\]\|$#\\/:_()0-9]/; + +function hasProtectedStructure(text: string): boolean { + if (!PROTECTED_STRUCTURE_PREFILTER_RE.test(text)) return false; + PROTECTED_STRUCTURE_RE.lastIndex = 0; + return PROTECTED_STRUCTURE_RE.test(text); +} + export function cavemanCompress( body: ChatRequestBody, options?: Partial @@ -80,23 +303,29 @@ export function cavemanCompress( let totalOriginalTokens = 0; let totalCompressedTokens = 0; const allAppliedRules: string[] = []; + const validationWarnings: string[] = []; + const validationErrors: string[] = []; + let fallbackApplied = false; + let preservedBlockCount = 0; + const customPreservation = compileUserPreservePatterns(config.preservePatterns ?? []); + validationWarnings.push(...customPreservation.warnings); const compressedMessages = body.messages.map((msg): ChatMessage => { - // Only compress simple string content — multi-part messages (arrays) - // would duplicate compressed text across all text parts if naively handled - if (typeof msg.content !== "string") { - const contentStr = Array.isArray(msg.content) - ? msg.content - .map((part) => (part.type === "text" && part.text ? part.text : "")) - .filter(Boolean) - .join("\n") - : ""; - totalOriginalTokens += estimateCompressionTokens(contentStr); - totalCompressedTokens += estimateCompressionTokens(contentStr); + if (typeof msg.content !== "string" && !Array.isArray(msg.content)) { return msg; } - const contentStr = msg.content; + const contentStr = + typeof msg.content === "string" + ? msg.content + : msg.content + .map((part) => + part && typeof part === "object" && "text" in part && typeof part.text === "string" + ? part.text + : "" + ) + .filter(Boolean) + .join("\n"); totalOriginalTokens += estimateCompressionTokens(contentStr); if (!contentStr || contentStr.length < config.minMessageLength) { @@ -109,33 +338,83 @@ export function cavemanCompress( return msg; } - const { text: extractedText, blocks } = extractPreservedBlocks(contentStr); + const compressTextPart = (textPart: string): string => { + if (!textPart || textPart.length < config.minMessageLength) return textPart; - const rules = getRulesForContext(msg.role).filter( - (rule) => !config.skipRules.includes(rule.name) - ); - const { text: rulesApplied, appliedRules } = applyRulesToText(extractedText, rules); - allAppliedRules.push(...appliedRules); + const shouldPreserve = + customPreservation.patterns.length > 0 || hasProtectedStructure(textPart); + const { text: extractedText, blocks } = shouldPreserve + ? extractPreservedBlocks(textPart, { + preservePatterns: customPreservation.patterns, + }) + : { text: textPart, blocks: [] }; + preservedBlockCount += blocks.length; - const restored = restorePreservedBlocks(rulesApplied, blocks); + const detectedLanguage = config.autoDetectLanguage + ? detectCompressionLanguage(textPart) + : (config.language ?? "en"); + const enabledPacks = config.enabledLanguagePacks ?? ["en", detectedLanguage]; + const language = enabledPacks.includes(detectedLanguage) + ? detectedLanguage + : enabledPacks.includes("en") + ? "en" + : detectedLanguage; + const rules = getRulesForContext(msg.role, config.intensity, language).filter( + (rule) => !config.skipRules.includes(rule.name) + ); + const { text: rulesApplied, appliedRules } = applyRulesToText(extractedText, rules); + allAppliedRules.push(...appliedRules); - const cleaned = cleanupArtifacts(restored); + const normalized = cleanupArtifacts(recapitalizeSentences(rulesApplied)); + const cleaned = + blocks.length > 0 + ? cleanupArtifacts(restorePreservedBlocks(normalized, blocks)) + : normalized; + if (shouldPreserve || blocks.length > 0) { + const validation = validateCompression(textPart, cleaned); + validationWarnings.push(...validation.warnings); + if (!validation.valid) { + validationErrors.push(...validation.errors); + fallbackApplied = true; + return textPart; + } + } + return cleaned; + }; + + const compressedMessage = mapTextContent(msg, compressTextPart) as ChatMessage; + const cleaned = + typeof compressedMessage.content === "string" + ? compressedMessage.content + : Array.isArray(compressedMessage.content) + ? compressedMessage.content + .map((part) => + part && typeof part === "object" && "text" in part && typeof part.text === "string" + ? part.text + : "" + ) + .filter(Boolean) + .join("\n") + : contentStr; totalCompressedTokens += estimateCompressionTokens(cleaned); - return { ...msg, content: cleaned }; + return compressedMessage; }); const durationMs = performance.now() - startMs; const uniqueRules = [...new Set(allAppliedRules)]; - const stats = createCompressionStats( - body as unknown as Record, - { ...body, messages: compressedMessages } as unknown as Record, - "standard" as CompressionMode, + const stats = createCavemanStats( + totalOriginalTokens, + totalCompressedTokens, uniqueRules.length > 0 ? ["caveman-rules"] : [], uniqueRules.length > 0 ? uniqueRules : undefined, Math.round(durationMs * 100) / 100 ); + if (validationWarnings.length > 0) stats.validationWarnings = [...new Set(validationWarnings)]; + if (validationErrors.length > 0) stats.validationErrors = [...new Set(validationErrors)]; + if (fallbackApplied) stats.fallbackApplied = true; + if (preservedBlockCount > 0) stats.preservedBlockCount = preservedBlockCount; const compressed = totalCompressedTokens < totalOriginalTokens; diff --git a/open-sse/services/compression/cavemanRules.ts b/open-sse/services/compression/cavemanRules.ts index 51e9958d04..c94ad6419e 100644 --- a/open-sse/services/compression/cavemanRules.ts +++ b/open-sse/services/compression/cavemanRules.ts @@ -1,14 +1,48 @@ import type { CavemanRule } from "./types.ts"; +import { loadAllRulesForLanguage } from "./ruleLoader.ts"; const CAVEMAN_RULES: CavemanRule[] = [ // ── Category 1: Filler Removal (10+ rules) ────────────────────────── + { + name: "redundant_phrasing", + pattern: + /\b(?:make sure to|be sure to|due to the fact that|the reason is because|it is important to|you should|remember to)\b\s*/gi, + replacement: (match: string): string => { + const map: Record = { + "make sure to": "ensure ", + "be sure to": "ensure ", + "due to the fact that": "because ", + "the reason is because": "because ", + "it is important to": "", + "you should": "", + "remember to": "", + }; + return map[match.trim().toLowerCase()] ?? ""; + }, + context: "all", + category: "structural", + minIntensity: "full", + description: "Replace verbose stock phrases with shorter equivalents.", + }, + { + name: "pleasantries", + pattern: + /(? { const map: Record = { - "provide a detailed": "provide", - "give me a comprehensive": "give", - "write an in-depth": "write", - "create a thorough": "create", - "explain in detail": "explain", + "provide a detailed explanation of": "explain ", + "give me a comprehensive explanation of": "explain ", + "write an in-depth explanation of": "explain ", + "create a thorough explanation of": "explain ", + "provide a detailed": "provide ", + "give me a comprehensive": "give ", + "write an in-depth": "write ", + "create a thorough": "create ", + "explain in detail": "explain ", }; const lower = match.toLowerCase(); return map[lower] ?? match; }, context: "all", + category: "filler", + minIntensity: "lite", }, { name: "filler_adverbs", - pattern: /(? { const trimmed = match.trimEnd().toLowerCase(); const map: Record = { - "can you explain why": "Explain why", - "could you show me how": "Show how", - "would you tell me": "Tell me", - "can you tell me": "Tell me", + "can you explain why": "Explain why ", + "could you show me how": "Show how ", + "would you tell me": "Tell me ", + "can you tell me": "Tell me ", }; return map[trimmed] ?? match; }, context: "user", + category: "context", + minIntensity: "lite", }, { name: "context_setup", pattern: /\b(?:I have the following code|Here is my code|Below is the code)\b\s*[:.]?\s*/gi, replacement: "Code:", context: "user", + category: "context", + minIntensity: "lite", }, { name: "intent_clarification", @@ -128,31 +210,40 @@ const CAVEMAN_RULES: CavemanRule[] = [ /\b(?:What I'm trying to do is|My objective is to|What I need is|I'm aiming to)\b\s*/gi, replacement: "Goal:", context: "user", + category: "context", + minIntensity: "lite", }, { name: "background_removal", pattern: /\b(?:As you may know,?\s*|As we discussed earlier,?\s*)/gi, replacement: "", context: "all", + category: "context", + minIntensity: "lite", }, { name: "meta_commentary", pattern: /^(?:Note that|Keep in mind that|Remember that)\b\s*/gim, replacement: "", context: "all", + category: "context", + minIntensity: "lite", }, { name: "purpose_statement", - pattern: /\b(?:for the purpose of|with the goal of|in an effort to)\b/gi, + pattern: /\b(?:for the purpose of|with the goal of|in an effort to|for every)\b/gi, replacement: (match: string): string => { const map: Record = { "for the purpose of": "for", "with the goal of": "to", "in an effort to": "to", + "for every": "per", }; return map[match.toLowerCase()] ?? match; }, context: "all", + category: "context", + minIntensity: "lite", }, // ── Category 3: Structural Compression (7+ rules) ──────────────────── @@ -162,12 +253,16 @@ const CAVEMAN_RULES: CavemanRule[] = [ pattern: /,\s*and also\s+|,\s*as well as\s+/gi, replacement: ", ", context: "all", + category: "structural", + minIntensity: "full", }, { name: "purpose_phrases", pattern: /\b(?:in order to|so as to)\b\s*/gi, replacement: "to ", context: "all", + category: "structural", + minIntensity: "lite", }, { name: "redundant_quantifiers", @@ -181,32 +276,42 @@ const CAVEMAN_RULES: CavemanRule[] = [ return map[match.toLowerCase()] ?? match; }, context: "all", + category: "structural", + minIntensity: "full", }, { name: "verbose_connectors", pattern: /\b(?:furthermore|additionally|moreover|in addition)\b\s*/gi, replacement: "also ", context: "all", + category: "structural", + minIntensity: "lite", }, { name: "transition_removal", pattern: /^(?:On the other hand,?\s*|In contrast,?\s*|However,?\s*)/gim, replacement: "", context: "all", + category: "structural", + minIntensity: "lite", }, { name: "emphasis_removal", pattern: /\b(?:very|really|extremely|highly|quite)\s+(?=[a-z])/gi, replacement: "", context: "all", + category: "structural", + minIntensity: "lite", }, { name: "passive_voice", - pattern: /\b(?:is being used|is being called|was created|was generated|was implemented)\b/gi, + pattern: + /\b(?:is being used|is being called|is being generated|was created|was generated|was implemented)\b/gi, replacement: (match: string): string => { const map: Record = { "is being used": "uses", "is being called": "calls", + "is being generated": "generated", "was created": "created", "was generated": "generated", "was implemented": "implemented", @@ -214,6 +319,8 @@ const CAVEMAN_RULES: CavemanRule[] = [ return map[match.toLowerCase()] ?? match; }, context: "all", + category: "structural", + minIntensity: "full", }, // ── Category 4: Multi-Turn Dedup (5+ rules) ───────────────────────── @@ -224,6 +331,8 @@ const CAVEMAN_RULES: CavemanRule[] = [ /\b(?:As we discussed earlier|As mentioned before|As previously stated|As I said before)\b[,.]?\s*/gi, replacement: "See above. ", context: "all", + category: "dedup", + minIntensity: "lite", }, { name: "repeated_question", @@ -231,12 +340,16 @@ const CAVEMAN_RULES: CavemanRule[] = [ /\b(?:Same question as before|I asked this earlier|This is the same question)\b[,.]?\s*/gi, replacement: "[same question] ", context: "user", + category: "dedup", + minIntensity: "lite", }, { name: "reestablished_context", pattern: /\b(?:Going back to the code above|Referring back to|Returning to)\b\s*/gi, replacement: "Re: ", context: "all", + category: "dedup", + minIntensity: "lite", }, { name: "summary_replacement", @@ -244,15 +357,76 @@ const CAVEMAN_RULES: CavemanRule[] = [ /\b(?:To summarize what we've discussed|In summary of our conversation|To recap)\b[,.]?\s*/gi, replacement: "Summary: ", context: "assistant", + category: "dedup", + minIntensity: "lite", + }, + + // ── Category 5: Ultra Abbreviations ───────────────────────────────── + + { + name: "ultra_abbreviations", + pattern: + /\b(?:database|configuration|function|request|response|implementation|authentication|authorization|application|dependency|dependencies)\b/gi, + replacement: (match: string): string => { + const map: Record = { + database: "DB", + configuration: "config", + function: "fn", + request: "req", + response: "res", + implementation: "impl", + authentication: "auth", + authorization: "authz", + application: "app", + dependency: "dep", + dependencies: "deps", + }; + return map[match.toLowerCase()] ?? match; + }, + context: "all", + category: "ultra", + minIntensity: "ultra", }, ]; -export function getRulesForContext(context: string): CavemanRule[] { - return CAVEMAN_RULES.filter((rule) => rule.context === "all" || rule.context === context); +const INTENSITY_RANK = { lite: 0, full: 1, ultra: 2 } as const; + +export function getRulesForContext( + context: string, + intensity: "lite" | "full" | "ultra" = "full", + language = "en" +): CavemanRule[] { + const rank = INTENSITY_RANK[intensity] ?? INTENSITY_RANK.full; + const fileRules = language ? loadAllRulesForLanguage(language) : []; + const rules = fileRules.length > 0 ? fileRules : CAVEMAN_RULES; + const selected = rules.filter((rule) => { + const minRank = INTENSITY_RANK[rule.minIntensity ?? "lite"]; + return (rule.context === "all" || rule.context === context) && minRank <= rank; + }); + return selected.length > 0 + ? selected + : CAVEMAN_RULES.filter((rule) => { + const minRank = INTENSITY_RANK[rule.minIntensity ?? "lite"]; + return (rule.context === "all" || rule.context === context) && minRank <= rank; + }); } export function getRuleByName(name: string): CavemanRule | undefined { return CAVEMAN_RULES.find((rule) => rule.name === name); } +export function getCavemanRuleMetadata() { + const intensities = ["lite", "full", "ultra"] as const; + return CAVEMAN_RULES.map((rule) => ({ + name: rule.name, + context: rule.context, + category: rule.category ?? "terse", + minIntensity: rule.minIntensity ?? "lite", + intensities: intensities.filter( + (intensity) => INTENSITY_RANK[intensity] >= INTENSITY_RANK[rule.minIntensity ?? "lite"] + ), + description: rule.description ?? rule.name.replace(/_/g, " "), + })); +} + export { CAVEMAN_RULES }; diff --git a/open-sse/services/compression/diffHelper.ts b/open-sse/services/compression/diffHelper.ts new file mode 100644 index 0000000000..5dbebd7b1d --- /dev/null +++ b/open-sse/services/compression/diffHelper.ts @@ -0,0 +1,117 @@ +import type { CompressionStats } from "./types.ts"; +import { extractPreservedBlocks } from "./preservation.ts"; +import { validateCompression } from "./validation.ts"; + +export interface CompressionDiffSegment { + type: "same" | "removed" | "added"; + text: string; +} + +export interface CompressionPreviewDiff { + segments: CompressionDiffSegment[]; + preservedBlocks: Array<{ kind: string; preview: string }>; + ruleRemovals: string[]; + validationWarnings: string[]; + validationErrors: string[]; + fallbackApplied: boolean; +} + +export interface CompressionPreviewDiffOptions { + maxTokenProduct?: number; +} + +export const DEFAULT_MAX_PREVIEW_DIFF_TOKEN_PRODUCT = 1_000_000; + +function tokenize(text: string): string[] { + return text.match(/\s+|[^\s]+/g) ?? []; +} + +function getDiffSkipWarning( + original: string, + compressed: string, + options: CompressionPreviewDiffOptions = {} +): string | null { + const maxTokenProduct = options.maxTokenProduct ?? DEFAULT_MAX_PREVIEW_DIFF_TOKEN_PRODUCT; + if (maxTokenProduct <= 0) return null; + + const originalTokens = tokenize(original).length; + const compressedTokens = tokenize(compressed).length; + if (originalTokens * compressedTokens <= maxTokenProduct) return null; + + return `Preview diff omitted because token product ${originalTokens}x${compressedTokens} exceeds safe limit ${maxTokenProduct}.`; +} + +export function buildCompressionDiff( + original: string, + compressed: string +): CompressionDiffSegment[] { + const a = tokenize(original); + const b = tokenize(compressed); + const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0)); + + for (let i = a.length - 1; i >= 0; i--) { + for (let j = b.length - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + + const segments: CompressionDiffSegment[] = []; + const push = (type: CompressionDiffSegment["type"], text: string) => { + if (!text) return; + const last = segments[segments.length - 1]; + if (last?.type === type) { + last.text += text; + } else { + segments.push({ type, text }); + } + }; + + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + push("same", a[i]); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + push("removed", a[i]); + i++; + } else { + push("added", b[j]); + j++; + } + } + while (i < a.length) push("removed", a[i++]); + while (j < b.length) push("added", b[j++]); + + return segments; +} + +export function buildCompressionPreviewDiff( + original: string, + compressed: string, + stats: CompressionStats | null | undefined, + options: CompressionPreviewDiffOptions = {} +): CompressionPreviewDiff { + const validation = validateCompression(original, compressed); + const preserved = extractPreservedBlocks(original).blocks.map((block) => ({ + kind: block.kind, + preview: block.content.replace(/\s+/g, " ").slice(0, 120), + })); + const diffSkipWarning = getDiffSkipWarning(original, compressed, options); + + return { + segments: diffSkipWarning + ? [{ type: "same", text: "[diff omitted: input too large]" }] + : buildCompressionDiff(original, compressed), + preservedBlocks: preserved, + ruleRemovals: stats?.rulesApplied ?? [], + validationWarnings: [ + ...(stats?.validationWarnings ?? []), + ...validation.warnings, + ...(diffSkipWarning ? [diffSkipWarning] : []), + ], + validationErrors: [...(stats?.validationErrors ?? []), ...validation.errors], + fallbackApplied: Boolean(stats?.fallbackApplied || validation.fallbackApplied), + }; +} diff --git a/open-sse/services/compression/engines/cavemanAdapter.ts b/open-sse/services/compression/engines/cavemanAdapter.ts new file mode 100644 index 0000000000..51ae2b6548 --- /dev/null +++ b/open-sse/services/compression/engines/cavemanAdapter.ts @@ -0,0 +1,408 @@ +import { applyLiteCompression } from "../lite.ts"; +import { cavemanCompress } from "../caveman.ts"; +import { compressAggressive } from "../aggressive.ts"; +import { ultraCompress } from "../ultra.ts"; +import { createCompressionStats } from "../stats.ts"; +import { + DEFAULT_AGGRESSIVE_CONFIG, + DEFAULT_ULTRA_CONFIG, + type CavemanIntensity, +} from "../types.ts"; +import type { CompressionEngine, EngineConfigField, EngineValidationResult } from "./types.ts"; + +const CAVEMAN_INTENSITIES: CavemanIntensity[] = ["lite", "full", "ultra"]; + +const CAVEMAN_SCHEMA: EngineConfigField[] = [ + { + key: "intensity", + type: "select", + label: "Intensity", + defaultValue: "full", + options: CAVEMAN_INTENSITIES.map((value) => ({ value, label: value })), + }, + { + key: "minMessageLength", + type: "number", + label: "Minimum message length", + defaultValue: 50, + min: 0, + max: 10000, + }, + { + key: "enabled", + type: "boolean", + label: "Enabled", + defaultValue: true, + }, +]; + +const AGGRESSIVE_SCHEMA: EngineConfigField[] = [ + { + key: "summarizerEnabled", + type: "boolean", + label: "Summarizer enabled", + defaultValue: DEFAULT_AGGRESSIVE_CONFIG.summarizerEnabled, + }, + { + key: "maxTokensPerMessage", + type: "number", + label: "Max tokens per message", + defaultValue: DEFAULT_AGGRESSIVE_CONFIG.maxTokensPerMessage, + min: 256, + max: 32768, + }, + { + key: "minSavingsThreshold", + type: "number", + label: "Minimum savings threshold", + defaultValue: DEFAULT_AGGRESSIVE_CONFIG.minSavingsThreshold, + min: 0, + max: 1, + }, + { + key: "preserveSystemPrompt", + type: "boolean", + label: "Preserve system prompt", + defaultValue: true, + }, +]; + +const ULTRA_SCHEMA: EngineConfigField[] = [ + { + key: "enabled", + type: "boolean", + label: "Enabled", + defaultValue: DEFAULT_ULTRA_CONFIG.enabled, + }, + { + key: "compressionRate", + type: "number", + label: "Compression rate", + defaultValue: DEFAULT_ULTRA_CONFIG.compressionRate, + min: 0, + max: 1, + }, + { + key: "minScoreThreshold", + type: "number", + label: "Minimum score threshold", + defaultValue: DEFAULT_ULTRA_CONFIG.minScoreThreshold, + min: 0, + max: 1, + }, + { + key: "slmFallbackToAggressive", + type: "boolean", + label: "Fallback to aggressive", + defaultValue: DEFAULT_ULTRA_CONFIG.slmFallbackToAggressive, + }, + { + key: "modelPath", + type: "string", + label: "Model path", + defaultValue: "", + }, + { + key: "maxTokensPerMessage", + type: "number", + label: "Max tokens per message", + defaultValue: DEFAULT_ULTRA_CONFIG.maxTokensPerMessage, + min: 0, + max: 32768, + }, + { + key: "preserveSystemPrompt", + type: "boolean", + label: "Preserve system prompt", + defaultValue: true, + }, +]; + +function ok(): EngineValidationResult { + return { valid: true, errors: [] }; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function validateBoolean(config: Record, key: string, errors: string[]): void { + if (config[key] !== undefined && typeof config[key] !== "boolean") { + errors.push(`${key} must be a boolean`); + } +} + +function validateNumberRange( + config: Record, + key: string, + min: number, + max: number, + errors: string[] +): void { + const value = config[key]; + if (value === undefined) return; + if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) { + errors.push(`${key} must be a number between ${min} and ${max}`); + } +} + +function validateCavemanLikeConfig(config: Record): EngineValidationResult { + const errors: string[] = []; + if ( + config.intensity !== undefined && + !CAVEMAN_INTENSITIES.includes(config.intensity as CavemanIntensity) + ) { + errors.push("intensity must be lite, full, or ultra"); + } + if ( + config.minMessageLength !== undefined && + (typeof config.minMessageLength !== "number" || config.minMessageLength < 0) + ) { + errors.push("minMessageLength must be a non-negative number"); + } + if (config.enabled !== undefined && typeof config.enabled !== "boolean") { + errors.push("enabled must be a boolean"); + } + return { valid: errors.length === 0, errors }; +} + +function validateAggressiveConfig(config: Record): EngineValidationResult { + const errors: string[] = []; + validateBoolean(config, "summarizerEnabled", errors); + validateBoolean(config, "preserveSystemPrompt", errors); + validateNumberRange(config, "maxTokensPerMessage", 256, 32768, errors); + validateNumberRange(config, "minSavingsThreshold", 0, 1, errors); + + if (config.thresholds !== undefined) { + if (!isRecord(config.thresholds)) { + errors.push("thresholds must be an object"); + } else { + for (const key of ["fullSummary", "moderate", "light", "verbatim"]) { + validateNumberRange(config.thresholds, key, 1, 100, errors); + } + } + } + + if (config.toolStrategies !== undefined) { + if (!isRecord(config.toolStrategies)) { + errors.push("toolStrategies must be an object"); + } else { + for (const key of ["fileContent", "grepSearch", "shellOutput", "json", "errorMessage"]) { + validateBoolean(config.toolStrategies, key, errors); + } + } + } + + return { valid: errors.length === 0, errors }; +} + +function validateUltraConfig(config: Record): EngineValidationResult { + const errors: string[] = []; + validateBoolean(config, "enabled", errors); + validateBoolean(config, "slmFallbackToAggressive", errors); + validateBoolean(config, "preserveSystemPrompt", errors); + validateNumberRange(config, "compressionRate", 0, 1, errors); + validateNumberRange(config, "minScoreThreshold", 0, 1, errors); + validateNumberRange(config, "maxTokensPerMessage", 0, 32768, errors); + if (config.modelPath !== undefined && typeof config.modelPath !== "string") { + errors.push("modelPath must be a string"); + } + return { valid: errors.length === 0, errors }; +} + +export const liteEngine: CompressionEngine = { + id: "lite", + name: "Lite", + description: "Fast whitespace, tool-result and image URL reduction.", + icon: "compress", + targets: ["messages", "tool_results"], + stackable: true, + stackPriority: 5, + metadata: { + id: "lite", + name: "Lite", + description: "Fast whitespace, tool-result and image URL reduction.", + inputScope: "messages", + targetLatencyMs: 1, + supportsPreview: true, + stable: true, + }, + apply(body, options) { + return applyLiteCompression(body, { + ...options, + preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false, + }); + }, + compress(body, config) { + return this.apply(body, { stepConfig: config }); + }, + getConfigSchema() { + return AGGRESSIVE_SCHEMA; + }, + validateConfig(config) { + return validateAggressiveConfig(config); + }, +}; + +export const cavemanEngine: CompressionEngine = { + id: "caveman", + name: "Caveman", + description: "Rule-based message compression with preservation and validation.", + icon: "compress", + targets: ["messages"], + stackable: true, + stackPriority: 20, + metadata: { + id: "caveman", + name: "Caveman", + description: "Rule-based message compression with preservation and validation.", + inputScope: "messages", + targetLatencyMs: 1, + supportsPreview: true, + stable: true, + }, + apply(body, options) { + const cavemanConfig = { + ...(options?.config?.cavemanConfig ?? {}), + ...(options?.stepConfig ?? {}), + ...(options?.config?.languageConfig?.enabled + ? { + language: options.config.languageConfig.defaultLanguage, + autoDetectLanguage: options.config.languageConfig.autoDetect, + enabledLanguagePacks: options.config.languageConfig.enabledPacks, + } + : {}), + ...(options?.config?.preserveSystemPrompt !== false + ? { + compressRoles: (options?.config?.cavemanConfig?.compressRoles ?? ["user"]).filter( + (role) => role !== "system" + ), + } + : {}), + }; + return cavemanCompress(body as Parameters[0], cavemanConfig); + }, + compress(body, config) { + return this.apply(body, { stepConfig: config }); + }, + getConfigSchema() { + return CAVEMAN_SCHEMA; + }, + validateConfig(config) { + return validateCavemanLikeConfig(config); + }, +}; + +export const aggressiveEngine: CompressionEngine = { + id: "aggressive", + name: "Aggressive", + description: "Summarization, tool result compression and progressive aging.", + icon: "speed", + targets: ["messages", "tool_results"], + stackable: true, + stackPriority: 30, + metadata: { + id: "aggressive", + name: "Aggressive", + description: "Summarization, tool result compression and progressive aging.", + inputScope: "messages", + targetLatencyMs: 5, + supportsPreview: true, + stable: true, + }, + apply(body, options) { + const messages = (body.messages ?? []) as Array<{ + role: string; + content?: string | Array<{ type: string; text?: string }>; + [key: string]: unknown; + }>; + if (!Array.isArray(messages) || messages.length === 0) { + return { body, compressed: false, stats: null }; + } + const aggressiveConfig = { + ...(options?.config?.aggressive ?? {}), + ...(options?.stepConfig ?? {}), + preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false, + }; + const result = compressAggressive(messages, aggressiveConfig); + const compressedBody = { ...body, messages: result.messages }; + return { + body: compressedBody, + compressed: result.stats.savingsPercent > 0, + stats: createCompressionStats( + body, + compressedBody, + "aggressive", + ["aggressive"], + result.stats.rulesApplied, + result.stats.durationMs + ), + }; + }, + compress(body, config) { + return this.apply(body, { stepConfig: config }); + }, + getConfigSchema() { + return AGGRESSIVE_SCHEMA; + }, + validateConfig(config) { + return validateAggressiveConfig(config); + }, +}; + +export const ultraEngine: CompressionEngine = { + id: "ultra", + name: "Ultra", + description: "Heuristic token pruning with optional local SLM fallback.", + icon: "bolt", + targets: ["messages"], + stackable: true, + stackPriority: 40, + metadata: { + id: "ultra", + name: "Ultra", + description: "Heuristic token pruning with optional local SLM fallback.", + inputScope: "messages", + targetLatencyMs: 5, + supportsPreview: true, + stable: true, + }, + apply(body, options) { + const messages = (body.messages ?? []) as Array<{ + role: string; + content?: string | unknown[]; + [key: string]: unknown; + }>; + if (!Array.isArray(messages) || messages.length === 0) { + return { body, compressed: false, stats: null }; + } + const ultraConfig = { + ...(options?.config?.ultra ?? {}), + ...(options?.stepConfig ?? {}), + preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false, + }; + const result = ultraCompress(messages, ultraConfig); + const compressedBody = { ...body, messages: result.messages }; + return { + body: compressedBody, + compressed: result.stats.savingsPercent > 0, + stats: createCompressionStats( + body, + compressedBody, + "ultra", + ["ultra"], + result.stats.rulesApplied, + result.stats.durationMs + ), + }; + }, + compress(body, config) { + return this.apply(body, { stepConfig: config }); + }, + getConfigSchema() { + return ULTRA_SCHEMA; + }, + validateConfig(config) { + return validateUltraConfig(config); + }, +}; diff --git a/open-sse/services/compression/engines/index.ts b/open-sse/services/compression/engines/index.ts new file mode 100644 index 0000000000..07094ee9ac --- /dev/null +++ b/open-sse/services/compression/engines/index.ts @@ -0,0 +1,14 @@ +import { registerCompressionEngine, getCompressionEngine } from "./registry.ts"; +import { aggressiveEngine, cavemanEngine, liteEngine, ultraEngine } from "./cavemanAdapter.ts"; +import { rtkEngine } from "./rtk/index.ts"; + +let registered = false; + +export function registerBuiltinCompressionEngines(): void { + const engines = [liteEngine, cavemanEngine, aggressiveEngine, ultraEngine, rtkEngine]; + if (registered && engines.every((engine) => getCompressionEngine(engine.id))) return; + for (const engine of engines) { + if (!getCompressionEngine(engine.id)) registerCompressionEngine(engine); + } + registered = true; +} diff --git a/open-sse/services/compression/engines/registry.ts b/open-sse/services/compression/engines/registry.ts new file mode 100644 index 0000000000..85d6a50cdd --- /dev/null +++ b/open-sse/services/compression/engines/registry.ts @@ -0,0 +1,87 @@ +import type { CompressionEngine, EngineRegistryEntry, EngineValidationResult } from "./types.ts"; + +const ENGINES = new Map(); + +function assertValidEngine(engine: CompressionEngine): void { + if ( + !engine?.id || + typeof engine.apply !== "function" || + typeof engine.compress !== "function" || + typeof engine.getConfigSchema !== "function" || + typeof engine.validateConfig !== "function" + ) { + throw new Error("Invalid compression engine registration"); + } +} + +export function registerEngine( + engine: CompressionEngine, + defaultConfig: Record = {} +): void { + assertValidEngine(engine); + const validation = engine.validateConfig(defaultConfig); + if (!validation.valid) { + throw new Error(`Invalid default config for ${engine.id}: ${validation.errors.join("; ")}`); + } + ENGINES.set(engine.id, { + engine, + enabled: true, + config: { ...defaultConfig }, + }); +} + +export function registerCompressionEngine(engine: CompressionEngine): void { + registerEngine(engine); +} + +export function unregisterCompressionEngine(id: string): boolean { + return ENGINES.delete(id); +} + +export function getEngine(id: string): CompressionEngine | null { + return ENGINES.get(id)?.engine ?? null; +} + +export function getCompressionEngine(id: string): CompressionEngine | null { + return getEngine(id); +} + +export function getEngineEntry(id: string): EngineRegistryEntry | null { + return ENGINES.get(id) ?? null; +} + +export function listEngines(): EngineRegistryEntry[] { + return Array.from(ENGINES.values()); +} + +export function listCompressionEngines(): CompressionEngine[] { + return listEngines().map((entry) => entry.engine); +} + +export function listEnabledEngines(): EngineRegistryEntry[] { + return listEngines().filter((entry) => entry.enabled); +} + +export function setEngineEnabled(id: string, enabled: boolean): boolean { + const entry = ENGINES.get(id); + if (!entry) return false; + entry.enabled = enabled; + return true; +} + +export function updateEngineConfig( + id: string, + config: Record +): EngineValidationResult { + const entry = ENGINES.get(id); + if (!entry) return { valid: false, errors: [`Unknown compression engine: ${id}`] }; + const nextConfig = { ...entry.config, ...config }; + const validation = entry.engine.validateConfig(nextConfig); + if (!validation.valid) return validation; + entry.config = nextConfig; + return { valid: true, errors: [] }; +} + +export function clearCompressionEngineRegistry(): void { + ENGINES.clear(); +} diff --git a/open-sse/services/compression/engines/rtk/codeStripper.ts b/open-sse/services/compression/engines/rtk/codeStripper.ts new file mode 100644 index 0000000000..27c3512910 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/codeStripper.ts @@ -0,0 +1,128 @@ +export type CodeLanguage = + | "javascript" + | "typescript" + | "python" + | "rust" + | "go" + | "ruby" + | "java" + | "unknown"; + +export interface CodeStripperOptions { + removeComments?: boolean; + removeEmptyLines?: boolean; + collapseWhitespace?: boolean; + preserveDocstrings?: boolean; +} + +const LANGUAGE_ALIASES: Record = { + js: "javascript", + jsx: "javascript", + javascript: "javascript", + ts: "typescript", + tsx: "typescript", + typescript: "typescript", + py: "python", + python: "python", + rs: "rust", + rust: "rust", + go: "go", + rb: "ruby", + ruby: "ruby", + java: "java", +}; + +export function normalizeCodeLanguage(language?: string | null): CodeLanguage { + if (!language) return "unknown"; + return LANGUAGE_ALIASES[language.trim().toLowerCase()] ?? "unknown"; +} + +export function detectCodeLanguage(text: string): CodeLanguage { + if (/\b(?:interface|type)\s+\w+\s*=|:\s*(?:string|number|boolean)\b/.test(text)) { + return "typescript"; + } + if (/\b(?:const|let|function|import|export)\b|=>/.test(text)) return "javascript"; + if (/\bdef\s+\w+\(|\bimport\s+\w+|print\(/.test(text)) return "python"; + if (/\bfn\s+\w+\(|\blet\s+mut\b|println!\(/.test(text)) return "rust"; + if (/\bfunc\s+\w+\(|package\s+\w+/.test(text)) return "go"; + if (/\bclass\s+\w+|System\.out\.println/.test(text)) return "java"; + if (/\bdef\s+\w+|puts\s+|end\s*$/.test(text)) return "ruby"; + return "unknown"; +} + +function removeSlashComments(text: string): string { + return text + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, "") + .replace(/\s+\/\/\s.*$/gm, ""); +} + +function removeHashComments(text: string): string { + return text.replace(/^\s*#.*$/gm, "").replace(/\s+#\s.*$/gm, ""); +} + +function stripByLanguage( + text: string, + language: CodeLanguage, + options: Required +): string { + if (!options.removeComments) return text; + + if (language === "javascript" || language === "typescript" || language === "rust") { + return removeSlashComments(text); + } + if (language === "go" || language === "java") return removeSlashComments(text); + if (language === "python") { + const withoutDocstrings = options.preserveDocstrings + ? text + : text.replace(/("""[\s\S]*?"""|'''[\s\S]*?''')/g, ""); + return removeHashComments(withoutDocstrings); + } + if (language === "ruby") { + return removeHashComments(text.replace(/^=begin[\s\S]*?^=end/gm, "")); + } + return text; +} + +export function stripCode( + text: string, + language: CodeLanguage = "unknown", + options: CodeStripperOptions = {} +): { + text: string; + strippedLines: number; + language: CodeLanguage; +} { + const resolvedLanguage = language === "unknown" ? detectCodeLanguage(text) : language; + const opts: Required = { + removeComments: options.removeComments !== false, + removeEmptyLines: options.removeEmptyLines !== false, + collapseWhitespace: options.collapseWhitespace !== false, + preserveDocstrings: options.preserveDocstrings === true, + }; + const originalLines = text.split(/\r?\n/).length; + let result = stripByLanguage(text, resolvedLanguage, opts); + + if (opts.removeEmptyLines) result = result.replace(/^\s*$(?:\r?\n)?/gm, ""); + if (opts.collapseWhitespace) { + result = result + .split(/\r?\n/) + .map((line) => line.replace(/[ \t]+$/g, "")) + .join("\n"); + } + + result = result.trim(); + const strippedLines = Math.max(0, originalLines - (result ? result.split(/\r?\n/).length : 0)); + return { text: result, strippedLines, language: resolvedLanguage }; +} + +export function stripCodeComments( + text: string, + language = "typescript" +): { + text: string; + stripped: boolean; +} { + const result = stripCode(text, normalizeCodeLanguage(language)); + return { text: result.text, stripped: result.strippedLines > 0 || result.text !== text }; +} diff --git a/open-sse/services/compression/engines/rtk/commandDetector.ts b/open-sse/services/compression/engines/rtk/commandDetector.ts new file mode 100644 index 0000000000..31806c65b7 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/commandDetector.ts @@ -0,0 +1,465 @@ +export interface CommandDetectionResult { + type: string; + command: string | null; + confidence: number; + category: + | "git" + | "test" + | "build" + | "shell" + | "docker" + | "package" + | "infra" + | "cloud" + | "generic"; + matchedPatterns: string[]; +} + +type Detector = { + type: string; + category: CommandDetectionResult["category"]; + commandPatterns: RegExp[]; + contentPatterns: RegExp[]; +}; + +const COMMAND_PREFIXES = [ + "git", + "make", + "terraform", + "tofu", + "opentofu", + "systemctl", + "npm", + "pnpm", + "yarn", + "vitest", + "jest", + "pytest", + "python", + "go", + "cargo", + "tsc", + "eslint", + "webpack", + "vite", + "biome", + "prettier", + "turbo", + "nx", + "playwright", + "ruff", + "mypy", + "pip", + "uv", + "poetry", + "golangci-lint", + "bundle", + "rubocop", + "docker", + "aws", + "gcloud", + "ssh", + "rsync", + "curl", + "wget", + "ls", + "find", + "grep", + "rg", + "ag", + "ps", + "df", + "du", +]; + +const COMMAND_PREFIX_PATTERN = new RegExp(`^(?:${COMMAND_PREFIXES.join("|")})\\b`); + +const DETECTORS: Detector[] = [ + { + type: "git-status", + category: "git", + commandPatterns: [/^git\s+status\b/i], + contentPatterns: [ + /^On branch /m, + /^Changes (?:not staged|to be committed)/m, + /^Untracked files:/m, + ], + }, + { + type: "git-branch", + category: "git", + commandPatterns: [/^git\s+branch\b/i, /^git\s+checkout\b/i, /^git\s+switch\b/i], + contentPatterns: [/^\*\s+\S+/m, /Switched to (?:a new )?branch/i, /Already on ['"][^'"]+['"]/i], + }, + { + type: "git-diff", + category: "git", + commandPatterns: [/^git\s+diff\b/i, /^git\s+show\b/i], + contentPatterns: [/^diff --git /m, /^@@\s+-\d+,\d+\s+\+\d+,\d+\s+@@/m], + }, + { + type: "git-log", + category: "git", + commandPatterns: [/^git\s+log\b/i], + contentPatterns: [/^commit [0-9a-f]{7,40}/m, /^Author: /m], + }, + { + type: "make", + category: "build", + commandPatterns: [/^make\b/i], + contentPatterns: [/^make\[\d+\]: (?:Entering|Leaving) directory/m, /make: \*\*\* /], + }, + { + type: "terraform-plan", + category: "infra", + commandPatterns: [/^terraform\s+plan\b/i], + contentPatterns: [/Terraform will perform the following actions:/, /Plan: \d+ to add/i], + }, + { + type: "tofu-plan", + category: "infra", + commandPatterns: [/^(?:tofu|opentofu)\s+plan\b/i], + contentPatterns: [/OpenTofu will perform the following actions:/, /Plan: \d+ to add/i], + }, + { + type: "systemctl-status", + category: "infra", + commandPatterns: [/^systemctl\s+status\b/i], + contentPatterns: [/^\s*Loaded:\s+/m, /^\s*Active:\s+/m, /^●\s+\S+\.service/m], + }, + { + type: "test-vitest", + category: "test", + commandPatterns: [/^vitest\b/i, /^npm\s+(?:run\s+)?test:vitest\b/i], + contentPatterns: [/\bvitest\b/i, /^ ✓ /m, /^ ❯ /m, /Test Files\s+\d+\s+(?:passed|failed)/i], + }, + { + type: "test-jest", + category: "test", + commandPatterns: [/^jest\b/i, /^npm\s+(?:run\s+)?test\b/i], + contentPatterns: [/Test Suites:\s+\d+/i, /Tests:\s+\d+/i, /^PASS\s+/m, /^FAIL\s+/m], + }, + { + type: "test-pytest", + category: "test", + commandPatterns: [/^pytest\b/i, /^python\s+-m\s+pytest\b/i], + contentPatterns: [/=+\s+(?:\d+\s+)?(?:passed|failed|errors?)/i, /^E\s+/m, /^FAILED /m], + }, + { + type: "test-cargo", + category: "test", + commandPatterns: [/^cargo\s+test\b/i, /^cargo\s+nextest\b/i], + contentPatterns: [ + /^running \d+ tests?/m, + /^test\s+[\w:.-]+\s+\.\.\.\s+(?:ok|FAILED|ignored)/m, + /test result:\s+(?:ok|FAILED)/i, + ], + }, + { + type: "test-go", + category: "test", + commandPatterns: [/^go\s+test\b/i], + contentPatterns: [/^(?:ok|FAIL)\s+[\w./-]+\s+[\d.]+s/m, /^--- FAIL: /m, /^panic: /m], + }, + { + type: "build-typescript", + category: "build", + commandPatterns: [/^tsc\b/i, /^npm\s+run\s+typecheck\b/i], + contentPatterns: [/TS\d{4}:/, /error TS\d{4}/i], + }, + { + type: "build-eslint", + category: "build", + commandPatterns: [/^eslint\b/i, /^npm\s+run\s+lint\b/i], + contentPatterns: [/\s+\d+:\d+\s+(?:error|warning)\s+/, /✖\s+\d+\s+problems?/], + }, + { + type: "build-webpack", + category: "build", + commandPatterns: [/^webpack\b/i, /^npx\s+webpack\b/i, /^npm\s+run\s+build:webpack\b/i], + contentPatterns: [ + /webpack\s+\d/i, + /compiled (?:successfully|with \d+ errors?)/i, + /asset .+\.js/i, + ], + }, + { + type: "build-vite", + category: "build", + commandPatterns: [/^vite\s+build\b/i, /^npm\s+run\s+build\b/i, /^pnpm\s+build\b/i], + contentPatterns: [/vite v[\d.]+/i, /✓ built in/i, /transforming \(\d+\)/i], + }, + { + type: "biome", + category: "build", + commandPatterns: [/^biome\b/i, /^npx\s+biome\b/i], + contentPatterns: [/lint\/[A-Za-z0-9/.-]+/, /Checked \d+ files? in/i], + }, + { + type: "prettier", + category: "build", + commandPatterns: [/^prettier\b/i, /^npx\s+prettier\b/i], + contentPatterns: [/^Checking formatting\.\.\./m, /Code style issues found/i], + }, + { + type: "turbo", + category: "build", + commandPatterns: [/^turbo\b/i, /^npx\s+turbo\b/i], + contentPatterns: [/^• Packages in scope:/m, /^Tasks:\s+\d+\s+successful/m], + }, + { + type: "nx", + category: "build", + commandPatterns: [/^nx\b/i, /^npx\s+nx\b/i], + contentPatterns: [/^NX\s+/m, /^> nx run /m], + }, + { + type: "playwright", + category: "test", + commandPatterns: [/^playwright\s+test\b/i, /^npx\s+playwright\s+test\b/i], + contentPatterns: [/Running \d+ tests? using \d+ workers?/i, /^\s+\d+ failed/m], + }, + { + type: "npm-install", + category: "package", + commandPatterns: [/^(?:npm|pnpm|yarn)\s+(?:install|add|update)\b/i], + contentPatterns: [ + /added \d+ packages/i, + /packages are looking for funding/i, + /audited \d+ packages/i, + ], + }, + { + type: "npm-audit", + category: "package", + commandPatterns: [/^(?:npm|pnpm|yarn)\s+audit\b/i], + contentPatterns: [/found \d+ vulnerabilities/i, /\b(?:low|moderate|high|critical)\b/i], + }, + { + type: "ruff", + category: "build", + commandPatterns: [/^ruff\b/i, /^uv\s+run\s+ruff\b/i], + contentPatterns: [/^[\w./-]+\.py:\d+:\d+:\s+[A-Z]\d+/m, /Found \d+ errors?\./i], + }, + { + type: "mypy", + category: "build", + commandPatterns: [/^mypy\b/i, /^python\s+-m\s+mypy\b/i], + contentPatterns: [/^[\w./-]+\.py:\d+:\s+error:/m, /Found \d+ errors? in \d+ files?/i], + }, + { + type: "pip", + category: "package", + commandPatterns: [/^pip\s+(?:install|download|uninstall)\b/i, /^python\s+-m\s+pip\b/i], + contentPatterns: [/^Collecting /m, /^Successfully installed /m], + }, + { + type: "uv-sync", + category: "package", + commandPatterns: [/^uv\s+sync\b/i, /^uv\s+pip\s+install\b/i], + contentPatterns: [/^Resolved \d+ packages?/m, /^Installed \d+ packages?/m], + }, + { + type: "poetry-install", + category: "package", + commandPatterns: [/^poetry\s+install\b/i], + contentPatterns: [/^Installing dependencies from lock file/m, /^Package operations:/m], + }, + { + type: "golangci-lint", + category: "build", + commandPatterns: [/^golangci-lint\b/i], + contentPatterns: [/^[\w./-]+\.go:\d+:\d+:/m, /^\d+ issues?:/m], + }, + { + type: "bundle-install", + category: "package", + commandPatterns: [/^bundle\s+install\b/i], + contentPatterns: [/^Fetching gem metadata from /m, /^Bundle complete!/m], + }, + { + type: "rubocop", + category: "build", + commandPatterns: [/^rubocop\b/i, /^bundle\s+exec\s+rubocop\b/i], + contentPatterns: [/^Inspecting \d+ files/m, /^[\w./-]+\.rb:\d+:\d+:\s+[A-Z]:/m], + }, + { + type: "docker-ps", + category: "docker", + commandPatterns: [/^docker\s+ps\b/i], + contentPatterns: [/^CONTAINER ID\s+IMAGE\s+COMMAND/m], + }, + { + type: "docker-logs", + category: "docker", + commandPatterns: [/^docker\s+logs\b/i, /^docker\s+compose\s+logs\b/i], + contentPatterns: [ + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/m, + /\b(?:ERROR|WARN|INFO)\b/, + /^Attaching to /m, + ], + }, + { + type: "aws", + category: "cloud", + commandPatterns: [/^aws\b/i], + contentPatterns: [/An error occurred \([A-Za-z0-9]+\) when calling/, /^(?:upload|download): /m], + }, + { + type: "gcloud", + category: "cloud", + commandPatterns: [/^gcloud\b/i], + contentPatterns: [/^ERROR: \(gcloud\./m, /^Updated property \[/m], + }, + { + type: "ssh", + category: "cloud", + commandPatterns: [/^ssh\b/i], + contentPatterns: [ + /Permission denied \(/, + /Host key verification failed/, + /Connection timed out/, + ], + }, + { + type: "rsync", + category: "cloud", + commandPatterns: [/^rsync\b/i], + contentPatterns: [/^sending incremental file list/m, /^rsync error:/m], + }, + { + type: "curl", + category: "cloud", + commandPatterns: [/^curl\b/i], + contentPatterns: [/curl: \(\d+\)/, /^HTTP\/\d(?:\.\d)? \d{3}/m], + }, + { + type: "wget", + category: "cloud", + commandPatterns: [/^wget\b/i], + contentPatterns: [/^--\d{4}-\d{2}-\d{2}/m, /^ERROR \d{3}:/m], + }, + { + type: "json-output", + category: "generic", + commandPatterns: [/^jq\b/i, /^cat\s+.*\.json\b/i], + contentPatterns: [/^\s*[\[{][\s\S]*[\]}]\s*$/], + }, + { + type: "shell-ls", + category: "shell", + commandPatterns: [/^ls(?:\s+-[A-Za-z]+)?\b/i], + contentPatterns: [/^total \d+/m, /^\S+\s+\S+\s+\d+\s+\w+\s+\d{1,2}\s+/m], + }, + { + type: "shell-find", + category: "shell", + commandPatterns: [/^find\b/i], + contentPatterns: [/^(?:\.{1,2}|\/|[\w.-]+\/).+/m], + }, + { + type: "shell-grep", + category: "shell", + commandPatterns: [/^(?:grep|rg|ag)\b/i], + contentPatterns: [ + /^[\w./-]+\.(?:ts|tsx|js|jsx|py|go|rs|java|rb|md|json|ya?ml|txt):\d*:/m, + /^[\w./-]+\/[\w./-]+:\d*:/m, + ], + }, + { + type: "shell-ps", + category: "shell", + commandPatterns: [/^ps\b/i], + contentPatterns: [/^(?:USER\s+PID|\s*PID\s+)/m], + }, + { + type: "shell-df", + category: "shell", + commandPatterns: [/^df\b/i], + contentPatterns: [/^Filesystem\s+.*Use%/m], + }, + { + type: "shell-du", + category: "shell", + commandPatterns: [/^du\b/i], + contentPatterns: [/^\d+(?:\.\d+)?[KMGTP]?\s+\S+/m], + }, + { + type: "error-stacktrace", + category: "generic", + commandPatterns: [], + contentPatterns: [ + /Traceback \(most recent call last\):/, + /^\s+at\s+\S+\s+\(.+:\d+:\d+\)/m, + /^panic: /m, + /^thread '[^']+' panicked at/m, + ], + }, + { + type: "generic-error", + category: "generic", + commandPatterns: [], + contentPatterns: [/Error:/, /Exception:/, /Traceback \(most recent call last\):/], + }, +]; + +export function detectCommandFromText(text: string): string | null { + const firstLines = text.split(/\r?\n/).slice(0, 4); + for (const line of firstLines) { + const trimmed = line.trim().replace(/^\$\s+/, ""); + if (!trimmed) continue; + if (COMMAND_PREFIX_PATTERN.test(trimmed)) { + return trimmed; + } + } + return null; +} + +export function detectCommandType(text: string, command?: string | null): CommandDetectionResult { + const detectedCommand = command?.trim() || detectCommandFromText(text); + let best: CommandDetectionResult | null = null; + + for (const detector of DETECTORS) { + const matchedPatterns: string[] = []; + const commandMatched = + Boolean(detectedCommand) && + detector.commandPatterns.some((pattern) => { + const matched = pattern.test(detectedCommand ?? ""); + if (matched) matchedPatterns.push(pattern.source); + return matched; + }); + const contentMatches = detector.contentPatterns.filter((pattern) => { + const matched = pattern.test(text); + if (matched) matchedPatterns.push(pattern.source); + return matched; + }).length; + if (!commandMatched && contentMatches === 0) continue; + + const confidence = Math.min(1, (commandMatched ? 0.55 : 0) + contentMatches * 0.25); + if (!best || confidence > best.confidence) { + best = { + type: detector.type, + command: detectedCommand, + confidence, + category: detector.category, + matchedPatterns, + }; + } + } + + return ( + best ?? { + type: "unknown", + command: detectedCommand, + confidence: detectedCommand ? 0.35 : 0.1, + category: "generic", + matchedPatterns: [], + } + ); +} + +export function listCommandTypes(): string[] { + return DETECTORS.map((detector) => detector.type); +} + +export const detectCommandOutput = detectCommandType; diff --git a/open-sse/services/compression/engines/rtk/deduplicator.ts b/open-sse/services/compression/engines/rtk/deduplicator.ts new file mode 100644 index 0000000000..ea473097fd --- /dev/null +++ b/open-sse/services/compression/engines/rtk/deduplicator.ts @@ -0,0 +1,37 @@ +export interface DeduplicateOptions { + threshold?: number; +} + +export function deduplicateRepeatedLines( + text: string, + options: DeduplicateOptions = {} +): { + text: string; + collapsed: number; +} { + const threshold = Math.max(2, Math.floor(options.threshold ?? 3)); + const lines = text.split(/\r?\n/); + const output: string[] = []; + let collapsed = 0; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + let runLength = 1; + while (index + runLength < lines.length && lines[index + runLength] === line) { + runLength++; + } + + if (line.trim() && runLength >= threshold) { + output.push(line); + output.push(`[line repeated ${runLength - 1}x]`); + output.push(`[rtk:dropped ${runLength - 1} repeated lines]`); + collapsed += runLength - 1; + index += runLength - 1; + continue; + } + + output.push(line); + } + + return { text: output.join("\n"), collapsed }; +} diff --git a/open-sse/services/compression/engines/rtk/filterLoader.ts b/open-sse/services/compression/engines/rtk/filterLoader.ts new file mode 100644 index 0000000000..8c776a9b85 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filterLoader.ts @@ -0,0 +1,220 @@ +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import os from "node:os"; +import crypto from "node:crypto"; +import { detectCommandType } from "./commandDetector.ts"; +import { validateRtkFilter, type RtkFilterDefinition } from "./filterSchema.ts"; + +let cache: RtkFilterDefinition[] | null = null; +let cacheKey: string | null = null; +let diagnostics: RtkFilterLoadDiagnostic[] = []; + +export interface RtkFilterLoadDiagnostic { + source: "project" | "global" | "builtin"; + path?: string; + level: "warning" | "error"; + message: string; +} + +interface FilterSource { + source: "project" | "global" | "builtin"; + path: string; + trusted: boolean; +} + +interface RtkFilterLoadOptions { + refresh?: boolean; + customFiltersEnabled?: boolean; + trustProjectFilters?: boolean; +} + +function getFiltersDir(): string { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.join(moduleDir, "filters"), + path.join(moduleDir, "..", "services", "compression", "engines", "rtk", "filters"), + path.join(process.cwd(), "open-sse", "services", "compression", "engines", "rtk", "filters"), + path.join( + process.cwd(), + "app", + "open-sse", + "services", + "compression", + "engines", + "rtk", + "filters" + ), + ]; + return ( + candidates.find((candidate, index) => { + return candidates.indexOf(candidate) === index && fs.existsSync(candidate); + }) ?? candidates[0] + ); +} + +function getDataDir(): string { + return process.env.DATA_DIR || path.join(os.homedir(), ".omniroute"); +} + +function sha256(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function projectFiltersTrusted( + filtersPath: string, + trustProjectFilters = false +): boolean | "changed" { + if (trustProjectFilters) return true; + if (process.env.OMNIROUTE_RTK_TRUST_PROJECT_FILTERS === "1") return true; + const trustPath = path.join(path.dirname(filtersPath), "trust.json"); + if (!fs.existsSync(trustPath)) return false; + try { + const filtersHash = sha256(fs.readFileSync(filtersPath, "utf8")); + const trust = JSON.parse(fs.readFileSync(trustPath, "utf8")) as Record; + const trustedHash = + typeof trust.filtersSha256 === "string" + ? trust.filtersSha256 + : typeof trust.trustedFiltersSha256 === "string" + ? trust.trustedFiltersSha256 + : null; + if (!trustedHash) return false; + return trustedHash === filtersHash ? true : "changed"; + } catch { + return false; + } +} + +function collectFilterSources(options: RtkFilterLoadOptions = {}): FilterSource[] { + const sources: FilterSource[] = []; + const projectPath = path.join(process.cwd(), ".rtk", "filters.json"); + if (options.customFiltersEnabled !== false && fs.existsSync(projectPath)) { + const trusted = projectFiltersTrusted(projectPath, options.trustProjectFilters === true); + if (trusted === true) { + sources.push({ source: "project", path: projectPath, trusted: true }); + } else { + diagnostics.push({ + source: "project", + path: projectPath, + level: "warning", + message: + trusted === "changed" + ? "Project RTK filters changed after trust and were skipped" + : "Project RTK filters are untrusted and were skipped", + }); + } + } + + const globalPath = path.join(getDataDir(), "rtk", "filters.json"); + if (options.customFiltersEnabled !== false && fs.existsSync(globalPath)) { + sources.push({ source: "global", path: globalPath, trusted: true }); + } + + const builtinDir = getFiltersDir(); + if (fs.existsSync(builtinDir)) { + for (const file of fs + .readdirSync(builtinDir) + .filter((entry) => entry.endsWith(".json")) + .sort()) { + sources.push({ + source: "builtin", + path: path.join(builtinDir, file), + trusted: true, + }); + } + } + + return sources; +} + +function parseFilterFile(source: FilterSource): RtkFilterDefinition[] { + try { + const parsed = JSON.parse(fs.readFileSync(source.path, "utf8")); + const entries = Array.isArray(parsed) ? parsed : [parsed]; + return entries.map(validateRtkFilter); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (source.source === "builtin") { + throw new Error(`Invalid RTK filter ${path.basename(source.path)}: ${message}`); + } + diagnostics.push({ + source: source.source, + path: source.path, + level: "warning", + message: `Invalid custom RTK filter skipped: ${message}`, + }); + return []; + } +} + +export function loadRtkFilters(options: RtkFilterLoadOptions = {}): RtkFilterDefinition[] { + const currentCacheKey = [ + process.cwd(), + getDataDir(), + options.customFiltersEnabled === false ? "builtin-only" : "custom", + options.trustProjectFilters === true ? "trusted-project" : "trust-file", + process.env.OMNIROUTE_RTK_TRUST_PROJECT_FILTERS === "1" ? "env-trust" : "env-normal", + ].join("|"); + if (cache && cacheKey === currentCacheKey && !options.refresh) return cache; + diagnostics = []; + const filters: RtkFilterDefinition[] = []; + for (const source of collectFilterSources(options)) { + filters.push(...parseFilterFile(source)); + } + + const sorted = filters.sort((a, b) => b.priority - a.priority || a.id.localeCompare(b.id)); + cache = sorted; + cacheKey = currentCacheKey; + return sorted; +} + +export function getRtkFilterLoadDiagnostics(): RtkFilterLoadDiagnostic[] { + loadRtkFilters(); + return diagnostics.map((entry) => ({ ...entry })); +} + +export function getRtkFilterCatalog(): Array< + Pick< + RtkFilterDefinition, + "id" | "name" | "description" | "commandTypes" | "category" | "priority" + > +> { + return loadRtkFilters().map((filter) => ({ + id: filter.id, + name: filter.name, + description: filter.description, + commandTypes: filter.commandTypes, + category: filter.category, + priority: filter.priority, + })); +} + +export function matchRtkFilter( + text: string, + command?: string | null, + options: RtkFilterLoadOptions = {} +): RtkFilterDefinition | null { + const detection = detectCommandType(text, command); + const detectedCommand = detection.command ?? command ?? ""; + const matchesPattern = (pattern: string, value: string): boolean => { + try { + return new RegExp(pattern, "im").test(value); + } catch { + return false; + } + }; + const filters = loadRtkFilters(options); + return ( + filters.find((filter) => filter.commandTypes.includes(detection.type)) ?? + filters.find( + (filter) => + detectedCommand && + filter.commandPatterns.some((pattern) => matchesPattern(pattern, detectedCommand)) + ) ?? + filters.find((filter) => + filter.matchPatterns.some((pattern) => matchesPattern(pattern, text)) + ) ?? + filters.find((filter) => filter.commandTypes.includes("generic-output")) ?? + null + ); +} diff --git a/open-sse/services/compression/engines/rtk/filterSchema.ts b/open-sse/services/compression/engines/rtk/filterSchema.ts new file mode 100644 index 0000000000..bb5f67d974 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filterSchema.ts @@ -0,0 +1,182 @@ +import { z } from "zod"; + +const rtkFilterCategorySchema = z.enum([ + "git", + "test", + "build", + "shell", + "docker", + "package", + "infra", + "cloud", + "generic", +]); + +const rtkReplaceRuleSchema = z + .object({ + pattern: z.string().min(1), + replacement: z.string(), + }) + .strict(); + +const rtkMatchOutputRuleSchema = z + .object({ + pattern: z.string().min(1), + message: z.string(), + unless: z.string().min(1).optional(), + }) + .strict(); + +const rtkInlineTestSchema = z + .object({ + name: z.string().min(1), + input: z.string(), + expected: z.string(), + command: z.string().optional(), + }) + .strict(); + +const rtkFilterMatchSchema = z + .object({ + commands: z.array(z.string().min(1)).default([]), + patterns: z.array(z.string().min(1)).default([]), + outputTypes: z.array(z.string().min(1)).default([]), + }) + .strict(); + +const rtkFilterRulesSchema = z + .object({ + stripAnsi: z.boolean().default(false), + replace: z.array(rtkReplaceRuleSchema).default([]), + matchOutput: z.array(rtkMatchOutputRuleSchema).default([]), + includePatterns: z.array(z.string()).default([]), + dropPatterns: z.array(z.string()).default([]), + collapsePatterns: z.array(z.string()).default([]), + deduplicate: z.boolean().default(false), + truncateLineAt: z.number().int().min(0).default(0), + maxLines: z.number().int().min(0).default(0), + headLines: z.number().int().min(0).default(20), + tailLines: z.number().int().min(0).default(20), + onEmpty: z.string().default(""), + filterStderr: z.boolean().default(false), + }) + .strict(); + +const rtkFilterPreserveSchema = z + .object({ + errorPatterns: z.array(z.string()).default([]), + summaryPatterns: z.array(z.string()).default([]), + }) + .strict(); + +export const rtkFilterPackSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + description: z.string().default(""), + category: rtkFilterCategorySchema, + priority: z.number().int().min(0).max(100).default(50), + match: rtkFilterMatchSchema, + rules: rtkFilterRulesSchema.default({}), + preserve: rtkFilterPreserveSchema.default({}), + tests: z.array(rtkInlineTestSchema).default([]), + }) + .strict(); + +const legacyRtkFilterSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().default(""), + commandTypes: z.array(z.string().min(1)).min(1), + category: rtkFilterCategorySchema, + priority: z.number().int().min(0).max(100).default(50), + stripPatterns: z.array(z.string()).default([]), + keepPatterns: z.array(z.string()).default([]), + priorityPatterns: z.array(z.string()).default([]), + collapsePatterns: z.array(z.string()).default([]), + stripAnsi: z.boolean().default(false), + replace: z.array(rtkReplaceRuleSchema).default([]), + matchOutput: z.array(rtkMatchOutputRuleSchema).default([]), + truncateLineAt: z.number().int().min(0).default(0), + onEmpty: z.string().default(""), + filterStderr: z.boolean().default(false), + maxLines: z.number().int().min(0).default(0), + preserveHead: z.number().int().min(0).default(20), + preserveTail: z.number().int().min(0).default(20), + tests: z.array(rtkInlineTestSchema).default([]), + }) + .strict(); + +export const rtkFilterSchema = z.union([rtkFilterPackSchema, legacyRtkFilterSchema]); + +export type RtkFilterPack = z.infer; + +export interface RtkFilterDefinition { + id: string; + name: string; + description: string; + commandTypes: string[]; + commandPatterns: string[]; + matchPatterns: string[]; + category: z.infer; + priority: number; + stripPatterns: string[]; + keepPatterns: string[]; + priorityPatterns: string[]; + collapsePatterns: string[]; + stripAnsi: boolean; + replace: Array<{ pattern: string; replacement: string }>; + matchOutput: Array<{ pattern: string; message: string; unless?: string }>; + truncateLineAt: number; + onEmpty: string; + filterStderr: boolean; + deduplicate: boolean; + maxLines: number; + preserveHead: number; + preserveTail: number; + tests: Array<{ name: string; input: string; expected: string; command?: string }>; +} + +function isCanonicalFilter(value: z.infer): value is RtkFilterPack { + return "label" in value && "match" in value && "rules" in value; +} + +export function validateRtkFilter(value: unknown): RtkFilterDefinition { + const parsed = rtkFilterSchema.parse(value); + if (!isCanonicalFilter(parsed)) { + return { + ...parsed, + commandPatterns: [], + matchPatterns: [], + deduplicate: parsed.collapsePatterns.length > 0, + }; + } + + const preservePatterns = [...parsed.preserve.errorPatterns, ...parsed.preserve.summaryPatterns]; + return { + id: parsed.id, + name: parsed.label, + description: parsed.description, + commandTypes: parsed.match.outputTypes, + commandPatterns: parsed.match.commands, + matchPatterns: parsed.match.patterns, + category: parsed.category, + priority: parsed.priority, + stripPatterns: parsed.rules.dropPatterns, + keepPatterns: parsed.rules.includePatterns, + priorityPatterns: preservePatterns, + collapsePatterns: parsed.rules.collapsePatterns, + stripAnsi: parsed.rules.stripAnsi, + replace: parsed.rules.replace, + matchOutput: parsed.rules.matchOutput, + truncateLineAt: parsed.rules.truncateLineAt, + onEmpty: parsed.rules.onEmpty, + filterStderr: parsed.rules.filterStderr, + deduplicate: parsed.rules.deduplicate, + maxLines: parsed.rules.maxLines, + preserveHead: parsed.rules.headLines, + preserveTail: parsed.rules.tailLines, + tests: parsed.tests, + }; +} diff --git a/open-sse/services/compression/engines/rtk/filters/aws.json b/open-sse/services/compression/engines/rtk/filters/aws.json new file mode 100644 index 0000000000..7a3f82d887 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/aws.json @@ -0,0 +1,34 @@ +{ + "id": "aws", + "label": "AWS CLI", + "description": "Compress AWS CLI progress output while preserving errors and completed transfers.", + "category": "cloud", + "priority": 76, + "match": { + "outputTypes": ["aws"], + "commands": ["^aws\\b"], + "patterns": ["An error occurred \\([A-Za-z0-9]+\\) when calling", "^(?:upload|download): "] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^Completed \\d", "^\\s*$"], + "includePatterns": ["^(?:upload|download): ", "An error occurred \\("], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 120, + "headLines": 30, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["An error occurred", "AccessDenied", "Throttling", "ValidationError"], + "summaryPatterns": ["^(?:upload|download): "] + }, + "tests": [ + { + "name": "inline sample", + "command": "aws s3 cp", + "input": "Completed 1.0 MiB/2.0 MiB\nupload: ./app.log to s3://bucket/app.log\nAn error occurred (AccessDenied) when calling the PutObject operation: denied\n", + "expected": "upload: ./app.log to s3://bucket/app.log\nAn error occurred (AccessDenied) when calling the PutObject operation: denied" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/biome.json b/open-sse/services/compression/engines/rtk/filters/biome.json new file mode 100644 index 0000000000..d0c7564b39 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/biome.json @@ -0,0 +1,35 @@ +{ + "id": "biome", + "label": "Biome", + "description": "Compact Biome lint/format output.", + "category": "build", + "priority": 80, + "match": { + "outputTypes": [], + "commands": ["^biome\\b", "^npx\\s+biome\\b"], + "patterns": ["Checked \\d+ files?", "biome"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*$", "^Checked \\d+ files? in"], + "includePatterns": ["error", "warning", "✖", "Checked", "Fixed", "^\\s*>"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 25, + "tailLines": 40, + "onEmpty": "biome: ok" + }, + "preserve": { + "errorPatterns": ["error", "✖"], + "summaryPatterns": ["Checked", "Fixed"] + }, + "tests": [ + { + "name": "inline sample", + "command": "biome", + "input": "Checked 10 files in 20ms\nerror lint/a.ts:1:1 boom\nFixed 1 file\n", + "expected": "error lint/a.ts:1:1 boom\nFixed 1 file" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/build-eslint.json b/open-sse/services/compression/engines/rtk/filters/build-eslint.json new file mode 100644 index 0000000000..610e616363 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/build-eslint.json @@ -0,0 +1,37 @@ +{ + "id": "build-eslint", + "label": "ESLint", + "description": "Keep lint diagnostics and problem summary.", + "category": "build", + "priority": 88, + "match": { + "outputTypes": ["build-eslint"], + "commands": ["^(?:eslint|npm\\s+run\\s+lint)\\b"], + "patterns": ["\\s+\\d+:\\d+\\s+(?:error|warning)\\s+", "✖\\s+\\d+\\s+problems?"] + }, + "rules": { + "includePatterns": [ + "\\s+\\d+:\\d+\\s+(error|warning)\\s+", + "✖\\s+\\d+\\s+problems?", + "^/.*\\.(ts|tsx|js|jsx)$" + ], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 120, + "headLines": 20, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["error", "warning", "problems?"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:eslint|npm run lint)", + "input": "/repo/a.ts\n 1:1 error boom no-boom\n✖ 1 problem (1 error, 0 warnings)\n", + "expected": "/repo/a.ts\n 1:1 error boom no-boom\n✖ 1 problem (1 error, 0 warnings)" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/build-typescript.json b/open-sse/services/compression/engines/rtk/filters/build-typescript.json new file mode 100644 index 0000000000..5ef3e314c5 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/build-typescript.json @@ -0,0 +1,33 @@ +{ + "id": "build-typescript", + "label": "TypeScript", + "description": "Keep TypeScript diagnostics and file locations.", + "category": "build", + "priority": 93, + "match": { + "outputTypes": ["build-typescript"], + "commands": ["^(?:tsc|npm\\s+run\\s+typecheck)\\b"], + "patterns": ["TS\\d{4}:", "error TS\\d{4}"] + }, + "rules": { + "includePatterns": ["TS\\d{4}:", "error TS\\d{4}", "^Found \\d+ errors?"], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 120, + "headLines": 20, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["TS\\d{4}", "Found \\d+"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:tsc|npm run typecheck)", + "input": "src/a.ts:1:1 - error TS2322: Type string is not assignable\nFound 1 error.\n", + "expected": "src/a.ts:1:1 - error TS2322: Type string is not assignable\nFound 1 error." + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/build-vite.json b/open-sse/services/compression/engines/rtk/filters/build-vite.json new file mode 100644 index 0000000000..7e81845b81 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/build-vite.json @@ -0,0 +1,41 @@ +{ + "id": "build-vite", + "label": "Vite Build", + "description": "Keep Vite errors and final build summary.", + "category": "build", + "priority": 83, + "match": { + "outputTypes": ["build-vite"], + "commands": ["^vite\\s+build\\b", "^npm\\s+run\\s+build\\b", "^pnpm\\s+build\\b"], + "patterns": ["vite v[\\d.]+", "✓ built in"] + }, + "rules": { + "stripAnsi": true, + "matchOutput": [ + { + "pattern": "✓ built in", + "message": "vite: build ok", + "unless": "error|failed" + } + ], + "includePatterns": ["error", "failed", "✓ built in", "dist/", "rendering chunks"], + "dropPatterns": ["^transforming ", "^✓ \\d+ modules transformed", "^\\s*$"], + "collapsePatterns": ["^dist/"], + "deduplicate": true, + "maxLines": 120, + "headLines": 30, + "tailLines": 45 + }, + "preserve": { + "errorPatterns": ["error", "failed"], + "summaryPatterns": ["✓ built in"] + }, + "tests": [ + { + "name": "inline sample", + "command": "vite build", + "input": "vite v5.0.0 building for production...\n✓ 10 modules transformed.\n✓ built in 100ms\n", + "expected": "vite: build ok" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/build-webpack.json b/open-sse/services/compression/engines/rtk/filters/build-webpack.json new file mode 100644 index 0000000000..55e1eb845b --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/build-webpack.json @@ -0,0 +1,34 @@ +{ + "id": "build-webpack", + "label": "Webpack Build", + "description": "Keep webpack errors/warnings and compilation summary.", + "category": "build", + "priority": 84, + "match": { + "outputTypes": ["build-webpack"], + "commands": ["^webpack\\b", "^npx\\s+webpack\\b"], + "patterns": ["webpack \\d", "compiled (?:successfully|with)"] + }, + "rules": { + "stripAnsi": true, + "includePatterns": ["ERROR in", "WARNING in", "compiled ", "asset ", "Entrypoint"], + "dropPatterns": ["^\\s*$", "^cacheable modules"], + "collapsePatterns": ["^asset "], + "deduplicate": true, + "maxLines": 140, + "headLines": 35, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["ERROR in"], + "summaryPatterns": ["compiled "] + }, + "tests": [ + { + "name": "inline sample", + "command": "webpack", + "input": "asset main.js 10 KiB\nERROR in ./src/a.ts\ncompiled with 1 error\n", + "expected": "asset main.js 10 KiB\nERROR in ./src/a.ts\ncompiled with 1 error" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/bundle-install.json b/open-sse/services/compression/engines/rtk/filters/bundle-install.json new file mode 100644 index 0000000000..7b126ae117 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/bundle-install.json @@ -0,0 +1,35 @@ +{ + "id": "bundle-install", + "label": "Bundle Install", + "description": "Strip bundle install noise and keep final status.", + "category": "package", + "priority": 70, + "match": { + "outputTypes": [], + "commands": ["^bundle\\s+install\\b"], + "patterns": ["Bundle complete", "Using "] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^Using ", "^Fetching ", "^Installing ", "^\\s*$"], + "includePatterns": ["Bundle complete", "Your bundle is complete", "ERROR", "Gem::"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 25, + "tailLines": 35, + "onEmpty": "bundle: ok" + }, + "preserve": { + "errorPatterns": ["ERROR", "Gem::"], + "summaryPatterns": ["Bundle complete", "Your bundle is complete"] + }, + "tests": [ + { + "name": "inline sample", + "command": "bundle install", + "input": "Using rake 13.0\nInstalling pg 1.5\nBundle complete! 4 Gemfile dependencies\n", + "expected": "Bundle complete! 4 Gemfile dependencies" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/curl.json b/open-sse/services/compression/engines/rtk/filters/curl.json new file mode 100644 index 0000000000..936f290706 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/curl.json @@ -0,0 +1,34 @@ +{ + "id": "curl", + "label": "curl", + "description": "Remove curl progress noise and keep HTTP status/errors.", + "category": "cloud", + "priority": 70, + "match": { + "outputTypes": ["curl"], + "commands": ["^curl\\b"], + "patterns": ["curl: \\(\\d+\\)", "^HTTP/\\d(?:\\.\\d)? \\d{3}"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*% Total\\s+% Received", "^\\s*\\d+\\s+\\d+", "^\\s*$"], + "includePatterns": ["^HTTP/\\d(?:\\.\\d)? \\d{3}", "^curl: \\(\\d+\\)", "^[<{]"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 30, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["curl: \\(\\d+\\)", "^HTTP/\\d(?:\\.\\d)? [45]\\d\\d"], + "summaryPatterns": ["^HTTP/\\d(?:\\.\\d)? \\d{3}"] + }, + "tests": [ + { + "name": "inline sample", + "command": "curl -i", + "input": "% Total % Received\n 0 0 0 0\nHTTP/1.1 500 Internal Server Error\ncurl: (22) The requested URL returned error: 500\n", + "expected": "HTTP/1.1 500 Internal Server Error\ncurl: (22) The requested URL returned error: 500" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/df.json b/open-sse/services/compression/engines/rtk/filters/df.json new file mode 100644 index 0000000000..5be798a273 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/df.json @@ -0,0 +1,34 @@ +{ + "id": "df", + "label": "df", + "description": "Keep filesystem usage table headers and mounted filesystems.", + "category": "shell", + "priority": 67, + "match": { + "outputTypes": ["shell-df"], + "commands": ["^df\\b"], + "patterns": ["^Filesystem\\s+.*Use%"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^tmpfs\\s", "^udev\\s", "^\\s*$"], + "includePatterns": ["^Filesystem\\s+", "^/dev/\\S+\\s+"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 80, + "headLines": 25, + "tailLines": 25 + }, + "preserve": { + "errorPatterns": ["No such file", "Permission denied"], + "summaryPatterns": ["Use%", "^/dev/"] + }, + "tests": [ + { + "name": "inline sample", + "command": "df -h", + "input": "Filesystem Size Used Avail Use% Mounted on\nudev 2.0G 0 2.0G 0% /dev\n/dev/sda1 50G 45G 5.0G 90% /\n", + "expected": "Filesystem Size Used Avail Use% Mounted on\n/dev/sda1 50G 45G 5.0G 90% /" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/docker-logs.json b/open-sse/services/compression/engines/rtk/filters/docker-logs.json new file mode 100644 index 0000000000..10b5d11f43 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/docker-logs.json @@ -0,0 +1,42 @@ +{ + "id": "docker-logs", + "label": "Docker Logs", + "description": "Keep warnings/errors and recent Docker log context.", + "category": "docker", + "priority": 84, + "match": { + "outputTypes": ["docker-logs"], + "commands": ["^docker\\s+(?:compose\\s+)?logs\\b"], + "patterns": ["\\b(?:ERROR|WARN|INFO)\\b", "^Attaching to "] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^Attaching to ", "^\\s*$"], + "includePatterns": [ + "ERROR", + "WARN", + "Exception", + "Traceback", + "failed", + "listening", + "started" + ], + "collapsePatterns": ["INFO"], + "deduplicate": true, + "maxLines": 120, + "headLines": 20, + "tailLines": 60 + }, + "preserve": { + "errorPatterns": ["ERROR", "Exception", "failed"], + "summaryPatterns": ["started", "listening"] + }, + "tests": [ + { + "name": "inline sample", + "command": "docker (?:compose )?logs", + "input": "Attaching to api\nINFO started\nINFO started\nERROR failed to connect\n", + "expected": "INFO started\nERROR failed to connect" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/docker-ps.json b/open-sse/services/compression/engines/rtk/filters/docker-ps.json new file mode 100644 index 0000000000..b7dc4efee0 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/docker-ps.json @@ -0,0 +1,33 @@ +{ + "id": "docker-ps", + "label": "Docker PS", + "description": "Keep docker container table header and rows under a fixed cap.", + "category": "docker", + "priority": 60, + "match": { + "outputTypes": ["docker-ps"], + "commands": ["^docker\\s+ps\\b"], + "patterns": ["^CONTAINER ID\\s+IMAGE\\s+COMMAND"] + }, + "rules": { + "includePatterns": [], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 80, + "headLines": 30, + "tailLines": 20 + }, + "preserve": { + "errorPatterns": ["^CONTAINER ID"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "docker ps", + "input": "CONTAINER ID IMAGE COMMAND\nabc123 node \"npm start\"\n", + "expected": "CONTAINER ID IMAGE COMMAND\nabc123 node \"npm start\"" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/du.json b/open-sse/services/compression/engines/rtk/filters/du.json new file mode 100644 index 0000000000..54cca64e89 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/du.json @@ -0,0 +1,33 @@ +{ + "id": "du", + "label": "du", + "description": "Summarize disk-usage listings while preserving largest entries.", + "category": "shell", + "priority": 66, + "match": { + "outputTypes": ["shell-du"], + "commands": ["^du\\b"], + "patterns": ["^\\d+(?:\\.\\d+)?[KMGTP]?\\s+\\S+"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 80, + "headLines": 20, + "tailLines": 30 + }, + "preserve": { + "errorPatterns": ["Permission denied", "No such file"], + "summaryPatterns": ["\\s+\\.$", "\\s+total$"] + }, + "tests": [ + { + "name": "inline sample", + "command": "du -sh *", + "input": "4.0K package.json\n120M node_modules\n120M node_modules\n", + "expected": "4.0K package.json\n120M node_modules\n120M node_modules" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/error-stacktrace.json b/open-sse/services/compression/engines/rtk/filters/error-stacktrace.json new file mode 100644 index 0000000000..0d5fd3b8bf --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/error-stacktrace.json @@ -0,0 +1,41 @@ +{ + "id": "error-stacktrace", + "label": "Error Stacktrace", + "description": "Collapse stack traces while preserving error text and top frames.", + "category": "generic", + "priority": 70, + "match": { + "outputTypes": ["error-stacktrace"], + "commands": [], + "patterns": ["Traceback \\(most recent call last\\):", "^\\s+at\\s+", "^panic: "] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [ + "Error:", + "Exception:", + "Traceback", + "^\\s+at ", + "^panic:", + "^thread ", + "^Caused by:" + ], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": ["^\\s+at "], + "deduplicate": true, + "maxLines": 80, + "headLines": 25, + "tailLines": 30 + }, + "preserve": { + "errorPatterns": ["Error:", "Exception:", "^panic:", "Traceback"], + "summaryPatterns": ["Caused by:"] + }, + "tests": [ + { + "name": "inline sample", + "input": "Error: boom\n at fn (/repo/a.ts:1:1)\n at fn (/repo/a.ts:1:1)\nCaused by: bad\n", + "expected": "Error: boom\n at fn (/repo/a.ts:1:1)\nCaused by: bad" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/gcloud.json b/open-sse/services/compression/engines/rtk/filters/gcloud.json new file mode 100644 index 0000000000..19c5dd09a9 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/gcloud.json @@ -0,0 +1,34 @@ +{ + "id": "gcloud", + "label": "gcloud", + "description": "Compress Google Cloud CLI output and preserve actionable errors.", + "category": "cloud", + "priority": 75, + "match": { + "outputTypes": ["gcloud"], + "commands": ["^gcloud\\b"], + "patterns": ["^ERROR: \\(gcloud\\.", "^Updated property \\["] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^Waiting for operation", "^\\s*$"], + "includePatterns": ["^ERROR: \\(gcloud\\.", "^Updated property \\[", "^Created \\["], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 120, + "headLines": 30, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["^ERROR: \\(gcloud\\.", "PERMISSION_DENIED", "NOT_FOUND"], + "summaryPatterns": ["^Updated property \\[", "^Created \\["] + }, + "tests": [ + { + "name": "inline sample", + "command": "gcloud run deploy", + "input": "Waiting for operation [deploy]\nUpdated property [core/project].\nERROR: (gcloud.run.deploy) PERMISSION_DENIED: denied\n", + "expected": "Updated property [core/project].\nERROR: (gcloud.run.deploy) PERMISSION_DENIED: denied" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/generic-output.json b/open-sse/services/compression/engines/rtk/filters/generic-output.json new file mode 100644 index 0000000000..da93022888 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/generic-output.json @@ -0,0 +1,32 @@ +{ + "id": "generic-output", + "label": "Generic Output", + "description": "Fallback filter for unknown command output.", + "category": "generic", + "priority": 10, + "match": { + "outputTypes": ["generic-output", "generic-error"], + "commands": [], + "patterns": ["Error:", "Exception:", "Traceback \\(most recent call last\\):"] + }, + "rules": { + "includePatterns": [], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 120, + "headLines": 35, + "tailLines": 45 + }, + "preserve": { + "errorPatterns": ["error", "failed", "exception", "traceback"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "input": "\nError: boom\n\nTraceback (most recent call last):\n file.py:1\n", + "expected": "Error: boom\nTraceback (most recent call last):\n file.py:1" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/git-branch.json b/open-sse/services/compression/engines/rtk/filters/git-branch.json new file mode 100644 index 0000000000..b333932f08 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/git-branch.json @@ -0,0 +1,45 @@ +{ + "id": "git-branch", + "label": "Git Branch", + "description": "Compact git branch/switch output.", + "category": "git", + "priority": 94, + "match": { + "outputTypes": ["git-branch"], + "commands": ["^git\\s+(?:branch|checkout|switch)\\b"], + "patterns": ["^\\*\\s+\\S+", "Switched to (?:a new )?branch", "Already on"] + }, + "rules": { + "stripAnsi": true, + "matchOutput": [ + { + "pattern": "Switched to (?:a new )?branch", + "message": "git: switched branch", + "unless": "error|failed" + }, + { + "pattern": "Already on", + "message": "git: already on branch" + } + ], + "includePatterns": ["^\\*\\s+\\S+", "^\\s{2}\\S+", "Switched to", "Already on"], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 60, + "headLines": 20, + "tailLines": 20 + }, + "preserve": { + "errorPatterns": ["error", "failed"], + "summaryPatterns": ["Switched to", "Already on"] + }, + "tests": [ + { + "name": "inline sample", + "command": "git (?:branch|checkout|switch)", + "input": " main\n* feature/rtk\n release\n", + "expected": " main\n* feature/rtk\n release" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/git-diff.json b/open-sse/services/compression/engines/rtk/filters/git-diff.json new file mode 100644 index 0000000000..a97df1fbac --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/git-diff.json @@ -0,0 +1,33 @@ +{ + "id": "git-diff", + "label": "Git Diff", + "description": "Drop low-signal diff metadata while preserving files, hunks, and changed lines.", + "category": "git", + "priority": 94, + "match": { + "outputTypes": ["git-diff"], + "commands": ["^git\\s+(?:diff|show)\\b"], + "patterns": ["^diff --git ", "^@@\\s+-\\d+,\\d+\\s+\\+\\d+,\\d+\\s+@@"] + }, + "rules": { + "includePatterns": [], + "dropPatterns": ["^index [0-9a-f]+\\.\\.[0-9a-f]+.*$", "^--- a/", "^\\+\\+\\+ b/"], + "collapsePatterns": ["^\\s*$"], + "deduplicate": true, + "maxLines": 180, + "headLines": 40, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["^diff --git ", "^@@ ", "^[+-](?![+-]{2})"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "git (?:diff|show)", + "input": "diff --git a/a.ts b/a.ts\n@@ -1,2 +1,2 @@\n-console.log(\"old\")\n+console.log(\"new\")\n", + "expected": "diff --git a/a.ts b/a.ts\n@@ -1,2 +1,2 @@\n-console.log(\"old\")\n+console.log(\"new\")\n" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/git-log.json b/open-sse/services/compression/engines/rtk/filters/git-log.json new file mode 100644 index 0000000000..4a54160537 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/git-log.json @@ -0,0 +1,33 @@ +{ + "id": "git-log", + "label": "Git Log", + "description": "Keep commit identifiers and subjects from verbose logs.", + "category": "git", + "priority": 70, + "match": { + "outputTypes": ["git-log"], + "commands": ["^git\\s+log\\b"], + "patterns": ["^commit [0-9a-f]{7,40}", "^Author: "] + }, + "rules": { + "includePatterns": ["^commit [0-9a-f]{7,40}", "^\\s{4}\\S"], + "dropPatterns": ["^Author: ", "^Date: ", "^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 120, + "headLines": 40, + "tailLines": 20 + }, + "preserve": { + "errorPatterns": ["^commit "], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "git log", + "input": "commit abcdef1234567890\nAuthor: Dev \nDate: today\n\n feat: add rtk\n", + "expected": "commit abcdef1234567890\n feat: add rtk" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/git-status.json b/open-sse/services/compression/engines/rtk/filters/git-status.json new file mode 100644 index 0000000000..d2d21a66f5 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/git-status.json @@ -0,0 +1,40 @@ +{ + "id": "git-status", + "label": "Git Status", + "description": "Keep branch, staged/unstaged headings, and changed file lines.", + "category": "git", + "priority": 95, + "match": { + "outputTypes": ["git-status"], + "commands": ["^git\\s+status\\b"], + "patterns": ["^On branch ", "^Changes (?:not staged|to be committed)", "^Untracked files:"] + }, + "rules": { + "includePatterns": [ + "^On branch ", + "^Your branch ", + "^Changes ", + "^Untracked files:", + "^\\s*(modified|new file|deleted|renamed|both modified):", + "^\\s*[MADRCU?!]{1,2}\\s+" + ], + "dropPatterns": ["^\\s*$", "^\\s*\\(use .*\\)$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 80, + "headLines": 12, + "tailLines": 20 + }, + "preserve": { + "errorPatterns": ["^Changes ", "^Untracked files:"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "git status", + "input": "On branch main\nChanges not staged for commit:\n (use \"git add\" to update)\n\tmodified: src/app.ts\nnothing added to commit\n", + "expected": "On branch main\nChanges not staged for commit:\n\tmodified: src/app.ts" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/golangci-lint.json b/open-sse/services/compression/engines/rtk/filters/golangci-lint.json new file mode 100644 index 0000000000..8bffbee1af --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/golangci-lint.json @@ -0,0 +1,34 @@ +{ + "id": "golangci-lint", + "label": "golangci-lint", + "description": "Keep golangci-lint findings and summary.", + "category": "build", + "priority": 82, + "match": { + "outputTypes": [], + "commands": ["^golangci-lint\\b"], + "patterns": ["issues:", "level=error", ":\\d+:\\d+:"] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [":\\d+:\\d+:", "level=error", "issues:", "Error:"], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 140, + "headLines": 35, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["level=error", "Error:"], + "summaryPatterns": ["issues:"] + }, + "tests": [ + { + "name": "inline sample", + "command": "golangci-lint", + "input": "main.go:1:1: issue text\nlevel=error msg=\"runner failed\"\nissues: 1\n", + "expected": "main.go:1:1: issue text\nlevel=error msg=\"runner failed\"\nissues: 1" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/json-output.json b/open-sse/services/compression/engines/rtk/filters/json-output.json new file mode 100644 index 0000000000..1ab8769266 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/json-output.json @@ -0,0 +1,40 @@ +{ + "id": "json-output", + "label": "JSON Output", + "description": "Keep actionable JSON status, error and message fields while preserving structure.", + "category": "generic", + "priority": 70, + "match": { + "outputTypes": ["json-output"], + "commands": ["^(?:jq\\b|cat\\s+.*\\.json\\b)"], + "patterns": ["^\\s*[\\[{][\\s\\S]*[\\]}]\\s*$"] + }, + "rules": { + "includePatterns": [ + "\"status\"\\s*:", + "\"error\"\\s*:", + "\"errors\"\\s*:", + "\"code\"\\s*:", + "\"message\"\\s*:", + "[{}\\[\\],]" + ], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 120, + "headLines": 20, + "tailLines": 30 + }, + "preserve": { + "errorPatterns": ["\"error\"", "\"errors\"", "\"message\""], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:jq\\b|cat .*\\.json\\b)", + "input": "{\n \"status\": \"ok\",\n \"items\": [1,2,3]\n}\n", + "expected": "{\n \"status\": \"ok\",\n \"items\": [1,2,3]\n}" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/make.json b/open-sse/services/compression/engines/rtk/filters/make.json new file mode 100644 index 0000000000..7b9d3a6b24 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/make.json @@ -0,0 +1,38 @@ +{ + "id": "make", + "label": "Make", + "description": "Strip make directory chatter and empty lines.", + "category": "build", + "priority": 78, + "match": { + "outputTypes": [], + "commands": ["^make\\b"], + "patterns": ["make\\[\\d+\\]: (?:Entering|Leaving) directory"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": [ + "^make\\[\\d+\\]: (?:Entering|Leaving) directory", + "^\\s*$", + "Nothing to be done for" + ], + "collapsePatterns": ["^(?:cc|gcc|clang|g\\+\\+)\\b"], + "deduplicate": true, + "maxLines": 120, + "headLines": 30, + "tailLines": 40, + "onEmpty": "make: ok" + }, + "preserve": { + "errorPatterns": ["error:", "failed", "\\*\\*\\*"], + "summaryPatterns": ["built", "linking"] + }, + "tests": [ + { + "name": "inline sample", + "command": "make", + "input": "make[1]: Entering directory '/repo'\ngcc -O2 foo.c\nmake[1]: Leaving directory '/repo'\n", + "expected": "gcc -O2 foo.c" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/mypy.json b/open-sse/services/compression/engines/rtk/filters/mypy.json new file mode 100644 index 0000000000..861bbe3e9b --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/mypy.json @@ -0,0 +1,34 @@ +{ + "id": "mypy", + "label": "mypy", + "description": "Keep mypy errors grouped by file and final summary.", + "category": "build", + "priority": 82, + "match": { + "outputTypes": [], + "commands": ["^mypy\\b", "^python\\s+-m\\s+mypy\\b"], + "patterns": [": error:", "Found \\d+ errors? in"] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [": error:", ": note:", "Found \\d+ errors?", "Success: no issues found"], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 160, + "headLines": 40, + "tailLines": 60 + }, + "preserve": { + "errorPatterns": [": error:", "Found \\d+ errors?"], + "summaryPatterns": ["Success:", "Found \\d+"] + }, + "tests": [ + { + "name": "inline sample", + "command": "mypy", + "input": "app.py:1: error: Incompatible types\nFound 1 error in 1 file\n", + "expected": "app.py:1: error: Incompatible types\nFound 1 error in 1 file" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/npm-audit.json b/open-sse/services/compression/engines/rtk/filters/npm-audit.json new file mode 100644 index 0000000000..19fa224a38 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/npm-audit.json @@ -0,0 +1,43 @@ +{ + "id": "npm-audit", + "label": "npm audit", + "description": "Keep vulnerability counts and remediation hints.", + "category": "package", + "priority": 84, + "match": { + "outputTypes": ["npm-audit"], + "commands": ["^(?:npm|pnpm|yarn)\\s+audit\\b"], + "patterns": ["found \\d+ vulnerabilities", "\\bcritical\\b"] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [ + "vulnerabilities", + "critical", + "high", + "moderate", + "low", + "fix available", + "npm audit fix", + "No vulnerabilities" + ], + "dropPatterns": ["^\\s*$", "^# npm audit report"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 120, + "headLines": 30, + "tailLines": 45 + }, + "preserve": { + "errorPatterns": ["critical", "high"], + "summaryPatterns": ["vulnerabilities", "No vulnerabilities"] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:npm|pnpm|yarn) audit", + "input": "# npm audit report\ncritical vulnerability in foo\nfound 1 vulnerabilities\nnpm audit fix\n", + "expected": "critical vulnerability in foo\nfound 1 vulnerabilities\nnpm audit fix" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/npm-install.json b/open-sse/services/compression/engines/rtk/filters/npm-install.json new file mode 100644 index 0000000000..df001e67b2 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/npm-install.json @@ -0,0 +1,41 @@ +{ + "id": "npm-install", + "label": "Package Install", + "description": "Keep install summaries, warnings and audit results.", + "category": "package", + "priority": 65, + "match": { + "outputTypes": ["npm-install"], + "commands": ["^(?:npm|pnpm|yarn)\\s+(?:install|add|update)\\b"], + "patterns": ["added \\d+ packages", "packages are looking for funding", "audited \\d+ packages"] + }, + "rules": { + "includePatterns": [ + "added \\d+ packages", + "removed \\d+ packages", + "changed \\d+ packages", + "audited \\d+ packages", + "vulnerabilities", + "WARN", + "ERR!" + ], + "dropPatterns": ["^\\s*$", "^\\s*\\|", "^Progress:"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 80, + "headLines": 20, + "tailLines": 30 + }, + "preserve": { + "errorPatterns": ["WARN", "ERR", "vulnerabilities"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:npm|pnpm|yarn) (?:install|add|update)", + "input": "added 3 packages\npackages are looking for funding\naudited 3 packages\n", + "expected": "added 3 packages\naudited 3 packages" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/nx.json b/open-sse/services/compression/engines/rtk/filters/nx.json new file mode 100644 index 0000000000..6973433228 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/nx.json @@ -0,0 +1,34 @@ +{ + "id": "nx", + "label": "Nx", + "description": "Compact Nx task graph and output.", + "category": "build", + "priority": 76, + "match": { + "outputTypes": [], + "commands": ["^nx\\b", "^npx\\s+nx\\b"], + "patterns": ["NX ", "Successfully ran target"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*$", "^> nx run"], + "includePatterns": ["NX ", "ERROR", "Failed", "Successfully ran", "Running target"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 120, + "headLines": 25, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["ERROR", "Failed"], + "summaryPatterns": ["Successfully ran"] + }, + "tests": [ + { + "name": "inline sample", + "command": "nx", + "input": "> nx run app:build\nNX Failed tasks:\nERROR boom\n", + "expected": "NX Failed tasks:\nERROR boom" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/pip.json b/open-sse/services/compression/engines/rtk/filters/pip.json new file mode 100644 index 0000000000..91edf38ca3 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/pip.json @@ -0,0 +1,41 @@ +{ + "id": "pip", + "label": "pip", + "description": "Strip pip install progress and keep install/failure summary.", + "category": "package", + "priority": 72, + "match": { + "outputTypes": [], + "commands": ["^pip\\s+(?:install|sync|download)\\b", "^python\\s+-m\\s+pip\\s+install\\b"], + "patterns": ["Successfully installed", "Requirement already satisfied"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": [ + "^Collecting ", + "^Downloading ", + "^Installing collected packages:", + "^Requirement already satisfied", + "^\\s*$" + ], + "includePatterns": ["Successfully installed", "ERROR:", "WARNING:", "failed", "Could not find"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 25, + "tailLines": 35, + "onEmpty": "pip: ok" + }, + "preserve": { + "errorPatterns": ["ERROR:", "failed", "Could not find"], + "summaryPatterns": ["Successfully installed"] + }, + "tests": [ + { + "name": "inline sample", + "command": "pip (?:install|sync|download)", + "input": "Collecting requests\nDownloading requests.whl\nSuccessfully installed requests-2.0\n", + "expected": "Successfully installed requests-2.0" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/playwright.json b/open-sse/services/compression/engines/rtk/filters/playwright.json new file mode 100644 index 0000000000..1899b6367c --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/playwright.json @@ -0,0 +1,42 @@ +{ + "id": "playwright", + "label": "Playwright", + "description": "Keep failed specs, error context, and summary.", + "category": "test", + "priority": 86, + "match": { + "outputTypes": [], + "commands": ["^playwright\\s+test\\b", "^npx\\s+playwright\\s+test\\b"], + "patterns": ["Running \\d+ tests?", "Error Context:", "failed"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*$", "^Running \\d+ tests? using"], + "includePatterns": [ + "✘", + "Error:", + "Error Context:", + "failed", + "passed", + "Slow test file", + "^\\s*\\d+\\)" + ], + "collapsePatterns": ["^\\s+at "], + "deduplicate": true, + "maxLines": 160, + "headLines": 35, + "tailLines": 60 + }, + "preserve": { + "errorPatterns": ["Error:", "✘", "failed"], + "summaryPatterns": ["passed", "failed"] + }, + "tests": [ + { + "name": "inline sample", + "command": "playwright test", + "input": "Running 2 tests using 1 worker\n ✘ 1 login.spec.ts:3:1 › login\nError: expected visible\n 1 failed\n", + "expected": " ✘ 1 login.spec.ts:3:1 › login\nError: expected visible\n 1 failed" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/poetry-install.json b/open-sse/services/compression/engines/rtk/filters/poetry-install.json new file mode 100644 index 0000000000..f40169365e --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/poetry-install.json @@ -0,0 +1,35 @@ +{ + "id": "poetry-install", + "label": "Poetry Install", + "description": "Strip Poetry package operation noise.", + "category": "package", + "priority": 72, + "match": { + "outputTypes": [], + "commands": ["^poetry\\s+(?:install|update)\\b"], + "patterns": ["Package operations:", "Installing dependencies"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^ - Installing ", "^ - Updating ", "^Installing dependencies", "^\\s*$"], + "includePatterns": ["Package operations:", "Writing lock file", "Error", "failed"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 25, + "tailLines": 35, + "onEmpty": "poetry: ok" + }, + "preserve": { + "errorPatterns": ["Error", "failed"], + "summaryPatterns": ["Package operations:", "Writing lock file"] + }, + "tests": [ + { + "name": "inline sample", + "command": "poetry (?:install|update)", + "input": "Installing dependencies\nPackage operations: 2 installs, 0 updates\n - Installing foo (1.0)\nWriting lock file\n", + "expected": "Package operations: 2 installs, 0 updates\nWriting lock file" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/prettier.json b/open-sse/services/compression/engines/rtk/filters/prettier.json new file mode 100644 index 0000000000..0a1c894fc6 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/prettier.json @@ -0,0 +1,40 @@ +{ + "id": "prettier", + "label": "Prettier", + "description": "Keep files that would be changed and final summary.", + "category": "build", + "priority": 79, + "match": { + "outputTypes": [], + "commands": ["^prettier\\b", "^npx\\s+prettier\\b"], + "patterns": ["\\[warn\\]", "All matched files use Prettier"] + }, + "rules": { + "stripAnsi": true, + "matchOutput": [ + { + "pattern": "All matched files use Prettier", + "message": "prettier: ok" + } + ], + "dropPatterns": ["^\\s*$"], + "includePatterns": ["\\[warn\\]", "Code style issues", "All matched files"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 80, + "headLines": 20, + "tailLines": 30 + }, + "preserve": { + "errorPatterns": ["\\[error\\]", "Code style issues"], + "summaryPatterns": ["All matched files"] + }, + "tests": [ + { + "name": "inline sample", + "command": "prettier", + "input": "[warn] src/a.ts\n[warn] Code style issues found in the above file. Forgot to run Prettier?\n", + "expected": "[warn] src/a.ts\n[warn] Code style issues found in the above file. Forgot to run Prettier?" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/ps.json b/open-sse/services/compression/engines/rtk/filters/ps.json new file mode 100644 index 0000000000..843866d70e --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/ps.json @@ -0,0 +1,34 @@ +{ + "id": "ps", + "label": "ps", + "description": "Keep process table headers and process rows, dropping empty noise.", + "category": "shell", + "priority": 68, + "match": { + "outputTypes": ["shell-ps"], + "commands": ["^ps\\b"], + "patterns": ["^(?:USER\\s+PID|\\s*PID\\s+)"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*$"], + "includePatterns": ["^(?:USER\\s+PID|\\s*PID\\s+)", "^\\S+\\s+\\d+\\s+", "^\\s*\\d+\\s+"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 120, + "headLines": 30, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["defunct", "Permission denied"], + "summaryPatterns": ["^(?:USER\\s+PID|\\s*PID\\s+)"] + }, + "tests": [ + { + "name": "inline sample", + "command": "ps aux", + "input": "USER PID %CPU COMMAND\nroot 1 0.0 init\nnode 1234 5.0 node server.js\n", + "expected": "USER PID %CPU COMMAND\nroot 1 0.0 init\nnode 1234 5.0 node server.js" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/rsync.json b/open-sse/services/compression/engines/rtk/filters/rsync.json new file mode 100644 index 0000000000..36ecd135bc --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/rsync.json @@ -0,0 +1,33 @@ +{ + "id": "rsync", + "label": "rsync", + "description": "Drop rsync transfer chatter while preserving copied files and errors.", + "category": "cloud", + "priority": 73, + "match": { + "outputTypes": ["rsync"], + "commands": ["^rsync\\b"], + "patterns": ["^sending incremental file list", "^rsync error:"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^sending incremental file list", "^sent \\d", "^total size is", "^\\s*$"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 140, + "headLines": 35, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["^rsync error:", "Permission denied", "No such file"], + "summaryPatterns": ["^sent \\d", "^total size is"] + }, + "tests": [ + { + "name": "inline sample", + "command": "rsync -av", + "input": "sending incremental file list\napp.js\nsent 120 bytes received 20 bytes\nrsync error: some files could not be transferred (code 23)\n", + "expected": "app.js\nrsync error: some files could not be transferred (code 23)" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/rubocop.json b/open-sse/services/compression/engines/rtk/filters/rubocop.json new file mode 100644 index 0000000000..da92cc4beb --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/rubocop.json @@ -0,0 +1,34 @@ +{ + "id": "rubocop", + "label": "RuboCop", + "description": "Keep RuboCop offenses and final summary.", + "category": "build", + "priority": 74, + "match": { + "outputTypes": ["rubocop"], + "commands": ["^rubocop\\b", "^bundle\\s+exec\\s+rubocop\\b"], + "patterns": ["^Inspecting \\d+ files", "^\\S+\\.rb:\\d+:\\d+: [A-Z]:"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^Inspecting \\d+ files", "^\\.+$", "^\\s*$"], + "includePatterns": ["^\\S+\\.rb:\\d+:\\d+: [A-Z]:", "^\\d+ files inspected"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 120, + "headLines": 30, + "tailLines": 45 + }, + "preserve": { + "errorPatterns": [" [EF]: ", "Offenses:"], + "summaryPatterns": ["^\\d+ files inspected"] + }, + "tests": [ + { + "name": "inline sample", + "command": "rubocop", + "input": "Inspecting 2 files\n.C\napp/models/user.rb:12:3: C: Style/IfUnlessModifier: Favor modifier if usage.\n2 files inspected, 1 offense detected\n", + "expected": "app/models/user.rb:12:3: C: Style/IfUnlessModifier: Favor modifier if usage.\n2 files inspected, 1 offense detected" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/ruff.json b/open-sse/services/compression/engines/rtk/filters/ruff.json new file mode 100644 index 0000000000..0e3d80d6ee --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/ruff.json @@ -0,0 +1,40 @@ +{ + "id": "ruff", + "label": "Ruff", + "description": "Keep Ruff diagnostics and summary.", + "category": "build", + "priority": 82, + "match": { + "outputTypes": [], + "commands": ["^ruff\\b", "^uv\\s+run\\s+ruff\\b"], + "patterns": ["Found \\d+ errors?", "All checks passed"] + }, + "rules": { + "stripAnsi": true, + "matchOutput": [ + { + "pattern": "All checks passed", + "message": "ruff: ok" + } + ], + "dropPatterns": ["^\\s*$"], + "includePatterns": ["^[A-Z]\\d{3}", "Found \\d+", "error:", "warning:", "-->"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 120, + "headLines": 30, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["^[A-Z]\\d{3}", "error:"], + "summaryPatterns": ["Found \\d+", "All checks passed"] + }, + "tests": [ + { + "name": "inline sample", + "command": "ruff", + "input": "F401 app.py:1:1 unused import\nFound 1 error.\n", + "expected": "F401 app.py:1:1 unused import\nFound 1 error." + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/shell-find.json b/open-sse/services/compression/engines/rtk/filters/shell-find.json new file mode 100644 index 0000000000..e95ce818d9 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/shell-find.json @@ -0,0 +1,33 @@ +{ + "id": "shell-find", + "label": "find", + "description": "Limit large find output while preserving head and tail.", + "category": "shell", + "priority": 80, + "match": { + "outputTypes": ["shell-find"], + "commands": ["^find\\b"], + "patterns": ["^(?:\\./|/|[A-Za-z0-9_.-]+/)"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 120, + "headLines": 40, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["Permission denied", "No such file"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "find", + "input": "./src/a.ts\n./src/b.ts\n./node_modules/pkg/index.js\n", + "expected": "./src/a.ts\n./src/b.ts\n./node_modules/pkg/index.js" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/shell-grep.json b/open-sse/services/compression/engines/rtk/filters/shell-grep.json new file mode 100644 index 0000000000..6429ff82f9 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/shell-grep.json @@ -0,0 +1,37 @@ +{ + "id": "shell-grep", + "label": "grep/rg", + "description": "Deduplicate grep output while preserving matches.", + "category": "shell", + "priority": 82, + "match": { + "outputTypes": ["shell-grep"], + "commands": ["^(?:grep|rg|ag)\\b"], + "patterns": [ + "^[\\w./-]+\\.(?:ts|tsx|js|jsx|py|go|rs|java|rb|md|json|ya?ml|txt):\\d*:", + "^[\\w./-]+/[\\w./-]+:\\d*:" + ] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*$"], + "includePatterns": ["^[\\w./-]+(?::\\d+)?:"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 160, + "headLines": 40, + "tailLines": 60 + }, + "preserve": { + "errorPatterns": ["No such file", "Permission denied"], + "summaryPatterns": ["^[^:\\n]+(?::\\d+)?:"] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:grep|rg|ag)", + "input": "src/a.ts:1:match\nsrc/a.ts:1:match\nsrc/b.ts:2:other\n", + "expected": "src/a.ts:1:match\nsrc/a.ts:1:match\nsrc/b.ts:2:other" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/shell-ls.json b/open-sse/services/compression/engines/rtk/filters/shell-ls.json new file mode 100644 index 0000000000..d8bb51cf8e --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/shell-ls.json @@ -0,0 +1,33 @@ +{ + "id": "shell-ls", + "label": "Shell Listing", + "description": "Keep directory listing boundaries without flooding the prompt.", + "category": "shell", + "priority": 50, + "match": { + "outputTypes": ["shell-ls"], + "commands": ["^(?:ls(?:\\s+-[A-Za-z]+)?|find)\\b"], + "patterns": ["^total \\d+", "^\\S+\\s+\\S+\\s+\\d+\\s+\\w+\\s+\\d{1,2}\\s+"] + }, + "rules": { + "includePatterns": [], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 100, + "headLines": 40, + "tailLines": 30 + }, + "preserve": { + "errorPatterns": ["^total \\d+"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:ls(?: -[A-Za-z]+)?|find)", + "input": "total 8\ndrwxr-xr-x 2 user user 4096 May 1 src\n-rw-r--r-- 1 user user 10 May 1 package.json\n", + "expected": "total 8\ndrwxr-xr-x 2 user user 4096 May 1 src\n-rw-r--r-- 1 user user 10 May 1 package.json" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/ssh.json b/open-sse/services/compression/engines/rtk/filters/ssh.json new file mode 100644 index 0000000000..2b67759776 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/ssh.json @@ -0,0 +1,43 @@ +{ + "id": "ssh", + "label": "ssh", + "description": "Compress SSH connection logs while preserving access and host-key failures.", + "category": "cloud", + "priority": 72, + "match": { + "outputTypes": ["ssh"], + "commands": ["^ssh\\b"], + "patterns": [ + "Permission denied \\(", + "Host key verification failed", + "Connection (?:closed|timed out)" + ] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^debug\\d:", "^\\s*$"], + "includePatterns": [ + "Permission denied", + "Host key verification failed", + "Connection (?:closed|timed out)", + "^Welcome " + ], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 25, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["Permission denied", "Host key verification failed", "Connection timed out"], + "summaryPatterns": ["^Welcome "] + }, + "tests": [ + { + "name": "inline sample", + "command": "ssh host", + "input": "debug1: Connecting to host\nWelcome to Ubuntu\nPermission denied (publickey).\n", + "expected": "Welcome to Ubuntu\nPermission denied (publickey)." + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/systemctl-status.json b/open-sse/services/compression/engines/rtk/filters/systemctl-status.json new file mode 100644 index 0000000000..f75b502bc1 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/systemctl-status.json @@ -0,0 +1,43 @@ +{ + "id": "systemctl-status", + "label": "systemctl status", + "description": "Preserve unit status, active state, and journal errors.", + "category": "infra", + "priority": 77, + "match": { + "outputTypes": [], + "commands": ["^systemctl\\s+status\\b"], + "patterns": ["Loaded:", "Active:", "Main PID:"] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [ + "Loaded:", + "Active:", + "Main PID:", + "CGroup:", + "error", + "failed", + "Started", + "Stopped" + ], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": ["^\\s+Docs:"], + "deduplicate": true, + "maxLines": 100, + "headLines": 30, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["failed", "error"], + "summaryPatterns": ["Active:", "Loaded:"] + }, + "tests": [ + { + "name": "inline sample", + "command": "systemctl status", + "input": "Loaded: loaded (/etc/systemd/system/app.service)\nActive: failed (Result: exit-code)\nMain PID: 123\nMay 1 app[123]: error boot failed\n", + "expected": "Loaded: loaded (/etc/systemd/system/app.service)\nActive: failed (Result: exit-code)\nMain PID: 123\nMay 1 app[123]: error boot failed" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/terraform-plan.json b/open-sse/services/compression/engines/rtk/filters/terraform-plan.json new file mode 100644 index 0000000000..3f9d417253 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/terraform-plan.json @@ -0,0 +1,34 @@ +{ + "id": "terraform-plan", + "label": "Terraform Plan", + "description": "Keep plan actions and summary while stripping refresh noise.", + "category": "infra", + "priority": 88, + "match": { + "outputTypes": [], + "commands": ["^terraform\\s+plan\\b"], + "patterns": ["Terraform will perform the following actions", "Plan: \\d+ to add"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["Refreshing state", "Acquiring state lock", "Releasing state lock", "^\\s*$"], + "includePatterns": ["Terraform will perform", "^[ ]*[#~+\\-/]", "Plan: \\d+", "Error:"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 160, + "headLines": 35, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["Error:", "failed"], + "summaryPatterns": ["Plan: \\d+"] + }, + "tests": [ + { + "name": "inline sample", + "command": "terraform plan", + "input": "Refreshing state... [id=a]\nTerraform will perform the following actions:\n # aws_instance.web will be created\nPlan: 1 to add, 0 to change, 0 to destroy.\n", + "expected": "Terraform will perform the following actions:\n # aws_instance.web will be created\nPlan: 1 to add, 0 to change, 0 to destroy." + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/test-cargo.json b/open-sse/services/compression/engines/rtk/filters/test-cargo.json new file mode 100644 index 0000000000..e7aecaca5c --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/test-cargo.json @@ -0,0 +1,42 @@ +{ + "id": "test-cargo", + "label": "Cargo Test", + "description": "Keep Cargo test failures and final summary.", + "category": "test", + "priority": 90, + "match": { + "outputTypes": ["test-cargo"], + "commands": ["^cargo\\s+(?:test|nextest)\\b"], + "patterns": ["^running \\d+ tests?", "test result:"] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [ + "^running \\d+ tests?", + "^test .* \\.\\.\\. FAILED", + "^failures:", + "^---- ", + "^thread ", + "^test result:", + "^error:" + ], + "dropPatterns": ["^test .* \\.\\.\\. ok$", "^\\s*$"], + "collapsePatterns": ["^\\s+at "], + "deduplicate": true, + "maxLines": 180, + "headLines": 40, + "tailLines": 70 + }, + "preserve": { + "errorPatterns": ["FAILED", "^error:", "^thread "], + "summaryPatterns": ["test result:"] + }, + "tests": [ + { + "name": "inline sample", + "command": "cargo (?:test|nextest)", + "input": "running 2 tests\ntest a ... ok\ntest b ... FAILED\nfailures:\n---- b stdout ----\nthread b panicked at boom\ntest result: FAILED. 1 passed; 1 failed\n", + "expected": "running 2 tests\ntest b ... FAILED\nfailures:\n---- b stdout ----\nthread b panicked at boom\ntest result: FAILED. 1 passed; 1 failed" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/test-go.json b/open-sse/services/compression/engines/rtk/filters/test-go.json new file mode 100644 index 0000000000..5e4f05e50d --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/test-go.json @@ -0,0 +1,41 @@ +{ + "id": "test-go", + "label": "Go Test", + "description": "Keep failing Go tests and package summaries.", + "category": "test", + "priority": 90, + "match": { + "outputTypes": ["test-go"], + "commands": ["^go\\s+test\\b"], + "patterns": ["^--- FAIL:", "^(?:ok|FAIL)\\s+"] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [ + "^--- FAIL:", + "^FAIL", + "^ok\\s+", + "^panic:", + "^\\s+.*_test\\.go:\\d+", + "^\\s*Error" + ], + "dropPatterns": ["^\\s*$"], + "collapsePatterns": ["^=== RUN"], + "deduplicate": true, + "maxLines": 160, + "headLines": 35, + "tailLines": 60 + }, + "preserve": { + "errorPatterns": ["^--- FAIL:", "^panic:", "^FAIL"], + "summaryPatterns": ["^ok\\s+", "^FAIL\\s+"] + }, + "tests": [ + { + "name": "inline sample", + "command": "go test", + "input": "=== RUN TestA\n--- FAIL: TestA (0.00s)\n a_test.go:1: boom\nFAIL\t./pkg\t0.1s\n", + "expected": "--- FAIL: TestA (0.00s)\n a_test.go:1: boom\nFAIL\t./pkg\t0.1s" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/test-jest.json b/open-sse/services/compression/engines/rtk/filters/test-jest.json new file mode 100644 index 0000000000..62f4c0a5d0 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/test-jest.json @@ -0,0 +1,43 @@ +{ + "id": "test-jest", + "label": "Jest", + "description": "Keep Jest failures and summary while removing passing noise.", + "category": "test", + "priority": 90, + "match": { + "outputTypes": ["test-jest"], + "commands": ["^(?:jest|npm\\s+(?:run\\s+)?test)\\b"], + "patterns": ["Test Suites:\\s+\\d+", "Tests:\\s+\\d+", "^PASS\\s+", "^FAIL\\s+"] + }, + "rules": { + "includePatterns": [ + "^FAIL\\s+", + "^\\s*●\\s+", + "Expected:", + "Received:", + "Test Suites:", + "Tests:", + "Snapshots:", + "Time:", + "\\s+at\\s+" + ], + "dropPatterns": ["^PASS\\s+", "^\\s*✓\\s+"], + "collapsePatterns": ["^\\s*at "], + "deduplicate": true, + "maxLines": 140, + "headLines": 24, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["FAIL", "Expected:", "Received:", "Test Suites:"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:jest|npm (?:run )?test)", + "input": "PASS src/a.test.ts\nFAIL src/b.test.ts\nError: boom\nTest Suites: 1 failed, 1 passed\nTests: 1 failed, 1 passed\n", + "expected": "FAIL src/b.test.ts\nTest Suites: 1 failed, 1 passed\nTests: 1 failed, 1 passed" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/test-pytest.json b/open-sse/services/compression/engines/rtk/filters/test-pytest.json new file mode 100644 index 0000000000..4e62bcc34c --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/test-pytest.json @@ -0,0 +1,41 @@ +{ + "id": "test-pytest", + "label": "Pytest", + "description": "Keep pytest failures, tracebacks and terminal summary.", + "category": "test", + "priority": 90, + "match": { + "outputTypes": ["test-pytest"], + "commands": ["^(?:pytest|python\\s+-m\\s+pytest)\\b"], + "patterns": ["=+\\s+(?:\\d+\\s+)?(?:passed|failed|errors?)", "^E\\s+", "^FAILED "] + }, + "rules": { + "includePatterns": [ + "^FAILED ", + "^ERROR ", + "^E\\s+", + "Traceback \\(most recent call last\\):", + "=+ short test summary info =+", + "=+ .*failed", + "AssertionError" + ], + "dropPatterns": ["^\\.", "^\\s*$"], + "collapsePatterns": ["^E\\s+"], + "deduplicate": true, + "maxLines": 160, + "headLines": 28, + "tailLines": 60 + }, + "preserve": { + "errorPatterns": ["FAILED", "ERROR", "Traceback", "AssertionError"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:pytest|python -m pytest)", + "input": "FAILED tests/test_a.py::test_a - AssertionError\nE assert 1 == 2\n================ 1 failed, 2 passed ================\n", + "expected": "FAILED tests/test_a.py::test_a - AssertionError\nE assert 1 == 2\n================ 1 failed, 2 passed ================" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/test-vitest.json b/open-sse/services/compression/engines/rtk/filters/test-vitest.json new file mode 100644 index 0000000000..bb8a7480de --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/test-vitest.json @@ -0,0 +1,43 @@ +{ + "id": "test-vitest", + "label": "Vitest", + "description": "Keep failing suites, failed tests, stack headers, and final summary.", + "category": "test", + "priority": 92, + "match": { + "outputTypes": ["test-vitest"], + "commands": ["^(?:vitest|npm\\s+(?:run\\s+)?test:vitest)\\b"], + "patterns": ["\\bvitest\\b", "^ ✓ ", "^ ❯ ", "Test Files\\s+\\d+\\s+(?:passed|failed)"] + }, + "rules": { + "includePatterns": [ + "^\\s*FAIL\\s+", + "^\\s*❯\\s+", + "^\\s*×\\s+", + "Test Files\\s+", + "Tests\\s+", + "Duration\\s+", + "Error:", + "AssertionError", + "\\s+at\\s+" + ], + "dropPatterns": ["^\\s*✓\\s+", "^\\s*stdout \\|", "^\\s*stderr \\|"], + "collapsePatterns": ["^\\s*at "], + "deduplicate": true, + "maxLines": 140, + "headLines": 24, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["FAIL", "Error:", "AssertionError", "Test Files"], + "summaryPatterns": [] + }, + "tests": [ + { + "name": "inline sample", + "command": "(?:vitest|npm (?:run )?test:vitest)", + "input": " ✓ src/a.test.ts (1)\n ❯ src/b.test.ts (1)\n FAIL src/b.test.ts > fails\nError: boom\nTest Files 1 failed | 1 passed\nTests 1 failed | 1 passed\n", + "expected": " ❯ src/b.test.ts (1)\n FAIL src/b.test.ts > fails\nError: boom\nTest Files 1 failed | 1 passed\nTests 1 failed | 1 passed" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/tofu-plan.json b/open-sse/services/compression/engines/rtk/filters/tofu-plan.json new file mode 100644 index 0000000000..d6b8c69ee5 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/tofu-plan.json @@ -0,0 +1,34 @@ +{ + "id": "tofu-plan", + "label": "OpenTofu Plan", + "description": "Keep OpenTofu plan actions and summary while stripping refresh noise.", + "category": "infra", + "priority": 87, + "match": { + "outputTypes": [], + "commands": ["^tofu\\s+plan\\b"], + "patterns": ["OpenTofu will perform the following actions", "Plan: \\d+ to add"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["Refreshing state", "Acquiring state lock", "Releasing state lock", "^\\s*$"], + "includePatterns": ["OpenTofu will perform", "^[ ]*[#~+\\-/]", "Plan: \\d+", "Error:"], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 160, + "headLines": 35, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["Error:", "failed"], + "summaryPatterns": ["Plan: \\d+"] + }, + "tests": [ + { + "name": "inline sample", + "command": "tofu plan", + "input": "Refreshing state... [id=a]\nOpenTofu will perform the following actions:\n # tofu_resource.web will be created\nPlan: 1 to add, 0 to change, 0 to destroy.\n", + "expected": "OpenTofu will perform the following actions:\n # tofu_resource.web will be created\nPlan: 1 to add, 0 to change, 0 to destroy." + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/turbo.json b/open-sse/services/compression/engines/rtk/filters/turbo.json new file mode 100644 index 0000000000..0727717b65 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/turbo.json @@ -0,0 +1,34 @@ +{ + "id": "turbo", + "label": "Turborepo", + "description": "Compact turbo task cache and failure output.", + "category": "build", + "priority": 76, + "match": { + "outputTypes": [], + "commands": ["^turbo\\b", "^npx\\s+turbo\\b"], + "patterns": ["Tasks:", "Remote caching"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["cache hit", "cache miss", "^\\s*$"], + "includePatterns": ["failed", "error", "Tasks:", "Cached:", "Time:", "successful"], + "collapsePatterns": ["^• Packages in scope"], + "deduplicate": true, + "maxLines": 120, + "headLines": 25, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["failed", "error"], + "summaryPatterns": ["Tasks:", "Cached:", "Time:"] + }, + "tests": [ + { + "name": "inline sample", + "command": "turbo", + "input": "cache hit app#build\napp#test: failed\nTasks: 1 successful, 1 failed\nTime: 2s\n", + "expected": "app#test: failed\nTasks: 1 successful, 1 failed\nTime: 2s" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/uv-sync.json b/open-sse/services/compression/engines/rtk/filters/uv-sync.json new file mode 100644 index 0000000000..ecac0a0321 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/uv-sync.json @@ -0,0 +1,35 @@ +{ + "id": "uv-sync", + "label": "uv sync", + "description": "Compact uv sync/install output.", + "category": "package", + "priority": 72, + "match": { + "outputTypes": [], + "commands": ["^uv\\s+sync\\b", "^uv\\s+pip\\s+install\\b"], + "patterns": ["Resolved \\d+ packages?", "Prepared \\d+ packages?"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^Resolved ", "^Prepared ", "^Installed ", "^ \\+", "^\\s*$"], + "includePatterns": ["error", "failed", "Installed", "Uninstalled", "Resolved"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 25, + "tailLines": 35, + "onEmpty": "uv: ok" + }, + "preserve": { + "errorPatterns": ["error", "failed"], + "summaryPatterns": ["Installed", "Resolved"] + }, + "tests": [ + { + "name": "inline sample", + "command": "uv sync", + "input": "Resolved 10 packages in 1ms\nInstalled 10 packages in 2ms\n", + "expected": "uv: ok" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/wget.json b/open-sse/services/compression/engines/rtk/filters/wget.json new file mode 100644 index 0000000000..b5de52bb99 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/wget.json @@ -0,0 +1,34 @@ +{ + "id": "wget", + "label": "wget", + "description": "Remove wget progress output and keep final errors or saved-file summaries.", + "category": "cloud", + "priority": 70, + "match": { + "outputTypes": ["wget"], + "commands": ["^wget\\b"], + "patterns": ["^--\\d{4}-\\d{2}-\\d{2}", "^ERROR \\d{3}:"] + }, + "rules": { + "stripAnsi": true, + "dropPatterns": ["^\\s*\\d+K", "^Saving to:", "^\\s*$"], + "includePatterns": ["^ERROR \\d{3}:", "saved \\[\\d", "^HTTP request sent"], + "collapsePatterns": [], + "deduplicate": true, + "maxLines": 100, + "headLines": 25, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": ["^ERROR \\d{3}:", "failed", "unable to resolve"], + "summaryPatterns": ["saved \\[\\d", "^HTTP request sent"] + }, + "tests": [ + { + "name": "inline sample", + "command": "wget", + "input": "--2026-05-02-- https://example.test/file\nSaving to: 'file'\nHTTP request sent, awaiting response... 404 Not Found\nERROR 404: Not Found.\n", + "expected": "HTTP request sent, awaiting response... 404 Not Found\nERROR 404: Not Found." + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/index.ts b/open-sse/services/compression/engines/rtk/index.ts new file mode 100644 index 0000000000..70d6efc529 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/index.ts @@ -0,0 +1,436 @@ +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 { detectCommandType } from "./commandDetector.ts"; +import { deduplicateRepeatedLines } from "./deduplicator.ts"; +import { matchRtkFilter } from "./filterLoader.ts"; +import { applyLineFilter } from "./lineFilter.ts"; +import { smartTruncate } from "./smartTruncate.ts"; +import { normalizeCodeLanguage, stripCode } from "./codeStripper.ts"; +import { maybePersistRtkRawOutput, type RtkRawOutputPointer } from "./rawOutput.ts"; +import { isTextBlock } from "../../messageContent.ts"; + +type Message = { + role: string; + content?: string | Array<{ type?: string; text?: string; [key: string]: unknown }>; + [key: string]: unknown; +}; + +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): 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; + originalTokens: number; + compressedTokens: number; + techniquesUsed: string[]; + rulesApplied: string[]; + rawOutputPointers?: RtkRawOutputPointer[]; +} + +function mergeRtkConfig(base?: Partial, override?: Record): RtkConfig { + const merged = { ...DEFAULT_RTK_CONFIG, ...(base ?? {}), ...(override ?? {}) }; + return { + ...merged, + intensity: + merged.intensity === "minimal" || + merged.intensity === "standard" || + merged.intensity === "aggressive" + ? merged.intensity + : DEFAULT_RTK_CONFIG.intensity, + enabledFilters: Array.isArray(merged.enabledFilters) + ? merged.enabledFilters.filter((id): id is string => typeof id === "string") + : [], + disabledFilters: Array.isArray(merged.disabledFilters) + ? merged.disabledFilters.filter((id): id is string => typeof id === "string") + : [], + maxLinesPerResult: + typeof merged.maxLinesPerResult === "number" && Number.isFinite(merged.maxLinesPerResult) + ? Math.max(0, Math.floor(merged.maxLinesPerResult)) + : DEFAULT_RTK_CONFIG.maxLinesPerResult, + maxCharsPerResult: + typeof merged.maxCharsPerResult === "number" && Number.isFinite(merged.maxCharsPerResult) + ? Math.max(0, Math.floor(merged.maxCharsPerResult)) + : DEFAULT_RTK_CONFIG.maxCharsPerResult, + deduplicateThreshold: + typeof merged.deduplicateThreshold === "number" && + Number.isFinite(merged.deduplicateThreshold) + ? Math.max(2, Math.floor(merged.deduplicateThreshold)) + : DEFAULT_RTK_CONFIG.deduplicateThreshold, + customFiltersEnabled: + typeof merged.customFiltersEnabled === "boolean" + ? merged.customFiltersEnabled + : DEFAULT_RTK_CONFIG.customFiltersEnabled, + trustProjectFilters: + typeof merged.trustProjectFilters === "boolean" + ? merged.trustProjectFilters + : DEFAULT_RTK_CONFIG.trustProjectFilters, + rawOutputRetention: + merged.rawOutputRetention === "never" || + merged.rawOutputRetention === "failures" || + merged.rawOutputRetention === "always" + ? merged.rawOutputRetention + : DEFAULT_RTK_CONFIG.rawOutputRetention, + rawOutputMaxBytes: + typeof merged.rawOutputMaxBytes === "number" && Number.isFinite(merged.rawOutputMaxBytes) + ? Math.max(1024, Math.floor(merged.rawOutputMaxBytes)) + : DEFAULT_RTK_CONFIG.rawOutputMaxBytes, + }; +} + +function shouldCompressMessage(message: Message, config: RtkConfig): boolean { + if (message.role === "tool") return config.applyToToolResults || config.applyToCodeBlocks; + if (message.role === "assistant") + return config.applyToAssistantMessages || config.applyToCodeBlocks; + return false; +} + +export function processRtkText( + text: string, + options: { command?: string | null; config?: Partial } = {} +): RtkProcessResult { + const config = mergeRtkConfig(options.config); + const originalTokens = estimateCompressionTokens(text); + const techniquesUsed: string[] = []; + const rulesApplied: string[] = []; + const rawOutputPointers: RtkRawOutputPointer[] = []; + let result = text; + + const detection = detectCommandType(text, options.command); + const filter = matchRtkFilter(text, detection.command, { + customFiltersEnabled: config.customFiltersEnabled, + trustProjectFilters: config.trustProjectFilters, + }); + if (filter && !config.disabledFilters.includes(filter.id)) { + if (config.enabledFilters.length === 0 || config.enabledFilters.includes(filter.id)) { + const filtered = applyLineFilter(result, { + ...filter, + maxLines: filter.maxLines || config.maxLinesPerResult, + }); + result = filtered.text; + if (filtered.appliedRules.length > 0) { + techniquesUsed.push("rtk-filter"); + rulesApplied.push(...filtered.appliedRules); + } + } + } + + if (config.applyToCodeBlocks) { + let strippedCodeBlocks = 0; + result = result.replace( + /```([A-Za-z0-9_+.-]*)\r?\n([\s\S]*?)```/g, + (match, languageHint: string, code: string) => { + const stripped = stripCode(code, normalizeCodeLanguage(languageHint)); + if (stripped.strippedLines <= 0 && stripped.text === code.trim()) return match; + strippedCodeBlocks++; + const fenceLanguage = languageHint?.trim() || stripped.language; + return `\`\`\`${fenceLanguage}\n${stripped.text}\n\`\`\``; + } + ); + if (strippedCodeBlocks > 0) { + techniquesUsed.push("rtk-code-strip"); + rulesApplied.push("rtk:code-strip"); + } + } + + if (config.intensity !== "minimal") { + const deduped = deduplicateRepeatedLines(result, { threshold: config.deduplicateThreshold }); + if (deduped.collapsed > 0) { + result = deduped.text; + techniquesUsed.push("rtk-dedup"); + rulesApplied.push("rtk:dedup"); + } + } + + const truncated = smartTruncate(result, { + maxLines: config.maxLinesPerResult, + maxChars: config.maxCharsPerResult, + preserveHead: config.intensity === "aggressive" ? 16 : 24, + preserveTail: config.intensity === "aggressive" ? 16 : 24, + priorityPatterns: [/error|failed|exception|traceback|TS\d{4}|FAIL|✖/i], + }); + if (truncated.truncated) { + result = truncated.text; + techniquesUsed.push("rtk-truncate"); + rulesApplied.push("rtk:truncate"); + } + + const compressedTokens = estimateCompressionTokens(result); + if (compressedTokens < originalTokens) { + const pointer = maybePersistRtkRawOutput(text, { + retention: config.rawOutputRetention, + command: detection.command, + maxBytes: config.rawOutputMaxBytes, + }); + if (pointer) { + rawOutputPointers.push(pointer); + techniquesUsed.push("rtk-raw-output-retention"); + rulesApplied.push("rtk:raw-output-retention"); + } + } + return { + text: result, + compressed: compressedTokens < originalTokens, + originalTokens, + compressedTokens, + techniquesUsed: [...new Set(techniquesUsed)], + rulesApplied: [...new Set(rulesApplied)], + ...(rawOutputPointers.length > 0 ? { rawOutputPointers } : {}), + }; +} + +function processRtkContent( + content: Message["content"], + config: RtkConfig +): { + content: Message["content"]; + compressed: boolean; + techniquesUsed: string[]; + rulesApplied: string[]; + rawOutputPointers: RtkRawOutputPointer[]; +} { + const techniquesUsed: string[] = []; + const rulesApplied: string[] = []; + const rawOutputPointers: RtkRawOutputPointer[] = []; + + const collect = (processed: RtkProcessResult) => { + techniquesUsed.push(...processed.techniquesUsed); + rulesApplied.push(...processed.rulesApplied); + if (processed.rawOutputPointers) rawOutputPointers.push(...processed.rawOutputPointers); + }; + + if (typeof content === "string") { + if (!content) { + return { content, compressed: false, techniquesUsed, rulesApplied, rawOutputPointers }; + } + const processed = processRtkText(content, { config }); + collect(processed); + return { + content: processed.compressed ? processed.text : content, + compressed: processed.compressed, + techniquesUsed, + rulesApplied, + rawOutputPointers, + }; + } + + if (!Array.isArray(content)) { + return { content, compressed: false, techniquesUsed, rulesApplied, rawOutputPointers }; + } + + let compressed = false; + const nextContent = content.map((part) => { + if (!isTextBlock(part) || !part.text) return part; + const processed = processRtkText(part.text, { config }); + collect(processed); + if (!processed.compressed) return part; + compressed = true; + return { ...part, text: processed.text }; + }); + + return { + content: compressed ? nextContent : content, + compressed, + techniquesUsed, + rulesApplied, + rawOutputPointers, + }; +} + +export function applyRtkCompression( + body: Record, + options: { config?: Partial; stepConfig?: Record } = {} +): CompressionResult { + const start = performance.now(); + const config = mergeRtkConfig(options.config, options.stepConfig); + if (!config.enabled) return { body, compressed: false, stats: null }; + + const messages = body.messages as Message[] | undefined; + if (!Array.isArray(messages) || messages.length === 0) { + return { body, compressed: false, stats: null }; + } + + const allTechniques: string[] = []; + const allRules: string[] = []; + const rawOutputPointers: RtkRawOutputPointer[] = []; + const compressedMessages = messages.map((message) => { + if (!shouldCompressMessage(message, config)) return message; + const processed = processRtkContent(message.content, config); + allTechniques.push(...processed.techniquesUsed); + allRules.push(...processed.rulesApplied); + rawOutputPointers.push(...processed.rawOutputPointers); + if (!processed.compressed) return message; + return { + ...message, + content: processed.content, + }; + }); + + const compressedBody = { ...body, messages: compressedMessages }; + const stats = createCompressionStats( + body, + compressedBody, + "rtk", + [...new Set(allTechniques)], + allRules.length > 0 ? [...new Set(allRules)] : undefined, + Math.round((performance.now() - start) * 100) / 100 + ); + stats.engine = "rtk"; + if (rawOutputPointers.length > 0) { + stats.rtkRawOutputPointers = rawOutputPointers; + } + return { + body: compressedBody, + compressed: stats.compressedTokens < stats.originalTokens, + stats, + }; +} + +export const rtkEngine: CompressionEngine = { + id: "rtk", + name: "RTK", + description: "Command-aware tool output compression with declarative filters.", + icon: "filter_alt", + targets: ["tool_results", "code_blocks"], + stackable: true, + stackPriority: 10, + metadata: { + id: "rtk", + name: "RTK", + description: "Command-aware tool output compression with declarative filters.", + inputScope: "tool-results", + targetLatencyMs: 5, + supportsPreview: true, + stable: true, + }, + apply(body, options) { + return applyRtkCompression(body, { + config: options?.config?.rtkConfig, + stepConfig: options?.stepConfig, + }); + }, + compress(body, config) { + return this.apply(body, { stepConfig: config }); + }, + getConfigSchema() { + return RTK_SCHEMA; + }, + validateConfig(config) { + return validateRtkEngineConfig(config); + }, +}; + +export { + detectCommandFromText, + detectCommandOutput, + detectCommandType, +} from "./commandDetector.ts"; +export { runRtkFilterTests } from "./verify.ts"; +export { maybePersistRtkRawOutput, readRtkRawOutput, redactRtkRawOutput } from "./rawOutput.ts"; diff --git a/open-sse/services/compression/engines/rtk/lineFilter.ts b/open-sse/services/compression/engines/rtk/lineFilter.ts new file mode 100644 index 0000000000..589b618086 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/lineFilter.ts @@ -0,0 +1,155 @@ +import type { RtkFilterDefinition } from "./filterSchema.ts"; +import { smartTruncate } from "./smartTruncate.ts"; + +export interface LineFilterResult { + text: string; + strippedLines: number; + keptByRule: boolean; + appliedRules: string[]; +} + +function compilePatterns(patterns: string[]): RegExp[] { + return patterns.flatMap((pattern) => { + try { + return [new RegExp(pattern, "i")]; + } catch { + return []; + } + }); +} + +function compileGlobalPattern(pattern: string): RegExp | null { + try { + return new RegExp(pattern, "g"); + } catch { + return null; + } +} + +function compileBlobPattern(pattern: string): RegExp | null { + try { + return new RegExp(pattern, "im"); + } catch { + return null; + } +} + +function stripAnsi(text: string): string { + return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); +} + +function normalizeStderrPrefix(line: string): string { + return line.replace(/^\s*(?:stderr|err)\s*(?:\||:)\s*/i, ""); +} + +function truncateUnicodeSafe(line: string, maxChars: number): string { + if (maxChars <= 0) return line; + const chars = Array.from(line); + if (chars.length <= maxChars) return line; + if (maxChars <= 3) return chars.slice(0, maxChars).join(""); + return `${chars.slice(0, maxChars - 3).join("")}...`; +} + +export function applyLineFilter(text: string, filter: RtkFilterDefinition): LineFilterResult { + const stripPatterns = compilePatterns(filter.stripPatterns); + const keepPatterns = compilePatterns(filter.keepPatterns); + const collapsePatterns = compilePatterns(filter.collapsePatterns); + const priorityPatterns = compilePatterns(filter.priorityPatterns); + const appliedRules: string[] = []; + + let lines = text.split(/\r?\n/); + const originalLineCount = lines.length; + + if (filter.stripAnsi) { + const stripped = lines.map(stripAnsi); + if (stripped.join("\n") !== lines.join("\n")) { + appliedRules.push(`${filter.id}:strip-ansi`); + } + lines = stripped; + } + + if (filter.filterStderr) { + const normalized = lines.map(normalizeStderrPrefix); + if (normalized.join("\n") !== lines.join("\n")) { + appliedRules.push(`${filter.id}:filter-stderr`); + } + lines = normalized; + } + + for (const rule of filter.replace) { + const pattern = compileGlobalPattern(rule.pattern); + if (!pattern) continue; + const replaced = lines.map((line) => line.replace(pattern, rule.replacement)); + if (replaced.join("\n") !== lines.join("\n")) { + appliedRules.push(`${filter.id}:replace`); + } + lines = replaced; + } + + if (filter.matchOutput.length > 0) { + const blob = lines.join("\n"); + for (const rule of filter.matchOutput) { + const pattern = compileBlobPattern(rule.pattern); + if (!pattern?.test(blob)) continue; + const unless = rule.unless ? compileBlobPattern(rule.unless) : null; + if (unless?.test(blob)) continue; + appliedRules.push(`${filter.id}:match-output`); + return { + text: rule.message, + strippedLines: Math.max(0, originalLineCount - 1), + keptByRule: false, + appliedRules, + }; + } + } + + if (stripPatterns.length > 0) { + lines = lines.filter((line) => !stripPatterns.some((pattern) => pattern.test(line))); + if (lines.length !== originalLineCount) appliedRules.push(`${filter.id}:strip`); + } + + if (keepPatterns.length > 0) { + const kept = lines.filter((line) => keepPatterns.some((pattern) => pattern.test(line))); + if (kept.length > 0) { + lines = kept; + appliedRules.push(`${filter.id}:keep`); + } + } + + if (collapsePatterns.length > 0) { + const seen = new Set(); + lines = lines.filter((line) => { + if (!collapsePatterns.some((pattern) => pattern.test(line))) return true; + const key = line.trim(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + appliedRules.push(`${filter.id}:collapse`); + } + + if (filter.truncateLineAt > 0) { + const truncatedLines = lines.map((line) => truncateUnicodeSafe(line, filter.truncateLineAt)); + if (truncatedLines.join("\n") !== lines.join("\n")) { + lines = truncatedLines; + appliedRules.push(`${filter.id}:truncate-line`); + } + } + + const truncated = smartTruncate(lines.join("\n"), { + maxLines: filter.maxLines, + preserveHead: filter.preserveHead, + preserveTail: filter.preserveTail, + priorityPatterns, + }); + if (truncated.truncated) appliedRules.push(`${filter.id}:truncate`); + const output = + truncated.text.trim().length === 0 && filter.onEmpty ? filter.onEmpty : truncated.text; + + return { + text: output, + strippedLines: Math.max(0, originalLineCount - output.split(/\r?\n/).length), + keptByRule: keepPatterns.length > 0, + appliedRules, + }; +} diff --git a/open-sse/services/compression/engines/rtk/rawOutput.ts b/open-sse/services/compression/engines/rtk/rawOutput.ts new file mode 100644 index 0000000000..7e40d6a9cb --- /dev/null +++ b/open-sse/services/compression/engines/rtk/rawOutput.ts @@ -0,0 +1,112 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import crypto from "node:crypto"; + +export type RtkRawOutputRetention = "never" | "failures" | "always"; + +export interface RtkRawOutputPointer { + id: string; + path: string; + bytes: number; + sha256: string; + redacted: boolean; +} + +const SECRET_PATTERNS: Array<[RegExp, string]> = [ + [/\b(sk-[A-Za-z0-9_-]{16,})\b/g, "[REDACTED_OPENAI_KEY]"], + [/\b(xox[baprs]-[A-Za-z0-9-]{16,})\b/g, "[REDACTED_SLACK_TOKEN]"], + [/\b(AKIA[0-9A-Z]{16})\b/g, "[REDACTED_AWS_KEY]"], + [/((?:api[_-]?key|token|secret|password)\s*[:=]\s*)("[^"]+"|'[^']+'|[^\s]+)/gi, "$1[REDACTED]"], + [/(Authorization:\s*Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, "$1[REDACTED]"], +]; + +function dataDir(): string { + return process.env.DATA_DIR || path.join(os.homedir(), ".omniroute"); +} + +function safeId(seed: string): string { + return crypto.createHash("sha256").update(seed).digest("hex").slice(0, 24); +} + +function safeUtf8Slice(value: string, maxBytes: number): string { + if (maxBytes <= 0 || Buffer.byteLength(value, "utf8") <= maxBytes) return value; + let bytes = 0; + let output = ""; + for (const char of value) { + const len = Buffer.byteLength(char, "utf8"); + if (bytes + len > maxBytes) break; + output += char; + bytes += len; + } + return `${output}\n\n--- truncated at ${maxBytes} bytes ---`; +} + +export function redactRtkRawOutput(value: string): { text: string; redacted: boolean } { + let redacted = false; + let text = value; + for (const [pattern, replacement] of SECRET_PATTERNS) { + const next = text.replace(pattern, (...args: string[]) => { + redacted = true; + return typeof replacement === "string" + ? replacement.replace("$1", args[1] ?? "") + : replacement; + }); + text = next; + } + return { text, redacted }; +} + +export function isLikelyFailureOutput(value: string): boolean { + return /\b(error|failed|failure|exception|traceback|panic|fatal|critical|TS\d{4}|FAIL)\b/i.test( + value + ); +} + +export function maybePersistRtkRawOutput( + raw: string, + options: { + retention: RtkRawOutputRetention; + command?: string | null; + maxBytes?: number; + failure?: boolean; + } +): RtkRawOutputPointer | null { + if (options.retention === "never") return null; + const failure = options.failure ?? isLikelyFailureOutput(raw); + if (options.retention === "failures" && !failure) return null; + if (raw.trim().length === 0) return null; + + const maxBytes = Math.max(1024, Math.floor(options.maxBytes ?? 1_048_576)); + const redaction = redactRtkRawOutput(safeUtf8Slice(raw, maxBytes)); + const now = Date.now(); + const commandSlug = (options.command || "tool-output") + .replace(/[^A-Za-z0-9_-]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 48); + const id = safeId(`${now}:${commandSlug}:${raw.length}:${redaction.text}`); + const dir = path.join(dataDir(), "rtk", "raw-output"); + const filePath = path.join(dir, `${now}-${commandSlug || "tool-output"}-${id}.log`); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, redaction.text); + + return { + id, + path: filePath, + bytes: Buffer.byteLength(redaction.text, "utf8"), + sha256: crypto.createHash("sha256").update(redaction.text).digest("hex"), + redacted: redaction.redacted, + }; +} + +export function readRtkRawOutput(pointerId: string): string | null { + const dir = path.join(dataDir(), "rtk", "raw-output"); + if (!fs.existsSync(dir)) return null; + const entry = fs + .readdirSync(dir) + .find((file) => file.endsWith(".log") && file.includes(pointerId)); + if (!entry) return null; + const fullPath = path.join(dir, entry); + if (!fullPath.startsWith(dir)) return null; + return fs.readFileSync(fullPath, "utf8"); +} diff --git a/open-sse/services/compression/engines/rtk/smartTruncate.ts b/open-sse/services/compression/engines/rtk/smartTruncate.ts new file mode 100644 index 0000000000..8af0e708c2 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/smartTruncate.ts @@ -0,0 +1,67 @@ +export interface SmartTruncateOptions { + maxLines?: number; + maxChars?: number; + preserveHead?: number; + preserveTail?: number; + priorityPatterns?: RegExp[]; +} + +export function smartTruncate( + text: string, + options: SmartTruncateOptions = {} +): { + text: string; + truncated: boolean; + droppedLines: number; +} { + const maxChars = Math.max(0, Math.floor(options.maxChars ?? 0)); + const maxLines = Math.max(0, Math.floor(options.maxLines ?? 0)); + const lines = text.split(/\r?\n/); + const overLineLimit = maxLines > 0 && lines.length > maxLines; + const overCharLimit = maxChars > 0 && text.length > maxChars; + if (!overLineLimit && !overCharLimit) { + return { text, truncated: false, droppedLines: 0 }; + } + + const preserveHead = Math.max(0, Math.floor(options.preserveHead ?? 20)); + const preserveTail = Math.max(0, Math.floor(options.preserveTail ?? 20)); + const priorityPatterns = options.priorityPatterns ?? []; + const priorityLines = priorityPatterns.length + ? lines.filter((line) => priorityPatterns.some((pattern) => pattern.test(line))) + : []; + + const head = lines.slice(0, preserveHead); + const tail = preserveTail > 0 ? lines.slice(-preserveTail) : []; + const selected = [...head]; + for (const line of priorityLines) { + if (!selected.includes(line)) selected.push(line); + } + const tailStart = lines.length - tail.length; + tail.forEach((line, offset) => { + const originalIndex = tailStart + offset; + if (originalIndex >= preserveHead && !selected.includes(line)) selected.push(line); + }); + + const droppedLines = Math.max(0, lines.length - selected.length); + let result = [ + ...selected.slice(0, head.length), + `[rtk:truncated ${droppedLines} lines]`, + ...selected.slice(head.length), + ].join("\n"); + + if (maxChars > 0 && result.length > maxChars) { + const marker = "\n[rtk:truncated by chars]\n"; + const budget = Math.max(0, maxChars - marker.length); + if (budget === 0) { + result = marker.slice(0, maxChars); + return { text: result, truncated: true, droppedLines }; + } + const headChars = Math.ceil(budget * 0.55); + const tailChars = Math.max(0, budget - headChars); + const tailText = tailChars > 0 ? result.slice(-tailChars) : ""; + result = `${result.slice(0, headChars)}${marker}${tailText}`; + if (result.length > maxChars) result = result.slice(0, maxChars); + } + + return { text: result, truncated: true, droppedLines }; +} diff --git a/open-sse/services/compression/engines/rtk/verify.ts b/open-sse/services/compression/engines/rtk/verify.ts new file mode 100644 index 0000000000..c59a5e9102 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/verify.ts @@ -0,0 +1,107 @@ +import { estimateCompressionTokens } from "../../stats.ts"; +import { + getRtkFilterLoadDiagnostics, + loadRtkFilters, + type RtkFilterLoadDiagnostic, +} from "./filterLoader.ts"; +import { applyLineFilter } from "./lineFilter.ts"; + +export interface RtkFilterTestOutcome { + filterId: string; + testName: string; + passed: boolean; + actual: string; + expected: string; +} + +export interface RtkFilterBenchmarkRow { + category: string; + filters: number; + tests: number; + averageSavingsPercent: number; +} + +export interface RtkVerifyResult { + passed: boolean; + outcomes: RtkFilterTestOutcome[]; + filtersWithoutTests: string[]; + benchmark: RtkFilterBenchmarkRow[]; + diagnostics: RtkFilterLoadDiagnostic[]; +} + +function trimComparable(value: string): string { + return value.replace(/\n+$/g, ""); +} + +export function runRtkFilterTests( + options: { + requireAll?: boolean; + customFiltersEnabled?: boolean; + trustProjectFilters?: boolean; + } = {} +): RtkVerifyResult { + const filters = loadRtkFilters({ + refresh: true, + customFiltersEnabled: options.customFiltersEnabled, + trustProjectFilters: options.trustProjectFilters, + }); + const outcomes: RtkFilterTestOutcome[] = []; + const filtersWithoutTests: string[] = []; + const benchmarkByCategory = new Map< + string, + { filters: Set; tests: number; savingsTotal: number } + >(); + + for (const filter of filters) { + const categoryStats = benchmarkByCategory.get(filter.category) ?? { + filters: new Set(), + tests: 0, + savingsTotal: 0, + }; + categoryStats.filters.add(filter.id); + benchmarkByCategory.set(filter.category, categoryStats); + + if (filter.tests.length === 0) { + filtersWithoutTests.push(filter.id); + continue; + } + + for (const test of filter.tests) { + const result = applyLineFilter(test.input, filter).text; + const actual = trimComparable(result); + const expected = trimComparable(test.expected); + const originalTokens = estimateCompressionTokens(test.input); + const compressedTokens = estimateCompressionTokens(result); + const savings = + originalTokens > 0 ? ((originalTokens - compressedTokens) / originalTokens) * 100 : 0; + categoryStats.tests += 1; + categoryStats.savingsTotal += Math.max(0, savings); + outcomes.push({ + filterId: filter.id, + testName: test.name, + passed: actual === expected, + actual, + expected, + }); + } + } + + const benchmark = Array.from(benchmarkByCategory.entries()) + .map(([category, value]) => ({ + category, + filters: value.filters.size, + tests: value.tests, + averageSavingsPercent: + value.tests > 0 ? Math.round((value.savingsTotal / value.tests) * 100) / 100 : 0, + })) + .sort((a, b) => a.category.localeCompare(b.category)); + + const failed = outcomes.some((outcome) => !outcome.passed); + return { + passed: !failed && (!options.requireAll || filtersWithoutTests.length === 0), + outcomes, + filtersWithoutTests, + benchmark, + diagnostics: options.customFiltersEnabled === false ? [] : getRtkFilterLoadDiagnostics(), + }; +} diff --git a/open-sse/services/compression/engines/types.ts b/open-sse/services/compression/engines/types.ts new file mode 100644 index 0000000000..b270d8d1d5 --- /dev/null +++ b/open-sse/services/compression/engines/types.ts @@ -0,0 +1,59 @@ +import type { CompressionConfig, CompressionResult } from "../types.ts"; + +export type CompressionEngineTarget = "messages" | "tool_results" | "code_blocks"; + +export interface EngineConfigField { + key: string; + type: "boolean" | "number" | "string" | "select" | "multiselect"; + label: string; + i18nKey?: string; + description?: string; + defaultValue: unknown; + options?: Array<{ value: string; label: string }>; + min?: number; + max?: number; +} + +export interface EngineValidationResult { + valid: boolean; + errors: string[]; +} + +export interface CompressionEngineMetadata { + id: string; + name: string; + description: string; + inputScope: "messages" | "tool-results" | "mixed"; + targetLatencyMs: number; + supportsPreview: boolean; + stable: boolean; +} + +export interface CompressionEngineApplyOptions { + model?: string; + supportsVision?: boolean | null; + config?: CompressionConfig; + compressionComboId?: string | null; + stepConfig?: Record; +} + +export interface CompressionEngine { + id: string; + name: string; + description: string; + icon: string; + targets: CompressionEngineTarget[]; + stackable: boolean; + stackPriority: number; + metadata: CompressionEngineMetadata; + apply(body: Record, options?: CompressionEngineApplyOptions): CompressionResult; + compress(body: Record, config?: Record): CompressionResult; + getConfigSchema(): EngineConfigField[]; + validateConfig(config: Record): EngineValidationResult; +} + +export interface EngineRegistryEntry { + engine: CompressionEngine; + enabled: boolean; + config: Record; +} diff --git a/open-sse/services/compression/index.ts b/open-sse/services/compression/index.ts index 98578a1f70..7f16cd9f00 100644 --- a/open-sse/services/compression/index.ts +++ b/open-sse/services/compression/index.ts @@ -5,6 +5,14 @@ export type { CompressionResult, CavemanConfig, CavemanRule, + CavemanIntensity, + CavemanOutputModeConfig, + RtkConfig, + RtkIntensity, + RtkRawOutputRetention, + CompressionEngineId, + CompressionLanguageConfig, + CompressionPipelineStep, AggressiveConfig, AgingThresholds, ToolStrategiesConfig, @@ -15,6 +23,9 @@ export type { export { DEFAULT_COMPRESSION_CONFIG, DEFAULT_CAVEMAN_CONFIG, + DEFAULT_CAVEMAN_OUTPUT_MODE_CONFIG, + DEFAULT_RTK_CONFIG, + DEFAULT_COMPRESSION_LANGUAGE_CONFIG, DEFAULT_AGGRESSIVE_CONFIG, } from "./types.ts"; @@ -28,8 +39,36 @@ export { } from "./lite.ts"; export { cavemanCompress, applyRulesToText } from "./caveman.ts"; -export { getRulesForContext, CAVEMAN_RULES } from "./cavemanRules.ts"; -export { extractPreservedBlocks, restorePreservedBlocks } from "./preservation.ts"; +export { getRulesForContext, getCavemanRuleMetadata, CAVEMAN_RULES } from "./cavemanRules.ts"; +export { + getAvailableLanguagePacks, + listCavemanRulePacks, + loadAllRulesForLanguage, + loadCavemanFileRules, + loadRulePack, + validateRulePack, +} from "./ruleLoader.ts"; +export type { RulePackMetadata } from "./ruleLoader.ts"; +export { + detectCompressionLanguage, + listSupportedCompressionLanguages, +} from "./languageDetector.ts"; +export { + extractPreservedBlocks, + restorePreservedBlocks, + findFencedCodeBlocks, +} from "./preservation.ts"; +export type { PreservedBlock, PreservationOptions } from "./preservation.ts"; +export { validateCompression } from "./validation.ts"; +export type { ValidationResult } from "./validation.ts"; +export { + applyCavemanOutputMode, + buildCavemanOutputInstruction, + shouldBypassCavemanOutputMode, +} from "./outputMode.ts"; +export type { CavemanOutputModeResult } from "./outputMode.ts"; +export { buildCompressionDiff, buildCompressionPreviewDiff } from "./diffHelper.ts"; +export type { CompressionDiffSegment, CompressionPreviewDiff } from "./diffHelper.ts"; export { estimateCompressionTokens, @@ -44,8 +83,62 @@ export { applyCompression, checkComboOverride, shouldAutoTrigger, + applyStackedCompression, } from "./strategySelector.ts"; +export type { + CompressionEngine, + CompressionEngineApplyOptions, + CompressionEngineMetadata, + CompressionEngineTarget, + EngineConfigField, + EngineRegistryEntry, + EngineValidationResult, +} from "./engines/types.ts"; + +export { + registerEngine, + registerCompressionEngine, + unregisterCompressionEngine, + getEngine, + getEngineEntry, + getCompressionEngine, + listEngines, + listCompressionEngines, + listEnabledEngines, + setEngineEnabled, + updateEngineConfig, + clearCompressionEngineRegistry, +} from "./engines/registry.ts"; +export { registerBuiltinCompressionEngines } from "./engines/index.ts"; + +export { applyRtkCompression, processRtkText, rtkEngine } from "./engines/rtk/index.ts"; +export { + detectCommandFromText, + detectCommandOutput, + detectCommandType, + type CommandDetectionResult, +} from "./engines/rtk/commandDetector.ts"; +export { + loadRtkFilters, + getRtkFilterCatalog, + matchRtkFilter, + getRtkFilterLoadDiagnostics, +} from "./engines/rtk/filterLoader.ts"; +export { runRtkFilterTests } from "./engines/rtk/verify.ts"; +export { + maybePersistRtkRawOutput, + readRtkRawOutput, + redactRtkRawOutput, +} from "./engines/rtk/rawOutput.ts"; +export { + detectCodeLanguage, + normalizeCodeLanguage, + stripCode, + stripCodeComments, +} from "./engines/rtk/codeStripper.ts"; +export type { CodeLanguage, CodeStripperOptions } from "./engines/rtk/codeStripper.ts"; + export { RuleBasedSummarizer, createSummarizer } from "./summarizer.ts"; export { compressToolResult } from "./toolResultCompressor.ts"; diff --git a/open-sse/services/compression/languageDetector.ts b/open-sse/services/compression/languageDetector.ts new file mode 100644 index 0000000000..3b3aa9b002 --- /dev/null +++ b/open-sse/services/compression/languageDetector.ts @@ -0,0 +1,18 @@ +const LANGUAGE_HINTS: Record = { + "pt-BR": [/\b(?:voce|você|preciso|arquivo|codigo|código|erro|falha|obrigado)\b/i], + es: [/\b(?:necesito|archivo|codigo|código|error|fallo|gracias|puedes)\b/i], + de: [/\b(?:ich|datei|fehler|bitte|kannst|konfiguration|danke)\b/i], + fr: [/\b(?:fichier|erreur|merci|peux|configuration|besoin)\b/i], + ja: [/[\u3040-\u30ff]/], +}; + +export function detectCompressionLanguage(text: string): string { + for (const [language, patterns] of Object.entries(LANGUAGE_HINTS)) { + if (patterns.some((pattern) => pattern.test(text))) return language; + } + return "en"; +} + +export function listSupportedCompressionLanguages(): string[] { + return ["en", ...Object.keys(LANGUAGE_HINTS)]; +} diff --git a/open-sse/services/compression/lite.ts b/open-sse/services/compression/lite.ts index 5e530e74f5..fba65badbe 100644 --- a/open-sse/services/compression/lite.ts +++ b/open-sse/services/compression/lite.ts @@ -15,6 +15,7 @@ interface ChatBody { interface LiteCompressionOptions { model?: string; supportsVision?: boolean | null; + preserveSystemPrompt?: boolean; } function trimTrailingHorizontalWhitespace(line: string): string { @@ -62,13 +63,17 @@ function modelSupportsVision(model: string): boolean { ); } -export function collapseWhitespace(body: ChatBody): { +export function collapseWhitespace( + body: ChatBody, + options: LiteCompressionOptions = {} +): { body: ChatBody; applied: boolean; } { if (!body.messages) return { body, applied: false }; let applied = false; const messages = body.messages.map((msg) => { + if (options.preserveSystemPrompt === true && msg.role === "system") return msg; if (typeof msg.content !== "string") return msg; const normalized = normalizeMessageWhitespace(msg.content); if (normalized !== msg.content) applied = true; @@ -77,11 +82,15 @@ export function collapseWhitespace(body: ChatBody): { return { body: { ...body, messages }, applied }; } -export function dedupSystemPrompt(body: ChatBody): { +export function dedupSystemPrompt( + body: ChatBody, + options: LiteCompressionOptions = {} +): { body: ChatBody; applied: boolean; } { if (!body.messages) return { body, applied: false }; + if (options.preserveSystemPrompt === true) return { body, applied: false }; const seen = new Set(); let applied = false; const messages = body.messages.filter((msg) => { @@ -116,7 +125,10 @@ export function compressToolResults(body: ChatBody): { return { body: { ...body, messages }, applied }; } -export function removeRedundantContent(body: ChatBody): { +export function removeRedundantContent( + body: ChatBody, + options: LiteCompressionOptions = {} +): { body: ChatBody; applied: boolean; } { @@ -125,6 +137,10 @@ export function removeRedundantContent(body: ChatBody): { const messages: Message[] = []; for (let i = 0; i < body.messages.length; i++) { const msg = body.messages[i]; + if (options.preserveSystemPrompt === true && msg.role === "system") { + messages.push(msg); + continue; + } const contentStr = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content); if ( i > 0 && @@ -188,11 +204,11 @@ export function applyLiteCompression( let current = body as ChatBody; const techniquesApplied: string[] = []; - const r1 = collapseWhitespace(current); + const r1 = collapseWhitespace(current, options); current = r1.body; if (r1.applied) techniquesApplied.push("whitespace"); - const r2 = dedupSystemPrompt(current); + const r2 = dedupSystemPrompt(current, options); current = r2.body; if (r2.applied) techniquesApplied.push("system-dedup"); @@ -200,7 +216,7 @@ export function applyLiteCompression( current = r3.body; if (r3.applied) techniquesApplied.push("tool-compress"); - const r4 = removeRedundantContent(current); + const r4 = removeRedundantContent(current, options); current = r4.body; if (r4.applied) techniquesApplied.push("redundant-remove"); diff --git a/open-sse/services/compression/messageContent.ts b/open-sse/services/compression/messageContent.ts new file mode 100644 index 0000000000..7bd414d924 --- /dev/null +++ b/open-sse/services/compression/messageContent.ts @@ -0,0 +1,79 @@ +export interface TextBlock { + type?: string; + text?: string; + [key: string]: unknown; +} + +export interface ChatMessageLike { + role: string; + content?: string | TextBlock[] | unknown[]; + [key: string]: unknown; +} + +export function isTextBlock(value: unknown): value is TextBlock { + return ( + !!value && + typeof value === "object" && + "text" in value && + typeof (value as TextBlock).text === "string" && + ((value as TextBlock).type === undefined || + (value as TextBlock).type === "text" || + (value as TextBlock).type === "input_text") + ); +} + +export function extractTextContent(content: ChatMessageLike["content"]): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + + const textParts: string[] = []; + for (const part of content) { + if (isTextBlock(part) && part.text) { + textParts.push(part.text); + } + } + return textParts.join("\n"); +} + +export function mapTextContent( + msg: ChatMessageLike, + transform: (text: string, index: number) => string +): ChatMessageLike { + if (typeof msg.content === "string") { + return { ...msg, content: transform(msg.content, 0) }; + } + if (!Array.isArray(msg.content)) return msg; + + let textIndex = 0; + let changed = false; + const content = msg.content.map((part) => { + if (!isTextBlock(part)) return part; + const nextText = transform(part.text ?? "", textIndex); + textIndex++; + if (nextText === part.text) return part; + changed = true; + return { ...part, text: nextText }; + }); + + return changed ? { ...msg, content } : msg; +} + +export function replaceTextContent(msg: ChatMessageLike, newText: string): ChatMessageLike { + if (typeof msg.content === "string" || !Array.isArray(msg.content)) { + return { ...msg, content: newText }; + } + + let replaced = false; + const content = msg.content.flatMap((part) => { + if (!isTextBlock(part)) return [part]; + if (replaced) return []; + replaced = true; + return [{ ...part, text: newText }]; + }); + + if (!replaced) { + return { ...msg, content: [{ type: "text", text: newText }, ...msg.content] }; + } + + return { ...msg, content }; +} diff --git a/open-sse/services/compression/outputMode.ts b/open-sse/services/compression/outputMode.ts new file mode 100644 index 0000000000..9f4e7bfe45 --- /dev/null +++ b/open-sse/services/compression/outputMode.ts @@ -0,0 +1,161 @@ +import { DEFAULT_CAVEMAN_OUTPUT_MODE_CONFIG, type CavemanOutputModeConfig } from "./types.ts"; +import { extractTextContent } from "./messageContent.ts"; + +interface ChatMessage { + role: string; + content?: string | unknown[]; + [key: string]: unknown; +} + +interface ChatRequestBody { + messages?: ChatMessage[]; + instructions?: string; + [key: string]: unknown; +} + +export interface CavemanOutputModeResult { + body: ChatRequestBody; + applied: boolean; + skippedReason?: string; +} + +const CAVEMAN_INSTRUCTION_BY_LANGUAGE = { + en: { + lite: "Respond concise. Drop filler, pleasantries, hedging. Keep full sentences, technical terms, code, errors, URLs, and identifiers exact.", + full: "Respond terse like smart caveman. Drop articles, filler, pleasantries, hedging. Fragments OK. Keep all technical substance, code, errors, URLs, identifiers exact.", + ultra: + "Respond ultra terse. Use short technical prose, arrows for causality, common abbreviations like DB/auth/config/req/res/fn. Never abbreviate code symbols, API names, error strings, URLs, or identifiers.", + }, + "pt-BR": { + lite: "Responda conciso. Remova enrolacao, cortesias e incerteza. Preserve termos tecnicos, codigo, erros, URLs e identificadores exatamente.", + full: "Responda seco e compacto. Frases curtas OK. Preserve todo conteudo tecnico, codigo, erros, URLs e identificadores exatamente.", + ultra: + "Responda ultra compacto. Use prosa tecnica curta e abreviacoes comuns como DB/auth/config/req/res/fn. Nunca abrevie simbolos de codigo, APIs, erros, URLs ou identificadores.", + }, + es: { + lite: "Responde conciso. Quita relleno, cortesias y dudas. Conserva terminos tecnicos, codigo, errores, URLs e identificadores exactos.", + full: "Responde seco y compacto. Fragmentos OK. Conserva todo el contenido tecnico, codigo, errores, URLs e identificadores exactos.", + ultra: + "Responde ultra compacto. Usa prosa tecnica corta y abreviaturas comunes como DB/auth/config/req/res/fn. Nunca abrevies simbolos de codigo, APIs, errores, URLs o identificadores.", + }, + de: { + lite: "Antworte knapp. Entferne Fuellwoerter, Hoeflichkeit und Unsicherheit. Bewahre Fachbegriffe, Code, Fehler, URLs und Bezeichner exakt.", + full: "Antworte sehr knapp. Fragmente OK. Bewahre alle technischen Inhalte, Code, Fehler, URLs und Bezeichner exakt.", + ultra: + "Antworte ultra knapp. Nutze kurze technische Prosa und uebliche Abkuerzungen wie DB/auth/config/req/res/fn. Code-Symbole, APIs, Fehler, URLs und Bezeichner nie abkuerzen.", + }, + fr: { + lite: "Reponds concis. Retire remplissage, politesses et hesitations. Garde termes techniques, code, erreurs, URLs et identifiants exacts.", + full: "Reponds tres compact. Fragments OK. Garde tout le contenu technique, code, erreurs, URLs et identifiants exacts.", + ultra: + "Reponds ultra compact. Utilise une prose technique courte et des abreviations communes comme DB/auth/config/req/res/fn. N'abrege jamais symboles de code, APIs, erreurs, URLs ou identifiants.", + }, + ja: { + lite: "簡潔に回答。冗長表現、挨拶、曖昧表現を削る。技術用語、コード、エラー、URL、識別子は正確に保持。", + full: "短く圧縮して回答。断片文可。技術内容、コード、エラー、URL、識別子は正確に保持。", + ultra: + "超短く回答。DB/auth/config/req/res/fn など一般的な略語は可。コード記号、API名、エラー文字列、URL、識別子は省略しない。", + }, +} as const; + +const CAVEMAN_OUTPUT_MARKER = "[OmniRoute Caveman Output Mode]"; + +export function shouldBypassCavemanOutputMode(messages: ChatMessage[]): string | null { + const text = messages + .slice(-3) + .map((message) => extractTextContent(message.content).toLowerCase()) + .join("\n"); + + if (!text.trim()) return null; + if ( + /\b(security|vulnerability|exploit|credential leak|secret leak|malware|phishing)\b/.test(text) + ) { + return "security_warning"; + } + if (/\b(delete|drop table|truncate|destroy|wipe|irreversible|permanently remove)\b/.test(text)) { + return "irreversible_action"; + } + if ( + /\b(clarify|explain in detail|more detail|step by step|why exactly|what do you mean)\b/.test( + text + ) + ) { + return "clarification_requested"; + } + if ( + /\b(first|then|after that|before|rollback|backup)\b[\s\S]{0,240}\b(delete|drop|migrate|deploy|release)\b/.test( + text + ) + ) { + return "order_sensitive_sequence"; + } + return null; +} + +export function buildCavemanOutputInstruction( + config: CavemanOutputModeConfig, + language = "en" +): string { + const intensity = config.intensity ?? "full"; + const instructions = + CAVEMAN_INSTRUCTION_BY_LANGUAGE[language as keyof typeof CAVEMAN_INSTRUCTION_BY_LANGUAGE] ?? + CAVEMAN_INSTRUCTION_BY_LANGUAGE.en; + return `${CAVEMAN_OUTPUT_MARKER}\n${instructions[intensity]}`; +} + +export function applyCavemanOutputMode( + body: ChatRequestBody, + options?: Partial, + language = "en" +): CavemanOutputModeResult { + const config: CavemanOutputModeConfig = { + ...DEFAULT_CAVEMAN_OUTPUT_MODE_CONFIG, + ...options, + }; + if (!config.enabled) return { body, applied: false, skippedReason: "disabled" }; + + const messages = Array.isArray(body.messages) ? body.messages : null; + if (!messages || messages.length === 0) { + if (typeof body.instructions === "string") { + if (body.instructions.includes(CAVEMAN_OUTPUT_MARKER)) { + return { body, applied: false, skippedReason: "already_applied" }; + } + return { + body: { + ...body, + instructions: `${body.instructions.trim()}\n\n${buildCavemanOutputInstruction(config, language)}`, + }, + applied: true, + }; + } + return { body, applied: false, skippedReason: "no_messages" }; + } + + if (config.autoClarity !== false) { + const bypass = shouldBypassCavemanOutputMode(messages); + if (bypass) return { body, applied: false, skippedReason: bypass }; + } + + const alreadyApplied = messages.some( + (message) => + message.role === "system" && + typeof message.content === "string" && + message.content.includes(CAVEMAN_OUTPUT_MARKER) + ); + if (alreadyApplied) return { body, applied: false, skippedReason: "already_applied" }; + + const instruction = buildCavemanOutputInstruction(config, language); + const nextMessages = [...messages]; + const first = nextMessages[0]; + + if (first?.role === "system" && typeof first.content === "string") { + nextMessages[0] = { + ...first, + content: `${first.content.trim()}\n\n${instruction}`, + }; + } else { + nextMessages.unshift({ role: "system", content: instruction }); + } + + return { body: { ...body, messages: nextMessages }, applied: true }; +} diff --git a/open-sse/services/compression/preservation.ts b/open-sse/services/compression/preservation.ts index d65edbd84b..dcd382b92a 100644 --- a/open-sse/services/compression/preservation.ts +++ b/open-sse/services/compression/preservation.ts @@ -1,72 +1,183 @@ -interface PreservedBlock { +export interface PreservedBlock { placeholder: string; content: string; + kind: string; } -export function extractPreservedBlocks(text: string): { text: string; blocks: PreservedBlock[] } { +export interface PreservationOptions { + preservePatterns?: Array; +} + +interface CompiledPattern { + pattern: RegExp; + kind: string; +} + +const SENTINEL_PREFIX = "\u0000OMNI_CAVEMAN"; + +function randomSentinelSeed(): string { + const bytes = new Uint8Array(8); + const cryptoLike = globalThis.crypto; + if (cryptoLike?.getRandomValues) { + cryptoLike.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + } + return Math.random().toString(36).slice(2); +} + +function ensureGlobal(pattern: RegExp): RegExp { + const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; + return new RegExp(pattern.source, flags); +} + +function compileUserPatterns(patterns: Array | undefined): CompiledPattern[] { + if (!patterns?.length) return []; + const compiled: CompiledPattern[] = []; + for (const pattern of patterns) { + try { + compiled.push({ + pattern: typeof pattern === "string" ? new RegExp(pattern, "g") : ensureGlobal(pattern), + kind: "custom", + }); + } catch { + // Invalid user regexes are ignored by the hot path. Validation/preview exposes warnings. + } + } + return compiled; +} + +function replacePattern( + text: string, + pattern: RegExp, + kind: string, + addBlock: (content: string, kind: string) => string +): string { + pattern.lastIndex = 0; + return text.replace(pattern, (match) => { + if (!match) return match; + if (match.includes(SENTINEL_PREFIX)) return match; + return addBlock(match, kind); + }); +} + +export function findFencedCodeBlocks(text: string): string[] { + const blocks: string[] = []; + extractFencedCodeBlocks(text, (content) => { + blocks.push(content); + return content; + }); + return blocks; +} + +export function extractPreservedBlocks( + text: string, + options: PreservationOptions = {} +): { text: string; blocks: PreservedBlock[] } { const blocks: PreservedBlock[] = []; - let result = text; + const seed = randomSentinelSeed(); let counter = 0; - const addBlock = (content: string): string => { - const placeholder = `[PRESERVED_${counter}]`; - blocks.push({ placeholder, content }); + const addBlock = (content: string, kind: string): string => { + const placeholder = `${SENTINEL_PREFIX}_${seed}_${counter}\u0000`; + blocks.push({ placeholder, content, kind }); counter++; return placeholder; }; - // Extract fenced code blocks with a linear scan to avoid ReDoS-prone patterns. - result = extractFencedCodeBlocks(result, addBlock); + let result = text; - // Extract inline code (`...`) - result = result.replace(/`[^`\n]+`/g, (match) => addBlock(match)); + result = extractFrontmatter(result, addBlock); + result = extractFencedCodeBlocks(result, (content) => addBlock(content, "fenced_code")); - // Extract URLs - result = result.replace(/https?:\/\/[^\s)\]"'>]+/g, (match) => addBlock(match)); + const builtIns: CompiledPattern[] = [ + { pattern: /\$\$[\s\S]*?\$\$/g, kind: "math_block" }, + { pattern: /\\\[[\s\S]*?\\\]/g, kind: "math_block" }, + { pattern: /(?]+/gi, kind: "url" }, + { pattern: /\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/g, kind: "const_case" }, + { pattern: /\bprocess\.env\.[A-Za-z_][A-Za-z0-9_]*\b/g, kind: "env_var" }, + { pattern: /\$[A-Z_][A-Z0-9_]*\b/g, kind: "env_var" }, + { pattern: /\b\d+(?:\.\d+){1,3}(?:[-+][A-Za-z0-9.-]+)?\b/g, kind: "version" }, + { pattern: /\b[a-zA-Z_$][\w$]*(?:\.[a-zA-Z_$][\w$]*)+\(\)?/g, kind: "dotted_identifier" }, + { pattern: /\b[A-Za-z_$][\w$]*\s*\([^()\n]*\)/g, kind: "function_call" }, + { + pattern: /(?:^|\s)(?:\.{0,2}\/[A-Za-z0-9_@./-]+|[A-Za-z]:\\[A-Za-z0-9_.\\/-]+)/g, + kind: "file_path", + }, + { + pattern: + /\b(?:TypeError|ReferenceError|SyntaxError|RangeError|URIError|EvalError|Error|Exception):[^\n]+/g, + kind: "error_message", + }, + ]; - // Extract file paths (Unix and Windows) - result = result.replace(/(?:\/[a-zA-Z0-9_./-]+|[a-zA-Z]:\\[a-zA-Z0-9_.\\/-]+)/g, (match) => { - if (match.length < 3) return match; - if (match.startsWith("[PRESERVED_")) return match; - return addBlock(match); - }); - - // Extract error-like patterns (TypeError:, Error:, 404, etc.) - result = result.replace( - /\b(?:TypeError|ReferenceError|SyntaxError|RangeError|URIError|EvalError|Error):\s*[^\s,;)]+/g, - (match) => addBlock(match) - ); + for (const { pattern, kind } of [...builtIns, ...compileUserPatterns(options.preservePatterns)]) { + result = replacePattern(result, ensureGlobal(pattern), kind, addBlock); + } return { text: result, blocks }; } +function extractFrontmatter( + text: string, + addBlock: (content: string, kind: string) => string +): string { + if (!text.startsWith("---\n")) return text; + const close = text.indexOf("\n---", 4); + if (close === -1) return text; + const closeEnd = text.indexOf("\n", close + 4); + const end = closeEnd === -1 ? text.length : closeEnd + 1; + return `${addBlock(text.slice(0, end), "frontmatter")}${text.slice(end)}`; +} + function extractFencedCodeBlocks(text: string, addBlock: (content: string) => string): string { + const lines = text.match(/[^\n]*(?:\n|$)/g) ?? [text]; let output = ""; - let cursor = 0; + let i = 0; - while (cursor < text.length) { - const start = text.indexOf("```", cursor); - if (start === -1) { - output += text.slice(cursor); - break; + while (i < lines.length) { + const line = lines[i]; + if (line === "" && i === lines.length - 1) break; + const opening = line.match(/^([ \t]{0,3})(`{3,}|~{3,})[^\n]*(?:\n|$)/); + if (!opening) { + output += line; + i++; + continue; } - const openingLineEnd = text.indexOf("\n", start + 3); - if (openingLineEnd === -1) { - output += text.slice(cursor); - break; + const fence = opening[2]; + const fenceChar = fence[0]; + const minLen = fence.length; + let block = line; + let j = i + 1; + let closed = false; + + while (j < lines.length) { + const candidate = lines[j]; + block += candidate; + const closeMatch = candidate.match(/^([ \t]{0,3})(`{3,}|~{3,})\s*(?:\n|$)/); + if (closeMatch && closeMatch[2][0] === fenceChar && closeMatch[2].length >= minLen) { + closed = true; + break; + } + j++; } - const closeStart = text.indexOf("\n```", openingLineEnd + 1); - if (closeStart === -1) { - output += text.slice(cursor); - break; + if (!closed) { + output += line; + i++; + continue; } - const closeEnd = closeStart + 4; - output += text.slice(cursor, start); - output += addBlock(text.slice(start, closeEnd)); - cursor = closeEnd; + output += addBlock(block); + i = j + 1; } return output; @@ -75,11 +186,14 @@ function extractFencedCodeBlocks(text: string, addBlock: (content: string) => st export function restorePreservedBlocks(text: string, blocks: PreservedBlock[]): string { let result = text; for (const block of blocks) { - result = result.replace(block.placeholder, () => block.content); + result = result.split(block.placeholder).join(block.content); } return result; } export function shouldPreserve(text: string, preservePatterns: RegExp[]): boolean { - return preservePatterns.some((pattern) => pattern.test(text)); + return preservePatterns.some((pattern) => { + pattern.lastIndex = 0; + return pattern.test(text); + }); } diff --git a/open-sse/services/compression/progressiveAging.ts b/open-sse/services/compression/progressiveAging.ts index 2b742ecf9f..f9f5399a60 100644 --- a/open-sse/services/compression/progressiveAging.ts +++ b/open-sse/services/compression/progressiveAging.ts @@ -2,6 +2,7 @@ import type { AgingThresholds, Summarizer } from "./types.ts"; import { DEFAULT_AGGRESSIVE_CONFIG } from "./types.ts"; import { applyLiteCompression } from "./lite.ts"; import { cavemanCompress } from "./caveman.ts"; +import { extractTextContent, replaceTextContent, type ChatMessageLike } from "./messageContent.ts"; const COMPRESSED_MARKER_RE = /^\[COMPRESSED:/; @@ -9,43 +10,24 @@ function estimateTokens(text: string): number { return Math.ceil(text.length / 4); } -interface ChatMessage { - role: string; - content?: string | Array<{ type: string; text?: string }>; -} - -function extractText(content?: string | Array<{ type: string; text?: string }>): string { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content - .filter( - (p): p is { type: string; text?: string } => - typeof p === "object" && p !== null && "text" in p - ) - .map((p) => p.text ?? "") - .join("\n"); - } - return ""; -} +type ChatMessage = ChatMessageLike; function setContent(msg: ChatMessage, newContent: string): ChatMessage { - if (typeof msg.content === "string") { - return { ...msg, content: newContent }; - } - return { ...msg, content: [{ type: "text", text: newContent }] }; + return replaceTextContent(msg, newContent) as ChatMessage; } export function applyAging( messages: unknown[], thresholds?: AgingThresholds, - summarizer?: Summarizer + summarizer?: Summarizer, + preserveSystemPrompt = true ): { messages: unknown[]; saved: number } { const t = thresholds ?? DEFAULT_AGGRESSIVE_CONFIG.thresholds; const sum = summarizer ?? { summarize: (msgs: unknown[]) => { const typed = msgs as ChatMessage[]; const last = typed.filter((m) => m.role === "assistant").pop(); - return last ? extractText(last.content).slice(0, 200) : ""; + return last ? extractTextContent(last.content).slice(0, 200) : ""; }, }; @@ -58,9 +40,9 @@ export function applyAging( for (let i = 0; i < typed.length; i++) { const msg = typed[i]; - const text = extractText(msg.content); + const text = extractTextContent(msg.content); - if (COMPRESSED_MARKER_RE.test(text)) { + if ((preserveSystemPrompt && msg.role === "system") || COMPRESSED_MARKER_RE.test(text)) { result.push(msg); continue; } @@ -75,7 +57,7 @@ export function applyAging( const newContent = typeof compressed.body.messages[0].content === "string" ? compressed.body.messages[0].content - : extractText(compressed.body.messages[0].content); + : extractTextContent(compressed.body.messages[0].content); const tagged = `[COMPRESSED:aging:light] ${newContent}`; saved += estimateTokens(text) - estimateTokens(tagged); result.push(setContent(msg, tagged)); @@ -88,7 +70,7 @@ export function applyAging( const newContent = typeof compressed.body.messages[0].content === "string" ? compressed.body.messages[0].content - : extractText(compressed.body.messages[0].content); + : extractTextContent(compressed.body.messages[0].content); const tagged = `[COMPRESSED:aging:moderate] ${newContent}`; saved += estimateTokens(text) - estimateTokens(tagged); result.push(setContent(msg, tagged)); diff --git a/open-sse/services/compression/ruleLoader.ts b/open-sse/services/compression/ruleLoader.ts new file mode 100644 index 0000000000..6bea11b030 --- /dev/null +++ b/open-sse/services/compression/ruleLoader.ts @@ -0,0 +1,258 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { CavemanIntensity, CavemanRule } from "./types.ts"; + +type CavemanRuleCategory = NonNullable; +type CavemanRuleContext = CavemanRule["context"]; + +interface FileRule { + name: string; + pattern: string; + replacement?: string; + replacementMap?: Record; + flags?: string; + context?: CavemanRuleContext; + category?: CavemanRuleCategory; + minIntensity?: CavemanIntensity; + description?: string; +} + +interface RulePack { + language: string; + category: string; + rules: FileRule[]; +} + +export interface RulePackMetadata { + language: string; + categories: string[]; + ruleCount: number; +} + +const VALID_CONTEXTS = new Set(["all", "user", "system", "assistant"]); +const VALID_CATEGORIES = new Set(["filler", "context", "structural", "dedup", "terse", "ultra"]); +const VALID_INTENSITIES = new Set(["lite", "full", "ultra"]); +const cache = new Map(); +let rulesDirCache: string | null = null; + +function normalizeReplacementKey(value: string): string { + return value.trim().replace(/\s+/g, " ").toLowerCase(); +} + +function compileReplacement(rule: FileRule): CavemanRule["replacement"] { + if (!rule.replacementMap) return rule.replacement ?? ""; + + const normalizedMap = new Map( + Object.entries(rule.replacementMap).map(([key, value]) => [normalizeReplacementKey(key), value]) + ); + const fallback = rule.replacement; + return (match: string) => { + const normalized = normalizeReplacementKey(match); + if (normalizedMap.has(normalized)) return normalizedMap.get(normalized) ?? ""; + return fallback ?? match; + }; +} + +function getRuleFlags(rule: FileRule): string { + return rule.flags ?? "gi"; +} + +function getRulesDir(): string { + if (rulesDirCache) return rulesDirCache; + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.join(moduleDir, "rules"), + path.join(moduleDir, "..", "services", "compression", "rules"), + path.join(process.cwd(), "open-sse", "services", "compression", "rules"), + path.join(process.cwd(), "app", "open-sse", "services", "compression", "rules"), + ]; + rulesDirCache = + candidates.find((candidate, index) => { + return candidates.indexOf(candidate) === index && fs.existsSync(candidate); + }) ?? candidates[0]; + return rulesDirCache; +} + +function compileRule(rule: FileRule, source: string): CavemanRule { + try { + const flags = getRuleFlags(rule); + return { + name: rule.name, + pattern: new RegExp(rule.pattern, flags), + replacement: compileReplacement(rule), + context: rule.context ?? "all", + category: rule.category ?? "filler", + minIntensity: rule.minIntensity ?? "lite", + description: rule.description, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid Caveman rule pattern in ${source}:${rule.name}: ${message}`); + } +} + +export function validateRulePack(pack: unknown): { valid: boolean; errors: string[] } { + const errors: string[] = []; + if (!pack || typeof pack !== "object") { + return { valid: false, errors: ["Rule pack must be an object"] }; + } + + const value = pack as Partial; + if (typeof value.language !== "string" || !value.language.trim()) { + errors.push("language must be a non-empty string"); + } + if (typeof value.category !== "string" || !value.category.trim()) { + errors.push("category must be a non-empty string"); + } + if (!Array.isArray(value.rules)) { + errors.push("rules must be an array"); + } else { + value.rules.forEach((rule, index) => { + if (!rule || typeof rule !== "object") { + errors.push(`rules[${index}] must be an object`); + return; + } + const entry = rule as Partial; + const flags = typeof entry.flags === "string" ? entry.flags : "gi"; + if (typeof entry.name !== "string" || !entry.name.trim()) { + errors.push(`rules[${index}].name must be a non-empty string`); + } + if (typeof entry.pattern !== "string" || !entry.pattern.trim()) { + errors.push(`rules[${index}].pattern must be a non-empty string`); + } else { + try { + new RegExp(entry.pattern, flags); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.push(`rules[${index}].pattern is invalid: ${message}`); + } + } + if (entry.flags !== undefined && typeof entry.flags !== "string") { + errors.push(`rules[${index}].flags must be a string`); + } + if (entry.replacement !== undefined && typeof entry.replacement !== "string") { + errors.push(`rules[${index}].replacement must be a string`); + } + if (entry.replacementMap !== undefined) { + if ( + !entry.replacementMap || + typeof entry.replacementMap !== "object" || + Array.isArray(entry.replacementMap) + ) { + errors.push(`rules[${index}].replacementMap must be an object`); + } else { + Object.entries(entry.replacementMap).forEach(([key, replacement]) => { + if (!key.trim()) { + errors.push(`rules[${index}].replacementMap contains an empty key`); + } + if (typeof replacement !== "string") { + errors.push(`rules[${index}].replacementMap.${key} must be a string`); + } + }); + } + } + if (typeof entry.replacement !== "string" && entry.replacementMap === undefined) { + errors.push(`rules[${index}] must define replacement or replacementMap`); + } + if (entry.context !== undefined && !VALID_CONTEXTS.has(entry.context)) { + errors.push(`rules[${index}].context is invalid`); + } + if (entry.category !== undefined && !VALID_CATEGORIES.has(entry.category)) { + errors.push(`rules[${index}].category is invalid`); + } + if (entry.minIntensity !== undefined && !VALID_INTENSITIES.has(entry.minIntensity)) { + errors.push(`rules[${index}].minIntensity is invalid`); + } + }); + } + return { valid: errors.length === 0, errors }; +} + +function readPack(language: string, category: string): RulePack | null { + const filename = path.join(getRulesDir(), language, `${category}.json`); + if (!fs.existsSync(filename)) return null; + const parsed = JSON.parse(fs.readFileSync(filename, "utf8")) as unknown; + const validation = validateRulePack(parsed); + if (!validation.valid) { + throw new Error( + `Invalid Caveman rule pack ${language}/${category}: ${validation.errors.join("; ")}` + ); + } + return parsed as RulePack; +} + +export function loadRulePack( + language: string, + category: string, + options: { refresh?: boolean } = {} +): CavemanRule[] { + const key = `${getRulesDir()}:${language}:${category}`; + if (cache.has(key) && !options.refresh) return cache.get(key) ?? []; + + const pack = readPack(language, category); + if (!pack) { + cache.set(key, []); + return []; + } + + const rules = pack.rules.map((rule) => compileRule(rule, `${language}/${category}`)); + cache.set(key, rules); + return rules; +} + +export function loadAllRulesForLanguage( + language: string, + options: { refresh?: boolean } = {} +): CavemanRule[] { + const key = `${getRulesDir()}:${language}:*`; + if (cache.has(key) && !options.refresh) return cache.get(key) ?? []; + + const languageDir = path.join(getRulesDir(), language); + if (!fs.existsSync(languageDir)) { + cache.set(key, []); + return []; + } + + const rules = fs + .readdirSync(languageDir) + .filter((entry) => entry.endsWith(".json")) + .sort() + .flatMap((entry) => loadRulePack(language, path.basename(entry, ".json"), options)); + + cache.set(key, rules); + return rules; +} + +export function getAvailableLanguagePacks(): RulePackMetadata[] { + const root = getRulesDir(); + if (!fs.existsSync(root)) return []; + + return fs + .readdirSync(root) + .filter((entry) => fs.statSync(path.join(root, entry)).isDirectory()) + .map((language) => { + const categories = fs + .readdirSync(path.join(root, language)) + .filter((entry) => entry.endsWith(".json")) + .map((entry) => path.basename(entry, ".json")) + .sort(); + const ruleCount = categories.reduce( + (count, category) => count + loadRulePack(language, category).length, + 0 + ); + return { language, categories, ruleCount }; + }) + .sort((a, b) => a.language.localeCompare(b.language)); +} + +export function loadCavemanFileRules( + language: string, + options: { refresh?: boolean } = {} +): CavemanRule[] { + return loadAllRulesForLanguage(language, options); +} + +export function listCavemanRulePacks(): RulePackMetadata[] { + return getAvailableLanguagePacks(); +} diff --git a/open-sse/services/compression/rules/_schema.json b/open-sse/services/compression/rules/_schema.json new file mode 100644 index 0000000000..9ec4fc4724 --- /dev/null +++ b/open-sse/services/compression/rules/_schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OmniRoute Caveman Rule Pack", + "type": "object", + "required": ["language", "category", "rules"], + "properties": { + "language": { "type": "string" }, + "category": { "type": "string" }, + "rules": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "pattern"], + "properties": { + "name": { "type": "string" }, + "pattern": { "type": "string" }, + "flags": { "type": "string" }, + "replacement": { "type": "string" }, + "replacementMap": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "context": { "enum": ["all", "user", "system", "assistant"] }, + "category": { "enum": ["filler", "context", "structural", "dedup", "terse", "ultra"] }, + "minIntensity": { "enum": ["lite", "full", "ultra"] }, + "description": { "type": "string" } + } + } + } + } +} diff --git a/open-sse/services/compression/rules/de/context.json b/open-sse/services/compression/rules/de/context.json new file mode 100644 index 0000000000..64173166bf --- /dev/null +++ b/open-sse/services/compression/rules/de/context.json @@ -0,0 +1,38 @@ +{ + "language": "de", + "category": "context", + "rules": [ + { + "name": "de_context_setup", + "pattern": "\\b(?:hier ist der code|unten ist der code|dies ist der code)\\b\\s*[:.]?\\s*", + "replacement": "Code:", + "context": "user", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "de_intent", + "pattern": "\\b(?:mein ziel ist|was ich brauche ist|ich versuche|die idee ist)\\b\\s*", + "replacement": "Ziel:", + "context": "user", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "de_question_directive", + "pattern": "\\b(?:kannst du erklaeren warum|kannst du erklären warum|kannst du zeigen wie)\\b\\s*", + "replacement": "Erklaere ", + "context": "user", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "de_background", + "pattern": "\\b(?:wie du weisst|wie du weißt|wie bereits erwaehnt|wie bereits erwähnt)\\b[,.]?\\s*", + "replacement": "", + "context": "all", + "category": "context", + "minIntensity": "lite" + } + ] +} diff --git a/open-sse/services/compression/rules/de/filler.json b/open-sse/services/compression/rules/de/filler.json new file mode 100644 index 0000000000..f4f2e6eb62 --- /dev/null +++ b/open-sse/services/compression/rules/de/filler.json @@ -0,0 +1,70 @@ +{ + "language": "de", + "category": "filler", + "rules": [ + { + "name": "de_polite_framing", + "pattern": "\\b(?:bitte|kannst du|koenntest du|könntest du|ich moechte|ich möchte|ich brauche)\\b\\s*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "de_pleasantries", + "pattern": "\\b(?:hallo|guten morgen|guten tag|danke|vielen dank)\\b[,.!?\\s]*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "de_hedging", + "pattern": "\\b(?:ich denke dass|ich glaube dass|es scheint dass|vielleicht|wahrscheinlich|moeglicherweise|möglicherweise)\\b\\s*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "de_filler_adverbs", + "pattern": "\\b(?:grundsaetzlich|grundsätzlich|eigentlich|buchstaeblich|buchstäblich|einfach)\\b\\s*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "de_self_reference", + "pattern": "^(?:ich versuche|ich will|ich moechte|ich möchte|ich brauche)\\b\\s*", + "replacement": "", + "context": "user", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "de_verbose_request", + "pattern": "\\b(?:kannst du erklaeren|kannst du erklären|koenntest du erklaeren|könntest du erklären)\\b\\s*", + "replacement": "Erklaere ", + "context": "user", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "de_qualifiers", + "pattern": "\\b(?:ein bisschen|etwas|irgendwie|mehr oder weniger)\\b\\s*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + }, + { + "name": "de_softeners", + "pattern": "\\b(?:wenn moeglich|wenn möglich|wenn du zeit hast|ohne eile)\\b[,.!?\\s]*", + "replacement": "", + "context": "all", + "category": "filler", + "minIntensity": "lite" + } + ] +} diff --git a/open-sse/services/compression/rules/de/structural.json b/open-sse/services/compression/rules/de/structural.json new file mode 100644 index 0000000000..7107c977b3 --- /dev/null +++ b/open-sse/services/compression/rules/de/structural.json @@ -0,0 +1,30 @@ +{ + "language": "de", + "category": "structural", + "rules": [ + { + "name": "de_purpose", + "pattern": "\\b(?:um zu|mit dem ziel|damit ich)\\b\\s*", + "replacement": "um ", + "context": "all", + "category": "structural", + "minIntensity": "lite" + }, + { + "name": "de_connectors", + "pattern": "\\b(?:ausserdem|außerdem|zusaetzlich|zusätzlich|darueber hinaus|darüber hinaus)\\b\\s*", + "replacement": "auch ", + "context": "all", + "category": "structural", + "minIntensity": "lite" + }, + { + "name": "de_emphasis", + "pattern": "\\b(?:sehr|wirklich|extrem|ziemlich)\\s+(?=[a-zäöüß])", + "replacement": "", + "context": "all", + "category": "structural", + "minIntensity": "lite" + } + ] +} diff --git a/open-sse/services/compression/rules/en/context.json b/open-sse/services/compression/rules/en/context.json new file mode 100644 index 0000000000..70ef148408 --- /dev/null +++ b/open-sse/services/compression/rules/en/context.json @@ -0,0 +1,88 @@ +{ + "language": "en", + "category": "context", + "rules": [ + { + "name": "compound_collapse", + "pattern": "\\band any potential\\b", + "replacement": "", + "context": "all", + "category": "context", + "minIntensity": "full" + }, + { + "name": "explanatory_prefix", + "pattern": "\\b(?:The function appears to be handling|The code seems to|The class is|This module is)\\b", + "replacement": "Function:", + "replacementMap": { + "the function appears to be handling": "Function:", + "the code seems to": "Code:", + "the class is": "Class:", + "this module is": "Module:" + }, + "context": "all", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "question_to_directive", + "pattern": "\\b(?:Can you explain why|Could you show me how|Would you tell me|Can you tell me)\\b\\s*", + "replacement": "Explain why ", + "replacementMap": { + "can you explain why": "Explain why ", + "could you show me how": "Show how ", + "would you tell me": "Tell me ", + "can you tell me": "Tell me " + }, + "context": "user", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "context_setup", + "pattern": "\\b(?:I have the following code|Here is my code|Below is the code)\\b\\s*[:.]?\\s*", + "replacement": "Code:", + "context": "user", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "intent_clarification", + "pattern": "\\b(?:What I'm trying to do is|My objective is to|What I need is|I'm aiming to)\\b\\s*", + "replacement": "Goal:", + "context": "user", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "background_removal", + "pattern": "\\b(?:As you may know,?\\s*|As we discussed earlier,?\\s*)", + "replacement": "", + "context": "all", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "meta_commentary", + "pattern": "^(?:Note that|Keep in mind that|Remember that)\\b\\s*", + "replacement": "", + "context": "all", + "category": "context", + "minIntensity": "lite" + }, + { + "name": "purpose_statement", + "pattern": "\\b(?:for the purpose of|with the goal of|in an effort to|for every)\\b", + "replacement": "for", + "replacementMap": { + "for the purpose of": "for", + "with the goal of": "to", + "in an effort to": "to", + "for every": "per" + }, + "context": "all", + "category": "context", + "minIntensity": "lite" + } + ] +} diff --git a/open-sse/services/compression/rules/en/dedup.json b/open-sse/services/compression/rules/en/dedup.json new file mode 100644 index 0000000000..23f0820bfe --- /dev/null +++ b/open-sse/services/compression/rules/en/dedup.json @@ -0,0 +1,38 @@ +{ + "language": "en", + "category": "dedup", + "rules": [ + { + "name": "repeated_context", + "pattern": "\\b(?:As we discussed earlier|As mentioned before|As previously stated|As I said before)\\b[,.]?\\s*", + "replacement": "See above. ", + "context": "all", + "category": "dedup", + "minIntensity": "lite" + }, + { + "name": "repeated_question", + "pattern": "\\b(?:Same question as before|I asked this earlier|This is the same question)\\b[,.]?\\s*", + "replacement": "[same question] ", + "context": "user", + "category": "dedup", + "minIntensity": "lite" + }, + { + "name": "reestablished_context", + "pattern": "\\b(?:Going back to the code above|Referring back to|Returning to)\\b\\s*", + "replacement": "Re: ", + "context": "all", + "category": "dedup", + "minIntensity": "lite" + }, + { + "name": "summary_replacement", + "pattern": "\\b(?:To summarize what we've discussed|In summary of our conversation|To recap)\\b[,.]?\\s*", + "replacement": "Summary: ", + "context": "assistant", + "category": "dedup", + "minIntensity": "lite" + } + ] +} diff --git a/open-sse/services/compression/rules/en/filler.json b/open-sse/services/compression/rules/en/filler.json new file mode 100644 index 0000000000..88e4a6978a --- /dev/null +++ b/open-sse/services/compression/rules/en/filler.json @@ -0,0 +1,129 @@ +{ + "language": "en", + "category": "filler", + "rules": [ + { + "name": "pleasantries", + "pattern": "(?[0], - options?.config?.cavemanConfig - ); + const cavemanConfig = { + ...(options?.config?.cavemanConfig ?? {}), + ...(options?.config?.languageConfig?.enabled + ? { + language: options.config.languageConfig.defaultLanguage, + autoDetectLanguage: options.config.languageConfig.autoDetect, + enabledLanguagePacks: options.config.languageConfig.enabledPacks, + } + : {}), + ...(options?.config?.preserveSystemPrompt !== false + ? { + compressRoles: (options?.config?.cavemanConfig?.compressRoles ?? ["user"]).filter( + (role) => role !== "system" + ), + } + : {}), + }; + return cavemanCompress(body as Parameters[0], cavemanConfig); } if (mode === "aggressive") { const messages = (body.messages ?? []) as Array<{ @@ -82,7 +116,10 @@ export function applyCompression( if (!Array.isArray(messages) || messages.length === 0) { return { body, compressed: false, stats: null }; } - const aggressiveConfig = options?.config?.aggressive; + const aggressiveConfig = { + ...(options?.config?.aggressive ?? {}), + preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false, + }; const result = compressAggressive(messages, aggressiveConfig); const compressedBody = { ...body, messages: result.messages }; return { @@ -107,8 +144,11 @@ export function applyCompression( if (!Array.isArray(messages) || messages.length === 0) { return { body, compressed: false, stats: null }; } - const ultraConfig = options?.config?.ultra; - const result = ultraCompress(messages, ultraConfig ?? {}); + const ultraConfig = { + ...(options?.config?.ultra ?? {}), + preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false, + }; + const result = ultraCompress(messages, ultraConfig); const compressedBody = { ...body, messages: result.messages }; return { body: compressedBody, @@ -125,3 +165,114 @@ export function applyCompression( } return { body, compressed: false, stats: null }; } + +function normalizePipelineStep(step: CompressionPipelineStep | string): CompressionPipelineStep { + if (typeof step !== "string") return step; + if (step === "standard") return { engine: "caveman" }; + if (step === "rtk") return { engine: "rtk" }; + if (step === "lite" || step === "aggressive" || step === "ultra") return { engine: step }; + return { engine: "caveman" }; +} + +export function applyStackedCompression( + body: Record, + pipeline?: Array, + options?: { + model?: string; + supportsVision?: boolean | null; + config?: CompressionConfig; + compressionComboId?: string | null; + } +): CompressionResult { + const steps = + pipeline && pipeline.length > 0 + ? pipeline.map(normalizePipelineStep) + : [ + { engine: "rtk" as const, intensity: "standard" as const }, + { engine: "caveman" as const, intensity: "full" as const }, + ]; + registerBuiltinCompressionEngines(); + + let currentBody = body; + let compressed = false; + const techniques = new Set(); + const rules = new Set(); + const breakdown: NonNullable = []; + const rtkRawOutputPointers: NonNullable = []; + const validationWarnings = new Set(); + const validationErrors = new Set(); + let fallbackApplied = false; + const start = performance.now(); + + for (const step of steps) { + const engine = getCompressionEngine(step.engine); + if (!engine) continue; + const result = engine.apply(currentBody, { + ...options, + compressionComboId: options?.compressionComboId ?? options?.config?.compressionComboId, + stepConfig: { + ...(step.config ?? {}), + ...(step.intensity ? { intensity: step.intensity } : {}), + }, + }); + if (result.stats) { + result.stats.techniquesUsed.forEach((technique) => techniques.add(technique)); + result.stats.rulesApplied?.forEach((rule) => rules.add(rule)); + result.stats.rtkRawOutputPointers?.forEach((pointer) => { + rtkRawOutputPointers.push(pointer); + }); + result.stats.validationWarnings?.forEach((warning) => validationWarnings.add(warning)); + result.stats.validationErrors?.forEach((error) => validationErrors.add(error)); + fallbackApplied = fallbackApplied || result.stats.fallbackApplied === true; + breakdown.push({ + engine: step.engine, + originalTokens: result.stats.originalTokens, + compressedTokens: result.stats.compressedTokens, + savingsPercent: result.stats.savingsPercent, + techniquesUsed: result.stats.techniquesUsed, + ...(result.stats.rulesApplied ? { rulesApplied: result.stats.rulesApplied } : {}), + ...(result.stats.durationMs !== undefined ? { durationMs: result.stats.durationMs } : {}), + }); + } + if (result.compressed) { + currentBody = result.body; + compressed = true; + } + } + + const stats = createCompressionStats( + body, + currentBody, + "stacked", + Array.from(techniques), + rules.size > 0 ? Array.from(rules) : undefined, + Math.round((performance.now() - start) * 100) / 100 + ); + stats.engine = "stacked"; + stats.compressionComboId = + options?.compressionComboId ?? options?.config?.compressionComboId ?? null; + stats.engineBreakdown = breakdown; + if (validationWarnings.size > 0) { + stats.validationWarnings = Array.from(validationWarnings); + } + if (validationErrors.size > 0) { + stats.validationErrors = Array.from(validationErrors); + } + if (fallbackApplied) { + stats.fallbackApplied = true; + } + if (rtkRawOutputPointers.length > 0) { + const seenPointers = new Set(); + stats.rtkRawOutputPointers = rtkRawOutputPointers.filter((pointer) => { + if (seenPointers.has(pointer.id)) return false; + seenPointers.add(pointer.id); + return true; + }); + } + + return { + body: currentBody, + compressed, + stats, + }; +} diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index f019f62bc6..abe842fb35 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -1,14 +1,26 @@ /** - * Compression Pipeline Types — Phase 1 (Lite) + Phase 2 (Standard/Caveman) + Phase 3 (Aggressive) + Phase 4 (Ultra) + * Compression Pipeline Types — Lite, Caveman, Aggressive, Ultra, RTK, and Stacked modes. * * Shared type definitions for the compression pipeline. * Phase 1: 'off' and 'lite' modes. * Phase 2: 'standard' mode (caveman engine). * Phase 3: 'aggressive' mode (summarization + tool compression + aging). * Phase 4: 'ultra' mode (heuristic token pruning + optional SLM tier). + * Phase 5: 'rtk' and 'stacked' modes (tool-output filters + multi-engine pipeline). */ -export type CompressionMode = "off" | "lite" | "standard" | "aggressive" | "ultra"; +export type CompressionMode = + | "off" + | "lite" + | "standard" + | "aggressive" + | "ultra" + | "rtk" + | "stacked"; +export type CavemanIntensity = "lite" | "full" | "ultra"; +export type RtkIntensity = "minimal" | "standard" | "aggressive"; +export type RtkRawOutputRetention = "never" | "failures" | "always"; +export type CompressionEngineId = "lite" | "caveman" | "aggressive" | "ultra" | "rtk"; export interface CavemanRule { name: string; @@ -16,6 +28,9 @@ export interface CavemanRule { replacement: string | ((match: string, ...groups: string[]) => string); context: "all" | "user" | "system" | "assistant"; preservePatterns?: RegExp[]; + category?: "filler" | "context" | "structural" | "dedup" | "terse" | "ultra"; + description?: string; + minIntensity?: CavemanIntensity; } export interface CavemanConfig { @@ -24,16 +39,63 @@ export interface CavemanConfig { skipRules: string[]; minMessageLength: number; preservePatterns: string[]; + intensity: CavemanIntensity; + language?: string; + autoDetectLanguage?: boolean; + enabledLanguagePacks?: string[]; +} + +export interface CavemanOutputModeConfig { + enabled: boolean; + intensity: CavemanIntensity; + autoClarity: boolean; +} + +export interface RtkConfig { + enabled: boolean; + intensity: RtkIntensity; + applyToToolResults: boolean; + applyToCodeBlocks: boolean; + applyToAssistantMessages: boolean; + enabledFilters: string[]; + disabledFilters: string[]; + maxLinesPerResult: number; + maxCharsPerResult: number; + deduplicateThreshold: number; + customFiltersEnabled: boolean; + trustProjectFilters: boolean; + rawOutputRetention: RtkRawOutputRetention; + rawOutputMaxBytes: number; +} + +export interface CompressionLanguageConfig { + enabled: boolean; + defaultLanguage: string; + autoDetect: boolean; + enabledPacks: string[]; +} + +export interface CompressionPipelineStep { + engine: CompressionEngineId; + intensity?: CavemanIntensity | RtkIntensity; + config?: Record; } export interface CompressionConfig { enabled: boolean; defaultMode: CompressionMode; + autoTriggerMode?: CompressionMode; autoTriggerTokens: number; cacheMinutes: number; preserveSystemPrompt: boolean; + mcpDescriptionCompressionEnabled?: boolean; comboOverrides: Record; + compressionComboId?: string | null; + stackedPipeline?: CompressionPipelineStep[]; cavemanConfig?: CavemanConfig; + cavemanOutputMode?: CavemanOutputModeConfig; + rtkConfig?: RtkConfig; + languageConfig?: CompressionLanguageConfig; aggressive?: AggressiveConfig; ultra?: UltraConfig; } @@ -44,14 +106,42 @@ export interface CompressionStats { savingsPercent: number; techniquesUsed: string[]; mode: CompressionMode; + engine?: string; + compressionComboId?: string | null; timestamp: number; rulesApplied?: string[]; durationMs?: number; + validationWarnings?: string[]; + validationErrors?: string[]; + fallbackApplied?: boolean; + preservedBlockCount?: number; + rtkRawOutputPointers?: Array<{ + id: string; + path: string; + bytes: number; + sha256: string; + redacted: boolean; + }>; + realUsage?: { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + source: "provider" | "estimated" | "stream"; + }; aggressive?: { summarizerSavings: number; toolResultSavings: number; agingSavings: number; }; + engineBreakdown?: Array<{ + engine: string; + originalTokens: number; + compressedTokens: number; + savingsPercent: number; + techniquesUsed: string[]; + rulesApplied?: string[]; + durationMs?: number; + }>; } export interface CompressionResult { @@ -63,10 +153,17 @@ export interface CompressionResult { export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = { enabled: false, defaultMode: "off", + autoTriggerMode: "lite", autoTriggerTokens: 0, cacheMinutes: 5, preserveSystemPrompt: true, + mcpDescriptionCompressionEnabled: true, comboOverrides: {}, + compressionComboId: null, + stackedPipeline: [ + { engine: "rtk", intensity: "standard" }, + { engine: "caveman", intensity: "full" }, + ], }; export const DEFAULT_CAVEMAN_CONFIG: CavemanConfig = { @@ -75,6 +172,37 @@ export const DEFAULT_CAVEMAN_CONFIG: CavemanConfig = { skipRules: [], minMessageLength: 50, preservePatterns: [], + intensity: "full", +}; + +export const DEFAULT_CAVEMAN_OUTPUT_MODE_CONFIG: CavemanOutputModeConfig = { + enabled: false, + intensity: "full", + autoClarity: true, +}; + +export const DEFAULT_RTK_CONFIG: RtkConfig = { + enabled: true, + intensity: "standard", + applyToToolResults: true, + applyToCodeBlocks: false, + applyToAssistantMessages: false, + enabledFilters: [], + disabledFilters: [], + maxLinesPerResult: 120, + maxCharsPerResult: 12000, + deduplicateThreshold: 3, + customFiltersEnabled: true, + trustProjectFilters: false, + rawOutputRetention: "never", + rawOutputMaxBytes: 1_048_576, +}; + +export const DEFAULT_COMPRESSION_LANGUAGE_CONFIG: CompressionLanguageConfig = { + enabled: false, + defaultLanguage: "en", + autoDetect: true, + enabledPacks: ["en"], }; /** Aging thresholds for progressive message degradation (Phase 3) */ @@ -101,6 +229,7 @@ export interface AggressiveConfig { summarizerEnabled: boolean; maxTokensPerMessage: number; minSavingsThreshold: number; + preserveSystemPrompt?: boolean; } /** Options for the Summarizer interface (Phase 3) */ @@ -159,6 +288,7 @@ export interface UltraConfig { * 0 = always apply when mode is "ultra". */ maxTokensPerMessage: number; + preserveSystemPrompt?: boolean; } export const DEFAULT_ULTRA_CONFIG: UltraConfig = { diff --git a/open-sse/services/compression/ultra.ts b/open-sse/services/compression/ultra.ts index 0c1dd8d219..1fc3cc0fc2 100644 --- a/open-sse/services/compression/ultra.ts +++ b/open-sse/services/compression/ultra.ts @@ -1,6 +1,7 @@ import { pruneByScore } from "./ultraHeuristic.ts"; import { DEFAULT_ULTRA_CONFIG } from "./types.ts"; import type { UltraConfig, CompressionStats, CompressionMode } from "./types.ts"; +import { extractTextContent, mapTextContent, type ChatMessageLike } from "./messageContent.ts"; const COMPRESSED_PREFIX = "[COMPRESSED:"; @@ -21,26 +22,7 @@ export interface UltraCompressResult { stats: CompressionStats; } -type Message = { role: string; content?: string | unknown[]; [key: string]: unknown }; - -function extractText(content: string | unknown[] | undefined): string { - if (!content) return ""; - if (typeof content === "string") return content; - return (content as Array<{ type?: string; text?: string }>) - .filter((b) => b.type === "text" && b.text) - .map((b) => b.text as string) - .join("\n"); -} - -function applyTextToContent( - original: string | unknown[] | undefined, - compressed: string -): string | unknown[] { - if (!original || typeof original === "string") return compressed; - return (original as Array<{ type?: string; text?: string; [k: string]: unknown }>).map((b) => - b.type === "text" ? { ...b, text: compressed } : b - ); -} +type Message = ChatMessageLike; export function ultraCompress( messages: Message[], @@ -57,21 +39,26 @@ export function ultraCompress( let compressedChars = 0; const compressed = messages.map((msg) => { - const text = extractText(msg.content); + if (effectiveConfig.preserveSystemPrompt !== false && msg.role === "system") return msg; + const text = extractTextContent(msg.content); if (!text) return msg; if (text.startsWith(COMPRESSED_PREFIX)) return msg; if (maxTokensPerMessage > 0 && Math.ceil(text.length / 4) <= maxTokensPerMessage) { return msg; } - originalChars += text.length; - const pruned = pruneByScore(text, compressionRate, minScoreThreshold); - compressedChars += pruned.length; - - return { - ...msg, - content: applyTextToContent(msg.content, pruned), - }; + let messageOriginalChars = 0; + let messageCompressedChars = 0; + const next = mapTextContent(msg, (textPart) => { + if (!textPart || textPart.startsWith(COMPRESSED_PREFIX)) return textPart; + messageOriginalChars += textPart.length; + const pruned = pruneByScore(textPart, compressionRate, minScoreThreshold); + messageCompressedChars += pruned.length; + return pruned; + }) as Message; + originalChars += messageOriginalChars; + compressedChars += messageCompressedChars; + return next; }); const originalTokens = Math.ceil(originalChars / 4); diff --git a/open-sse/services/compression/validation.ts b/open-sse/services/compression/validation.ts new file mode 100644 index 0000000000..7f2ae31115 --- /dev/null +++ b/open-sse/services/compression/validation.ts @@ -0,0 +1,132 @@ +import { findFencedCodeBlocks } from "./preservation.ts"; + +export interface ValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; + fallbackApplied: boolean; +} + +function collectMatches(text: string, pattern: RegExp): string[] { + const matches: string[] = []; + pattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + if (match[0]) matches.push(match[0]); + if (match.index === pattern.lastIndex) pattern.lastIndex++; + } + return matches; +} + +function requireExactPresence( + label: string, + originalItems: string[], + compressed: string, + errors: string[] +) { + for (const item of originalItems) { + if (!compressed.includes(item)) { + const preview = item.replace(/\s+/g, " ").slice(0, 80); + errors.push(`${label} changed or missing: ${preview}`); + } + } +} + +export function validateCompression(original: string, compressed: string): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (typeof original !== "string" || typeof compressed !== "string") { + return { + valid: false, + errors: ["validation received non-string input"], + warnings, + fallbackApplied: true, + }; + } + + if (original.length > 0 && compressed.trim().length === 0) { + errors.push("compressed text is empty"); + } + + requireExactPresence("fenced code block", findFencedCodeBlocks(original), compressed, errors); + requireExactPresence("inline code", collectMatches(original, /`[^`\n]+`/g), compressed, errors); + requireExactPresence( + "URL", + collectMatches(original, /\bhttps?:\/\/[^\s)\]"'>]+/gi), + compressed, + errors + ); + requireExactPresence( + "markdown link", + collectMatches(original, /\[[^\]\n]{1,1000}\]\([^)[ \t\n]{1,2000}(?:[ \t]+"[^"]{0,1000}")?\)/g), + compressed, + errors + ); + requireExactPresence("frontmatter", collectFrontmatter(original), compressed, errors); + requireExactPresence("heading", collectMatches(original, /^#{1,6}\s+.+$/gm), compressed, errors); + requireExactPresence( + "table row", + collectMatches(original, /^[ \t]*\|(?:[^|\n]{0,1000}\|){1,100}[ \t]*$/gm), + compressed, + errors + ); + requireExactPresence( + "math block", + collectMatches(original, /\$\$[\s\S]{0,10000}?\$\$/g), + compressed, + errors + ); + requireExactPresence( + "inline math", + collectMatches(original, /(? ${compressedFenceCount}` + ); + } + + if (compressed.length > original.length) { + warnings.push("compressed text is longer than original"); + } + + return { + valid: errors.length === 0, + errors, + warnings, + fallbackApplied: errors.length > 0, + }; +} + +function collectFrontmatter(text: string): string[] { + if (!text.startsWith("---\n")) return []; + const close = text.indexOf("\n---", 4); + if (close === -1) return []; + const closeEnd = text.indexOf("\n", close + 4); + const end = closeEnd === -1 ? text.length : closeEnd + 1; + return [text.slice(0, end)]; +} diff --git a/open-sse/services/responsesInputSanitizer.ts b/open-sse/services/responsesInputSanitizer.ts new file mode 100644 index 0000000000..31a540feb8 --- /dev/null +++ b/open-sse/services/responsesInputSanitizer.ts @@ -0,0 +1,36 @@ +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +function isResponsesMessageItem(record: JsonRecord): boolean { + return record.type === "message" || (!record.type && typeof record.role === "string"); +} + +export function isInternalAssistantMessage(record: JsonRecord): boolean { + if (!isResponsesMessageItem(record)) return false; + if (record.role !== "assistant") return false; + + const phase = typeof record.phase === "string" ? record.phase.trim().toLowerCase() : ""; + if (!phase) return false; + + // OpenCode can send assistant-side commentary/analysis frames in Responses + // shape. Those frames are local runtime state, not durable conversation turns. + return phase !== "final"; +} + +export function sanitizeResponsesInputItems(items: readonly unknown[], clone = true): unknown[] { + const sanitized: unknown[] = []; + + for (const item of items) { + const record = toRecord(item); + if (record && isInternalAssistantMessage(record)) { + continue; + } + + sanitized.push(clone ? structuredClone(item) : item); + } + + return sanitized; +} diff --git a/open-sse/services/responsesToolCallState.ts b/open-sse/services/responsesToolCallState.ts index 5160f02ec2..9ded2ecefe 100644 --- a/open-sse/services/responsesToolCallState.ts +++ b/open-sse/services/responsesToolCallState.ts @@ -1,3 +1,5 @@ +import { sanitizeResponsesInputItems } from "./responsesInputSanitizer.ts"; + type JsonRecord = Record; type RememberedFunctionCall = { @@ -28,36 +30,8 @@ function toRecord(value: unknown): JsonRecord | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; } -function isResponsesMessageItem(record: JsonRecord): boolean { - return record.type === "message" || (!record.type && typeof record.role === "string"); -} - -function isInternalAssistantMessage(record: JsonRecord): boolean { - if (!isResponsesMessageItem(record)) return false; - if (record.role !== "assistant") return false; - - const phase = typeof record.phase === "string" ? record.phase.trim().toLowerCase() : ""; - if (!phase) return false; - - // OpenCode can send assistant-side commentary/analysis frames in Responses - // shape. Those frames are local runtime state, not durable conversation turns - // for Codex replay. Re-injecting them makes the model continue hidden notes. - return phase !== "final"; -} - function sanitizeRememberedConversationItems(items: readonly unknown[]): unknown[] { - const sanitized: unknown[] = []; - - for (const item of items) { - const record = toRecord(item); - if (record && isInternalAssistantMessage(record)) { - continue; - } - - sanitized.push(structuredClone(item)); - } - - return sanitized; + return sanitizeResponsesInputItems(items); } function cleanupRememberedResponseToolCalls(now: number = Date.now()) { diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 0b9edc7bfc..5001730875 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -13,6 +13,11 @@ const CACHE_SECRET = "omniroute-token-cache"; // Key: "provider:sha256(refreshToken)" → Value: Promise const refreshPromiseCache = new Map(); +// Per-connection mutex: prevents parallel OAuth refresh for rotating tokens. +// Key: connectionId → Value: { promise, waiters } +// Primary dedup when credentials.connectionId is present; refreshPromiseCache is fallback. +const connectionRefreshMutex = new Map(); + type RefreshLogger = { info?: (tag: string, message: string, data?: Record) => void; warn?: (tag: string, message: string, data?: Record) => void; @@ -415,8 +420,8 @@ export async function refreshQwenToken(refreshToken, log, proxyConfig: unknown = /** * Specialized refresh for Codex (OpenAI) OAuth tokens. * OpenAI uses rotating (one-time-use) refresh tokens. - * Returns { error: 'refresh_token_reused' } when the token has already been consumed, - * so callers can stop retrying and request re-authentication. + * Returns { error: 'unrecoverable_refresh_error', code } when the token has already been + * consumed or is invalid, so callers can stop retrying and request re-authentication. */ export async function refreshCodexToken(refreshToken, log, proxyConfig: unknown = null) { try { @@ -831,9 +836,18 @@ export function isUnrecoverableRefreshError(result) { /** * Get access token for a specific provider (with deduplication). - * If a refresh is already in-flight for the same provider+token, - * subsequent calls share the existing promise instead of making - * parallel OAuth requests. + * + * Deduplication strategy (two layers): + * 1. Per-connection mutex (primary): if credentials.connectionId is present, all concurrent + * callers for that connection share one in-flight promise regardless of which token they + * loaded. This prevents refresh_token_reused errors with rotating (one-time-use) tokens, + * e.g. Codex/OpenAI, where callers that loaded credentials at different times may hold + * different token strings but refer to the same connection. + * 2. Token-hash fallback: if no connectionId, dedup by provider+sha256(refreshToken) as before. + * + * Additionally, when connectionId is present, the stale-token check reads the DB to detect + * whether another process already refreshed the token. If the DB token is still valid it is + * returned immediately without a new upstream call. */ export async function getAccessToken(provider, credentials, log, proxyConfig: unknown = null) { if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") { @@ -841,6 +855,57 @@ export async function getAccessToken(provider, credentials, log, proxyConfig: un return null; } + const connectionId = credentials.connectionId; + + // ── Layer 1: per-connection mutex ────────────────────────────────────────── + if (connectionId && typeof connectionId === "string") { + const existing = connectionRefreshMutex.get(connectionId); + if (existing) { + existing.waiters++; + log?.info?.("TOKEN_REFRESH", "Concurrent refresh detected — sharing in-flight refresh", { + provider, + connectionId, + waiters: existing.waiters, + }); + return existing.promise; + } + + const entry = { promise: null, waiters: 0 }; + entry.promise = _getAccessTokenWithStalenessCheck( + provider, + credentials, + log, + proxyConfig + ).finally(() => { + connectionRefreshMutex.delete(connectionId); + }); + connectionRefreshMutex.set(connectionId, entry); + return entry.promise; + } + + // ── Layer 2: token-hash fallback (no connectionId) ───────────────────────── + const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken); + + if (refreshPromiseCache.has(cacheKey)) { + log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`); + return refreshPromiseCache.get(cacheKey); + } + + const refreshPromise = _getAccessTokenInternal(provider, credentials, log, proxyConfig).finally( + () => { + refreshPromiseCache.delete(cacheKey); + } + ); + + refreshPromiseCache.set(cacheKey, refreshPromise); + return refreshPromise; +} + +/** + * Internal helper: performs the DB staleness check then calls the actual refresh. + * Only called from the per-connection mutex path (Layer 1 above). + */ +async function _getAccessTokenWithStalenessCheck(provider, credentials, log, proxyConfig) { // RACE CONDITION PREVENTION: // If the credentials object in memory is stale (e.g. it waited in a semaphore while another // request refreshed the token), using its OLD refreshToken will cause the provider (e.g. OpenAI) @@ -886,23 +951,7 @@ export async function getAccessToken(provider, credentials, log, proxyConfig: un } } - const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken); - - // If a refresh is already in-flight, reuse it - if (refreshPromiseCache.has(cacheKey)) { - log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`); - return refreshPromiseCache.get(cacheKey); - } - - // Start a new refresh and cache the promise - const refreshPromise = _getAccessTokenInternal(provider, credentials, log, proxyConfig).finally( - () => { - refreshPromiseCache.delete(cacheKey); - } - ); - - refreshPromiseCache.set(cacheKey, refreshPromise); - return refreshPromise; + return _getAccessTokenInternal(provider, credentials, log, proxyConfig); } /** @@ -1036,6 +1085,18 @@ export function isProviderBlocked(provider: string): boolean { return false; } +/** + * Get active per-connection mutex entries (for diagnostics/metrics). + * Returns a snapshot of connections that have an in-flight refresh and their waiter count. + */ +export function getConnectionRefreshMutexStatus(): Record { + const result: Record = {}; + for (const [connectionId, entry] of connectionRefreshMutex.entries()) { + result[connectionId] = { waiters: entry.waiters }; + } + return result; +} + /** * Get circuit breaker status for all providers (for diagnostics). */ diff --git a/open-sse/translator/request/claude-to-openai.ts b/open-sse/translator/request/claude-to-openai.ts index 8f890fd31c..fdf6687512 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -5,6 +5,21 @@ import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts"; type JsonRecord = Record; const TOOL_CHOICE_ANY = ["a", "n", "y"].join(""); +/** + * Normalize tool input schema for OpenAI compatibility. + * OpenAI strict mode requires `properties: {}` on object-type schemas, + * even for zero-argument tools. Anthropic/MCP tools may omit it (#1898). + */ +function normalizeToolSchema(schema: unknown): Record { + const fallback = { type: "object", properties: {} }; + if (!schema || typeof schema !== "object" || Array.isArray(schema)) return fallback; + const s = schema as Record; + if (s.type === "object" && !s.properties) { + return { ...s, properties: {} }; + } + return s; +} + function normalizeOpenAIReasoningEffort(effort: unknown): string | undefined { if (typeof effort !== "string") return undefined; const normalized = effort.toLowerCase(); @@ -86,7 +101,7 @@ export function claudeToOpenAIRequest(model, body, stream) { function: { name, description: typeof tool.description === "string" ? tool.description : "", // fix: never null (#276) - parameters: tool.input_schema || { type: "object", properties: {} }, + parameters: normalizeToolSchema(tool.input_schema), }, }; }) diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 77289dfb71..07ff889385 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -827,6 +827,9 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { if (eventType === "response.reasoning_summary_text.delta") { const reasoningDelta = data.delta || ""; if (!reasoningDelta) return null; + const reasoningDeltaShape = state.copilotCompatibleReasoning + ? { reasoning_text: reasoningDelta } + : { reasoning: { summary: reasoningDelta } }; return { id: state.chatId, object: "chat.completion.chunk", @@ -835,7 +838,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { choices: [ { index: 0, - delta: { reasoning: { summary: reasoningDelta } }, + delta: reasoningDeltaShape, finish_reason: null, }, ], diff --git a/open-sse/utils/aiSdkCompat.ts b/open-sse/utils/aiSdkCompat.ts index 91d284bfc1..58ff08b738 100644 --- a/open-sse/utils/aiSdkCompat.ts +++ b/open-sse/utils/aiSdkCompat.ts @@ -22,7 +22,9 @@ export function resolveStreamFlag(bodyStream: unknown, acceptHeader: unknown): b // Explicit body value always wins if (bodyStream === true) return true; if (bodyStream === false) return false; - // No explicit stream param — fall back to Accept header heuristic + + // No explicit stream param — preserve OmniRoute's streaming default unless + // the client explicitly asks for JSON and does not also accept SSE. return !clientWantsJsonResponse(acceptHeader); } diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 111fd1095d..b75acecf51 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -134,6 +134,7 @@ type StreamOptions = { targetFormat?: string; sourceFormat?: string; clientResponseFormat?: string | null; + copilotCompatibleReasoning?: boolean; provider?: string | null; reqLogger?: StreamLogger | null; toolNameMap?: unknown; @@ -150,6 +151,7 @@ type TranslateState = ReturnType & { toolNameMap?: unknown; usage?: unknown; finishReason?: unknown; + copilotCompatibleReasoning?: boolean; /** Accumulated message content for call log response body */ accumulatedContent?: string; upstreamError?: { @@ -531,6 +533,7 @@ export function createSSEStream(options: StreamOptions = {}) { targetFormat, sourceFormat, clientResponseFormat = null, + copilotCompatibleReasoning = false, provider = null, reqLogger = null, toolNameMap = null, @@ -563,6 +566,7 @@ export function createSSEStream(options: StreamOptions = {}) { ...(initState(sourceFormat) as TranslateState), provider, toolNameMap, + copilotCompatibleReasoning, accumulatedContent: "", } : null; @@ -1904,7 +1908,8 @@ export function createSSETransformStreamWithLogger( body: unknown = null, onComplete: ((payload: StreamCompletePayload) => void) | null = null, apiKeyInfo: unknown = null, - onFailure: ((payload: StreamFailurePayload) => void | Promise) | null = null + onFailure: ((payload: StreamFailurePayload) => void | Promise) | null = null, + copilotCompatibleReasoning = false ) { return createSSEStream({ mode: STREAM_MODE.TRANSLATE, @@ -1919,6 +1924,7 @@ export function createSSETransformStreamWithLogger( body, onComplete, onFailure, + copilotCompatibleReasoning, }); } diff --git a/open-sse/utils/streamHelpers.ts b/open-sse/utils/streamHelpers.ts index 7221cd1035..2146f8cccd 100644 --- a/open-sse/utils/streamHelpers.ts +++ b/open-sse/utils/streamHelpers.ts @@ -45,6 +45,7 @@ export function hasValuableContent(chunk, format) { if (typeof delta.content === "string" && delta.content.length > 0) return true; if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) return true; + if (typeof delta.reasoning_text === "string" && delta.reasoning_text.length > 0) return true; if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) return true; if (chunk.choices[0].finish_reason) return true; if (typeof delta.role === "string" && delta.role.length > 0) return true; diff --git a/package-lock.json b/package-lock.json index 0fc3bcf712..8753eccd38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.7.8", + "version": "3.7.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.7.8", + "version": "3.7.9", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -79,6 +79,7 @@ "husky": "^9.1.7", "jsdom": "^29.0.1", "lint-staged": "^16.2.7", + "node-loader": "^2.1.0", "prettier": "^3.8.1", "tailwindcss": "^4", "typescript": "^6.0.2", @@ -5569,6 +5570,16 @@ "require-from-string": "^2.0.2" } }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -6773,6 +6784,16 @@ "dev": true, "license": "MIT" }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -10080,6 +10101,21 @@ "dev": true, "license": "MIT" }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -11260,6 +11296,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-loader": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/node-loader/-/node-loader-2.1.0.tgz", + "integrity": "sha512-OwjPkyh8+7jW8DMd/iq71uU1Sspufr/C2+c3t0p08J3CrM9ApZ4U53xuisNrDXOHyGi5OYHgtfmmh+aK9zJA6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.3" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, "node_modules/node-machine-id": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/node-machine-id/-/node-machine-id-1.1.12.tgz", @@ -15206,7 +15262,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.7.8" + "version": "3.7.9" } } } diff --git a/package.json b/package.json index a971888c4a..12d4fc61f0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omniroute", - "version": "3.7.8", - "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", + "version": "3.7.9", + "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { "omniroute": "bin/omniroute.mjs", @@ -174,6 +174,7 @@ "husky": "^9.1.7", "jsdom": "^29.0.1", "lint-staged": "^16.2.7", + "node-loader": "^2.1.0", "prettier": "^3.8.1", "tailwindcss": "^4", "typescript": "^6.0.2", diff --git a/pr_metadata.json b/pr_metadata.json new file mode 100644 index 0000000000..37ad0c69a5 --- /dev/null +++ b/pr_metadata.json @@ -0,0 +1,2102 @@ +[ + { + "additions": 149, + "author": { + "id": "MDQ6VXNlcjM1NzM4Ng==", + "is_bot": false, + "login": "andrewmunsell", + "name": "Andrew Munsell" + }, + "baseRefName": "main", + "body": "## Summary\r\n\r\n- Add tokens_compressed INTEGER column to call_logs (migration 041)\r\n- Capture compression savings from both legacy compressContext() and modular applyCompression() pipelines in chatCore.ts\r\n- Display inline ↓N purple badge in log list view tokens cell\r\n- Show \"Compressed: FROM → TO (-X%)\" badge in detail modal Input section\r\n- Bump CallLogArtifact schemaVersion from 4 to 5\r\n\r\n\"CleanShot\r\n\"CleanShot\r\n\r\n## Tests Added Or Updated\r\n\r\n- Add compression tokens unit tests\r\n", + "createdAt": "2026-05-04T03:48:16Z", + "deletions": 7, + "files": [ + { + "path": "open-sse/handlers/chatCore.ts", + "additions": 7, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "src/i18n/messages/en.json", + "additions": 1, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "src/lib/db/core.ts", + "additions": 4, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "src/lib/db/migrationRunner.ts", + "additions": 2, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "src/lib/db/migrations/041_compression_tokens.sql", + "additions": 4, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/lib/usage/callLogArtifacts.ts", + "additions": 2, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "src/lib/usage/callLogs.ts", + "additions": 8, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "src/lib/usage/migrations.ts", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "src/shared/components/RequestLoggerDetail.tsx", + "additions": 10, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "src/shared/components/RequestLoggerV2.tsx", + "additions": 11, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "tests/unit/call-log-cap.test.ts", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "tests/unit/compression-tokens.test.ts", + "additions": 98, + "deletions": 0, + "changeType": "ADDED" + } + ], + "headRefName": "emdash/indicate-compression-in-logs-use9w", + "number": 1923, + "title": "feat(logs): show compression tokens in request log UI" + }, + { + "additions": 1698, + "author": { + "id": "MDQ6VXNlcjE0OTIxOTgz", + "is_bot": false, + "login": "oyi77", + "name": "Paijo" + }, + "baseRefName": "release/v3.7.9", + "body": "Adds automated model assessment, categorization, and self-healing system for omniroute combos.\n\n## The Problem\nWhen providers fail (rate limits, auth errors, model deprecation), combos silently degrade. Our production experience: 2+ hours manually testing models and updating 44 combos because most returned errors.\n\n## The Solution\nThree new domain modules:\n1. **Assessor** - Probes every provider/model pair (quick/standard/deep tiers)\n2. **Categorizer** - Classifies models by capability with fitness scores\n3. **SelfHealer** - Auto-fixes combos: removes dead models, reduces weights for rate-limited, emergency-adds working alternatives\n\n## New Files\n- src/domain/assessment/assessor.ts\n- src/domain/assessment/categorizer.ts\n- src/domain/assessment/selfHealer.ts\n- src/domain/assessment/types.ts\n- src/domain/assessment/migration.ts\n- src/domain/assessment/index.ts\n- src/app/api/assess/route.ts\n- docs/RFC-AUTO-ASSESSMENT.md (full RFC)\n\n## New DB Tables\n- model_assessments, assessment_runs, combo_health, heal_actions\n\n## API\nPOST /api/assess/models - Run assessment\nGET /api/assess/models?action=working - Get working models\nGET /api/assess/models?action=combo-health - Get combo health\n\n> [!NOTE]\n> **OmniRoute Maintainer Note:** This PR has been deferred and will be accepted and redirected for the `v4.0.0` (or greater) release cycle. Please skip this PR during `v3.x` stabilization reviews.", + "createdAt": "2026-05-03T20:53:04Z", + "deletions": 0, + "files": [ + { + "path": "docs/RFC-AUTO-ASSESSMENT.md", + "additions": 518, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/app/api/assess/route.ts", + "additions": 110, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/domain/assessment/assessor.ts", + "additions": 206, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/domain/assessment/categorizer.ts", + "additions": 118, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/domain/assessment/index.ts", + "additions": 24, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/domain/assessment/migration.ts", + "additions": 109, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/domain/assessment/selfHealer.ts", + "additions": 261, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/domain/assessment/types.ts", + "additions": 352, + "deletions": 0, + "changeType": "ADDED" + } + ], + "headRefName": "feat/auto-assessment-engine", + "number": 1918, + "title": "feat: Auto-Assessment and Self-Healing Combo Engine" + }, + { + "additions": 22666, + "author": { + "id": "MDQ6VXNlcjgwMTY4NDE=", + "is_bot": false, + "login": "diegosouzapw", + "name": "Diego Rodrigues de Sa e Souza" + }, + "baseRefName": "main", + "body": "### ✨ New Features\n\n- **feat(compression):** major upgrade to Caveman and RTK compression pipelines (#1876, #1889):\n - Add RTK tool-output compression, stacked Caveman + RTK pipelines, compression combo assignments, dashboard context pages, MCP management tools, and language-aware Caveman rule packs.\n - Expand RTK parity with a 39-filter catalog, RTK-style JSON DSL stages, inline verify/benchmark coverage, trust-gated custom filters, expanded command detection, and redacted raw-output recovery.\n - Expose rule intensities, track USD savings, unify config validation, and persist MCP savings.\n - Expand Caveman parity and MCP metadata compression.\n- **feat(provider):** update Jina AI model catalog to support Embeddings and Rerank natively (#1874 — thanks @backryun)\n- **feat(provider):** add NanoGPT image generation provider (#1899 — thanks @Aculeasis)\n- **feat(ui):** move proxy configuration to dedicated System → Proxy page (#1907 — thanks @oyi77)\n- **feat(ui):** add K/M/B/T cost shortener utility (#1902 — thanks @oyi77)\n- **feat(providers):** implement bulk paste for extra API keys (#1916 — thanks @0xtbug)\n- **feat(analytics):** usage history API key backfill + dark mode pricing (#1896 — thanks @Gi99lin)\n\n### 🐛 Bug Fixes\n\n- **fix(providers):** allow local OpenAI-compatible endpoints (like Ollama) to be added without an API key (fixes #1893)\n- **fix(copilot):** emit compatible reasoning text deltas (#1919 — thanks @ivan-mezentsev)\n- **fix(api-manager):** show validation errors inline in modals, not behind (#1920 — thanks @andrewmunsell)\n- **fix(compression):** align seeded standard savings combo with stacked default, preserve stacked defaults, and secure metadata routes.\n- **fix(gemini-cli):** separate Cloud Code transport from Antigravity (#1869 — thanks @dhaern)\n- **fix(codex):** map prompt field to input array for Cursor compatibility (fixes #1872)\n- **fix(core):** align stream parameter default to false per strict OpenAI spec (fixes #1873)\n- **fix(ui):** restore Next.js CSP `unsafe-eval` in production `script-src` to fix unresponsive Onboarding button (fixes #1883)\n- **fix(proxy):** globally strip `prompt_cache_retention` in `BaseExecutor` to prevent upstream 400 errors from strict endpoints like droid/gemini-2-pro (fixes #1884)\n- **fix(ui):** include `isOpen` dependency in `EditConnectionModal` state sync to ensure `maxConcurrent` is properly hydrated when reopening the modal (fixes #1859)\n- **fix(security):** remediate 4 polynomial-redos CodeQL alerts in compression regexes by bounding repetitions and removing overlapping quantifiers\n- **fix(codex):** flatten Chat Completions tool format to Codex Responses format in `normalizeCodexTools` — prevents `Missing required parameter: tools[0].name` upstream errors (#1914 — thanks @tranduykhanh030)\n- **fix(proxy):** add proxy-aware execution context to image generation route — proxy settings are now correctly applied for image providers behind restricted networks (#1904 — thanks @Aculeasis)\n- **fix(translator):** inject `properties: {}` into zero-argument MCP tool schemas during Anthropic→OpenAI translation — prevents 400 errors from OpenAI strict schema validation (#1898 — thanks @bryceIT)\n- **fix(codex):** sanitize raw responses input (#1895 — thanks @dhaern)\n- **fix(combos):** align strategy contracts (#1892 — thanks @dhaern)\n- **fix(combos):** fix combo provider breaker profile handling (#1891 — thanks @rdself)\n- **fix(migrations):** duplicate-column no-op fix (#1886 — thanks @smartenok-ops)\n- **fix(auth):** per-connection OAuth refresh mutex (#1885 — thanks @smartenok-ops)\n- **fix(auth):** require dashboard management auth for compression preview\n\n### 📝 Documentation\n\n- **docs(compression):** document RTK+Caveman stacked savings ranges\n\n### 🏆 Release Attribution & Retroactive Credits\n\n- **@payne0420** (PR #1828 / #1839) — Implementation of the **Rate Limit Watchdog** and environment overrides. (This feature was manually backported to v3.7.8, causing the automatic GitHub Release notes to omit the author's credit).\n", + "createdAt": "2026-05-03T20:35:53Z", + "deletions": 1470, + "files": [ + { + "path": ".agents/workflows/deploy-vps-akamai.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".agents/workflows/deploy-vps-both.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".agents/workflows/deploy-vps-local.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".agents/workflows/generate-release.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".agents/workflows/version-bump.md", + "additions": 9, + "deletions": 9, + "changeType": "MODIFIED" + }, + { + "path": "AGENTS.md", + "additions": 25, + "deletions": 9, + "changeType": "MODIFIED" + }, + { + "path": "CHANGELOG.md", + "additions": 47, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "README.md", + "additions": 71, + "deletions": 29, + "changeType": "MODIFIED" + }, + { + "path": "docs/API_REFERENCE.md", + "additions": 32, + "deletions": 13, + "changeType": "MODIFIED" + }, + { + "path": "docs/ARCHITECTURE.md", + "additions": 16, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/COMPRESSION_ENGINES.md", + "additions": 143, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "docs/COMPRESSION_GUIDE.md", + "additions": 105, + "deletions": 12, + "changeType": "MODIFIED" + }, + { + "path": "docs/COMPRESSION_LANGUAGE_PACKS.md", + "additions": 96, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "docs/COMPRESSION_RULES_FORMAT.md", + "additions": 186, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "docs/ENVIRONMENT.md", + "additions": 6, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "docs/FEATURES.md", + "additions": 16, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/MCP-SERVER.md", + "additions": 55, + "deletions": 19, + "changeType": "MODIFIED" + }, + { + "path": "docs/RTK_COMPRESSION.md", + "additions": 234, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "docs/SETUP_GUIDE.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/openapi.yaml", + "additions": 199, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "electron/package-lock.json", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "electron/package.json", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "llm.txt", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "next.config.mjs", + "additions": 8, + "deletions": 95, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/config/embeddingRegistry.ts", + "additions": 62, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/config/imageRegistry.ts", + "additions": 14, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/config/rerankRegistry.ts", + "additions": 44, + "deletions": 8, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/executors/base.ts", + "additions": 3, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/executors/codex.ts", + "additions": 75, + "deletions": 5, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/handlers/chatCore.ts", + "additions": 338, + "deletions": 68, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/handlers/imageGeneration.ts", + "additions": 1, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/handlers/responseSanitizer.ts", + "additions": 3, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/mcp-server/README.md", + "additions": 17, + "deletions": 9, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/mcp-server/descriptionCompressor.ts", + "additions": 243, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/mcp-server/schemas/tools.ts", + "additions": 121, + "deletions": 32, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/mcp-server/server.ts", + "additions": 41, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/mcp-server/tools/advancedTools.ts", + "additions": 15, + "deletions": 16, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/mcp-server/tools/compressionTools.ts", + "additions": 157, + "deletions": 11, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/package.json", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/AGENTS.md", + "additions": 5, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/accountFallback.ts", + "additions": 21, + "deletions": 5, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/autoCombo/index.ts", + "additions": 1, + "deletions": 10, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/combo.ts", + "additions": 56, + "deletions": 7, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/compression/aggressive.ts", + "additions": 19, + "deletions": 29, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/compression/caveman.ts", + "additions": 310, + "deletions": 31, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/compression/cavemanRules.ts", + "additions": 189, + "deletions": 15, + "changeType": "MODIFIED" + }, + { + "path": "open-sse/services/compression/diffHelper.ts", + "additions": 117, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/cavemanAdapter.ts", + "additions": 408, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/index.ts", + "additions": 14, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/registry.ts", + "additions": 87, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/codeStripper.ts", + "additions": 128, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/commandDetector.ts", + "additions": 465, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/deduplicator.ts", + "additions": 37, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filterLoader.ts", + "additions": 220, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filterSchema.ts", + "additions": 182, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/aws.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/biome.json", + "additions": 35, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/build-eslint.json", + "additions": 37, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/build-typescript.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/build-vite.json", + "additions": 41, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/build-webpack.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/bundle-install.json", + "additions": 35, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/curl.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/df.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/docker-logs.json", + "additions": 42, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/docker-ps.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/du.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/error-stacktrace.json", + "additions": 41, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/gcloud.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/generic-output.json", + "additions": 32, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/git-branch.json", + "additions": 45, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/git-diff.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/git-log.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/git-status.json", + "additions": 40, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/golangci-lint.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/json-output.json", + "additions": 40, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/make.json", + "additions": 38, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/mypy.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/npm-audit.json", + "additions": 43, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/npm-install.json", + "additions": 41, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/nx.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/pip.json", + "additions": 41, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/playwright.json", + "additions": 42, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/poetry-install.json", + "additions": 35, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/prettier.json", + "additions": 40, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/ps.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/rsync.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/rubocop.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/ruff.json", + "additions": 40, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/shell-find.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/shell-grep.json", + "additions": 37, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/shell-ls.json", + "additions": 33, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/ssh.json", + "additions": 43, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/systemctl-status.json", + "additions": 43, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/terraform-plan.json", + "additions": 34, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/test-cargo.json", + "additions": 42, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/test-go.json", + "additions": 41, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/test-jest.json", + "additions": 43, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/test-pytest.json", + "additions": 41, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "open-sse/services/compression/engines/rtk/filters/test-vitest.json", + "additions": 43, + "deletions": 0, + "changeType": "ADDED" + } + ], + "headRefName": "release/v3.7.9", + "number": 1917, + "title": "Release v3.7.9" + }, + { + "additions": 788, + "author": { + "id": "MDQ6VXNlcjI0MTk4NDIy", + "is_bot": false, + "login": "backryun", + "name": "backryun" + }, + "baseRefName": "release/v3.7.9", + "body": "## Summary\r\n\r\ncompletely removing support for Node.js 20.\r\nNode.js 20 has reached EOL.\r\n\"image\"\r\n\r\n## Validation\r\n\r\n- [x] `npm run lint`\r\n- [x] `npm run test:unit`\r\n- [ ] `npm run test:coverage`\r\n- [ ] Coverage is still `>= 60%` for statements, lines, functions, and branches\r\n\r\n## Tests Added Or Updated\r\n\r\nUpdated automated test files:\r\n\r\n- `tests/unit/node-runtime-support.test.ts`\r\n- `tests/unit/chatcore-sanitization.test.ts`\r\n- `tests/unit/codex-stream-false.test.ts`\r\n- `tests/unit/t26-ai-sdk-accept-header-compat.test.ts`\r\n- `tests/integration/chat-pipeline.test.ts`\r\n- `tests/e2e/protocol-clients.test.ts`\r\n\r\nProduction code changed: Node runtime support logic was updated in `src/shared/utils/nodeRuntimeSupport.ts` and `bin/nodeRuntimeSupport.mjs`.\r\n\r\n## Coverage Notes\r\n\r\nThis PR changes `src/` and `bin/`.\r\n\r\nCoverage for the runtime support change is primarily provided by:\r\n\r\n- `tests/unit/node-runtime-support.test.ts`\r\n- `npm run check:node-runtime`\r\n\r\nAdditional regression coverage was run through:\r\n\r\n- `npm run test:unit`\r\n- `npm run test:integration`\r\n- `npm run test:e2e`\r\n- `npm run test:protocols:e2e`\r\n\r\n`npm run test:coverage` was not run, so coverage movement for touched files has not been measured yet. Follow-up before merge: run `npm run test:coverage` and confirm statements, lines, functions, and branches remain `>= 60%`.\r\n\r\n## Reviewer Notes\r\n\r\n- Node.js 20 support is removed from runtime policy, package engines, CI, docs, and runtime warning copy.\r\n- Supported runtime policy is now Node.js `>=22.22.2 <23 || >=24.0.0 <25`.\r\n- CI matrix and local version files now target Node 22/24 support.\r\n- Docs and i18n runtime wording were updated broadly; reviewers should expect many documentation-only diffs.\r\n- `tests/e2e/protocol-clients.test.ts` now explicitly enables A2A before validating the A2A flow, matching the current default where A2A starts disabled.\r\n- Manual validation was performed on Node `v22.22.2`.\n\n> [!NOTE]\n> **OmniRoute Maintainer Note:** This PR has been deferred and will be accepted and redirected for the `v4.0.0` (or greater) release cycle. Please skip this PR during `v3.x` stabilization reviews.", + "createdAt": "2026-05-03T14:11:25Z", + "deletions": 776, + "files": [ + { + "path": ".dockerignore", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".env.example", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".github/workflows/ci.yml", + "additions": 8, + "deletions": 5, + "changeType": "MODIFIED" + }, + { + "path": ".npmignore", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "AGENTS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "CLAUDE.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "Dockerfile", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "README.md", + "additions": 10, + "deletions": 10, + "changeType": "MODIFIED" + }, + { + "path": "bin/nodeRuntimeSupport.mjs", + "additions": 3, + "deletions": 5, + "changeType": "MODIFIED" + }, + { + "path": "compose.prod.yaml", + "additions": 3, + "deletions": 3, + "changeType": "RENAMED" + }, + { + "path": "compose.yaml", + "additions": 0, + "deletions": 0, + "changeType": "RENAMED" + }, + { + "path": "docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bg/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bn/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bn/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bn/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bn/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bn/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bn/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/bn/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/da/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/de/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/es/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fa/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fa/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fa/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fa/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fa/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fa/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fa/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fi/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/docs/RELEASE_CHECKLIST.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/docs/TROUBLESHOOTING.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/fr/llm.txt", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/gu/CONTRIBUTING.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/gu/README.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/gu/docs/CLI-TOOLS.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/gu/docs/ENVIRONMENT.md", + "additions": 3, + "deletions": 3, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/gu/docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + } + ], + "headRefName": "release/v3.7.9", + "number": 1915, + "title": "[will be merge 4.0 update] (Security Fix): Drop node.js 20 support + Update docker files to lastest docker standards" + }, + { + "additions": 1119, + "author": { + "id": "U_kgDODPQT-Q", + "is_bot": false, + "login": "vrdons", + "name": "" + }, + "baseRefName": "release/v3.7.9", + "body": "## Summary\r\n\r\nTo allow using on bun interface, faster typescript checks\r\n\r\n## Related Issues\r\nNone\r\n\r\n## Validation\r\n\r\n- [x] `npm run lint`\r\n- [ ] `npm run test:unit`\r\nNo, because last tests stucks\r\n- [x] `npm run test:coverage`\r\n- [ ] Coverage is still `>= 60%` for statements, lines, functions, and branches\r\n- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below\r\n\r\n## Tests Added Or Updated\r\n\r\n- List every changed or added automated test file.\r\n- If no production code changed, state that here.\r\n\r\n## Coverage Notes\r\n\r\n- If this PR changes `src/`, `open-sse/`, `electron/`, or `bin/`, explain which tests cover the change.\r\n- If coverage moved down in any touched file, explain why and what follow-up task will recover it.\r\n\r\n## Reviewer Notes\r\n\r\n- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.\r\n\n> [!NOTE]\n> **OmniRoute Maintainer Note:** This PR has been deferred and will be accepted and redirected for the `v4.0.0` (or greater) release cycle. Please skip this PR during `v3.x` stabilization reviews.", + "createdAt": "2026-05-02T17:57:46Z", + "deletions": 231022, + "files": [ + { + "path": ".agents/workflows/deploy-vps-akamai.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".agents/workflows/deploy-vps-both.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": ".agents/workflows/deploy-vps-local.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": ".agents/workflows/generate-release.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "AGENTS.md", + "additions": 4, + "deletions": 4, + "changeType": "MODIFIED" + }, + { + "path": "CLAUDE.md", + "additions": 1, + "deletions": 218, + "changeType": "MODIFIED" + }, + { + "path": "GEMINI.md", + "additions": 1, + "deletions": 21, + "changeType": "MODIFIED" + }, + { + "path": "README.md", + "additions": 63, + "deletions": 36, + "changeType": "MODIFIED" + }, + { + "path": "docs/API_REFERENCE.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/ARCHITECTURE.md", + "additions": 2, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "docs/CODEBASE_DOCUMENTATION.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/ENVIRONMENT.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/FEATURES.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/I18N.md", + "additions": 82, + "deletions": 52, + "changeType": "MODIFIED" + }, + { + "path": "docs/SETUP_GUIDE.md", + "additions": 9, + "deletions": 8, + "changeType": "MODIFIED" + }, + { + "path": "docs/TERMUX_GUIDE.md", + "additions": 2, + "deletions": 10, + "changeType": "MODIFIED" + }, + { + "path": "docs/TROUBLESHOOTING.md", + "additions": 17, + "deletions": 41, + "changeType": "MODIFIED" + }, + { + "path": "docs/UNINSTALL.md", + "additions": 12, + "deletions": 12, + "changeType": "MODIFIED" + }, + { + "path": "docs/USER_GUIDE.md", + "additions": 2, + "deletions": 10, + "changeType": "MODIFIED" + }, + { + "path": "docs/VM_DEPLOYMENT_GUIDE.md", + "additions": 1, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/ar/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/ar/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/ar/README.md", + "additions": 2, + "deletions": 13, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/ar/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/bg/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/bg/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/bg/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/bg/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/bn/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/bn/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/bn/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/cs/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/cs/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/cs/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/cs/README.md", + "additions": 0, + "deletions": 2379, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/A2A-SERVER.md", + "additions": 0, + "deletions": 200, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/API_REFERENCE.md", + "additions": 0, + "deletions": 469, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/ARCHITECTURE.md", + "additions": 0, + "deletions": 891, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/AUTO-COMBO.md", + "additions": 0, + "deletions": 67, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/CLI-TOOLS.md", + "additions": 0, + "deletions": 398, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/CODEBASE_DOCUMENTATION.md", + "additions": 0, + "deletions": 591, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/COVERAGE_PLAN.md", + "additions": 0, + "deletions": 170, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/ENVIRONMENT.md", + "additions": 0, + "deletions": 669, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/FEATURES.md", + "additions": 0, + "deletions": 270, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/FLY_IO_DEPLOYMENT_GUIDE.md", + "additions": 0, + "deletions": 455, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/I18N.md", + "additions": 0, + "deletions": 441, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/MCP-SERVER.md", + "additions": 0, + "deletions": 87, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/RELEASE_CHECKLIST.md", + "additions": 0, + "deletions": 44, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/TROUBLESHOOTING.md", + "additions": 0, + "deletions": 341, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/UNINSTALL.md", + "additions": 0, + "deletions": 157, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/USER_GUIDE.md", + "additions": 0, + "deletions": 966, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/VM_DEPLOYMENT_GUIDE.md", + "additions": 0, + "deletions": 407, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/cloudflare-zero-trust-guide.md", + "additions": 0, + "deletions": 106, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/docs/features/context-relay.md", + "additions": 0, + "deletions": 130, + "changeType": "MODIFIED" + }, + { + "path": "docs/i18n/cs/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/da/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/da/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/da/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/da/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/de/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/de/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/de/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/de/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/es/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/es/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/es/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/es/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fa/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fa/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fa/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fi/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fi/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fi/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fi/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fr/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fr/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fr/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/fr/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/gu/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/gu/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/gu/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/he/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/he/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/he/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/he/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hi/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hi/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hi/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hi/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hu/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hu/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hu/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/hu/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/id/CHANGELOG.md", + "additions": 0, + "deletions": 4921, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/id/CLAUDE.md", + "additions": 0, + "deletions": 233, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/id/GEMINI.md", + "additions": 0, + "deletions": 25, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/id/llm.txt", + "additions": 0, + "deletions": 476, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/in/CHANGELOG.md", + "additions": 0, + "deletions": 3232, + "changeType": "DELETED" + }, + { + "path": "docs/i18n/in/CLAUDE.md", + "additions": 0, + "deletions": 229, + "changeType": "DELETED" + } + ], + "headRefName": "main", + "number": 1894, + "title": "refactor(db): migrate from better-sqlite3 to node:sqlite, use tsgo" + }, + { + "additions": 276, + "author": { + "id": "U_kgDOD9eSjQ", + "is_bot": false, + "login": "smartenok-ops", + "name": "" + }, + "baseRefName": "release/v3.7.9", + "body": "When a codex SSE stream hits 429 mid-task, OmniRoute previously gave up (maxAttempts=1, no rotation). Add retry with account rotation when session affinity allows.\r\n\r\nDepends on: per-session sticky routing PR (extractSessionAffinityKey, deleteSessionAccountAffinity).\r\n\r\n## Summary\r\n\r\n- Describe the user-facing or operational change.\r\n\r\n## Related Issues\r\n\r\n- Closes #\r\n- Related to #\r\n\r\n## Validation\r\n\r\n- [ ] `npm run lint`\r\n- [ ] `npm run test:unit`\r\n- [ ] `npm run test:coverage`\r\n- [ ] Coverage is still `>= 60%` for statements, lines, functions, and branches\r\n- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below\r\n\r\n## Tests Added Or Updated\r\n\r\n- List every changed or added automated test file.\r\n- If no production code changed, state that here.\r\n\r\n## Coverage Notes\r\n\r\n- If this PR changes `src/`, `open-sse/`, `electron/`, or `bin/`, explain which tests cover the change.\r\n- If coverage moved down in any touched file, explain why and what follow-up task will recover it.\r\n\r\n## Reviewer Notes\r\n\r\n- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.\r\n\n> [!NOTE]\n> **OmniRoute Maintainer Note:** This PR has been deferred and will be accepted and redirected for the `v4.0.0` (or greater) release cycle. Please skip this PR during `v3.x` stabilization reviews.", + "createdAt": "2026-05-02T16:06:11Z", + "deletions": 2, + "files": [ + { + "path": "open-sse/handlers/chatCore.ts", + "additions": 84, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "tests/unit/codex-failover.test.ts", + "additions": 192, + "deletions": 0, + "changeType": "ADDED" + } + ], + "headRefName": "feat/codex-429-mid-task-failover", + "number": 1888, + "title": "feat(sse): codex 429 mid-task failover with account rotation" + }, + { + "additions": 461, + "author": { + "id": "U_kgDOD9eSjQ", + "is_bot": false, + "login": "smartenok-ops", + "name": "" + }, + "baseRefName": "release/v3.7.9", + "body": "When concurrent SSE clients use a shared OmniRoute key, randomized account selection causes per-session inconsistencies (one stream hits account A, next request from same logical session hits B). Adds session_account_affinity table + extractSessionAffinityKey helper so a session_key (e.g. SHA of input headers) deterministically pins to one account for its lifetime.\r\n\r\nMigration: 041_session_account_affinity.sql (renumbered from local 026 — please confirm next available slot).\r\n\r\n## Summary\r\n\r\n- Describe the user-facing or operational change.\r\n\r\n## Related Issues\r\n\r\n- Closes #\r\n- Related to #\r\n\r\n## Validation\r\n\r\n- [ ] `npm run lint`\r\n- [ ] `npm run test:unit`\r\n- [ ] `npm run test:coverage`\r\n- [ ] Coverage is still `>= 60%` for statements, lines, functions, and branches\r\n- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below\r\n\r\n## Tests Added Or Updated\r\n\r\n- List every changed or added automated test file.\r\n- If no production code changed, state that here.\r\n\r\n## Coverage Notes\r\n\r\n- If this PR changes `src/`, `open-sse/`, `electron/`, or `bin/`, explain which tests cover the change.\r\n- If coverage moved down in any touched file, explain why and what follow-up task will recover it.\r\n\r\n## Reviewer Notes\r\n\r\n- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.\r\n\n> [!NOTE]\n> **OmniRoute Maintainer Note:** This PR has been deferred and will be accepted and redirected for the `v4.0.0` (or greater) release cycle. Please skip this PR during `v3.x` stabilization reviews.", + "createdAt": "2026-05-02T16:05:53Z", + "deletions": 122, + "files": [ + { + "path": "src/instrumentation-node.ts", + "additions": 10, + "deletions": 5, + "changeType": "MODIFIED" + }, + { + "path": "src/lib/db/migrations/041_session_account_affinity.sql", + "additions": 11, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/lib/db/sessionAccountAffinity.ts", + "additions": 122, + "deletions": 0, + "changeType": "ADDED" + }, + { + "path": "src/lib/localDb.ts", + "additions": 10, + "deletions": 0, + "changeType": "MODIFIED" + }, + { + "path": "src/sse/handlers/chat.ts", + "additions": 14, + "deletions": 1, + "changeType": "MODIFIED" + }, + { + "path": "src/sse/services/auth.ts", + "additions": 177, + "deletions": 2, + "changeType": "MODIFIED" + }, + { + "path": "tests/unit/sse-auth.test.ts", + "additions": 117, + "deletions": 114, + "changeType": "MODIFIED" + } + ], + "headRefName": "feat/per-session-sticky-routing", + "number": 1887, + "title": "feat(auth): per-session sticky routing for codex" + } +] diff --git a/scripts/pack-artifact-policy.ts b/scripts/pack-artifact-policy.ts index b2e55c0232..06793c10c6 100644 --- a/scripts/pack-artifact-policy.ts +++ b/scripts/pack-artifact-policy.ts @@ -42,6 +42,8 @@ export const APP_STAGING_ALLOWED_PATH_PREFIXES: string[] = [ ".next/", "data/", "node_modules/", + "open-sse/services/compression/engines/rtk/filters/", + "open-sse/services/compression/rules/", "public/", "src/lib/db/migrations/", "src/mitm/", @@ -88,6 +90,8 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES: string[] = [ ]; export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ + "app/open-sse/services/compression/engines/rtk/filters/generic-output.json", + "app/open-sse/services/compression/rules/en/filler.json", "app/server.js", "app/server-ws.mjs", "app/responses-ws-proxy.mjs", diff --git a/scripts/prepublish.ts b/scripts/prepublish.ts index 199c76c68d..8b54f60067 100644 --- a/scripts/prepublish.ts +++ b/scripts/prepublish.ts @@ -475,6 +475,23 @@ if (existsSync(migrationsSrc)) { cpSync(migrationsSrc, migrationsDest, { recursive: true, force: true }); } +const runtimeAssetDirs = [ + { + source: join(ROOT, "open-sse", "services", "compression", "engines", "rtk", "filters"), + destination: join(APP_DIR, "open-sse", "services", "compression", "engines", "rtk", "filters"), + }, + { + source: join(ROOT, "open-sse", "services", "compression", "rules"), + destination: join(APP_DIR, "open-sse", "services", "compression", "rules"), + }, +]; +for (const assetDir of runtimeAssetDirs) { + if (existsSync(assetDir.source)) { + mkdirSync(dirname(assetDir.destination), { recursive: true }); + cpSync(assetDir.source, assetDir.destination, { recursive: true, force: true }); + } +} + // ── Step 10: Ensure data/ directory exists ────────────────── mkdirSync(join(APP_DIR, "data"), { recursive: true }); diff --git a/scripts/scratch/pr_body.md b/scripts/scratch/pr_body.md new file mode 100644 index 0000000000..25326e6e60 --- /dev/null +++ b/scripts/scratch/pr_body.md @@ -0,0 +1,46 @@ +### ✨ New Features + +- **feat(compression):** major upgrade to Caveman and RTK compression pipelines (#1876, #1889): + - Add RTK tool-output compression, stacked Caveman + RTK pipelines, compression combo assignments, dashboard context pages, MCP management tools, and language-aware Caveman rule packs. + - Expand RTK parity with a 39-filter catalog, RTK-style JSON DSL stages, inline verify/benchmark coverage, trust-gated custom filters, expanded command detection, and redacted raw-output recovery. + - Expose rule intensities, track USD savings, unify config validation, and persist MCP savings. + - Expand Caveman parity and MCP metadata compression. +- **feat(provider):** update Jina AI model catalog to support Embeddings and Rerank natively (#1874 — thanks @backryun) +- **feat(provider):** add NanoGPT image generation provider (#1899 — thanks @Aculeasis) +- **feat(ui):** move proxy configuration to dedicated System → Proxy page (#1907 — thanks @oyi77) +- **feat(ui):** add K/M/B/T cost shortener utility (#1902 — thanks @oyi77) +- **feat(providers):** implement bulk paste for extra API keys (#1916 — thanks @0xtbug) +- **feat(analytics):** usage history API key backfill + dark mode pricing (#1896 — thanks @Gi99lin) +- **feat(logs):** show RTK and Caveman compression token savings accurately in request log UI (#1923 — thanks @emdash) + +### 🐛 Bug Fixes + +- **fix(providers):** allow local OpenAI-compatible endpoints (like Ollama) to be added without an API key (fixes #1893) +- **fix(providers):** bypass AgentRouter unauthorized_client_error by spoofing Claude CLI headers via Anthropic endpoints (fixes #1921) +- **fix(copilot):** emit compatible reasoning text deltas (#1919 — thanks @ivan-mezentsev) +- **fix(api-manager):** show validation errors inline in modals, not behind (#1920 — thanks @andrewmunsell) +- **fix(compression):** align seeded standard savings combo with stacked default, preserve stacked defaults, and secure metadata routes. +- **fix(gemini-cli):** separate Cloud Code transport from Antigravity (#1869 — thanks @dhaern) +- **fix(codex):** map prompt field to input array for Cursor compatibility (fixes #1872) +- **fix(core):** align stream parameter default to false per strict OpenAI spec (fixes #1873) +- **fix(ui):** restore Next.js CSP `unsafe-eval` in production `script-src` to fix unresponsive Onboarding button (fixes #1883) +- **fix(proxy):** globally strip `prompt_cache_retention` in `BaseExecutor` to prevent upstream 400 errors from strict endpoints like droid/gemini-2-pro (fixes #1884) +- **fix(ui):** include `isOpen` dependency in `EditConnectionModal` state sync to ensure `maxConcurrent` is properly hydrated when reopening the modal (fixes #1859) +- **fix(security):** remediate 4 polynomial-redos CodeQL alerts in compression regexes by bounding repetitions and removing overlapping quantifiers +- **fix(codex):** flatten Chat Completions tool format to Codex Responses format in `normalizeCodexTools` — prevents `Missing required parameter: tools[0].name` upstream errors (#1914 — thanks @tranduykhanh030) +- **fix(proxy):** add proxy-aware execution context to image generation route — proxy settings are now correctly applied for image providers behind restricted networks (#1904 — thanks @Aculeasis) +- **fix(translator):** inject `properties: {}` into zero-argument MCP tool schemas during Anthropic→OpenAI translation — prevents 400 errors from OpenAI strict schema validation (#1898 — thanks @bryceIT) +- **fix(codex):** sanitize raw responses input (#1895 — thanks @dhaern) +- **fix(combos):** align strategy contracts (#1892 — thanks @dhaern) +- **fix(combos):** fix combo provider breaker profile handling (#1891 — thanks @rdself) +- **fix(migrations):** duplicate-column no-op fix (#1886 — thanks @smartenok-ops) +- **fix(auth):** per-connection OAuth refresh mutex (#1885 — thanks @smartenok-ops) +- **fix(auth):** require dashboard management auth for compression preview + +### 📝 Documentation + +- **docs(compression):** document RTK+Caveman stacked savings ranges + +### 🏆 Release Attribution & Retroactive Credits + +- **@payne0420** (PR #1828 / #1839) — Implementation of the **Rate Limit Watchdog** and environment overrides. (This feature was manually backported to v3.7.8, causing the automatic GitHub Release notes to omit the author's credit). diff --git a/scripts/scratch/test-migrations.ts b/scripts/scratch/test-migrations.ts index d908738b1d..f21ca16ca8 100644 --- a/scripts/scratch/test-migrations.ts +++ b/scripts/scratch/test-migrations.ts @@ -1,47 +1,56 @@ import Database from "better-sqlite3"; -import fs from "fs"; -import path from "path"; +import { runMigrations, getMigrationStatus } from "../../src/lib/db/migrationRunner.js"; -import { runMigrations, getMigrationStatus } from "../../src/lib/db/migrationRunner.ts"; +const db = new Database("/tmp/test-migrations.sqlite"); +db.exec( + "CREATE TABLE _omniroute_migrations (version TEXT PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT (datetime('now')))" +); -const db = new Database(":memory:"); -db.exec(` - CREATE TABLE IF NOT EXISTS _omniroute_migrations ( - version TEXT PRIMARY KEY, - name TEXT NOT NULL, - applied_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - INSERT INTO _omniroute_migrations (version, name) VALUES ('001', 'initial_schema'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('002', 'mcp_a2a_tables'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('003', 'provider_node_custom_paths'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('004', 'proxy_registry'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('005', 'combo_agent_fields'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('006', 'detailed_request_logs'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('007', 'search_request_type'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('008', 'registered_keys'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('009', 'requested_model'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('010', 'model_combo_mappings'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('011', 'webhooks'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('012', 'fix_token_input_cache_tokens'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('013', 'quota_snapshots'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('014', 'unified_log_artifacts'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('015', 'create_memories'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('016', 'create_skills'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('017', 'version_manager_upstream_proxy'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('018', 'call_logs_detailed_tokens'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('019', 'context_handoffs'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('020', 'combo_sort_order'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('021', 'combo_call_log_targets'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('026', 'call_logs_cache_source'); - INSERT INTO _omniroute_migrations (version, name) VALUES ('029', 'provider_connection_max_concurrent'); -`); - -console.log("Status before:", getMigrationStatus(db)); +const fakeApplied = [ + "001", + "002", + "003", + "004", + "005", + "006", + "007", + "008", + "009", + "010", + "011", + "012", + "013", + "014", + "015", + "016", + "017", + "018", + "019", + "020", + "021", +]; +for (const v of fakeApplied) { + db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run(v, "fake_" + v); +} +db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run( + "026", + "call_logs_cache_source" +); +db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run( + "029", + "provider_connection_max_concurrent" +); +console.log( + "Status before:", + getMigrationStatus(db).pending.map((p) => p.version) +); try { runMigrations(db, { isNewDb: false }); -} catch (e) { - console.error("Migration threw error:", e); +} catch (e: any) { + console.error("Error:", e.message); } - -console.log("Status after:", getMigrationStatus(db)); +console.log( + "Status after:", + getMigrationStatus(db).pending.map((p) => p.version) +); diff --git a/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx b/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx index 184eb936db..6c84c6b077 100644 --- a/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/CompressionAnalyticsTab.tsx @@ -17,6 +17,17 @@ interface CompressionAnalyticsSummary { byMode: Record; byProvider: Record; last24h: Array<{ hour: string; count: number; tokensSaved: number }>; + validationFallbacks: number; + realUsage: { + requestsWithReceipts: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + estimatedUsdSaved: number; + bySource: Record; + }; } function StatCard({ @@ -178,7 +189,7 @@ export default function CompressionAnalyticsTab() { {/* KPI Cards */} -
+
+ +
+ {stats.realUsage.requestsWithReceipts > 0 && ( +
+

+ receipt_long + Real Usage Receipts +

+
+
+
Prompt tokens
+
+ {stats.realUsage.promptTokens.toLocaleString()} +
+
+
+
Completion tokens
+
+ {stats.realUsage.completionTokens.toLocaleString()} +
+
+
+
Total tokens
+
+ {stats.realUsage.totalTokens.toLocaleString()} +
+
+
+
Cache tokens
+
+ {( + (stats.realUsage.cacheReadTokens ?? 0) + (stats.realUsage.cacheWriteTokens ?? 0) + ).toLocaleString()} +
+
+
+
Sources
+
+ {Object.entries(stats.realUsage.bySource) + .map(([source, count]) => `${source}: ${count}`) + .join(", ")} +
+
+
+
+ )} + {/* Mode Breakdown */} {modes.length > 0 && (
@@ -290,8 +358,8 @@ export default function CompressionAnalyticsTab() { info Compression analytics: Token savings tracked per mode (off, lite, - standard, aggressive, ultra) and provider. Hover over charts for details. Use the time - selector to view different time periods. + standard, aggressive, ultra, RTK, stacked), engine, compression combo, and provider. Hover + over charts for details. Use the time selector to view different time periods.
diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index e2c71e54d6..06e27cc639 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -107,7 +107,9 @@ export default function ApiManagerPageClient() { const [editingKey, setEditingKey] = useState(null); const [showPermissionsModal, setShowPermissionsModal] = useState(false); const [searchModel, setSearchModel] = useState(""); - const [error, setError] = useState(null); + const [pageError, setPageError] = useState(null); + const [nameError, setNameError] = useState(null); + const [createError, setCreateError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [usageStats, setUsageStats] = useState>({}); const [sessionCounts, setSessionCounts] = useState>({}); @@ -226,19 +228,20 @@ export default function ApiManagerPageClient() { } }; - const clearError = useCallback(() => setError(null), []); + const clearPageError = useCallback(() => setPageError(null), []); const handleCreateKey = async () => { // Validate raw input first, then sanitize const validation = validateKeyName(newKeyName, t); if (!validation.valid) { - setError(validation.error || t("invalidKeyName")); + setNameError(validation.error || t("invalidKeyName")); return; } const sanitizedName = sanitizeInput(newKeyName); setIsSubmitting(true); - clearError(); + setNameError(null); + setCreateError(null); try { const res = await fetch("/api/keys", { @@ -254,11 +257,11 @@ export default function ApiManagerPageClient() { setNewKeyName(""); setShowAddModal(false); } else { - setError(data.error || t("failedCreateKey")); + setCreateError(data.error || t("failedCreateKey")); } } catch (error) { console.error("Error creating key:", error); - setError(t("failedCreateKeyRetry")); + setCreateError(t("failedCreateKeyRetry")); } finally { setIsSubmitting(false); } @@ -267,14 +270,14 @@ export default function ApiManagerPageClient() { const handleDeleteKey = async (id: string) => { // Validate ID format to prevent injection if (!id || typeof id !== "string" || !/^[a-zA-Z0-9_-]+$/.test(id)) { - setError(t("invalidKeyId")); + setPageError(t("invalidKeyId")); return; } if (!confirm(t("deleteConfirm"))) return; setIsSubmitting(true); - clearError(); + clearPageError(); try { const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, { method: "DELETE" }); @@ -282,11 +285,11 @@ export default function ApiManagerPageClient() { setKeys((prev) => prev.filter((k) => k.id !== id)); } else { const data = await res.json(); - setError(data.error || t("failedDeleteKey")); + setPageError(data.error || t("failedDeleteKey")); } } catch (error) { console.error("Error deleting key:", error); - setError(t("failedDeleteKeyRetry")); + setPageError(t("failedDeleteKeyRetry")); } finally { setIsSubmitting(false); } @@ -329,23 +332,15 @@ export default function ApiManagerPageClient() { ) => { if (!editingKey || !editingKey.id) return; - // Validate raw input first, then sanitize - const nameValidation = validateKeyName(name, t); - if (!nameValidation.valid) { - setError(nameValidation.error || t("invalidKeyName")); - return; - } const sanitizedName = sanitizeInput(name); // Validate models array if (!Array.isArray(allowedModels)) { - setError(t("invalidModelsSelection")); return; } // Limit number of selected models to prevent abuse if (allowedModels.length > MAX_SELECTED_MODELS) { - setError(t("cannotSelectMoreThanModels", { max: MAX_SELECTED_MODELS })); return; } @@ -364,7 +359,7 @@ export default function ApiManagerPageClient() { : 0; setIsSubmitting(true); - clearError(); + clearPageError(); try { const res = await fetch(`/api/keys/${encodeURIComponent(editingKey.id)}`, { @@ -388,11 +383,11 @@ export default function ApiManagerPageClient() { setEditingKey(null); } else { const data = await res.json(); - setError(data.error || t("failedUpdatePermissions")); + setPageError(data.error || t("failedUpdatePermissions")); } } catch (error) { console.error("Error updating permissions:", error); - setError(t("failedUpdatePermissionsRetry")); + setPageError(t("failedUpdatePermissionsRetry")); } finally { setIsSubmitting(false); } @@ -441,12 +436,12 @@ export default function ApiManagerPageClient() { return (
{/* Error Banner */} - {error && ( + {pageError && (
error -

{error}

+

{pageError}

-
@@ -554,7 +557,14 @@ export default function ApiManagerPageClient() {

{t("noKeys")}

{t("noKeysDesc")}

- @@ -759,6 +769,8 @@ export default function ApiManagerPageClient() { onClose={() => { setShowAddModal(false); setNewKeyName(""); + setNameError(null); + setCreateError(null); }} >
@@ -768,25 +780,42 @@ export default function ApiManagerPageClient() { setNewKeyName(e.target.value)} + onChange={(e) => { + setNewKeyName(e.target.value); + setNameError(null); + }} placeholder={t("keyNamePlaceholder")} maxLength={MAX_KEY_NAME_LENGTH} + error={nameError} autoFocus />

{t("keyNameDesc")}

+ {createError && ( +
+ error +

{createError}

+
+ )}
-
@@ -905,6 +934,8 @@ const PermissionsModal = memo(function PermissionsModal({ const [scheduleTz, setScheduleTz] = useState( apiKey?.accessSchedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ); + const [nameError, setNameError] = useState(null); + const [saveError, setSaveError] = useState(null); const [selectedConnections, setSelectedConnections] = useState(initialConnections); const [allowAllConnections, setAllowAllConnections] = useState(initialConnections.length === 0); const [expandedProviders, setExpandedProviders] = useState>(() => { @@ -992,6 +1023,29 @@ const PermissionsModal = memo(function PermissionsModal({ ); const handleSave = useCallback(() => { + // Clear previous inline errors + setNameError(null); + setSaveError(null); + + // Validate name inline before calling onSave + const validation = validateKeyName(keyName, t); + if (!validation.valid) { + setNameError(validation.error || t("invalidKeyName")); + return; + } + + // Validate models selection + if (!allowAll && !Array.isArray(selectedModels)) { + setSaveError(t("invalidModelsSelection")); + return; + } + + // Limit number of selected models to prevent abuse + if (!allowAll && selectedModels.length > MAX_SELECTED_MODELS) { + setSaveError(t("cannotSelectMoreThanModels", { max: MAX_SELECTED_MODELS })); + return; + } + const schedule: AccessSchedule | null = scheduleEnabled ? { enabled: true, @@ -1027,6 +1081,7 @@ const PermissionsModal = memo(function PermissionsModal({ scheduleUntil, scheduleDays, scheduleTz, + t, ]); const selectedCount = selectedModels.length; @@ -1048,13 +1103,25 @@ const PermissionsModal = memo(function PermissionsModal({
setKeyName(e.target.value)} + onChange={(e) => { + setKeyName(e.target.value); + setNameError(null); + }} placeholder={t("keyNamePlaceholder")} maxLength={MAX_KEY_NAME_LENGTH} + error={nameError} />
+ {/* Inline save error */} + {saveError && ( +
+ error +

{saveError}

+
+ )} + {/* Access Mode Toggle */}
- ); + redirect("/dashboard/context/caveman"); } diff --git a/src/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient.tsx b/src/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient.tsx new file mode 100644 index 0000000000..e879548954 --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/caveman/CavemanContextPageClient.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import CompressionSettingsTab from "@/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab"; + +type AnalyticsSummary = { + totalRequests: number; + totalTokensSaved: number; + avgSavingsPct: number; + avgDurationMs: number; + byEngine?: Record; + byMode?: Record; + last24h?: Array<{ hour: string; count: number; tokensSaved: number }>; +}; + +type LanguageConfig = { + enabled: boolean; + defaultLanguage: string; + autoDetect: boolean; + enabledPacks: string[]; +}; + +type OutputModeConfig = { + enabled: boolean; + intensity: "lite" | "full" | "ultra"; + autoClarity: boolean; +}; + +type CompressionSettings = { + languageConfig?: LanguageConfig; + cavemanOutputMode?: OutputModeConfig; +}; + +type LanguagePack = { language: string; ruleCount: number; categories?: string[] }; + +function formatNumber(value: number | undefined): string { + return new Intl.NumberFormat().format(value ?? 0); +} + +export default function CavemanContextPageClient() { + const t = useTranslations("contextCaveman"); + const [analytics, setAnalytics] = useState(null); + const [settings, setSettings] = useState(null); + const [languagePacks, setLanguagePacks] = useState([]); + const [saving, setSaving] = useState(false); + + const refreshSettings = () => { + fetch("/api/context/caveman/config") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setSettings(data)) + .catch(() => setSettings(null)); + }; + + useEffect(() => { + fetch("/api/context/analytics?since=7d") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setAnalytics(data)) + .catch(() => setAnalytics(null)); + fetch("/api/compression/language-packs") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setLanguagePacks(Array.isArray(data?.packs) ? data.packs : [])) + .catch(() => setLanguagePacks([])); + refreshSettings(); + }, []); + + const languageConfig: LanguageConfig = settings?.languageConfig ?? { + enabled: false, + defaultLanguage: "en", + autoDetect: true, + enabledPacks: ["en"], + }; + const outputMode: OutputModeConfig = settings?.cavemanOutputMode ?? { + enabled: false, + intensity: "full", + autoClarity: true, + }; + + const saveSettings = async (patch: Partial) => { + setSaving(true); + try { + const res = await fetch("/api/context/caveman/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (res.ok) setSettings(await res.json()); + } finally { + setSaving(false); + } + }; + + const updateLanguageConfig = (patch: Partial) => { + saveSettings({ languageConfig: { ...languageConfig, ...patch } }); + }; + + const updateOutputMode = (patch: Partial) => { + saveSettings({ cavemanOutputMode: { ...outputMode, ...patch } }); + }; + + const togglePack = (language: string, enabled: boolean) => { + const enabledPacks = enabled + ? [...new Set([...languageConfig.enabledPacks, language])] + : languageConfig.enabledPacks.filter((pack) => pack !== language && pack !== "en"); + updateLanguageConfig({ enabledPacks }); + }; + + const cavemanStats = analytics?.byEngine?.caveman ?? analytics?.byEngine?.standard; + const modeBreakdown = useMemo(() => Object.entries(analytics?.byMode ?? {}), [analytics]); + const statCards = [ + [t("requests"), formatNumber(cavemanStats?.count ?? analytics?.totalRequests)], + [t("tokensSaved"), formatNumber(cavemanStats?.tokensSaved ?? analytics?.totalTokensSaved)], + [t("savingsPercent"), `${cavemanStats?.avgSavingsPct ?? analytics?.avgSavingsPct ?? 0}%`], + [t("avgLatency"), `${analytics?.avgDurationMs ?? 0}ms`], + ]; + const previewPrompt = `[OmniRoute Caveman Output Mode]\n${t(`preview.${outputMode.intensity}`)}`; + + return ( +
+
+
+ compress +
+

{t("title")}

+

{t("description")}

+
+
+
+ +
+ {statCards.map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+ +
+
+

{t("languagePacks")}

+

{t("languagePacksDesc")}

+
+
+ + + +
+
+ {languagePacks.map((pack) => ( + + ))} +
+
+ +
+
+

{t("analyticsTitle")}

+
+ {modeBreakdown.length === 0 ? ( +

{t("noAnalytics")}

+ ) : ( + modeBreakdown.map(([mode, stats]) => ( +
+ {mode} + + {formatNumber(stats.tokensSaved)} / {stats.avgSavingsPct}% + +
+ )) + )} +
+
+ +
+

{t("outputModeTitle")}

+

{t("outputModeDesc")}

+
+ + + +
+
+            {previewPrompt}
+          
+

+ {t("bypassConditions")}: {t("bypassConditionsList")} +

+
+
+ + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/context/caveman/page.tsx b/src/app/(dashboard)/dashboard/context/caveman/page.tsx new file mode 100644 index 0000000000..bfeae4c1cb --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/caveman/page.tsx @@ -0,0 +1,5 @@ +import CavemanContextPageClient from "./CavemanContextPageClient"; + +export default function CavemanContextPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx b/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx new file mode 100644 index 0000000000..58149a9aa6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient.tsx @@ -0,0 +1,391 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; + +type PipelineStep = { engine: string; intensity?: string }; +type CompressionCombo = { + id: string; + name: string; + description: string; + pipeline: PipelineStep[]; + languagePacks: string[]; + outputMode: boolean; + outputModeIntensity: string; + isDefault: boolean; +}; +type RoutingCombo = { id?: string; name?: string }; +type LanguagePack = { language: string; ruleCount: number }; + +const EMPTY_PIPELINE: PipelineStep[] = [ + { engine: "rtk", intensity: "standard" }, + { engine: "caveman", intensity: "full" }, +]; + +const ENGINE_INTENSITIES: Record = { + rtk: ["minimal", "standard", "aggressive"], + caveman: ["lite", "full", "ultra"], + lite: ["lite"], + aggressive: ["standard"], + ultra: ["ultra"], +}; + +export default function CompressionCombosPageClient() { + const t = useTranslations("contextCombos"); + const [combos, setCombos] = useState([]); + const [routingCombos, setRoutingCombos] = useState([]); + const [languagePacks, setLanguagePacks] = useState([]); + const [editingId, setEditingId] = useState(null); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [pipeline, setPipeline] = useState(EMPTY_PIPELINE); + const [selectedPacks, setSelectedPacks] = useState(["en"]); + const [outputMode, setOutputMode] = useState(false); + const [outputModeIntensity, setOutputModeIntensity] = useState("full"); + const [assignmentIds, setAssignmentIds] = useState([]); + const [saving, setSaving] = useState(false); + + const refresh = () => { + fetch("/api/context/combos") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setCombos(Array.isArray(data?.combos) ? data.combos : [])) + .catch(() => {}); + }; + + useEffect(() => { + refresh(); + fetch("/api/combos") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setRoutingCombos(Array.isArray(data?.combos) ? data.combos : [])) + .catch(() => {}); + fetch("/api/compression/language-packs") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setLanguagePacks(Array.isArray(data?.packs) ? data.packs : [])) + .catch(() => {}); + }, []); + + const resetForm = () => { + setEditingId(null); + setName(""); + setDescription(""); + setPipeline(EMPTY_PIPELINE); + setSelectedPacks(["en"]); + setOutputMode(false); + setOutputModeIntensity("full"); + setAssignmentIds([]); + }; + + const loadAssignments = async (id: string) => { + const res = await fetch(`/api/context/combos/${id}/assignments`); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data?.assignments) + ? data.assignments.map((item: { routingComboId: string }) => item.routingComboId) + : []; + }; + + const editCombo = async (combo: CompressionCombo) => { + setEditingId(combo.id); + setName(combo.name); + setDescription(combo.description ?? ""); + setPipeline(combo.pipeline.length > 0 ? combo.pipeline : EMPTY_PIPELINE); + setSelectedPacks(combo.languagePacks?.length ? combo.languagePacks : ["en"]); + setOutputMode(Boolean(combo.outputMode)); + setOutputModeIntensity(combo.outputModeIntensity ?? "full"); + setAssignmentIds(await loadAssignments(combo.id)); + }; + + const saveCombo = async () => { + const trimmed = name.trim(); + if (!trimmed) return; + setSaving(true); + try { + const payload = { + name: trimmed, + description, + pipeline, + languagePacks: selectedPacks, + outputMode, + outputModeIntensity, + }; + const res = await fetch( + editingId ? `/api/context/combos/${editingId}` : "/api/context/combos", + { + method: editingId ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + } + ); + if (!res.ok) return; + const combo = await res.json(); + await fetch(`/api/context/combos/${combo.id}/assignments`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ routingComboIds: assignmentIds }), + }); + resetForm(); + refresh(); + } finally { + setSaving(false); + } + }; + + const deleteCombo = async (combo: CompressionCombo) => { + if (!confirm(t("deleteConfirm"))) return; + const res = await fetch(`/api/context/combos/${combo.id}`, { method: "DELETE" }); + if (res.ok) refresh(); + }; + + const setDefault = async (id: string) => { + const res = await fetch(`/api/context/combos/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isDefault: true }), + }); + if (res.ok) refresh(); + }; + + const updateStep = (index: number, patch: Partial) => { + setPipeline((current) => + current.map((step, stepIndex) => { + if (stepIndex !== index) return step; + const next = { ...step, ...patch }; + const allowed = ENGINE_INTENSITIES[next.engine] ?? ["standard"]; + return { + ...next, + intensity: allowed.includes(next.intensity ?? "") ? next.intensity : allowed[0], + }; + }) + ); + }; + + const togglePack = (language: string, enabled: boolean) => { + setSelectedPacks((current) => + enabled + ? [...new Set([...current, language])] + : current.filter((item) => item !== language && item !== "en") + ); + }; + + const toggleAssignment = (id: string, enabled: boolean) => { + setAssignmentIds((current) => + enabled ? [...new Set([...current, id])] : current.filter((item) => item !== id) + ); + }; + + return ( +
+
+
+ hub +
+

{t("title")}

+

{t("description")}

+
+
+
+ +
+
+ setName(event.target.value)} + placeholder={t("name")} + className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main" + /> + setDescription(event.target.value)} + placeholder={t("descriptionField")} + className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-main" + /> +
+ +
+
+

{t("pipeline")}

+ +
+ {pipeline.map((step, index) => ( +
+ + + +
+ ))} +
+ +
+
+

{t("languagePacks")}

+
+ {languagePacks.map((pack) => ( + + ))} +
+
+
+

{t("outputMode")}

+ + +
+
+

{t("assignToRouting")}

+
+ {routingCombos.length === 0 ? ( +

{t("noAssignments")}

+ ) : ( + routingCombos.map((combo) => { + const id = combo.id ?? combo.name ?? ""; + if (!id) return null; + return ( + + ); + }) + )} +
+
+
+ +
+ + {editingId && ( + + )} +
+
+ +
+ {combos.map((combo) => ( +
+
+
+

{combo.name}

+

{combo.description}

+
+ {combo.isDefault && ( + + {t("default")} + + )} +
+
+ {combo.pipeline.map((step, index) => ( + + {index + 1}. {step.engine} + {step.intensity ? `:${step.intensity}` : ""} + + ))} +
+

+ {t("languagePacks")}: {combo.languagePacks.join(", ")} +

+
+ + {!combo.isDefault && ( + + )} + {!combo.isDefault && ( + + )} +
+
+ ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/context/combos/page.tsx b/src/app/(dashboard)/dashboard/context/combos/page.tsx new file mode 100644 index 0000000000..934ab7d451 --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/combos/page.tsx @@ -0,0 +1,5 @@ +import CompressionCombosPageClient from "./CompressionCombosPageClient"; + +export default function CompressionCombosPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient.tsx b/src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient.tsx new file mode 100644 index 0000000000..e42b098115 --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/rtk/RtkContextPageClient.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; + +type RtkFilter = { + id: string; + name: string; + description: string; + commandTypes: string[]; + category: string; + priority: number; +}; + +type RtkConfig = { + enabled: boolean; + intensity: "minimal" | "standard" | "aggressive"; + applyToToolResults: boolean; + applyToAssistantMessages: boolean; + applyToCodeBlocks: boolean; + enabledFilters: string[]; + disabledFilters: string[]; + maxLinesPerResult: number; + maxCharsPerResult: number; + deduplicateThreshold: number; + customFiltersEnabled: boolean; + trustProjectFilters: boolean; + rawOutputRetention: "never" | "failures" | "always"; + rawOutputMaxBytes: number; +}; + +type AnalyticsSummary = { + totalRequests: number; + totalTokensSaved: number; + avgSavingsPct: number; + byEngine?: Record; +}; + +type PreviewResult = { + text?: string; + compressed?: boolean; + originalTokens?: number; + compressedTokens?: number; + techniquesUsed?: string[]; + detection?: { type: string; confidence: number; category: string }; + error?: string; +}; + +const SAMPLE_OUTPUT = `$ npm run typecheck +src/lib/example.ts:10:15 - error TS2322: Type 'string' is not assignable to type 'number'. + +10 const value: number = "bad"; + ~~~~~ + +Found 1 error in src/lib/example.ts:10`; + +function formatNumber(value: number | undefined): string { + return new Intl.NumberFormat().format(value ?? 0); +} + +export default function RtkContextPageClient() { + const t = useTranslations("contextRtk"); + const [filters, setFilters] = useState([]); + const [config, setConfig] = useState(null); + const [analytics, setAnalytics] = useState(null); + const [sample, setSample] = useState(SAMPLE_OUTPUT); + const [preview, setPreview] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetch("/api/context/rtk/filters") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setFilters(Array.isArray(data?.filters) ? data.filters : [])) + .catch(() => {}); + fetch("/api/context/rtk/config") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setConfig(data)) + .catch(() => {}); + fetch("/api/context/analytics?since=7d") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setAnalytics(data)) + .catch(() => {}); + }, []); + + const groupedFilters = useMemo(() => { + return filters.reduce>((groups, filter) => { + groups[filter.category] = [...(groups[filter.category] ?? []), filter]; + return groups; + }, {}); + }, [filters]); + + const activeFilterCount = useMemo(() => { + if (!config) return 0; + if (config.enabledFilters.length > 0) return config.enabledFilters.length; + return filters.filter((filter) => !config.disabledFilters.includes(filter.id)).length; + }, [config, filters]); + + const saveConfig = async (patch: Partial) => { + if (!config) return; + const nextConfig = { ...config, ...patch }; + setConfig(nextConfig); + setSaving(true); + try { + const res = await fetch("/api/context/rtk/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (res.ok) setConfig(await res.json()); + } finally { + setSaving(false); + } + }; + + const toggleFilter = (filterId: string, enabled: boolean) => { + if (!config) return; + const disabledFilters = enabled + ? config.disabledFilters.filter((id) => id !== filterId) + : [...new Set([...config.disabledFilters, filterId])]; + saveConfig({ disabledFilters }); + }; + + const runPreview = async () => { + const res = await fetch("/api/context/rtk/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: sample, config: config ?? undefined }), + }); + setPreview(res.ok ? await res.json() : { error: await res.text() }); + }; + + const rtkStats = analytics?.byEngine?.rtk; + const statCards = [ + [t("tokensFiltered"), formatNumber(rtkStats?.tokensSaved ?? analytics?.totalTokensSaved)], + [t("filtersActive"), formatNumber(activeFilterCount)], + [t("requests"), formatNumber(rtkStats?.count ?? analytics?.totalRequests)], + [t("avgSavings"), `${rtkStats?.avgSavingsPct ?? analytics?.avgSavingsPct ?? 0}%`], + ]; + + return ( +
+
+
+ filter_alt +
+

{t("title")}

+

{t("description")}

+
+
+
+ +
+ {statCards.map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+ + {config && ( +
+
+ + + + + + +
+
+ {[ + ["applyToToolResults", t("toolResults")], + ["applyToAssistantMessages", t("assistantMessages")], + ["applyToCodeBlocks", t("codeBlocks")], + ["customFiltersEnabled", t("customFilters")], + ["trustProjectFilters", t("trustProjectFilters")], + ].map(([key, label]) => ( + + ))} +
+
+ +
+
+ )} + +
+
+
+

{t("filterTesting")}

+ +
+