@monitoro/herd
Version:
Automate your browser, build AI web tools and MCP servers with Monitoro Herd
912 lines (911 loc) âĸ 44.6 kB
JavaScript
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;
}
}