UNPKG

@monitoro/herd

Version:

Automate your browser, build AI web tools and MCP servers with Monitoro Herd

912 lines (911 loc) â€ĸ 44.6 kB
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { loadTrail } from "./loadTrail.js"; import { z } from "zod"; import express from "express"; // @ts-ignore import cors from "cors"; import { isRemoteTrailIdentifier, parseTrailIdentifier } from "../TrailEngine.js"; import { buildTrail } from "./build.js"; import { downloadTrail } from "./download.js"; import * as fs from 'fs'; import * as path from 'path'; import { homedir } from 'os'; // Max log file size (10MB) const MAX_LOG_SIZE = 10 * 1024 * 1024; export class TrailServer { constructor(client, options) { this.toolNameMappings = new Map(); // Maps both naming patterns to canonical names this.loadedTrails = new Map(); this.client = client; this.transports = options.transports; this.trails = options.trails; this.autoBuild = options.autoBuild !== false; this.usingStdioTransport = this.hasStdioTransport(options.transports); this.silent = options.silent || this.usingStdioTransport || false; this.cacheEnabled = options.cacheEnabled !== false; this.includeOrgPrefix = options.includeOrgPrefix || false; this.abridge = options.abridge !== false; // Default to true this.maxResponseLength = options.maxResponseLength || 4000; // Default 4k characters // Setup log directory and files this.logDir = path.join(homedir(), '.herd', 'logs'); this.outputLogPath = path.join(this.logDir, 'output.log'); this.errorLogPath = path.join(this.logDir, 'error.log'); this.setupLogDirectory(); // Initialize MCP Server this.mcpServer = new McpServer({ name: options.name, version: options.version, description: options.description || `Trail server exposing actions from: ${this.trails.join(', ')}` }); // Log server startup this.logToFile('output', `TrailServer initializing with options: ${JSON.stringify({ name: options.name, version: options.version, transports: options.transports.map(t => t.type), trails: options.trails, silent: this.silent, includeOrgPrefix: this.includeOrgPrefix })}`); } /** * Check if transports include mcp-stdio */ hasStdioTransport(transports) { return transports.some(t => t.type === 'mcp-stdio'); } /** * Setup log directory and files */ setupLogDirectory() { try { // Create the .herd/logs directory if it doesn't exist if (!fs.existsSync(this.logDir)) { fs.mkdirSync(this.logDir, { recursive: true }); } // Create log files if they don't exist if (!fs.existsSync(this.outputLogPath)) { fs.writeFileSync(this.outputLogPath, ''); } if (!fs.existsSync(this.errorLogPath)) { fs.writeFileSync(this.errorLogPath, ''); } } catch (error) { console.error('Failed to setup log directory:', error); } } /** * Append a log entry to a log file, respecting size limits */ logToFile(type, message) { try { const filePath = type === 'output' ? this.outputLogPath : this.errorLogPath; const timestamp = new Date().toISOString(); const logEntry = JSON.stringify({ timestamp, message }) + '\n'; // Check file size and truncate if necessary this.truncateLogIfNeeded(filePath); // Append the log entry fs.appendFileSync(filePath, logEntry); } catch (error) { // Only console.error if this is a critical error we should know about console.error(`Failed to write to ${type} log:`, error); } } /** * Truncate log file if it exceeds the maximum size */ truncateLogIfNeeded(filePath) { try { const stats = fs.statSync(filePath); if (stats.size >= MAX_LOG_SIZE) { // Read the last half of the file const halfSize = Math.floor(MAX_LOG_SIZE / 2); const buffer = Buffer.alloc(halfSize); const fd = fs.openSync(filePath, 'r'); fs.readSync(fd, buffer, 0, halfSize, stats.size - halfSize); fs.closeSync(fd); // Find the first newline to ensure we start with a complete line let startPos = 0; for (let i = 0; i < buffer.length; i++) { if (buffer[i] === 10) { // newline character startPos = i + 1; break; } } // Replace the file with the truncated content const truncatedContent = buffer.slice(startPos).toString(); fs.writeFileSync(filePath, truncatedContent); // Add a truncation notice const timestamp = new Date().toISOString(); fs.appendFileSync(filePath, `[${timestamp}] --- LOG TRUNCATED DUE TO SIZE LIMIT ---\n`); } } catch (error) { console.error(`Failed to truncate log file ${filePath}:`, error); } } /** * Safe logging that respects silent mode and stdio transport */ log(message) { // Always log to file this.logToFile('output', message); // Only log to console if not silent if (!this.silent) { console.log(message); } } /** * Error logging that respects stdio transport * (errors should be logged unless using stdio transport) */ logError(message, error) { // Always log errors to file const errorMessage = error ? `${message} ${error}` : message; this.logToFile('error', errorMessage); // Only log to console if not using stdio transport if (!this.usingStdioTransport) { if (error) { console.error(message, error); } else { console.error(message); } } } /** * Load a trail and convert its actions to MCP tools */ async loadTrailAndRegisterTools(trailIdentifier) { try { this.logToFile('output', `🔍 Loading trail: ${trailIdentifier}`); const isRemoteTrail = isRemoteTrailIdentifier(trailIdentifier); let trailPath = trailIdentifier; let org, trail, version; if (isRemoteTrail) { this.log(`🔍 Loading remote trail: ${trailIdentifier}`); this.logToFile('output', `Trail ${trailIdentifier} is a remote trail`); ({ org, trail, version } = parseTrailIdentifier(trailIdentifier)); trailPath = `${org ? `@${org}` : ''}/${trail}`; // Download the trail with caching this.logToFile('output', `Downloading trail: ${trailPath} (version: ${version || 'latest'})`); trailPath = await downloadTrail(this.client, trailPath, { cacheEnabled: this.cacheEnabled, version, silent: this.silent || this.usingStdioTransport }); this.logToFile('output', `Downloaded trail to: ${trailPath}`); } else { // Local trail path this.log(`🔍 Loading local trail: ${trailIdentifier}`); this.logToFile('output', `Trail ${trailIdentifier} is a local trail`); // Auto-build if enabled and path is a directory if (this.autoBuild && this.isLocalTrailDirectory(trailPath)) { this.log(`đŸ—ī¸ Building local trail at: ${trailPath}`); this.logToFile('output', `Building local trail at: ${trailPath}`); await buildTrail(trailPath, { silent: this.silent || this.usingStdioTransport }); this.logToFile('output', `Trail built successfully`); } } // Load the trail this.logToFile('output', `Loading trail from path: ${trailPath}`); const { actions, resources } = await loadTrail(trailPath); this.logToFile('output', `Loaded trail with ${Object.keys(actions).length} actions`); // Register each action as an MCP tool let registeredActionCount = 0; for (const [actionName, action] of Object.entries(actions)) { this.logToFile('output', `Registering action: ${actionName}`); this.registerActionAsTool(trailIdentifier, actionName, action); registeredActionCount++; } // Store the loaded trail for later use this.loadedTrails.set(trailIdentifier, { actions, resources }); const successMessage = `✅ Registered trail: ${trailIdentifier} with ${Object.keys(actions).length} actions`; this.logToFile('output', successMessage); this.log(successMessage); } catch (error) { const errorMessage = `❌ Error loading trail ${trailIdentifier}: ${error.message}`; this.logToFile('error', errorMessage); this.logError(`❌ Error loading trail ${trailIdentifier}:`, error.message); throw error; } } /** * Register a trail action as an MCP tool */ registerActionAsTool(trailIdentifier, actionName, action) { // Generate tool name based on the includeOrgPrefix setting let toolName; if (isRemoteTrailIdentifier(trailIdentifier)) { const { org, trail } = parseTrailIdentifier(trailIdentifier); toolName = this.includeOrgPrefix ? `${org}-${trail}-${actionName}` : `${trail}-${actionName}`; } else { // For local trails, just use the directory name const parts = trailIdentifier.split('/'); const trailName = parts[parts.length - 1]; toolName = `${trailName}-${actionName}`; } // Store the tool name mapping this.toolNameMappings.set(toolName, toolName); this.logToFile('output', `Creating tool with name: ${toolName}`); // Extract action manifest for parameter schema const manifest = action.manifest || {}; const description = manifest.description || `${actionName} action from ${trailIdentifier}`; this.logToFile('output', `Tool description: ${description}`); // Convert action parameters to zod schema const paramsSchema = {}; if (manifest.params) { for (const [paramName, paramInfoRaw] of Object.entries(manifest.params)) { // Cast to our extended type to access additional properties const paramInfo = paramInfoRaw; this.logToFile('output', `Processing parameter: ${paramName} (${paramInfo.type || 'any'})`); try { // Create a properly typed Zod validator with description let zodSchema; // Convert type to zod validator and apply constraints switch (paramInfo.type) { case 'string': zodSchema = z.string(); // Apply constraints where possible try { if (paramInfo.enum && Array.isArray(paramInfo.enum) && paramInfo.enum.length > 0) { // Use enum if available try { zodSchema = z.enum(paramInfo.enum); this.logToFile('output', `Parameter ${paramName} has enum values: ${paramInfo.enum.join(', ')}`); } catch (e) { // Fall back to regular string if enum fails zodSchema = z.string(); this.logToFile('output', `Failed to create enum for parameter ${paramName}, falling back to string`); } } } catch (e) { // Ignore constraint errors, keep basic string schema this.logToFile('output', `Error applying constraints to parameter ${paramName}: ${e}`); } break; case 'number': zodSchema = z.number(); break; case 'boolean': zodSchema = z.boolean(); break; case 'object': zodSchema = z.record(z.any()); break; case 'array': zodSchema = z.array(z.any()); break; default: zodSchema = z.any(); this.logToFile('output', `Unknown parameter type for ${paramName}: ${paramInfo.type}, using 'any'`); } // Add description to the schema if (paramInfo.description) { zodSchema = zodSchema.describe(paramInfo.description); } // Make param optional if not required if (!paramInfo.required) { zodSchema = zodSchema.optional(); this.logToFile('output', `Parameter ${paramName} is optional`); } else { this.logToFile('output', `Parameter ${paramName} is required`); } // Store the schema paramsSchema[paramName] = zodSchema; } catch (error) { this.logToFile('error', `Warning: Error creating schema for parameter ${paramName}: ${error}`); console.warn(`Warning: Error creating schema for parameter ${paramName}:`, error); // Fall back to any type paramsSchema[paramName] = z.any(); if (paramInfo.description) { paramsSchema[paramName] = paramsSchema[paramName].describe(paramInfo.description); } } } } // Create the tool handler const toolHandler = async (params, _extra) => { try { this.logToFile('output', `Executing tool: ${toolName} with params: ${JSON.stringify(params)}`); // Get the loaded trail const loadedTrail = this.loadedTrails.get(trailIdentifier); if (!loadedTrail) { const error = `Trail ${trailIdentifier} not found`; this.logToFile('error', error); throw new Error(error); } // Get a device const devices = await this.client.listDevices(); const device = devices[0]; if (!device) { const error = 'No devices available for running this action'; this.logToFile('error', error); throw new Error(error); } this.logToFile('output', `Running action ${actionName} on device ${device.deviceId}`); // Run the action const result = await action.run(device, params, loadedTrail.resources); this.logToFile('output', `Action completed successfully: ${toolName}`); // Format the result appropriately let formattedResponse; // If the result is an object with a 'content' field, use it directly if (result && typeof result === 'object' && 'content' in result && Array.isArray(result.content)) { formattedResponse = result; } else { // Default formatting as JSON formattedResponse = { content: [ { type: "text", text: JSON.stringify(result, null, 0) } ] }; } // Calculate total length if (this.abridge) { const totalLength = this.calculateTextLength(formattedResponse); this.logToFile('output', `Response length check: ${totalLength} characters (limit: ${this.maxResponseLength})`); // Apply abridging for MCP responses if needed if (totalLength > this.maxResponseLength) { // Process response for size limit const abridgedResponse = this.processResponseForSizeLimit(formattedResponse); // Add an explicit notice about abridging at the beginning of the response if (abridgedResponse && typeof abridgedResponse === 'object' && 'content' in abridgedResponse && Array.isArray(abridgedResponse.content)) { abridgedResponse.content.push({ type: "text", text: `[Response abridged due to length: ${Math.round(totalLength / 1000)}k → ${Math.round(this.maxResponseLength / 1000)}k chars]` }); return abridgedResponse; } return abridgedResponse; } } return formattedResponse; } catch (error) { this.logToFile('error', `Error executing tool ${toolName}: ${error.message}`); return { content: [ { type: "text", text: `Error: ${error.message}` } ], isError: true }; } }; // Register the MCP tool this.mcpServer.tool(toolName, description, paramsSchema, toolHandler); this.logToFile('output', `✅ Registered tool: ${toolName}`); this.log(` Registered tool: ${toolName}`); } /** * Initialize HTTP transport */ async initializeHttpTransport(config) { const port = config.port || 3000; const path = config.path || '/api'; this.logToFile('output', `Initializing HTTP transport on port ${port} with path ${path}`); this.expressApp = express(); // Enable CORS if configured if (config.cors) { this.logToFile('output', `Enabling CORS for HTTP transport: ${JSON.stringify(config.cors)}`); this.expressApp.use(cors(config.cors === true ? undefined : config.cors)); } // Parse JSON body this.expressApp.use(express.json()); // Set up API routes for trail actions // @ts-ignore - TypeScript has trouble with Express route handler return types. // The handler can return Promise<Response> which conflicts with Express's expected void | Promise<void> return type this.expressApp.post(`${path}/run/:trail/:action`, async (req, res) => { try { const { trail, action } = req.params; const params = req.body || {}; this.logToFile('output', `HTTP request received for trail: ${trail}, action: ${action}, params: ${JSON.stringify(params)}`); // Get the loaded trail const loadedTrail = this.loadedTrails.get(trail); if (!loadedTrail) { const errorMsg = `Trail ${trail} not found`; this.logToFile('error', errorMsg); return res.status(404).json({ error: errorMsg }); } // Check if action exists if (!loadedTrail.actions[action]) { const errorMsg = `Action ${action} not found in trail ${trail}`; this.logToFile('error', errorMsg); return res.status(404).json({ error: errorMsg }); } // Get a device const devices = await this.client.listDevices(); const device = devices[0]; if (!device) { const errorMsg = 'No devices available for running this action'; this.logToFile('error', errorMsg); return res.status(503).json({ error: errorMsg }); } this.logToFile('output', `Running HTTP action ${action} from trail ${trail} on device ${device.deviceId}`); // Run the action const result = await loadedTrail.actions[action].run(device, params, loadedTrail.resources); this.logToFile('output', `HTTP action completed successfully: ${trail}/${action}`); // Return the result res.json({ result }); } catch (error) { this.logToFile('error', `Error in HTTP handler: ${error.message}`); res.status(500).json({ error: error.message }); } }); // Start the HTTP server this.httpServer = this.expressApp.listen(port, () => { this.logToFile('output', `HTTP server listening on port ${port}`); this.log(`🚀 HTTP server listening on port ${port}`); }); } /** * Initialize MCP STDIO transport */ async initializeMcpStdioTransport() { this.logToFile('output', 'Initializing MCP STDIO transport'); // Create the base STDIO transport this.stdioTransport = new StdioServerTransport(); // Connect to MCP server await this.mcpServer.connect(this.stdioTransport); this.logToFile('output', 'MCP STDIO transport initialized and connected'); } /** * Initialize MCP SSE transport */ async initializeMcpSseTransport(config) { const port = config.port || 3001; const path = config.path || '/messages'; this.logToFile('output', `Initializing MCP SSE transport on port ${port} with path ${path}`); if (!this.expressApp) { this.expressApp = express(); // Enable CORS if configured if (config.cors) { this.logToFile('output', `Enabling CORS for SSE transport: ${JSON.stringify(config.cors)}`); this.expressApp.use(cors(config.cors === true ? undefined : config.cors)); } } // Create a separate HTTP server for SSE if we don't have one already const needNewServer = !this.httpServer; this.logToFile('output', `Creating ${needNewServer ? 'new' : 'existing'} HTTP server for SSE transport`); // Set up routes for SSE // @ts-ignore - TypeScript has trouble with Express route handler return types this.expressApp.get('/sse', (req, res) => { this.logToFile('output', 'New SSE connection requested'); // Close any existing connection if (this.sseTransport) { this.log('â„šī¸ New SSE connection requested, closing existing connection'); this.logToFile('output', 'Closing existing SSE connection'); // No need to await, we're replacing it anyway try { this.mcpServer.close().catch(err => { this.logToFile('error', `Error closing existing MCP server connection: ${err}`); this.logError('Error closing existing MCP server connection:', err); }); } catch (error) { // Ignore errors on close this.logToFile('error', `Error during close of existing connection: ${error}`); } this.sseTransport = undefined; } // Create a new transport with the connection this.sseTransport = new SSEServerTransport(path, res); this.logToFile('output', 'New SSE transport created'); // Wrap the send method to capture outgoing messages const originalSend = this.sseTransport.send.bind(this.sseTransport); // Log connection status this.log('🔌 New SSE connection established'); // Connect to the MCP server this.mcpServer.connect(this.sseTransport).catch(err => { this.logToFile('error', `Error connecting to SSE transport: ${err}`); this.logError('Error connecting to SSE transport:', err); }); // Handle client disconnect req.on('close', () => { this.logToFile('output', 'SSE client disconnected'); this.log('â„šī¸ SSE client disconnected'); // Don't set to undefined here as we need to detect connection status }); }); // Improved error handling for messages endpoint // @ts-ignore - TypeScript has trouble with Express route handler return types this.expressApp.post(path, (req, res) => { this.logToFile('output', `Received POST to ${path}`); // Check if there's an active SSE connection if (!this.sseTransport) { const errorMsg = 'No active SSE connection. Please connect to /sse first.'; this.logToFile('error', errorMsg); return res.status(503).json({ error: errorMsg }); } // Handle the message through the transport this.sseTransport.handlePostMessage(req, res).catch(err => { this.logToFile('error', `Error processing message: ${err.message}`); // Only try to send an error if headers haven't been sent yet if (!res.headersSent) { res.status(500).json({ error: `Error processing message: ${err.message}` }); } else { // Just log the error if headers already sent this.logError('Error processing message:', err.message); } }); }); // Start a new HTTP server if needed if (needNewServer) { this.httpServer = this.expressApp.listen(port, () => { this.logToFile('output', `MCP SSE server listening on port ${port}`); this.log(`🚀 MCP SSE server listening on port ${port}`); }); } } /** * Start the trail server */ async start() { try { this.logToFile('output', '🚀 Starting TrailServer'); // Initialize the client if (!this.client.isInitialized()) { await this.client.initialize(); } // Load all trails and register them as tools for (const trailIdentifier of this.trails) { await this.loadTrailAndRegisterTools(trailIdentifier); } // Initialize transports for (const transport of this.transports) { switch (transport.type) { case 'http': await this.initializeHttpTransport(transport); break; case 'mcp-sse': await this.initializeMcpSseTransport(transport); break; case 'mcp-stdio': await this.initializeMcpStdioTransport(); break; } } this.logToFile('output', '✅ TrailServer started successfully'); this.log('✅ Trail server started successfully'); } catch (error) { this.logToFile('error', `❌ Error starting TrailServer: ${error.message}`); this.logError('❌ Error starting trail server:', error.message); throw error; } } /** * Stop the trail server */ async stop() { this.logToFile('output', '🛑 Stopping TrailServer'); // Close MCP server connections try { await this.mcpServer.close(); // Clean up SSE transport reference this.sseTransport = undefined; this.logToFile('output', '✅ MCP server closed'); this.log('✅ MCP server closed'); } catch (error) { this.logToFile('error', `❌ Error closing MCP server: ${error}`); this.logError('Error closing MCP server:', error); } // Close HTTP server if it exists if (this.httpServer) { try { await new Promise((resolve, reject) => { this.httpServer.close((err) => { if (err) { reject(err); } else { resolve(); } }); }); this.logToFile('output', '✅ HTTP server closed'); this.log('✅ HTTP server closed'); } catch (error) { this.logToFile('error', `❌ Error closing HTTP server: ${error}`); this.logError('Error closing HTTP server:', error); } } this.logToFile('output', '✅ TrailServer stopped'); this.log('✅ Trail server stopped'); } /** * Check if a path is a local trail directory */ isLocalTrailDirectory(dirPath) { try { const fs = require('fs'); const path = require('path'); // Check if the path exists and is a directory if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { return false; } // Check for basic trail files return fs.existsSync(path.join(dirPath, 'actions.ts')) || fs.existsSync(path.join(dirPath, 'actions.js')) || fs.existsSync(path.join(dirPath, '.build/actions.js')); } catch (error) { return false; } } /** * Calculate the total text length in a response */ calculateTextLength(obj) { if (typeof obj === 'string') { return obj.length; } else if (typeof obj === 'object' && obj !== null) { if (Array.isArray(obj)) { // Handle arrays return obj.reduce((sum, item) => sum + this.calculateTextLength(item), 0); } else { // Special handling for MCP content objects if ('content' in obj && Array.isArray(obj.content)) { let contentLength = 0; // Process content array - where most of the text lives for (const item of obj.content) { if (item && typeof item === 'object') { // MCP content items usually have a text field if ('text' in item && typeof item.text === 'string') { contentLength += item.text.length; this.logToFile('output', `Found content item with text length: ${item.text.length}`); } else { // For other types of content items contentLength += this.calculateTextLength(item); } } else if (typeof item === 'string') { contentLength += item.length; } } this.logToFile('output', `Total content array length: ${contentLength}`); return contentLength; } // Regular object - process all properties return Object.values(obj).reduce((sum, value) => sum + this.calculateTextLength(value), 0); } } return 0; } /** * Abridge content recursively to fit within the limit */ abridgeContent(obj, scaleFactor) { // Special handling for MCP formatted responses with content arrays if (obj && typeof obj === 'object' && 'content' in obj && Array.isArray(obj.content)) { // Create a shallow copy of the response object const result = { ...obj }; // Handle the content array specifically if (result.content.length > 0) { this.logToFile('output', `Abridging content array with ${result.content.length} items and scale factor ${scaleFactor}`); // Make a copy of the content array result.content = [...result.content]; // Process each content item for (let i = 0; i < result.content.length; i++) { const item = result.content[i]; // Handle content items with text property (most common in MCP) if (item && typeof item === 'object' && 'text' in item && typeof item.text === 'string') { // Make a copy of the item result.content[i] = { ...item }; // If text is long, abridge it if (item.text.length > 100 && scaleFactor < 0.9) { const text = item.text; const targetLength = Math.max(100, Math.floor(text.length * scaleFactor)); // Abridge the text if (targetLength < text.length) { // Determine how many cuts to make based on text length const numCuts = Math.min(4, Math.ceil((text.length - targetLength) / 300)); if (numCuts <= 1) { // For small texts, just do a simple cut const firstPart = Math.floor(targetLength * 0.6); const lastPart = targetLength - firstPart - 10; result.content[i].text = text.slice(0, firstPart) + "\n[...]\n" + text.slice(-lastPart); } else { // For longer texts, do multiple smaller cuts const segmentLength = Math.floor(text.length / (numCuts + 1)); const keepPerSegment = Math.floor(targetLength / (numCuts + 1)); let abridgedText = ""; // First segment - keep more from beginning (important context) const firstKeep = Math.floor(keepPerSegment * 1.2); abridgedText += text.slice(0, firstKeep); abridgedText += "\n[...]\n"; // Middle segments for (let j = 1; j < numCuts; j++) { const segmentStart = j * segmentLength; const midpoint = segmentStart + Math.floor(segmentLength / 2); const halfKeep = Math.floor(keepPerSegment / 2); abridgedText += text.slice(Math.max(0, midpoint - halfKeep), Math.min(text.length, midpoint + halfKeep)); abridgedText += "\n[...]\n"; } // Last segment - keep more from end (conclusions) const lastKeep = Math.floor(keepPerSegment * 1.2); abridgedText += text.slice(-lastKeep); result.content[i].text = abridgedText; } this.logToFile('output', `Abridged text from ${text.length} to ${result.content[i].text.length} chars using ${numCuts} cuts`); } } } else { // For other content types, use the generic approach result.content[i] = this.abridgeContent(item, scaleFactor); } } // If there are many content items and scale factor is low, reduce the number of items if (result.content.length > 5 && scaleFactor < 0.5) { const originalLength = result.content.length; const keepCount = Math.max(3, Math.floor(result.content.length * scaleFactor)); if (keepCount < originalLength) { // Keep first, last, and sample from the middle const abridgedContent = [result.content[0]]; // Add middle samples const step = Math.floor((originalLength - 2) / (keepCount - 2)); for (let i = step; i < originalLength - 1; i += step) { abridgedContent.push(result.content[i]); } // Add last item abridgedContent.push(result.content[originalLength - 1]); // Add a notice that content was abridged abridgedContent.splice(1, 0, { type: "text", text: `[${originalLength - abridgedContent.length} content items were abridged to reduce response size]` }); result.content = abridgedContent; this.logToFile('output', `Reduced content items from ${originalLength} to ${result.content.length}`); } } } return result; } // Original implementation for other types if (typeof obj === 'string') { if (scaleFactor >= 1) { return obj; // No need to abridge } if (obj.length <= 100) { return obj; // Don't abridge very short strings } // Attempt to shorten the string proportionally const targetLength = Math.floor(obj.length * scaleFactor); // If JSON, try to parse and re-stringify with fewer spaces if (obj.trim().startsWith('{') || obj.trim().startsWith('[')) { try { const parsed = JSON.parse(obj); return JSON.stringify(parsed, null, 0); } catch (e) { // Not valid JSON, continue with normal abridging } } // Simple approach: keep first and last parts with ellipsis in the middle if (targetLength < obj.length) { const firstPart = Math.floor(targetLength * 0.6); // 60% from start const lastPart = targetLength - firstPart - 5; // Rest from end, minus ellipsis return obj.slice(0, firstPart) + " ... " + obj.slice(-lastPart); } return obj; } else if (Array.isArray(obj)) { if (obj.length === 0) { return obj; } // If array is too large, sample entries if (scaleFactor < 0.5 && obj.length > 10) { // Keep first, last, and sample some middle entries const samplesToKeep = Math.max(5, Math.floor(obj.length * scaleFactor)); if (samplesToKeep >= obj.length) { // Just abridge each item if we're keeping all return obj.map(item => this.abridgeContent(item, scaleFactor)); } // Keep first item, last item and sample some in the middle const result = [ this.abridgeContent(obj[0], scaleFactor) ]; // Sample middle items const step = Math.floor((obj.length - 2) / (samplesToKeep - 2)); for (let i = step; i < obj.length - step; i += step) { result.push(this.abridgeContent(obj[i], scaleFactor)); } // Add last item result.push(this.abridgeContent(obj[obj.length - 1], scaleFactor)); return result; } // Otherwise, just abridge each item return obj.map(item => this.abridgeContent(item, scaleFactor)); } else if (typeof obj === 'object' && obj !== null) { const result = {}; // For objects, abridge each value for (const [key, value] of Object.entries(obj)) { result[key] = this.abridgeContent(value, scaleFactor); } return result; } // For other types (numbers, booleans, null, etc.), return as is return obj; } /** * Process response to ensure it fits within the limit */ processResponseForSizeLimit(response) { // Skip if abridging is disabled if (!this.abridge) { this.logToFile('output', `Response abridging is disabled, returning original response`); return response; } try { // Check if we have a content property with text if (response && typeof response === 'object' && 'content' in response && Array.isArray(response.content)) { // Calculate total text length const totalLength = this.calculateTextLength(response); this.logToFile('output', `Response size check: length=${totalLength}, limit=${this.maxResponseLength}`); if (totalLength > this.maxResponseLength) { this.logToFile('output', `Response exceeds length limit (${totalLength} > ${this.maxResponseLength}), abridging content`); // Calculate scale factor to target maxResponseLength const scaleFactor = this.maxResponseLength / totalLength; this.logToFile('output', `Using scale factor: ${scaleFactor.toFixed(2)}`); // Create abridged copy const abridgedResponse = this.abridgeContent(response, scaleFactor); // Log abridgement effect const newLength = this.calculateTextLength(abridgedResponse); this.logToFile('output', `Abridged response from ${totalLength} to ${newLength} characters (${Math.round(newLength / totalLength * 100)}%)`); return abridgedResponse; } else { this.logToFile('output', `Response is within size limits, no abridging needed`); } } else { this.logToFile('output', `Response doesn't have expected content structure for abridging: ${JSON.stringify(response).slice(0, 100)}...`); } } catch (error) { this.logToFile('error', `Error while trying to abridge response: ${error}`); // On error, return the original response } return response; } }