@wonderwhy-er/desktop-commander
Version:
MCP server for terminal operations and file editing
258 lines (257 loc) • 9.09 kB
JavaScript
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
// Format timestamp in local timezone for display
function formatLocalTimestamp(isoTimestamp) {
const date = new Date(isoTimestamp);
return date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}
class ToolHistory {
constructor() {
this.history = [];
this.MAX_ENTRIES = 1000;
this.MAX_HISTORY_FILE_SIZE_BYTES = 5 * 1024 * 1024;
// When the file exceeds the cap we trim it down to this target instead of
// all the way to zero, so a single overflow doesn't cause every subsequent
// flush to re-trim.
this.HISTORY_FILE_TRIM_TARGET_BYTES = 4 * 1024 * 1024;
this.writeQueue = [];
this.isWriting = false;
// Store history in same directory as config to keep everything together
const historyDir = path.join(os.homedir(), '.claude-server-commander');
// Ensure directory exists
if (!fs.existsSync(historyDir)) {
fs.mkdirSync(historyDir, { recursive: true });
}
// Use append-only JSONL format (JSON Lines)
this.historyFile = path.join(historyDir, 'tool-history.jsonl');
// Load existing history on startup
this.loadFromDisk();
// Start async write processor
this.startWriteProcessor();
}
/**
* Load history from disk (all instances share the same file)
*/
loadFromDisk() {
try {
if (!fs.existsSync(this.historyFile)) {
return;
}
// If the file is over the cap, trim it down before reading so we
// load a bounded amount.
this.trimHistoryFileIfTooLarge();
const content = fs.readFileSync(this.historyFile, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.trim());
// Parse each line as JSON
const records = [];
for (const line of lines) {
try {
records.push(JSON.parse(line));
}
catch (e) {
// Silently skip invalid lines
}
}
// Keep only last 1000 entries
this.history = records.slice(-this.MAX_ENTRIES);
// If file is getting too large, trim it
if (lines.length > this.MAX_ENTRIES * 2) {
this.trimHistoryFile();
}
}
catch (error) {
// Silently fail
}
}
/**
* Trim the on-disk history file to stay under the size cap by dropping the
* oldest entries (lines) until the kept tail fits within the trim target.
* Returns true only when the file was actually rewritten with a smaller
* tail, so callers can fall through to their normal path on failure or
* no-op rather than mutating in-memory state.
*
* Always keeps at least the most recent entry, even if a single record
* exceeds the trim target — there is no useful state below that.
*/
trimHistoryFileIfTooLarge() {
let stats;
try {
if (!fs.existsSync(this.historyFile)) {
return false;
}
stats = fs.statSync(this.historyFile);
if (stats.size <= this.MAX_HISTORY_FILE_SIZE_BYTES) {
return false;
}
}
catch (error) {
return false;
}
try {
const content = fs.readFileSync(this.historyFile, 'utf-8');
const lines = content.split('\n').filter(line => line.length > 0);
if (lines.length === 0) {
return false;
}
// Walk lines from newest to oldest, accumulating bytes (line + '\n'),
// and keep as many as fit within the trim target. Always keep at
// least the last line.
const kept = [];
let bytes = 0;
for (let i = lines.length - 1; i >= 0; i--) {
const lineBytes = Buffer.byteLength(lines[i], 'utf-8') + 1; // +1 for '\n'
if (kept.length > 0 && bytes + lineBytes > this.HISTORY_FILE_TRIM_TARGET_BYTES) {
break;
}
kept.push(lines[i]);
bytes += lineBytes;
}
kept.reverse();
fs.writeFileSync(this.historyFile, kept.join('\n') + '\n', 'utf-8');
return true;
}
catch (error) {
// Trim failed; do not claim the file was changed.
return false;
}
}
/**
* Trim history file to prevent it from growing indefinitely
*/
trimHistoryFile() {
try {
// Keep last 1000 entries in memory
const keepEntries = this.history.slice(-this.MAX_ENTRIES);
// Write them back
const lines = keepEntries.map(entry => JSON.stringify(entry)).join('\n') + '\n';
fs.writeFileSync(this.historyFile, lines, 'utf-8');
}
catch (error) {
// Silently fail
}
}
/**
* Async write processor - batches writes to avoid blocking
*/
startWriteProcessor() {
this.writeInterval = setInterval(() => {
if (this.writeQueue.length > 0 && !this.isWriting) {
this.flushToDisk();
}
}, 1000); // Flush every second
// Prevent interval from keeping process alive during shutdown/tests
this.writeInterval.unref();
}
/**
* Flush queued writes to disk
*/
async flushToDisk() {
if (this.isWriting || this.writeQueue.length === 0)
return;
this.isWriting = true;
const toWrite = [...this.writeQueue];
this.writeQueue = [];
try {
// If the on-disk file has grown past the cap, trim it down to the
// target size (keeping the most recent entries) before appending.
// The in-memory cache is unaffected — it is already bounded by
// MAX_ENTRIES via addCall.
this.trimHistoryFileIfTooLarge();
// Append to file (atomic append operation)
const lines = toWrite.map(entry => JSON.stringify(entry)).join('\n') + '\n';
fs.appendFileSync(this.historyFile, lines, 'utf-8');
}
catch (error) {
// Put back in queue on failure
this.writeQueue.unshift(...toWrite);
}
finally {
this.isWriting = false;
}
}
/**
* Add a tool call to history
*/
addCall(toolName, args, output, duration) {
const record = {
timestamp: new Date().toISOString(),
toolName,
arguments: args,
output,
duration
};
this.history.push(record);
// Keep only last 1000 in memory
if (this.history.length > this.MAX_ENTRIES) {
this.history.shift();
}
// Queue for async write
this.writeQueue.push(record);
}
/**
* Get recent tool calls with filters
*/
getRecentCalls(options) {
let results = [...this.history];
// Filter by tool name
if (options.toolName) {
results = results.filter(r => r.toolName === options.toolName);
}
// Filter by timestamp
if (options.since) {
const sinceDate = new Date(options.since);
results = results.filter(r => new Date(r.timestamp) >= sinceDate);
}
// Limit results (default 50, max 1000)
const limit = Math.min(options.maxResults || 50, 1000);
return results.slice(-limit);
}
/**
* Get recent calls formatted with local timezone
*/
getRecentCallsFormatted(options) {
const calls = this.getRecentCalls(options);
// Format timestamps to local timezone
return calls.map(call => ({
...call,
timestamp: formatLocalTimestamp(call.timestamp)
}));
}
/**
* Get current stats
*/
getStats() {
return {
totalEntries: this.history.length,
oldestEntry: this.history[0]?.timestamp,
newestEntry: this.history[this.history.length - 1]?.timestamp,
historyFile: this.historyFile,
queuedWrites: this.writeQueue.length
};
}
/**
* Cleanup method - clears interval and flushes pending writes
* Call this during shutdown or in tests
*/
async cleanup() {
// Clear the interval
if (this.writeInterval) {
clearInterval(this.writeInterval);
this.writeInterval = undefined;
}
// Flush any remaining writes
if (this.writeQueue.length > 0) {
await this.flushToDisk();
}
}
}
export const toolHistory = new ToolHistory();