aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
293 lines (248 loc) • 8.21 kB
JavaScript
/**
* Chat handler for 2-way AI communication via messaging platforms.
*
* Spawns `claude -p` to process free-text messages and returns responses.
* Tracks conversation context per chat/user for multi-turn conversations.
*
* @implements @.aiwg/requirements/use-cases/UC-CHAT-001.md
* @tests @test/unit/messaging/chat-handler.test.js
*/
import { spawn } from 'node:child_process';
import path from 'path';
/**
* @typedef {Object} ChatHandlerOptions
* @property {string} [agentCommand='claude'] - Command to spawn for AI processing
* @property {string[]} [agentArgs=[]] - Additional args for the agent command
* @property {string} [cwd] - Working directory for spawned processes
* @property {number} [maxContextMessages=10] - Max messages to include as context
* @property {number} [timeoutMs=120000] - Timeout for AI response (2 minutes)
* @property {number} [maxConcurrent=3] - Max concurrent AI processes
* @property {number} [maxResponseLength=4000] - Max response chars (Telegram limit)
*/
export class ChatHandler {
/** @type {string} */
#agentCommand;
/** @type {string[]} */
#agentArgs;
/** @type {string} */
#cwd;
/** @type {number} */
#maxContextMessages;
/** @type {number} */
#timeoutMs;
/** @type {number} */
#maxConcurrent;
/** @type {number} */
#maxResponseLength;
/** @type {Map<string, Array<{role: string, content: string, timestamp: string}>>} */
#conversations;
/** @type {number} */
#activeProcesses;
/** @type {Map<string, boolean>} */
#processingChats;
/**
* @param {ChatHandlerOptions} options
*/
constructor(options = {}) {
this.#agentCommand = options.agentCommand || 'claude';
this.#agentArgs = options.agentArgs || [];
this.#cwd = options.cwd || process.cwd();
this.#maxContextMessages = options.maxContextMessages || 10;
this.#timeoutMs = options.timeoutMs || 120_000;
this.#maxConcurrent = options.maxConcurrent || 3;
this.#maxResponseLength = options.maxResponseLength || 4000;
this.#conversations = new Map();
this.#activeProcesses = 0;
this.#processingChats = new Map();
}
/**
* Process a chat message and return the AI response.
*
* @param {string} text - The user's message
* @param {Object} context - Platform context
* @param {string} context.chatId - Chat/conversation identifier
* @param {string} context.platform - Platform name
* @param {Object} [context.from] - Sender info
* @returns {Promise<{response: string, conversationId: string}>}
*/
async processMessage(text, context) {
const conversationId = this.#getConversationId(context);
// Check if this chat is already being processed
if (this.#processingChats.get(conversationId)) {
return {
response: 'Still processing your previous message. Please wait.',
conversationId,
};
}
// Check concurrency limit
if (this.#activeProcesses >= this.#maxConcurrent) {
return {
response: `AI is busy (${this.#activeProcesses}/${this.#maxConcurrent} active). Please try again shortly.`,
conversationId,
};
}
// Record user message in conversation history
this.#addMessage(conversationId, 'user', text);
// Build prompt with conversation context
const prompt = this.#buildPrompt(conversationId, text);
this.#processingChats.set(conversationId, true);
this.#activeProcesses++;
try {
const response = await this.#spawnAgent(prompt);
const truncated = this.#truncateResponse(response);
// Record assistant response
this.#addMessage(conversationId, 'assistant', truncated);
return { response: truncated, conversationId };
} catch (error) {
return {
response: `Error: ${error.message}`,
conversationId,
};
} finally {
this.#activeProcesses--;
this.#processingChats.set(conversationId, false);
}
}
/**
* Get conversation history for a chat.
*
* @param {string} conversationId
* @returns {Array<{role: string, content: string, timestamp: string}>}
*/
getConversation(conversationId) {
return [...(this.#conversations.get(conversationId) || [])];
}
/**
* Clear conversation history for a chat.
*
* @param {string} conversationId
*/
clearConversation(conversationId) {
this.#conversations.delete(conversationId);
}
/**
* Get stats about active processing.
*
* @returns {{activeProcesses: number, maxConcurrent: number, conversationCount: number}}
*/
getStats() {
return {
activeProcesses: this.#activeProcesses,
maxConcurrent: this.#maxConcurrent,
conversationCount: this.#conversations.size,
};
}
// --- Private methods ---
/**
* Get a stable conversation ID from context.
*
* @param {Object} context
* @returns {string}
*/
#getConversationId(context) {
return `${context.platform || 'unknown'}:${context.chatId || 'default'}`;
}
/**
* Add a message to conversation history.
*
* @param {string} conversationId
* @param {string} role
* @param {string} content
*/
#addMessage(conversationId, role, content) {
if (!this.#conversations.has(conversationId)) {
this.#conversations.set(conversationId, []);
}
const history = this.#conversations.get(conversationId);
history.push({
role,
content,
timestamp: new Date().toISOString(),
});
// Trim to max context size
while (history.length > this.#maxContextMessages * 2) {
history.shift();
}
}
/**
* Build a prompt that includes conversation context.
*
* @param {string} conversationId
* @param {string} currentMessage
* @returns {string}
*/
#buildPrompt(conversationId, currentMessage) {
const history = this.#conversations.get(conversationId) || [];
// If there's only the current message, just return it
if (history.length <= 1) {
return currentMessage;
}
// Build context from previous messages (excluding the current one which was just added)
const contextMessages = history.slice(0, -1).slice(-this.#maxContextMessages);
if (contextMessages.length === 0) {
return currentMessage;
}
const contextBlock = contextMessages
.map((msg) => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}`)
.join('\n');
return `Previous conversation context:\n${contextBlock}\n\nCurrent message:\n${currentMessage}`;
}
/**
* Spawn the AI agent and return its response.
*
* @param {string} prompt
* @returns {Promise<string>}
*/
#spawnAgent(prompt) {
return new Promise((resolve, reject) => {
const args = [...this.#agentArgs, '-p', prompt];
let stdout = '';
let stderr = '';
const proc = spawn(this.#agentCommand, args, {
cwd: this.#cwd,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env },
});
const timeout = setTimeout(() => {
try {
proc.kill('SIGTERM');
} catch {
// Process may have already exited
}
reject(new Error('AI response timed out'));
}, this.#timeoutMs);
proc.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
proc.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
proc.on('exit', (code, signal) => {
clearTimeout(timeout);
if (signal === 'SIGTERM' || signal === 'SIGKILL') {
reject(new Error('AI process was terminated'));
} else if (code === 0) {
resolve(stdout.trim());
} else {
reject(new Error(`AI process exited with code ${code}`));
}
});
proc.on('error', (err) => {
clearTimeout(timeout);
reject(new Error(`Failed to spawn AI: ${err.message}`));
});
});
}
/**
* Truncate response to fit platform limits.
*
* @param {string} response
* @returns {string}
*/
#truncateResponse(response) {
if (response.length <= this.#maxResponseLength) {
return response;
}
return response.slice(0, this.#maxResponseLength - 20) + '\n\n[...truncated]';
}
}