smalltalk-ai
Version:
A complete TypeScript framework for building LLM applications with agent support and MCP integration
720 lines β’ 29.4 kB
JavaScript
import { EventEmitter } from 'events';
import { nanoid } from 'nanoid';
import { readdirSync, statSync } from 'fs';
import { join } from 'path';
import { Chat } from './Chat.js';
import { Memory } from './Memory.js';
import { MCPClient } from './MCPClient.js';
import { InteractiveOrchestratorAgent } from '../agents/InteractiveOrchestratorAgent.js';
import { ManifestParser } from '../utils/ManifestParser.js';
import { Agent as AgentClass } from '../agents/Agent.js';
export class SmallTalk extends EventEmitter {
config;
agents = new Map();
interfaces = [];
chat;
memory;
mcpClient;
orchestrator;
isRunning = false;
activeSessions = new Map();
currentAgents = new Map(); // userId -> agentName
orchestrationEnabled = true;
streamingEnabled = false;
interruptionEnabled = false;
lastAnalysisResponse = null;
constructor(config = {}) {
super();
this.config = {
llmProvider: 'openai',
model: 'gpt-4o',
temperature: 0.7,
maxTokens: 2048,
debugMode: false,
orchestration: true,
...config
};
this.chat = new Chat(this.config);
this.memory = new Memory({
maxMessages: 100,
truncationStrategy: 'sliding_window',
contextSize: 4000
});
// Initialize interactive orchestrator
this.orchestrator = new InteractiveOrchestratorAgent({
...this.config,
maxAutoResponses: this.config.orchestrationConfig?.maxAutoResponses || 10
});
this.orchestrationEnabled = this.config.orchestration !== false;
this.streamingEnabled = this.config.orchestrationConfig?.streamResponses || false;
this.interruptionEnabled = this.config.orchestrationConfig?.enableInterruption || true;
this.setupOrchestratorEventHandlers();
this.setupEventHandlers();
}
setupEventHandlers() {
this.on('message', this.handleMessage.bind(this));
this.on('agent_switch', this.handleAgentSwitch.bind(this));
this.on('session_start', this.handleSessionStart.bind(this));
this.on('session_end', this.handleSessionEnd.bind(this));
}
setupOrchestratorEventHandlers() {
// Listen to plan execution events
this.orchestrator.on('plan_created', (event) => {
this.emit('plan_created', event);
if (this.config.debugMode) {
console.log(`[SmallTalk] π Plan created: ${event.planId}`);
}
});
this.orchestrator.on('step_started', (event) => {
this.emit('step_started', event);
if (this.config.debugMode) {
console.log(`[SmallTalk] βΆοΈ Step started: ${event.stepId} in plan ${event.planId}`);
}
});
this.orchestrator.on('step_completed', (event) => {
this.emit('step_completed', event);
if (this.config.debugMode) {
console.log(`[SmallTalk] β
Step completed: ${event.stepId} in plan ${event.planId}`);
}
});
this.orchestrator.on('agent_response', (event) => {
// Forward agent responses to interfaces for immediate display
this.interfaces.forEach(iface => {
if (typeof iface.displayAgentResponse === 'function') {
iface.displayAgentResponse(event);
}
});
this.emit('agent_response', event);
});
this.orchestrator.on('analysis_response', (event) => {
// Store the analysis response to return to user
this.lastAnalysisResponse = event.response;
this.emit('analysis_response', event);
});
this.orchestrator.on('user_interrupted', (event) => {
this.emit('user_interrupted', event);
if (this.config.debugMode) {
console.log(`[SmallTalk] βΈοΈ User interrupted plan: ${event.planId}`);
}
});
this.orchestrator.on('plan_completed', (event) => {
this.emit('plan_completed', event);
if (this.config.debugMode) {
console.log(`[SmallTalk] π Plan completed: ${event.planId}`);
}
});
this.orchestrator.on('auto_response_limit_reached', (data) => {
this.emit('auto_response_limit_reached', data);
if (this.config.debugMode) {
console.log(`[SmallTalk] π Auto-response limit reached for user: ${data.userId}`);
}
});
// Setup streaming response handler
if (this.streamingEnabled) {
this.orchestrator.onStreamingResponse((response) => {
this.handleStreamingResponse(response);
});
}
}
addAgent(agent, capabilities) {
this.agents.set(agent.name, agent);
// Register with orchestrator if capabilities provided
if (capabilities && this.orchestrationEnabled) {
this.orchestrator.registerAgent(agent, capabilities);
}
else if (this.orchestrationEnabled) {
// Infer capabilities from agent properties
const inferredCapabilities = this.inferAgentCapabilities(agent);
this.orchestrator.registerAgent(agent, inferredCapabilities);
}
this.emit('agent_added', { name: agent.name, config: agent.config, capabilities });
if (this.config.debugMode) {
console.log(`[SmallTalk] Agent '${agent.name}' added to framework`);
}
}
removeAgent(name) {
const removed = this.agents.delete(name);
if (removed) {
this.emit('agent_removed', { name });
if (this.config.debugMode) {
console.log(`[SmallTalk] Agent '${name}' removed from framework`);
}
}
return removed;
}
getAgent(name) {
return this.agents.get(name);
}
listAgents() {
return Array.from(this.agents.keys());
}
getAgents() {
return Array.from(this.agents.values());
}
/**
* Add agent from manifest file (YAML or JSON)
*/
async addAgentFromFile(filePath) {
try {
const parser = new ManifestParser();
const manifest = parser.parseManifestFile(filePath);
// Create agent from manifest config
const agent = new AgentClass(manifest.config);
// Add agent with capabilities if provided
this.addAgent(agent, manifest.capabilities);
this.emit('agent_loaded_from_file', {
filePath,
agentName: agent.name,
manifest
});
if (this.config.debugMode) {
console.log(`[SmallTalk] Agent '${agent.name}' loaded from manifest: ${filePath}`);
}
return agent;
}
catch (error) {
const errorMessage = `Failed to load agent from file ${filePath}: ${error instanceof Error ? error.message : String(error)}`;
if (this.config.debugMode) {
console.error(`[SmallTalk] ${errorMessage}`);
}
throw new Error(errorMessage);
}
}
/**
* Load all agents from a directory containing manifest files
*/
async loadAgentsFromDirectory(dirPath, pattern) {
const defaultPattern = /\.(yaml|yml|json)$/i;
const filePattern = pattern || defaultPattern;
const loadedAgents = [];
const errors = [];
try {
const files = readdirSync(dirPath);
const manifestFiles = files.filter(file => {
const fullPath = join(dirPath, file);
return statSync(fullPath).isFile() && filePattern.test(file);
});
if (manifestFiles.length === 0) {
if (this.config.debugMode) {
console.log(`[SmallTalk] No manifest files found in directory: ${dirPath}`);
}
return loadedAgents;
}
// Load each manifest file
for (const file of manifestFiles) {
try {
const filePath = join(dirPath, file);
const agent = await this.addAgentFromFile(filePath);
loadedAgents.push(agent);
}
catch (error) {
const errorMessage = `Failed to load ${file}: ${error instanceof Error ? error.message : String(error)}`;
errors.push(errorMessage);
if (this.config.debugMode) {
console.error(`[SmallTalk] ${errorMessage}`);
}
}
}
this.emit('agents_loaded_from_directory', {
dirPath,
loadedCount: loadedAgents.length,
errorCount: errors.length,
agentNames: loadedAgents.map(a => a.name)
});
if (this.config.debugMode) {
console.log(`[SmallTalk] Loaded ${loadedAgents.length} agents from directory: ${dirPath}`);
if (errors.length > 0) {
console.log(`[SmallTalk] ${errors.length} files failed to load`);
}
}
// If there were errors but some agents loaded successfully, log the errors but don't throw
if (errors.length > 0 && loadedAgents.length === 0) {
throw new Error(`Failed to load any agents from directory ${dirPath}:\n${errors.join('\n')}`);
}
return loadedAgents;
}
catch (error) {
if (error instanceof Error && error.message.includes('ENOENT')) {
throw new Error(`Directory not found: ${dirPath}`);
}
throw error;
}
}
/**
* Create agent manifest template file
*/
static createAgentManifestTemplate(agentName, format = 'yaml') {
const template = ManifestParser.createTemplate(agentName);
return format === 'yaml' ? ManifestParser.toYaml(template) : ManifestParser.toJson(template);
}
addInterface(iface) {
this.interfaces.push(iface);
// Set framework reference so interface can access agents
iface.setFramework(this);
// Set up message handling for this interface
iface.onMessage(async (message) => {
return await this.processMessage(message);
});
this.emit('interface_added', { type: iface.constructor.name });
if (this.config.debugMode) {
console.log(`[SmallTalk] Interface '${iface.constructor.name}' added to framework`);
}
}
async enableMCP(servers) {
this.mcpClient = new MCPClient();
for (const serverConfig of servers) {
try {
await this.mcpClient.connect(serverConfig);
if (this.config.debugMode) {
console.log(`[SmallTalk] Connected to MCP server: ${serverConfig.name}`);
}
}
catch (error) {
console.error(`[SmallTalk] Failed to connect to MCP server ${serverConfig.name}:`, error);
}
}
// Make MCP tools available to all agents
const tools = await this.mcpClient.getAvailableTools();
for (const agent of this.agents.values()) {
for (const tool of tools) {
agent.addTool(tool);
}
}
this.emit('mcp_enabled', { servers: servers.map(s => s.name) });
}
async start() {
if (this.isRunning) {
throw new Error('SmallTalk framework is already running');
}
// Start all interfaces
for (const iface of this.interfaces) {
try {
await iface.start();
if (this.config.debugMode) {
console.log(`[SmallTalk] Interface '${iface.constructor.name}' started`);
}
}
catch (error) {
console.error(`[SmallTalk] Failed to start interface '${iface.constructor.name}':`, error);
}
}
this.isRunning = true;
this.emit('framework_started', { config: this.config });
if (this.config.debugMode) {
console.log('[SmallTalk] Framework started successfully');
console.log(`[SmallTalk] Available agents: ${this.listAgents().join(', ')}`);
}
}
async stop() {
if (!this.isRunning) {
return;
}
// Stop all interfaces
for (const iface of this.interfaces) {
try {
await iface.stop();
if (this.config.debugMode) {
console.log(`[SmallTalk] Interface '${iface.constructor.name}' stopped`);
}
}
catch (error) {
console.error(`[SmallTalk] Failed to stop interface '${iface.constructor.name}':`, error);
}
}
// Disconnect MCP client
if (this.mcpClient) {
await this.mcpClient.disconnect();
}
this.isRunning = false;
this.emit('framework_stopped');
if (this.config.debugMode) {
console.log('[SmallTalk] Framework stopped');
}
}
createSession(sessionId) {
const id = sessionId || nanoid();
const session = {
id,
messages: [],
createdAt: new Date(),
updatedAt: new Date()
};
this.activeSessions.set(id, session);
this.emit('session_created', { sessionId: id });
return id;
}
getSession(sessionId) {
return this.activeSessions.get(sessionId);
}
deleteSession(sessionId) {
const deleted = this.activeSessions.delete(sessionId);
if (deleted) {
this.emit('session_deleted', { sessionId });
}
return deleted;
}
async processMessage(content, sessionId, userId) {
const session = sessionId ? this.getSession(sessionId) : this.createTempSession();
if (!session) {
throw new Error('Session not found');
}
const effectiveUserId = userId || sessionId || 'default';
// Create user message
const userMessage = {
id: nanoid(),
role: 'user',
content,
timestamp: new Date()
};
session.messages.push(userMessage);
session.updatedAt = new Date();
// Check for agent switching commands
const agentMatch = content.match(/^\/agent\s+([\w-]+)/);
if (agentMatch) {
const agentName = agentMatch[1];
if (this.agents.has(agentName)) {
session.activeAgent = agentName;
this.currentAgents.set(effectiveUserId, agentName);
return `Switched to agent: ${agentName}`;
}
else {
const availableAgents = this.listAgents();
const suggestions = availableAgents.filter(name => name.toLowerCase().includes(agentName.toLowerCase()) ||
agentName.toLowerCase().includes(name.toLowerCase()));
let errorMessage = `Agent '${agentName}' not found.`;
if (suggestions.length > 0) {
errorMessage += ` Did you mean: ${suggestions.join(', ')}?`;
}
errorMessage += ` Available agents: ${availableAgents.join(', ')}`;
errorMessage += `\nπ‘ Tip: Agent names can contain letters, numbers, hyphens (-), and underscores (_)`;
return errorMessage;
}
}
// Check for orchestration commands
if (content.match(/^\/orchestration\s+(on|off)/)) {
const command = content.match(/^\/orchestration\s+(on|off)/)?.[1];
this.orchestrationEnabled = command === 'on';
return `Orchestration ${this.orchestrationEnabled ? 'enabled' : 'disabled'}`;
}
let selectedAgentName;
let handoffReason = null;
// Use interactive orchestrator for enhanced planning and execution
if (this.orchestrationEnabled && this.agents.size > 1) {
try {
const currentAgent = this.currentAgents.get(effectiveUserId) || session.activeAgent;
const orchestrationResult = await this.orchestrator.orchestrateWithPlan(content, effectiveUserId, session.id, currentAgent);
if (!orchestrationResult.shouldExecute) {
// Auto-response limit reached or other constraint
return 'I\'ve reached the maximum number of automatic responses. Please let me know if you\'d like me to continue.';
}
if (orchestrationResult.plan) {
// Execute the plan step by step
const planExecuted = await this.orchestrator.executePlan(orchestrationResult.plan.id, session.id, effectiveUserId, this.streamingEnabled ? (response) => this.handleStreamingResponse(response) : undefined);
if (planExecuted) {
// Return the intelligent analysis response if available
const response = this.lastAnalysisResponse || 'β
Plan completed successfully.';
this.lastAnalysisResponse = null; // Clear for next plan
return response;
}
else {
return 'Plan execution was paused or failed. Please provide guidance to continue.';
}
}
else if (orchestrationResult.handoff) {
// Standard handoff
selectedAgentName = orchestrationResult.handoff.targetAgent;
handoffReason = orchestrationResult.handoff.reason;
// Update current agent tracking
this.currentAgents.set(effectiveUserId, selectedAgentName);
session.activeAgent = selectedAgentName;
this.emit('agent_handoff', {
userId: effectiveUserId,
fromAgent: currentAgent,
toAgent: selectedAgentName,
reason: handoffReason,
confidence: orchestrationResult.handoff.confidence
});
if (this.config.debugMode) {
console.log(`[SmallTalk] π― Orchestrator: ${handoffReason}`);
}
}
else {
// Stay with current agent
selectedAgentName = currentAgent || this.listAgents()[0];
}
}
catch (error) {
console.error('[SmallTalk] Interactive orchestration error:', error);
// Fallback to current or first available agent
selectedAgentName = this.currentAgents.get(effectiveUserId) || session.activeAgent || this.listAgents()[0];
}
}
else {
// Use current agent or default to first available
selectedAgentName = this.currentAgents.get(effectiveUserId) || session.activeAgent || this.listAgents()[0];
}
const agent = selectedAgentName ? this.agents.get(selectedAgentName) : undefined;
if (!agent) {
return 'No agents available. Please add an agent to the framework.';
}
// Prepare context for agent
const context = {
session,
message: userMessage,
agent,
tools: this.mcpClient ? await this.mcpClient.getAvailableTools() : [],
config: this.config
};
// Use enhanced history management
const managedMessages = await this.memory.manageHistory(session.messages);
const managedSession = { ...session, messages: managedMessages };
const managedContext = { ...context, session: managedSession };
// Generate response from agent
try {
let response = await agent.generateResponse(content, managedContext);
// Add handoff explanation if orchestrator switched agents
if (handoffReason && this.config.debugMode) {
response = `π€ *${agent.name}*: ${response}`;
}
// Create assistant message
const assistantMessage = {
id: nanoid(),
role: 'assistant',
content: response,
timestamp: new Date(),
agentName: agent.name
};
session.messages.push(assistantMessage);
session.updatedAt = new Date();
this.emit('message_processed', {
sessionId: session.id,
userMessage,
assistantMessage,
agentName: agent.name,
orchestrated: !!handoffReason
});
return response;
}
catch (error) {
const errorMessage = `Error generating response: ${error instanceof Error ? error.message : String(error)}`;
if (this.config.debugMode) {
console.error('[SmallTalk] Agent response error:', error);
}
return errorMessage;
}
}
createTempSession() {
return {
id: nanoid(),
messages: [],
createdAt: new Date(),
updatedAt: new Date()
};
}
async handleMessage(data) {
try {
await this.processMessage(data.content, data.sessionId);
}
catch (error) {
console.error('[SmallTalk] Message handling error:', error);
}
}
handleAgentSwitch(data) {
const session = this.getSession(data.sessionId);
if (session && this.agents.has(data.agentName)) {
session.activeAgent = data.agentName;
session.updatedAt = new Date();
if (this.config.debugMode) {
console.log(`[SmallTalk] Session ${data.sessionId} switched to agent: ${data.agentName}`);
}
}
}
handleSessionStart(data) {
if (this.config.debugMode) {
console.log(`[SmallTalk] Session started: ${data.sessionId}`);
}
}
handleSessionEnd(data) {
this.deleteSession(data.sessionId);
if (this.config.debugMode) {
console.log(`[SmallTalk] Session ended: ${data.sessionId}`);
}
}
// π― Orchestration Methods
enableOrchestration(enabled = true) {
this.orchestrationEnabled = enabled;
if (this.config.debugMode) {
console.log(`[SmallTalk] Orchestration ${enabled ? 'enabled' : 'disabled'}`);
}
}
isOrchestrationEnabled() {
return this.orchestrationEnabled;
}
addHandoffRule(condition, targetAgent, priority = 0) {
this.orchestrator.addHandoffRule(condition, targetAgent, priority);
}
getOrchestrationStats() {
return {
enabled: this.orchestrationEnabled,
totalAgents: this.agents.size,
availableAgents: this.orchestrator.getAvailableAgents(),
currentAgentAssignments: Object.fromEntries(this.currentAgents.entries())
};
}
getCurrentAgent(userId) {
return this.currentAgents.get(userId);
}
forceAgentSwitch(userId, agentName) {
if (!this.agents.has(agentName)) {
return false;
}
this.currentAgents.set(userId, agentName);
// Reset auto-response count on manual switch
this.orchestrator.resetAutoResponseCount(userId);
this.emit('agent_forced_switch', { userId, agentName });
if (this.config.debugMode) {
console.log(`[SmallTalk] Forced agent switch for user ${userId} to ${agentName}`);
}
return true;
}
// Helper method to infer agent capabilities from agent properties
inferAgentCapabilities(agent) {
const config = agent.config;
// Extract expertise from agent properties (inferred from personality and name)
const expertise = [];
// Infer expertise from agent name and personality
const name = config.name.toLowerCase();
if (name.includes('code') || name.includes('dev')) {
expertise.push('programming', 'development');
}
if (name.includes('helper') || name.includes('assist')) {
expertise.push('general assistance');
}
if (name.includes('writer') || name.includes('creative')) {
expertise.push('writing', 'content creation');
}
// If no expertise inferred, provide general assistance
if (expertise.length === 0) {
expertise.push('general assistance');
}
// Infer task types from personality and expertise
const taskTypes = [];
const personality = config.personality?.toLowerCase() || '';
if (personality.includes('helpful') || personality.includes('supportive')) {
taskTypes.push('assistance');
}
if (personality.includes('creative') || personality.includes('innovative')) {
taskTypes.push('creative');
}
if (personality.includes('analytical') || personality.includes('precise')) {
taskTypes.push('analysis');
}
if (personality.includes('educational') || personality.includes('teaching')) {
taskTypes.push('educational');
}
if (expertise.some(exp => exp.includes('problem') || exp.includes('debug'))) {
taskTypes.push('problem');
}
// Default to conversation if no specific task types identified
if (taskTypes.length === 0) {
taskTypes.push('conversation');
}
// Infer complexity level from expertise depth
let complexity = 'intermediate';
if (expertise.length >= 5) {
complexity = 'expert';
}
else if (expertise.length >= 3) {
complexity = 'advanced';
}
else if (expertise.length <= 1) {
complexity = 'basic';
}
// Get tool names
const tools = config.tools || [];
return {
expertise,
tools,
personalityTraits: personality.split(',').map(trait => trait.trim()),
taskTypes,
complexity,
contextAwareness: 0.8, // Default reasonable value
collaborationStyle: 'collaborative' // Default style
};
}
// Utility methods for framework management
getConfig() {
return Object.freeze({ ...this.config });
}
updateConfig(newConfig) {
this.config = { ...this.config, ...newConfig };
this.emit('config_updated', { config: this.config });
}
// Enhanced streaming and interruption methods
handleStreamingResponse(response) {
// Broadcast streaming response to all interfaces that support it
this.interfaces.forEach(iface => {
if (iface.handleStreamingResponse) {
iface.handleStreamingResponse(response.chunk, response.messageId);
}
});
this.emit('streaming_response', response);
}
enableStreaming(enabled = true) {
this.streamingEnabled = enabled;
if (this.config.debugMode) {
console.log(`[SmallTalk] Streaming ${enabled ? 'enabled' : 'disabled'}`);
}
}
enableInterruption(enabled = true) {
this.interruptionEnabled = enabled;
if (this.config.debugMode) {
console.log(`[SmallTalk] Interruption ${enabled ? 'enabled' : 'disabled'}`);
}
}
getActivePlans() {
return this.orchestrator.getAllPlans();
}
getPlan(planId) {
return this.orchestrator.getPlan(planId);
}
pausePlan(planId) {
const plan = this.orchestrator.getPlan(planId);
if (plan) {
plan.status = 'paused';
return true;
}
return false;
}
resumePlan(planId, sessionId, userId) {
const plan = this.orchestrator.getPlan(planId);
if (plan && plan.status === 'paused') {
plan.status = 'pending';
return this.orchestrator.executePlan(planId, sessionId, userId);
}
return Promise.resolve(false);
}
updateMaxAutoResponses(max) {
this.orchestrator.updateMaxAutoResponses(max);
}
resetAutoResponseCount(userId) {
this.orchestrator.resetAutoResponseCount(userId);
}
getAutoResponseCount(userId) {
return this.orchestrator.getAutoResponseCount(userId);
}
getStats() {
return {
agentCount: this.agents.size,
interfaceCount: this.interfaces.length,
activeSessionCount: this.activeSessions.size,
isRunning: this.isRunning,
mcpEnabled: !!this.mcpClient,
orchestrationStats: this.orchestrator.getStats(),
memoryStats: this.memory.getStats(),
streamingEnabled: this.streamingEnabled,
interruptionEnabled: this.interruptionEnabled
};
}
}
//# sourceMappingURL=SmallTalk.js.map