src / promptPreprocessor.ts
src / promptPreprocessor.ts
import { type PromptPreprocessorController, type ChatMessage } from "@lmstudio/sdk";
import { Troglodyte, type CompressionLevel } from './troglodyte';
import { phrases } from './dictionaries/phrases';
import { synonyms } from './dictionaries/synonyms';
import { configSchematics } from './config';
// Initialize Troglodyte with dictionaries
const troglodyte = new Troglodyte({
phrases,
blacklist: [], // Blacklist now handled internally by compression level
synonyms,
});
// ==================== RATE LIMITING (P2-8) ====================
// Simple sliding window rate limiter to prevent resource exhaustion from rapid message flooding.
// Default: max 10 requests per second per session.
const RATE_LIMIT = {
MAX_REQUESTS_PER_SECOND: 10,
WINDOW_MS: 1000,
};
let requestTimestamps: number[] = [];
function checkRateLimit(): boolean {
const now = Date.now();
// Remove timestamps outside the current window
requestTimestamps = requestTimestamps.filter(ts => now - ts < RATE_LIMIT.WINDOW_MS);
if (requestTimestamps.length >= RATE_LIMIT.MAX_REQUESTS_PER_SECOND) {
console.warn(`[Troglodyte] ⚠️ Rate limit exceeded (${RATE_LIMIT.MAX_REQUESTS_PER_SECOND} req/s). Skipping compression.`);
return false; // Reject request
}
requestTimestamps.push(now);
return true; // Allow request
}
// Reset rate limiter every minute to prevent memory leaks from stale timestamps
setInterval(() => {
const now = Date.now();
requestTimestamps = requestTimestamps.filter(ts => now - ts < RATE_LIMIT.WINDOW_MS);
}, 60_000);
/**
* Extracts only the actual user input from a message that may contain system metadata.
* System metadata markers: [Zeit:, **SYSTEMEMPFEHLUNG:**, SYSTEMEMPFEHLUNG!
* FIXED: Case-insensitive matching with word boundary to avoid false positives on timestamps/file names.
*
* NOTE: If a marker appears mid-sentence, only text BEFORE it is processed.
* Text after the marker is preserved but passed through uncompressed.
*/
function extractUserInput(text: string): { userInput: string; hasSystemMetadata: boolean } {
// Look for system metadata markers - FIXED: case-insensitive with word boundary
const zeitMatch = text.match(/(?<=\s|^)\[zeit:\s*/i);
const systemEmpfehlungMatch1 = text.match(/\*\*SYSTEMEMPFEHLUNG:\*\*/); // With asterisks and colon
const systemEmpfehlungMatch2 = text.match(/\bSYSTEMEMPFEHLUNG!/); // Without asterisks, with exclamation
let markerIndex = -1;
// Find the earliest matching marker
if (zeitMatch) {
markerIndex = zeitMatch.index ?? -1;
}
if (systemEmpfehlungMatch1 && systemEmpfehlungMatch1.index != null) {
const idx = systemEmpfehlungMatch1.index;
if (markerIndex === -1 || idx < markerIndex) {
markerIndex = idx;
}
}
if (systemEmpfehlungMatch2 && systemEmpfehlungMatch2.index != null) {
const idx = systemEmpfehlungMatch2.index;
if (markerIndex === -1 || idx < markerIndex) {
markerIndex = idx;
}
}
// If no system metadata found, return full text
if (markerIndex === -1) {
return { userInput: text, hasSystemMetadata: false };
}
// Extract everything before the first system metadata marker
const userInput = text.substring(0, markerIndex).trim();
// Safety: if userInput is empty but text isn't, the marker was at the start — process full text
if (!userInput && text.trim()) {
return { userInput: text, hasSystemMetadata: false };
}
return { userInput, hasSystemMetadata: true };
}
/**
* Prompt Preprocessor - Compresses user prompts by removing fluff and filler words.
* Reduces token usage by ~45% while preserving core meaning.
*/
export async function preprocess(ctl: PromptPreprocessorController, userMessage: ChatMessage): Promise<string> {
// Handle abort signal - exit early if preprocessing was cancelled
if (ctl.abortSignal.aborted) {
return userMessage.getText();
}
// Read all configuration from plugin config
// ==================== RATE LIMIT CHECK (P2-8) ====================
if (!checkRateLimit()) {
return userMessage.getText(); // Return original text on rate limit rejection
}
const pluginConfig = ctl.getPluginConfig(configSchematics);
const compressionLevel: CompressionLevel = (pluginConfig.get("compressionLevel") as CompressionLevel) ?? "balanced";
const smartMode = pluginConfig.get("smartMode") as boolean ?? true; // NEW
const protectUrls = pluginConfig.get("protectUrls") as boolean ?? true;
const protectNumbers = pluginConfig.get("protectNumbers") as boolean ?? true;
const protectHeaders = pluginConfig.get("protectHeaders") as boolean ?? true;
const protectFilePaths = pluginConfig.get("protectFilePaths") as boolean ?? true;
const protectJsonXml = pluginConfig.get("protectJsonXml") as boolean ?? true; // NEW
const languageMode = pluginConfig.get("languageMode") as string ?? "auto";
// Read showStats from config (default: true)
const showStats = pluginConfig.get("showStats") as boolean ?? true;
// Create status report for UI feedback
const status = ctl.createStatus({
status: "loading" as const,
text: `Compressing prompt (${compressionLevel})...`,
});
let compressedText = userMessage.getText(); // Default to original text
try {
const fullText = userMessage.getText();
// OPTION 2 FIX: Extract only actual user input, skip system metadata
const { userInput, hasSystemMetadata } = extractUserInput(fullText);
if (showStats && hasSystemMetadata) {
console.log(`[Troglodyte] Detected system metadata. Processing ${userInput.length} chars of user input (skipped ${fullText.length - userInput.length} chars of metadata)`);
}
// Compress only the actual user input
const compressedUserInput = troglodyte.compress(userInput, {
level: compressionLevel,
protectUrls,
protectNumbers,
protectHeaders,
protectFilePaths,
protectJsonXml, // NEW
smartMode, // NEW
language: languageMode !== "auto" ? (languageMode as import('./troglodyte').LanguageCode) : undefined,
verbose: showStats, // Pass showStats as verbose flag
});
// Reconstruct the full message with compressed user input + original system metadata
const systemMetadata = hasSystemMetadata ? fullText.substring(userInput.length) : '';
compressedText = compressedUserInput + systemMetadata;
// Calculate compression stats (only on user input portion)
const originalLength = userInput.length;
const compressedLength = compressedUserInput.length;
const savings = Math.round(((originalLength - compressedLength) / originalLength) * 100);
// Get cumulative statistics
const cumulativeStats = troglodyte.getStats();
// Detailed logging is now handled in troglodyte.ts to avoid duplication
// and to include the new ▶ INPUT / ▶ COMPRESSED debug lines.
// Update status to completed with detailed info
const protectionInfo = [];
if (protectUrls) protectionInfo.push("URLs");
if (protectNumbers) protectionInfo.push("IDs");
let statusText = `Compressed by ${savings}%`;
if (protectionInfo.length > 0) {
statusText += ` | Protecting: ${protectionInfo.join(', ')}`;
}
if (smartMode) {
statusText += " | Smart Mode"; // NEW
}
// Update status with compression rate
status.setState({
status: "done",
text: `Compressed by ${savings}%`,
});
} catch (error) {
console.error('[Troglodyte] Compression failed:', error);
// FIXED: Provide user feedback instead of silent failure
status.setState({
status: "error" as const,
text: `Compression failed: ${error instanceof Error ? error.message.substring(0, 50) : 'Unknown'}`
});
// Keep original text on error (existing behavior)
}
return compressedText;
}
import { type PromptPreprocessorController, type ChatMessage } from "@lmstudio/sdk";
import { Troglodyte, type CompressionLevel } from './troglodyte';
import { phrases } from './dictionaries/phrases';
import { synonyms } from './dictionaries/synonyms';
import { configSchematics } from './config';
// Initialize Troglodyte with dictionaries
const troglodyte = new Troglodyte({
phrases,
blacklist: [], // Blacklist now handled internally by compression level
synonyms,
});
// ==================== RATE LIMITING (P2-8) ====================
// Simple sliding window rate limiter to prevent resource exhaustion from rapid message flooding.
// Default: max 10 requests per second per session.
const RATE_LIMIT = {
MAX_REQUESTS_PER_SECOND: 10,
WINDOW_MS: 1000,
};
let requestTimestamps: number[] = [];
function checkRateLimit(): boolean {
const now = Date.now();
// Remove timestamps outside the current window
requestTimestamps = requestTimestamps.filter(ts => now - ts < RATE_LIMIT.WINDOW_MS);
if (requestTimestamps.length >= RATE_LIMIT.MAX_REQUESTS_PER_SECOND) {
console.warn(`[Troglodyte] ⚠️ Rate limit exceeded (${RATE_LIMIT.MAX_REQUESTS_PER_SECOND} req/s). Skipping compression.`);
return false; // Reject request
}
requestTimestamps.push(now);
return true; // Allow request
}
// Reset rate limiter every minute to prevent memory leaks from stale timestamps
setInterval(() => {
const now = Date.now();
requestTimestamps = requestTimestamps.filter(ts => now - ts < RATE_LIMIT.WINDOW_MS);
}, 60_000);
/**
* Extracts only the actual user input from a message that may contain system metadata.
* System metadata markers: [Zeit:, **SYSTEMEMPFEHLUNG:**, SYSTEMEMPFEHLUNG!
* FIXED: Case-insensitive matching with word boundary to avoid false positives on timestamps/file names.
*
* NOTE: If a marker appears mid-sentence, only text BEFORE it is processed.
* Text after the marker is preserved but passed through uncompressed.
*/
function extractUserInput(text: string): { userInput: string; hasSystemMetadata: boolean } {
// Look for system metadata markers - FIXED: case-insensitive with word boundary
const zeitMatch = text.match(/(?<=\s|^)\[zeit:\s*/i);
const systemEmpfehlungMatch1 = text.match(/\*\*SYSTEMEMPFEHLUNG:\*\*/); // With asterisks and colon
const systemEmpfehlungMatch2 = text.match(/\bSYSTEMEMPFEHLUNG!/); // Without asterisks, with exclamation
let markerIndex = -1;
// Find the earliest matching marker
if (zeitMatch) {
markerIndex = zeitMatch.index ?? -1;
}
if (systemEmpfehlungMatch1 && systemEmpfehlungMatch1.index != null) {
const idx = systemEmpfehlungMatch1.index;
if (markerIndex === -1 || idx < markerIndex) {
markerIndex = idx;
}
}
if (systemEmpfehlungMatch2 && systemEmpfehlungMatch2.index != null) {
const idx = systemEmpfehlungMatch2.index;
if (markerIndex === -1 || idx < markerIndex) {
markerIndex = idx;
}
}
// If no system metadata found, return full text
if (markerIndex === -1) {
return { userInput: text, hasSystemMetadata: false };
}
// Extract everything before the first system metadata marker
const userInput = text.substring(0, markerIndex).trim();
// Safety: if userInput is empty but text isn't, the marker was at the start — process full text
if (!userInput && text.trim()) {
return { userInput: text, hasSystemMetadata: false };
}
return { userInput, hasSystemMetadata: true };
}
/**
* Prompt Preprocessor - Compresses user prompts by removing fluff and filler words.
* Reduces token usage by ~45% while preserving core meaning.
*/
export async function preprocess(ctl: PromptPreprocessorController, userMessage: ChatMessage): Promise<string> {
// Handle abort signal - exit early if preprocessing was cancelled
if (ctl.abortSignal.aborted) {
return userMessage.getText();
}
// Read all configuration from plugin config
// ==================== RATE LIMIT CHECK (P2-8) ====================
if (!checkRateLimit()) {
return userMessage.getText(); // Return original text on rate limit rejection
}
const pluginConfig = ctl.getPluginConfig(configSchematics);
const compressionLevel: CompressionLevel = (pluginConfig.get("compressionLevel") as CompressionLevel) ?? "balanced";
const smartMode = pluginConfig.get("smartMode") as boolean ?? true; // NEW
const protectUrls = pluginConfig.get("protectUrls") as boolean ?? true;
const protectNumbers = pluginConfig.get("protectNumbers") as boolean ?? true;
const protectHeaders = pluginConfig.get("protectHeaders") as boolean ?? true;
const protectFilePaths = pluginConfig.get("protectFilePaths") as boolean ?? true;
const protectJsonXml = pluginConfig.get("protectJsonXml") as boolean ?? true; // NEW
const languageMode = pluginConfig.get("languageMode") as string ?? "auto";
// Read showStats from config (default: true)
const showStats = pluginConfig.get("showStats") as boolean ?? true;
// Create status report for UI feedback
const status = ctl.createStatus({
status: "loading" as const,
text: `Compressing prompt (${compressionLevel})...`,
});
let compressedText = userMessage.getText(); // Default to original text
try {
const fullText = userMessage.getText();
// OPTION 2 FIX: Extract only actual user input, skip system metadata
const { userInput, hasSystemMetadata } = extractUserInput(fullText);
if (showStats && hasSystemMetadata) {
console.log(`[Troglodyte] Detected system metadata. Processing ${userInput.length} chars of user input (skipped ${fullText.length - userInput.length} chars of metadata)`);
}
// Compress only the actual user input
const compressedUserInput = troglodyte.compress(userInput, {
level: compressionLevel,
protectUrls,
protectNumbers,
protectHeaders,
protectFilePaths,
protectJsonXml, // NEW
smartMode, // NEW
language: languageMode !== "auto" ? (languageMode as import('./troglodyte').LanguageCode) : undefined,
verbose: showStats, // Pass showStats as verbose flag
});
// Reconstruct the full message with compressed user input + original system metadata
const systemMetadata = hasSystemMetadata ? fullText.substring(userInput.length) : '';
compressedText = compressedUserInput + systemMetadata;
// Calculate compression stats (only on user input portion)
const originalLength = userInput.length;
const compressedLength = compressedUserInput.length;
const savings = Math.round(((originalLength - compressedLength) / originalLength) * 100);
// Get cumulative statistics
const cumulativeStats = troglodyte.getStats();
// Detailed logging is now handled in troglodyte.ts to avoid duplication
// and to include the new ▶ INPUT / ▶ COMPRESSED debug lines.
// Update status to completed with detailed info
const protectionInfo = [];
if (protectUrls) protectionInfo.push("URLs");
if (protectNumbers) protectionInfo.push("IDs");
let statusText = `Compressed by ${savings}%`;
if (protectionInfo.length > 0) {
statusText += ` | Protecting: ${protectionInfo.join(', ')}`;
}
if (smartMode) {
statusText += " | Smart Mode"; // NEW
}
// Update status with compression rate
status.setState({
status: "done",
text: `Compressed by ${savings}%`,
});
} catch (error) {
console.error('[Troglodyte] Compression failed:', error);
// FIXED: Provide user feedback instead of silent failure
status.setState({
status: "error" as const,
text: `Compression failed: ${error instanceof Error ? error.message.substring(0, 50) : 'Unknown'}`
});
// Keep original text on error (existing behavior)
}
return compressedText;
}