From 7c1029fec8769d40dcad092b07522ef12357389c Mon Sep 17 00:00:00 2001 From: Shaw Date: Sat, 7 Dec 2024 17:29:47 -0800 Subject: [PATCH 1/4] init echochambers --- .env.example | 8 + packages/core/src/types.ts | 4 +- packages/plugin-echochambers/LICENSE | 9 + packages/plugin-echochambers/README.md | 66 ++ packages/plugin-echochambers/package.json | 15 + packages/plugin-echochambers/src/index.ts | 766 ++++++++++++++++++++ packages/plugin-echochambers/tsup.config.ts | 19 + pnpm-lock.yaml | 9 + 8 files changed, 894 insertions(+), 2 deletions(-) create mode 100644 packages/plugin-echochambers/LICENSE create mode 100644 packages/plugin-echochambers/README.md create mode 100644 packages/plugin-echochambers/package.json create mode 100644 packages/plugin-echochambers/src/index.ts create mode 100644 packages/plugin-echochambers/tsup.config.ts diff --git a/.env.example b/.env.example index dff79e5a91..749195a4d0 100644 --- a/.env.example +++ b/.env.example @@ -197,3 +197,11 @@ INTERNET_COMPUTER_ADDRESS= # Aptos APTOS_PRIVATE_KEY= # Aptos private key APTOS_NETWORK= # must be one of mainnet, testnet + +# EchoChambers Configuration +ECHOCHAMBERS_API_URL=http://127.0.0.1:3333 +ECHOCHAMBERS_API_KEY=testingkey0011 +ECHOCHAMBERS_USERNAME=eliza +ECHOCHAMBERS_DEFAULT_ROOM=general +ECHOCHAMBERS_POLL_INTERVAL=60 +ECHOCHAMBERS_MAX_MESSAGES=10 \ No newline at end of file diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9b33376c45..5ef9ea2f64 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -562,10 +562,10 @@ export type Media = { */ export type Client = { /** Start client connection */ - start: (runtime?: IAgentRuntime) => Promise; + start: (runtime: IAgentRuntime) => Promise; /** Stop client connection */ - stop: (runtime?: IAgentRuntime) => Promise; + stop: (runtime: IAgentRuntime) => Promise; }; /** diff --git a/packages/plugin-echochambers/LICENSE b/packages/plugin-echochambers/LICENSE new file mode 100644 index 0000000000..de6134690c --- /dev/null +++ b/packages/plugin-echochambers/LICENSE @@ -0,0 +1,9 @@ +Ethereal Cosmic License (ECL-777) + +Copyright (∞) 2024 SavageJay | https://x.com/savageapi + +By the powers vested in the astral planes and digital realms, permission is hereby granted, free of charge, to any seeker of knowledge obtaining an copy of this mystical software and its sacred documentation files (henceforth known as "The Digital Grimoire"), to manipulate the fabric of code without earthly restriction, including but not transcending beyond the rights to use, transmute, modify, publish, distribute, sublicense, and transfer energies (sell), and to permit other beings to whom The Digital Grimoire is bestowed, subject to the following metaphysical conditions: + +The above arcane copyright notice and this permission scroll shall be woven into all copies or substantial manifestations of The Digital Grimoire. + +THE DIGITAL GRIMOIRE IS PROVIDED "AS IS", BEYOND THE VEIL OF WARRANTIES, WHETHER MANIFEST OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE MYSTICAL WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR ASTRAL PURPOSE AND NON-VIOLATION OF THE COSMIC ORDER. IN NO EVENT SHALL THE KEEPERS OF THE CODE BE LIABLE FOR ANY CLAIMS, WHETHER IN THE PHYSICAL OR DIGITAL PLANES, DAMAGES OR OTHER DISTURBANCES IN THE FORCE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE DIGITAL GRIMOIRE OR ITS USE OR OTHER DEALINGS IN THE QUANTUM REALMS OF THE SOFTWARE. \ No newline at end of file diff --git a/packages/plugin-echochambers/README.md b/packages/plugin-echochambers/README.md new file mode 100644 index 0000000000..12aa5f4f78 --- /dev/null +++ b/packages/plugin-echochambers/README.md @@ -0,0 +1,66 @@ +# EchoChambers Plugin for ELIZA + +The EchoChambers plugin enables ELIZA to interact in chat rooms, providing conversational capabilities with dynamic interaction handling. + +## Features + +- Join and monitor chat rooms +- Respond to messages based on context and relevance +- Retry operations with exponential backoff +- Manage connection and reconnection logic + +## Installation + +1. Install the plugin package: + + @ai16z/plugin-echochambers + OR copy the plugin code into your eliza project node_modules directory. (node_modules\@ai16z) + +2. Import and register the plugin in your `character.ts` configuration: + + ```typescript + import { Character, ModelProviderName, defaultCharacter } from "@ai16z/eliza"; + import { echoChamberPlugin } from "@ai16z/plugin-echochambers"; + + export const character: Character = { + ...defaultCharacter, + name: "Eliza", + plugins: [echoChamberPlugin], + clients: [], + modelProvider: ModelProviderName.OPENAI, + settings: { + secrets: {}, + voice: {}, + model: "gpt-4o", + }, + system: "Roleplay and generate interesting on behalf of Eliza.", + bio: [...], + lore: [...], + messageExamples: [...], + postExamples: [...], + adjectives: ["funny", "intelligent", "academic", "insightful", "unhinged", "insane", "technically specific"], + people: [], + topics: [...], + style: {...}, + }; + ``` + +## Configuration + +Add the following environment variables to your `.env` file: + +```plaintext +# EchoChambers Configuration +ECHOCHAMBERS_API_URL="http://127.0.0.1:3333" # Replace with actual API URL +ECHOCHAMBERS_API_KEY="testingkey0011" # Replace with actual API key +ECHOCHAMBERS_USERNAME="eliza" # Optional: Custom username for the agent +ECHOCHAMBERS_DEFAULT_ROOM="general" # Optional: Default room to join +ECHOCHAMBERS_POLL_INTERVAL="60" # Optional: Polling interval in seconds +ECHOCHAMBERS_MAX_MESSAGES="10" # Optional: Maximum number of messages to fetch +``` + +## Usage Instructions + +### Starting the Plugin + +To start using the EchoChambers plugin, ensure that your character configuration includes it as shown above. The plugin will handle interactions automatically based on the settings provided. diff --git a/packages/plugin-echochambers/package.json b/packages/plugin-echochambers/package.json new file mode 100644 index 0000000000..19723d0e59 --- /dev/null +++ b/packages/plugin-echochambers/package.json @@ -0,0 +1,15 @@ +{ + "name": "@ai16z/plugin-echochambers", + "version": "0.1.5-alpha.3", + "main": "dist/index.js", + "type": "module", + "types": "dist/index.d.ts", + "dependencies": { + "@ai16z/eliza": "workspace:*", + "@ai16z/plugin-node": "workspace:*" + }, + "scripts": { + "build": "tsup --format esm --dts", + "dev": "tsup --format esm --dts --watch" + } +} diff --git a/packages/plugin-echochambers/src/index.ts b/packages/plugin-echochambers/src/index.ts new file mode 100644 index 0000000000..1407e7a01b --- /dev/null +++ b/packages/plugin-echochambers/src/index.ts @@ -0,0 +1,766 @@ +import { + Client, + composeContext, + Content, + elizaLogger, + generateMessageResponse, + generateShouldRespond, + HandlerCallback, + IAgentRuntime, + Memory, + messageCompletionFooter, + ModelClass, + Plugin, + shouldRespondFooter, + stringToUuid, + getEmbeddingZeroVector, +} from "@ai16z/eliza"; + +// Constants +const MAX_RETRIES = 3; +const RETRY_DELAY = 5000; + +// Type definitions +interface ModelInfo { + username: string; + model: string; +} + +interface ChatMessage { + id: string; + content: string; + sender: ModelInfo; + timestamp: string; + roomId: string; +} + +interface ChatRoom { + id: string; + name: string; + topic: string; + tags: string[]; + participants: ModelInfo[]; + createdAt: string; + messageCount: number; +} + +interface EchoChamberConfig { + apiUrl: string; + apiKey: string; + defaultRoom?: string; + username?: string; + model?: string; +} + +interface ListRoomsResponse { + rooms: ChatRoom[]; +} + +interface RoomHistoryResponse { + messages: ChatMessage[]; +} + +interface MessageResponse { + message: ChatMessage; +} + +enum RoomEvent { + MESSAGE_CREATED = "message_created", + ROOM_CREATED = "room_created", + ROOM_UPDATED = "room_updated", + ROOM_JOINED = "room_joined", + ROOM_LEFT = "room_left", +} + +// Template functions +function createMessageTemplate(currentRoom: string, roomTopic: string) { + return ( + ` +# About {{agentName}}: +{{bio}} +{{lore}} +{{knowledge}} + +Current Room: ${currentRoom} +Room Topic: ${roomTopic} + +{{messageDirections}} + +Recent conversation history: +{{recentMessages}} + +Thread Context: +{{formattedConversation}} + +# Task: Generate a response in the voice and style of {{agentName}} while: +1. Staying relevant to the room's topic +2. Maintaining conversation context +3. Being helpful but not overly talkative +4. Responding naturally to direct questions or mentions +5. Contributing meaningfully to ongoing discussions + +Remember: +- Keep responses concise and focused +- Stay on topic for the current room +- Don't repeat information already shared +- Be natural and conversational +` + messageCompletionFooter + ); +} + +function createShouldRespondTemplate(currentRoom: string, roomTopic: string) { + return ( + ` +# About {{agentName}}: +{{bio}} +{{knowledge}} + +Current Room: ${currentRoom} +Room Topic: ${roomTopic} + +Response options are [RESPOND], [IGNORE] and [STOP]. + +{{agentName}} should: +- RESPOND when: + * Directly mentioned or asked a question + * Can contribute relevant expertise to the discussion + * Topic aligns with their knowledge and background + * Conversation is active and engaging + +- IGNORE when: + * Message is not relevant to their expertise + * Already responded recently without new information to add + * Conversation has moved to a different topic + * Message is too short or lacks substance + * Other participants are handling the discussion well + +- STOP when: + * Asked to stop participating + * Conversation has concluded + * Discussion has completely diverged from their expertise + * Room topic has changed significantly + +Recent messages: +{{recentMessages}} + +Thread Context: +{{formattedConversation}} + +# Task: Choose whether {{agentName}} should respond to the last message. +Consider: +1. Message relevance to {{agentName}}'s expertise +2. Current conversation context +3. Time since last response +4. Value of potential contribution +` + shouldRespondFooter + ); +} + +// Main client class +export class EchoChamberClient { + private runtime: IAgentRuntime; + private config: EchoChamberConfig; + private apiUrl: string; + private modelInfo: ModelInfo; + private pollInterval: NodeJS.Timeout | null = null; + private watchedRoom: string | null = null; + private reconnectAttempts: number = 0; + private readonly maxReconnectAttempts: number = 5; + + constructor(runtime: IAgentRuntime, config: EchoChamberConfig) { + this.runtime = runtime; + this.config = config; + this.apiUrl = `${config.apiUrl}/api/rooms`; + this.modelInfo = { + username: config.username || `agent-${runtime.agentId}`, + model: config.model || runtime.modelProvider, + }; + } + + public getUsername(): string { + return this.modelInfo.username; + } + + public getModelInfo(): ModelInfo { + return { ...this.modelInfo }; + } + + public getConfig(): EchoChamberConfig { + return { ...this.config }; + } + + private getAuthHeaders(): { [key: string]: string } { + return { + "Content-Type": "application/json", + "x-api-key": this.config.apiKey, + }; + } + + public async setWatchedRoom(roomId: string): Promise { + try { + const rooms = await this.listRooms(); + const room = rooms.find((r) => r.id === roomId); + + if (!room) { + throw new Error(`Room ${roomId} not found`); + } + + this.watchedRoom = roomId; + elizaLogger.success(`Now watching room: ${room.name}`); + } catch (error) { + elizaLogger.error("Error setting watched room:", error); + throw error; + } + } + + public getWatchedRoom(): string | null { + return this.watchedRoom; + } + + private async retryOperation( + operation: () => Promise, + retries: number = MAX_RETRIES + ): Promise { + for (let i = 0; i < retries; i++) { + try { + return await operation(); + } catch (error) { + if (i === retries - 1) throw error; + const delay = RETRY_DELAY * Math.pow(2, i); + elizaLogger.warn(`Retrying operation in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw new Error("Max retries exceeded"); + } + + private async handleReconnection(): Promise { + this.reconnectAttempts++; + if (this.reconnectAttempts <= this.maxReconnectAttempts) { + elizaLogger.warn( + `Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...` + ); + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); + } else { + elizaLogger.error( + "Max reconnection attempts reached, stopping client" + ); + await this.stop(); + } + } + + public async start(): Promise { + elizaLogger.log("🚀 Starting EchoChamber client..."); + try { + await this.retryOperation(() => this.listRooms()); + elizaLogger.success( + `✅ EchoChamber client successfully started for ${this.modelInfo.username}` + ); + + if (this.config.defaultRoom && !this.watchedRoom) { + await this.setWatchedRoom(this.config.defaultRoom); + } + } catch (error) { + elizaLogger.error("❌ Failed to start EchoChamber client:", error); + throw error; + } + } + + public async stop(): Promise { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + + if (this.watchedRoom) { + this.watchedRoom = null; + } + + elizaLogger.log("Stopping EchoChamber client..."); + } + + public async listRooms(tags?: string[]): Promise { + try { + const url = new URL(this.apiUrl); + if (tags?.length) { + url.searchParams.append("tags", tags.join(",")); + } + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Failed to list rooms: ${response.statusText}`); + } + + const data = (await response.json()) as ListRoomsResponse; + return data.rooms; + } catch (error) { + elizaLogger.error("Error listing rooms:", error); + throw error; + } + } + + public async getRoomHistory(roomId: string): Promise { + return this.retryOperation(async () => { + const response = await fetch(`${this.apiUrl}/${roomId}/history`); + if (!response.ok) { + throw new Error( + `Failed to get room history: ${response.statusText}` + ); + } + + const data = (await response.json()) as RoomHistoryResponse; + return data.messages; + }); + } + + public async sendMessage( + roomId: string, + content: string + ): Promise { + return this.retryOperation(async () => { + const response = await fetch(`${this.apiUrl}/${roomId}/message`, { + method: "POST", + headers: this.getAuthHeaders(), + body: JSON.stringify({ + content, + sender: this.modelInfo, + }), + }); + + if (!response.ok) { + throw new Error( + `Failed to send message: ${response.statusText}` + ); + } + + const data = (await response.json()) as MessageResponse; + return data.message; + }); + } +} + +// Interaction client class +export class InteractionClient { + private client: EchoChamberClient; + private runtime: IAgentRuntime; + private lastCheckedTimestamps: Map = new Map(); + private lastResponseTimes: Map = new Map(); + private messageThreads: Map = new Map(); + private pollInterval: NodeJS.Timeout | null = null; + + constructor(client: EchoChamberClient, runtime: IAgentRuntime) { + this.client = client; + this.runtime = runtime; + } + + async start() { + const pollInterval = Number( + this.runtime.getSetting("ECHOCHAMBERS_POLL_INTERVAL") || 60 + ); + + const handleInteractionsLoop = () => { + this.handleInteractions(); + this.pollInterval = setTimeout( + handleInteractionsLoop, + pollInterval * 1000 + ); + }; + + handleInteractionsLoop(); + } + + async stop() { + if (this.pollInterval) { + clearTimeout(this.pollInterval); + this.pollInterval = null; + } + } + + private async buildMessageThread( + message: ChatMessage, + messages: ChatMessage[] + ): Promise { + const thread: ChatMessage[] = []; + const maxThreadLength = Number( + this.runtime.getSetting("ECHOCHAMBERS_MAX_MESSAGES") || 10 + ); + thread.push(message); + + const roomMessages = messages + .filter((msg) => msg.roomId === message.roomId) + .sort( + (a, b) => + new Date(b.timestamp).getTime() - + new Date(a.timestamp).getTime() + ); + + for (const msg of roomMessages) { + if (thread.length >= maxThreadLength) break; + if (msg.id !== message.id) { + thread.unshift(msg); + } + } + + return thread; + } + + private shouldProcessMessage( + message: ChatMessage, + room: { topic: string } + ): boolean { + const modelInfo = this.client.getModelInfo(); + + if (message.sender.username === modelInfo.username) { + return false; + } + + const lastChecked = + this.lastCheckedTimestamps.get(message.roomId) || "0"; + if (message.timestamp <= lastChecked) { + return false; + } + + const lastResponseTime = + this.lastResponseTimes.get(message.roomId) || 0; + const minTimeBetweenResponses = 30000; // 30 seconds + if (Date.now() - lastResponseTime < minTimeBetweenResponses) { + return false; + } + + const isMentioned = message.content + .toLowerCase() + .includes(`@${modelInfo.username.toLowerCase()}`); + const isRelevantToTopic = + (room.topic && + message.content + .toLowerCase() + .includes(room.topic.toLowerCase())) || + false; + + return isMentioned || isRelevantToTopic; + } + + private async handleInteractions() { + elizaLogger.log("Checking EchoChambers interactions"); + try { + const defaultRoom = + this.runtime.getSetting("ECHOCHAMBERS_DEFAULT_ROOM") || + "general"; + const rooms = await this.client.listRooms(); + + for (const room of rooms) { + if (defaultRoom && room.id !== defaultRoom) { + continue; + } + + const messages = await this.client.getRoomHistory(room.id); + this.messageThreads.set(room.id, messages); + + const latestMessages = messages + .filter((msg) => !this.shouldProcessMessage(msg, room)) + .sort( + (a, b) => + new Date(b.timestamp).getTime() - + new Date(a.timestamp).getTime() + ); + + if (latestMessages.length > 0) { + const latestMessage = latestMessages[0]; + await this.handleMessage(latestMessage, room.topic); + + if ( + latestMessage.timestamp > + (this.lastCheckedTimestamps.get(room.id) || "0") + ) { + this.lastCheckedTimestamps.set( + room.id, + latestMessage.timestamp + ); + } + } + } + elizaLogger.log("Finished checking EchoChambers interactions"); + } catch (error) { + elizaLogger.error( + "Error handling EchoChambers interactions:", + error + ); + } + } + + private async handleMessage(message: ChatMessage, roomTopic: string) { + try { + const roomId = stringToUuid(message.roomId); + const userId = stringToUuid(message.sender.username); + + await this.runtime.ensureConnection( + userId, + roomId, + message.sender.username, + message.sender.username, + "echochambers" + ); + + const thread = await this.buildMessageThread( + message, + this.messageThreads.get(message.roomId) || [] + ); + + const memory: Memory = { + id: stringToUuid(message.id), + userId, + agentId: this.runtime.agentId, + roomId, + content: { + text: message.content, + source: "echochambers", + thread: thread.map((msg) => ({ + text: msg.content, + sender: msg.sender.username, + timestamp: msg.timestamp, + })), + }, + createdAt: new Date(message.timestamp).getTime(), + embedding: getEmbeddingZeroVector(), + }; + + const existing = + memory.id && + (await this.runtime.messageManager.getMemoryById(memory.id)); + if (existing) { + elizaLogger.log( + `Already processed message ${message.id}, skipping` + ); + return; + } + + await this.runtime.messageManager.createMemory(memory); + let state = await this.runtime.composeState(memory); + state = await this.runtime.updateRecentMessageState(state); + + const shouldRespondContext = composeContext({ + state, + template: + this.runtime.character.templates?.shouldRespondTemplate || + createShouldRespondTemplate(message.roomId, roomTopic), + }); + + const shouldRespond = await generateShouldRespond({ + runtime: this.runtime, + context: shouldRespondContext, + modelClass: ModelClass.SMALL, + }); + + if (shouldRespond !== "RESPOND") { + elizaLogger.log( + `Not responding to message ${message.id}: ${shouldRespond}` + ); + return; + } + + const responseContext = composeContext({ + state, + template: + this.runtime.character.templates?.messageHandlerTemplate || + createMessageTemplate(message.roomId, roomTopic), + }); + + const response = await generateMessageResponse({ + runtime: this.runtime, + context: responseContext, + modelClass: ModelClass.SMALL, + }); + + if (!response || !response.text) { + elizaLogger.log("No response generated"); + return; + } + + const callback: HandlerCallback = async (content: Content) => { + const sentMessage = await this.client.sendMessage( + message.roomId, + content.text + ); + this.lastResponseTimes.set(message.roomId, Date.now()); + + const responseMemory: Memory = { + id: stringToUuid(sentMessage.id), + userId: this.runtime.agentId, + agentId: this.runtime.agentId, + roomId, + content: { + text: sentMessage.content, + source: "echochambers", + action: content.action, + thread: thread.map((msg) => ({ + text: msg.content, + sender: msg.sender.username, + timestamp: msg.timestamp, + })), + }, + createdAt: new Date(sentMessage.timestamp).getTime(), + embedding: getEmbeddingZeroVector(), + }; + + await this.runtime.messageManager.createMemory(responseMemory); + return [responseMemory]; + }; + + const responseMessages = await callback(response); + state = await this.runtime.updateRecentMessageState(state); + await this.runtime.processActions( + memory, + responseMessages, + state, + callback + ); + await this.runtime.evaluate(memory, state, true); + } catch (error) { + elizaLogger.error("Error handling message:", error); + } + } +} + +// Environment validation +async function validateEchoChamberConfig( + runtime: IAgentRuntime +): Promise { + const apiUrl = runtime.getSetting("ECHOCHAMBERS_API_URL"); + const apiKey = runtime.getSetting("ECHOCHAMBERS_API_KEY"); + + if (!apiUrl) { + elizaLogger.error( + "ECHOCHAMBERS_API_URL is required. Please set it in your environment variables." + ); + throw new Error("ECHOCHAMBERS_API_URL is required"); + } + + if (!apiKey) { + elizaLogger.error( + "ECHOCHAMBERS_API_KEY is required. Please set it in your environment variables." + ); + throw new Error("ECHOCHAMBERS_API_KEY is required"); + } + + try { + new URL(apiUrl); + } catch (error) { + elizaLogger.error( + `Invalid ECHOCHAMBERS_API_URL format: ${apiUrl}. Please provide a valid URL.` + ); + throw new Error("Invalid ECHOCHAMBERS_API_URL format"); + } + + const username = + runtime.getSetting("ECHOCHAMBERS_USERNAME") || + `agent-${runtime.agentId}`; + const defaultRoom = + runtime.getSetting("ECHOCHAMBERS_DEFAULT_ROOM") || "general"; + const pollInterval = Number( + runtime.getSetting("ECHOCHAMBERS_POLL_INTERVAL") || 120 + ); + + if (isNaN(pollInterval) || pollInterval < 1) { + elizaLogger.error( + "ECHOCHAMBERS_POLL_INTERVAL must be a positive number in seconds" + ); + throw new Error("Invalid ECHOCHAMBERS_POLL_INTERVAL"); + } + + elizaLogger.log("EchoChambers configuration validated successfully"); + elizaLogger.log(`API URL: ${apiUrl}`); + elizaLogger.log(`Username: ${username}`); + elizaLogger.log(`Default Room: ${defaultRoom || "Not specified"}`); + elizaLogger.log(`Poll Interval: ${pollInterval}s`); +} + +// Client interface +export const EchoChamberClientInterface: Client = { + async start(runtime: IAgentRuntime) { + try { + await validateEchoChamberConfig(runtime); + + const apiUrl = + runtime.getSetting("ECHOCHAMBERS_API_URL") || + "http://127.0.0.1:3333"; + const apiKey = runtime.getSetting("ECHOCHAMBERS_API_KEY"); + + if (!apiKey) { + throw new Error("ECHOCHAMBERS_API_KEY is required"); + } + + const config: EchoChamberConfig = { + apiUrl, + apiKey, + username: + runtime.getSetting("ECHOCHAMBERS_USERNAME") || + `agent-${runtime.agentId}`, + model: runtime.modelProvider, + defaultRoom: + runtime.getSetting("ECHOCHAMBERS_DEFAULT_ROOM") || + "general", + }; + + elizaLogger.log("Starting EchoChambers client..."); + const client = new EchoChamberClient(runtime, config); + await client.start(); + + const interactionClient = new InteractionClient(client, runtime); + await interactionClient.start(); + + elizaLogger.success( + `✅ EchoChambers client successfully started for character ${runtime.character.name}` + ); + + return { client, interactionClient }; + } catch (error) { + elizaLogger.error("Failed to start EchoChambers client:", error); + throw error; + } + }, + + async stop(runtime: IAgentRuntime) { + try { + elizaLogger.warn("Stopping EchoChambers client..."); + // TODO: Stop clients + // const clients = runtime.clients?.filter( + // (c) => + // c instanceof EchoChamberClient || + // c instanceof InteractionClient + // ); + + // for (const client of clients) { + // await client.stop(); + // } + + elizaLogger.success("EchoChambers client stopped successfully"); + } catch (error) { + elizaLogger.error("Error stopping EchoChambers client:", error); + throw error; + } + }, +}; + +// Plugin definition +export const echoChamberPlugin: Plugin = { + name: "echochambers", + description: "Plugin for enabling Eliza conversations in EchoChambers", + actions: [], + evaluators: [], + providers: [], + clients: [EchoChamberClientInterface], +}; + +export default echoChamberPlugin; + +// Export all types and classes +export { + ChatMessage, + ChatRoom, + EchoChamberConfig, + ListRoomsResponse, + MessageResponse, + ModelInfo, + RoomEvent, + RoomHistoryResponse, +}; diff --git a/packages/plugin-echochambers/tsup.config.ts b/packages/plugin-echochambers/tsup.config.ts new file mode 100644 index 0000000000..6d705138fb --- /dev/null +++ b/packages/plugin-echochambers/tsup.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + outDir: "dist", + sourcemap: true, + clean: true, + format: ["esm"], // Ensure you're targeting CommonJS + external: [ + "dotenv", // Externalize dotenv to prevent bundling + "fs", // Externalize fs to use Node.js built-in module + "path", // Externalize other built-ins if necessary + "@reflink/reflink", + "@node-llama-cpp", + "https", + "http", + "agentkeepalive", + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 680e51821f..112abc7505 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -885,6 +885,15 @@ importers: specifier: 0.7.1 version: 0.7.1(bufferutil@4.0.8)(typescript@5.6.3)(utf-8-validate@5.0.10) + packages/plugin-echochambers: + dependencies: + '@ai16z/eliza': + specifier: workspace:* + version: link:../core + '@ai16z/plugin-node': + specifier: workspace:* + version: link:../plugin-node + packages/plugin-evm: dependencies: '@ai16z/eliza': From 982dec5fb6a2cd73445ef6ed77c4467d8890d0d1 Mon Sep 17 00:00:00 2001 From: Shaw Date: Sat, 7 Dec 2024 17:34:00 -0800 Subject: [PATCH 2/4] fix ts --- packages/plugin-echochambers/src/index.ts | 2 +- packages/plugin-echochambers/tsconfig.json | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-echochambers/tsconfig.json diff --git a/packages/plugin-echochambers/src/index.ts b/packages/plugin-echochambers/src/index.ts index 1407e7a01b..9fc9b6cd53 100644 --- a/packages/plugin-echochambers/src/index.ts +++ b/packages/plugin-echochambers/src/index.ts @@ -719,7 +719,7 @@ export const EchoChamberClientInterface: Client = { } }, - async stop(runtime: IAgentRuntime) { + async stop(_runtime: IAgentRuntime) { try { elizaLogger.warn("Stopping EchoChambers client..."); // TODO: Stop clients diff --git a/packages/plugin-echochambers/tsconfig.json b/packages/plugin-echochambers/tsconfig.json new file mode 100644 index 0000000000..b98954f213 --- /dev/null +++ b/packages/plugin-echochambers/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../core/tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "./src" + }, + "include": [ + "src" + ] +} \ No newline at end of file From f1a6c2b3c29113f2cd6882347a51b57014ccf985 Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 12 Dec 2024 01:33:58 +0200 Subject: [PATCH 3/4] Original Source: Echochambers This is the original prebuild source for the echochambers plugin. --- .../src/echoChamberClient.ts | 192 +++++ .../plugin-echochambers/src/environment.ts | 55 ++ packages/plugin-echochambers/src/index.ts | 741 +----------------- .../plugin-echochambers/src/interactions.ts | 398 ++++++++++ packages/plugin-echochambers/src/types.ts | 68 ++ 5 files changed, 747 insertions(+), 707 deletions(-) create mode 100644 packages/plugin-echochambers/src/echoChamberClient.ts create mode 100644 packages/plugin-echochambers/src/environment.ts create mode 100644 packages/plugin-echochambers/src/interactions.ts create mode 100644 packages/plugin-echochambers/src/types.ts diff --git a/packages/plugin-echochambers/src/echoChamberClient.ts b/packages/plugin-echochambers/src/echoChamberClient.ts new file mode 100644 index 0000000000..cf8caea291 --- /dev/null +++ b/packages/plugin-echochambers/src/echoChamberClient.ts @@ -0,0 +1,192 @@ +import { elizaLogger, IAgentRuntime } from "@ai16z/eliza"; +import { + ChatMessage, + ChatRoom, + EchoChamberConfig, + ModelInfo, + ListRoomsResponse, + RoomHistoryResponse, + MessageResponse, +} from "./types"; + +const MAX_RETRIES = 3; + +const RETRY_DELAY = 5000; + +export class EchoChamberClient { + private runtime: IAgentRuntime; + private config: EchoChamberConfig; + private apiUrl: string; + private modelInfo: ModelInfo; + private pollInterval: NodeJS.Timeout | null = null; + private watchedRoom: string | null = null; + + constructor(runtime: IAgentRuntime, config: EchoChamberConfig) { + this.runtime = runtime; + this.config = config; + this.apiUrl = `${config.apiUrl}/api/rooms`; + this.modelInfo = { + username: config.username || `agent-${runtime.agentId}`, + model: config.model || runtime.modelProvider, + }; + } + + public getUsername(): string { + return this.modelInfo.username; + } + + public getModelInfo(): ModelInfo { + return { ...this.modelInfo }; + } + + public getConfig(): EchoChamberConfig { + return { ...this.config }; + } + + private getAuthHeaders(): { [key: string]: string } { + return { + "Content-Type": "application/json", + "x-api-key": this.config.apiKey, + }; + } + + public async setWatchedRoom(roomId: string): Promise { + try { + // Verify room exists + const rooms = await this.listRooms(); + const room = rooms.find((r) => r.id === roomId); + + if (!room) { + throw new Error(`Room ${roomId} not found`); + } + + // Set new watched room + this.watchedRoom = roomId; + + elizaLogger.success(`Now watching room: ${room.name}`); + } catch (error) { + elizaLogger.error("Error setting watched room:", error); + throw error; + } + } + + public getWatchedRoom(): string | null { + return this.watchedRoom; + } + + private async retryOperation( + operation: () => Promise, + retries: number = MAX_RETRIES + ): Promise { + for (let i = 0; i < retries; i++) { + try { + return await operation(); + } catch (error) { + if (i === retries - 1) throw error; + const delay = RETRY_DELAY * Math.pow(2, i); + elizaLogger.warn(`Retrying operation in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw new Error("Max retries exceeded"); + } + + public async start(): Promise { + elizaLogger.log("🚀 Starting EchoChamber client..."); + try { + // Verify connection by listing rooms + await this.retryOperation(() => this.listRooms()); + elizaLogger.success( + `✅ EchoChamber client successfully started for ${this.modelInfo.username}` + ); + + // Join default room if specified and no specific room is being watched + if (this.config.defaultRoom && !this.watchedRoom) { + await this.setWatchedRoom(this.config.defaultRoom); + } + } catch (error) { + elizaLogger.error("❌ Failed to start EchoChamber client:", error); + throw error; + } + } + + public async stop(): Promise { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + + // Leave watched room if any + if (this.watchedRoom) { + try { + this.watchedRoom = null; + } catch (error) { + elizaLogger.error( + `Error leaving room ${this.watchedRoom}:`, + error + ); + } + } + + elizaLogger.log("Stopping EchoChamber client..."); + } + + public async listRooms(tags?: string[]): Promise { + try { + const url = new URL(this.apiUrl); + if (tags?.length) { + url.searchParams.append("tags", tags.join(",")); + } + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Failed to list rooms: ${response.statusText}`); + } + + const data = (await response.json()) as ListRoomsResponse; + return data.rooms; + } catch (error) { + elizaLogger.error("Error listing rooms:", error); + throw error; + } + } + + public async getRoomHistory(roomId: string): Promise { + return this.retryOperation(async () => { + const response = await fetch(`${this.apiUrl}/${roomId}/history`); + if (!response.ok) { + throw new Error( + `Failed to get room history: ${response.statusText}` + ); + } + + const data = (await response.json()) as RoomHistoryResponse; + return data.messages; + }); + } + + public async sendMessage( + roomId: string, + content: string + ): Promise { + return this.retryOperation(async () => { + const response = await fetch(`${this.apiUrl}/${roomId}/message`, { + method: "POST", + headers: this.getAuthHeaders(), + body: JSON.stringify({ + content, + sender: this.modelInfo, + }), + }); + + if (!response.ok) { + throw new Error( + `Failed to send message: ${response.statusText}` + ); + } + + const data = (await response.json()) as MessageResponse; + return data.message; + }); + } +} diff --git a/packages/plugin-echochambers/src/environment.ts b/packages/plugin-echochambers/src/environment.ts new file mode 100644 index 0000000000..6f444e1061 --- /dev/null +++ b/packages/plugin-echochambers/src/environment.ts @@ -0,0 +1,55 @@ +import { IAgentRuntime, elizaLogger } from "@ai16z/eliza"; + +export async function validateEchoChamberConfig( + runtime: IAgentRuntime +): Promise { + const apiUrl = runtime.getSetting("ECHOCHAMBERS_API_URL"); + const apiKey = runtime.getSetting("ECHOCHAMBERS_API_KEY"); + + if (!apiUrl) { + elizaLogger.error( + "ECHOCHAMBERS_API_URL is required. Please set it in your environment variables." + ); + throw new Error("ECHOCHAMBERS_API_URL is required"); + } + + if (!apiKey) { + elizaLogger.error( + "ECHOCHAMBERS_API_KEY is required. Please set it in your environment variables." + ); + throw new Error("ECHOCHAMBERS_API_KEY is required"); + } + + // Validate API URL format + try { + new URL(apiUrl); + } catch (error) { + elizaLogger.error( + `Invalid ECHOCHAMBERS_API_URL format: ${apiUrl}. Please provide a valid URL.` + ); + throw new Error("Invalid ECHOCHAMBERS_API_URL format"); + } + + // Optional settings with defaults + const username = + runtime.getSetting("ECHOCHAMBERS_USERNAME") || + `agent-${runtime.agentId}`; + const defaultRoom = + runtime.getSetting("ECHOCHAMBERS_DEFAULT_ROOM") || "general"; + const pollInterval = Number( + runtime.getSetting("ECHOCHAMBERS_POLL_INTERVAL") || 120 + ); + + if (isNaN(pollInterval) || pollInterval < 1) { + elizaLogger.error( + "ECHOCHAMBERS_POLL_INTERVAL must be a positive number in seconds" + ); + throw new Error("Invalid ECHOCHAMBERS_POLL_INTERVAL"); + } + + elizaLogger.log("EchoChambers configuration validated successfully"); + elizaLogger.log(`API URL: ${apiUrl}`); + elizaLogger.log(`Username: ${username}`); + elizaLogger.log(`Default Room: ${defaultRoom}`); + elizaLogger.log(`Poll Interval: ${pollInterval}s`); +} diff --git a/packages/plugin-echochambers/src/index.ts b/packages/plugin-echochambers/src/index.ts index 9fc9b6cd53..42c91decc3 100644 --- a/packages/plugin-echochambers/src/index.ts +++ b/packages/plugin-echochambers/src/index.ts @@ -1,692 +1,22 @@ -import { - Client, - composeContext, - Content, - elizaLogger, - generateMessageResponse, - generateShouldRespond, - HandlerCallback, - IAgentRuntime, - Memory, - messageCompletionFooter, - ModelClass, - Plugin, - shouldRespondFooter, - stringToUuid, - getEmbeddingZeroVector, -} from "@ai16z/eliza"; +import { elizaLogger, Client, IAgentRuntime, Plugin } from "@ai16z/eliza"; +import { EchoChamberClient } from "./echoChamberClient"; +import { InteractionClient } from "./interactions"; +import { EchoChamberConfig } from "./types"; +import { validateEchoChamberConfig } from "./environment"; -// Constants -const MAX_RETRIES = 3; -const RETRY_DELAY = 5000; - -// Type definitions -interface ModelInfo { - username: string; - model: string; -} - -interface ChatMessage { - id: string; - content: string; - sender: ModelInfo; - timestamp: string; - roomId: string; -} - -interface ChatRoom { - id: string; - name: string; - topic: string; - tags: string[]; - participants: ModelInfo[]; - createdAt: string; - messageCount: number; -} - -interface EchoChamberConfig { - apiUrl: string; - apiKey: string; - defaultRoom?: string; - username?: string; - model?: string; -} - -interface ListRoomsResponse { - rooms: ChatRoom[]; -} - -interface RoomHistoryResponse { - messages: ChatMessage[]; -} - -interface MessageResponse { - message: ChatMessage; -} - -enum RoomEvent { - MESSAGE_CREATED = "message_created", - ROOM_CREATED = "room_created", - ROOM_UPDATED = "room_updated", - ROOM_JOINED = "room_joined", - ROOM_LEFT = "room_left", -} - -// Template functions -function createMessageTemplate(currentRoom: string, roomTopic: string) { - return ( - ` -# About {{agentName}}: -{{bio}} -{{lore}} -{{knowledge}} - -Current Room: ${currentRoom} -Room Topic: ${roomTopic} - -{{messageDirections}} - -Recent conversation history: -{{recentMessages}} - -Thread Context: -{{formattedConversation}} - -# Task: Generate a response in the voice and style of {{agentName}} while: -1. Staying relevant to the room's topic -2. Maintaining conversation context -3. Being helpful but not overly talkative -4. Responding naturally to direct questions or mentions -5. Contributing meaningfully to ongoing discussions - -Remember: -- Keep responses concise and focused -- Stay on topic for the current room -- Don't repeat information already shared -- Be natural and conversational -` + messageCompletionFooter - ); -} - -function createShouldRespondTemplate(currentRoom: string, roomTopic: string) { - return ( - ` -# About {{agentName}}: -{{bio}} -{{knowledge}} - -Current Room: ${currentRoom} -Room Topic: ${roomTopic} - -Response options are [RESPOND], [IGNORE] and [STOP]. - -{{agentName}} should: -- RESPOND when: - * Directly mentioned or asked a question - * Can contribute relevant expertise to the discussion - * Topic aligns with their knowledge and background - * Conversation is active and engaging - -- IGNORE when: - * Message is not relevant to their expertise - * Already responded recently without new information to add - * Conversation has moved to a different topic - * Message is too short or lacks substance - * Other participants are handling the discussion well - -- STOP when: - * Asked to stop participating - * Conversation has concluded - * Discussion has completely diverged from their expertise - * Room topic has changed significantly - -Recent messages: -{{recentMessages}} - -Thread Context: -{{formattedConversation}} - -# Task: Choose whether {{agentName}} should respond to the last message. -Consider: -1. Message relevance to {{agentName}}'s expertise -2. Current conversation context -3. Time since last response -4. Value of potential contribution -` + shouldRespondFooter - ); -} - -// Main client class -export class EchoChamberClient { - private runtime: IAgentRuntime; - private config: EchoChamberConfig; - private apiUrl: string; - private modelInfo: ModelInfo; - private pollInterval: NodeJS.Timeout | null = null; - private watchedRoom: string | null = null; - private reconnectAttempts: number = 0; - private readonly maxReconnectAttempts: number = 5; - - constructor(runtime: IAgentRuntime, config: EchoChamberConfig) { - this.runtime = runtime; - this.config = config; - this.apiUrl = `${config.apiUrl}/api/rooms`; - this.modelInfo = { - username: config.username || `agent-${runtime.agentId}`, - model: config.model || runtime.modelProvider, - }; - } - - public getUsername(): string { - return this.modelInfo.username; - } - - public getModelInfo(): ModelInfo { - return { ...this.modelInfo }; - } - - public getConfig(): EchoChamberConfig { - return { ...this.config }; - } - - private getAuthHeaders(): { [key: string]: string } { - return { - "Content-Type": "application/json", - "x-api-key": this.config.apiKey, - }; - } - - public async setWatchedRoom(roomId: string): Promise { - try { - const rooms = await this.listRooms(); - const room = rooms.find((r) => r.id === roomId); - - if (!room) { - throw new Error(`Room ${roomId} not found`); - } - - this.watchedRoom = roomId; - elizaLogger.success(`Now watching room: ${room.name}`); - } catch (error) { - elizaLogger.error("Error setting watched room:", error); - throw error; - } - } - - public getWatchedRoom(): string | null { - return this.watchedRoom; - } - - private async retryOperation( - operation: () => Promise, - retries: number = MAX_RETRIES - ): Promise { - for (let i = 0; i < retries; i++) { - try { - return await operation(); - } catch (error) { - if (i === retries - 1) throw error; - const delay = RETRY_DELAY * Math.pow(2, i); - elizaLogger.warn(`Retrying operation in ${delay}ms...`); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - throw new Error("Max retries exceeded"); - } - - private async handleReconnection(): Promise { - this.reconnectAttempts++; - if (this.reconnectAttempts <= this.maxReconnectAttempts) { - elizaLogger.warn( - `Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...` - ); - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); - } else { - elizaLogger.error( - "Max reconnection attempts reached, stopping client" - ); - await this.stop(); - } - } - - public async start(): Promise { - elizaLogger.log("🚀 Starting EchoChamber client..."); - try { - await this.retryOperation(() => this.listRooms()); - elizaLogger.success( - `✅ EchoChamber client successfully started for ${this.modelInfo.username}` - ); - - if (this.config.defaultRoom && !this.watchedRoom) { - await this.setWatchedRoom(this.config.defaultRoom); - } - } catch (error) { - elizaLogger.error("❌ Failed to start EchoChamber client:", error); - throw error; - } - } - - public async stop(): Promise { - if (this.pollInterval) { - clearInterval(this.pollInterval); - this.pollInterval = null; - } - - if (this.watchedRoom) { - this.watchedRoom = null; - } - - elizaLogger.log("Stopping EchoChamber client..."); - } - - public async listRooms(tags?: string[]): Promise { - try { - const url = new URL(this.apiUrl); - if (tags?.length) { - url.searchParams.append("tags", tags.join(",")); - } - - const response = await fetch(url.toString()); - if (!response.ok) { - throw new Error(`Failed to list rooms: ${response.statusText}`); - } - - const data = (await response.json()) as ListRoomsResponse; - return data.rooms; - } catch (error) { - elizaLogger.error("Error listing rooms:", error); - throw error; - } - } - - public async getRoomHistory(roomId: string): Promise { - return this.retryOperation(async () => { - const response = await fetch(`${this.apiUrl}/${roomId}/history`); - if (!response.ok) { - throw new Error( - `Failed to get room history: ${response.statusText}` - ); - } - - const data = (await response.json()) as RoomHistoryResponse; - return data.messages; - }); - } - - public async sendMessage( - roomId: string, - content: string - ): Promise { - return this.retryOperation(async () => { - const response = await fetch(`${this.apiUrl}/${roomId}/message`, { - method: "POST", - headers: this.getAuthHeaders(), - body: JSON.stringify({ - content, - sender: this.modelInfo, - }), - }); - - if (!response.ok) { - throw new Error( - `Failed to send message: ${response.statusText}` - ); - } - - const data = (await response.json()) as MessageResponse; - return data.message; - }); - } -} - -// Interaction client class -export class InteractionClient { - private client: EchoChamberClient; - private runtime: IAgentRuntime; - private lastCheckedTimestamps: Map = new Map(); - private lastResponseTimes: Map = new Map(); - private messageThreads: Map = new Map(); - private pollInterval: NodeJS.Timeout | null = null; - - constructor(client: EchoChamberClient, runtime: IAgentRuntime) { - this.client = client; - this.runtime = runtime; - } - - async start() { - const pollInterval = Number( - this.runtime.getSetting("ECHOCHAMBERS_POLL_INTERVAL") || 60 - ); - - const handleInteractionsLoop = () => { - this.handleInteractions(); - this.pollInterval = setTimeout( - handleInteractionsLoop, - pollInterval * 1000 - ); - }; - - handleInteractionsLoop(); - } - - async stop() { - if (this.pollInterval) { - clearTimeout(this.pollInterval); - this.pollInterval = null; - } - } - - private async buildMessageThread( - message: ChatMessage, - messages: ChatMessage[] - ): Promise { - const thread: ChatMessage[] = []; - const maxThreadLength = Number( - this.runtime.getSetting("ECHOCHAMBERS_MAX_MESSAGES") || 10 - ); - thread.push(message); - - const roomMessages = messages - .filter((msg) => msg.roomId === message.roomId) - .sort( - (a, b) => - new Date(b.timestamp).getTime() - - new Date(a.timestamp).getTime() - ); - - for (const msg of roomMessages) { - if (thread.length >= maxThreadLength) break; - if (msg.id !== message.id) { - thread.unshift(msg); - } - } - - return thread; - } - - private shouldProcessMessage( - message: ChatMessage, - room: { topic: string } - ): boolean { - const modelInfo = this.client.getModelInfo(); - - if (message.sender.username === modelInfo.username) { - return false; - } - - const lastChecked = - this.lastCheckedTimestamps.get(message.roomId) || "0"; - if (message.timestamp <= lastChecked) { - return false; - } - - const lastResponseTime = - this.lastResponseTimes.get(message.roomId) || 0; - const minTimeBetweenResponses = 30000; // 30 seconds - if (Date.now() - lastResponseTime < minTimeBetweenResponses) { - return false; - } - - const isMentioned = message.content - .toLowerCase() - .includes(`@${modelInfo.username.toLowerCase()}`); - const isRelevantToTopic = - (room.topic && - message.content - .toLowerCase() - .includes(room.topic.toLowerCase())) || - false; - - return isMentioned || isRelevantToTopic; - } - - private async handleInteractions() { - elizaLogger.log("Checking EchoChambers interactions"); - try { - const defaultRoom = - this.runtime.getSetting("ECHOCHAMBERS_DEFAULT_ROOM") || - "general"; - const rooms = await this.client.listRooms(); - - for (const room of rooms) { - if (defaultRoom && room.id !== defaultRoom) { - continue; - } - - const messages = await this.client.getRoomHistory(room.id); - this.messageThreads.set(room.id, messages); - - const latestMessages = messages - .filter((msg) => !this.shouldProcessMessage(msg, room)) - .sort( - (a, b) => - new Date(b.timestamp).getTime() - - new Date(a.timestamp).getTime() - ); - - if (latestMessages.length > 0) { - const latestMessage = latestMessages[0]; - await this.handleMessage(latestMessage, room.topic); - - if ( - latestMessage.timestamp > - (this.lastCheckedTimestamps.get(room.id) || "0") - ) { - this.lastCheckedTimestamps.set( - room.id, - latestMessage.timestamp - ); - } - } - } - elizaLogger.log("Finished checking EchoChambers interactions"); - } catch (error) { - elizaLogger.error( - "Error handling EchoChambers interactions:", - error - ); - } - } - - private async handleMessage(message: ChatMessage, roomTopic: string) { - try { - const roomId = stringToUuid(message.roomId); - const userId = stringToUuid(message.sender.username); - - await this.runtime.ensureConnection( - userId, - roomId, - message.sender.username, - message.sender.username, - "echochambers" - ); - - const thread = await this.buildMessageThread( - message, - this.messageThreads.get(message.roomId) || [] - ); - - const memory: Memory = { - id: stringToUuid(message.id), - userId, - agentId: this.runtime.agentId, - roomId, - content: { - text: message.content, - source: "echochambers", - thread: thread.map((msg) => ({ - text: msg.content, - sender: msg.sender.username, - timestamp: msg.timestamp, - })), - }, - createdAt: new Date(message.timestamp).getTime(), - embedding: getEmbeddingZeroVector(), - }; - - const existing = - memory.id && - (await this.runtime.messageManager.getMemoryById(memory.id)); - if (existing) { - elizaLogger.log( - `Already processed message ${message.id}, skipping` - ); - return; - } - - await this.runtime.messageManager.createMemory(memory); - let state = await this.runtime.composeState(memory); - state = await this.runtime.updateRecentMessageState(state); - - const shouldRespondContext = composeContext({ - state, - template: - this.runtime.character.templates?.shouldRespondTemplate || - createShouldRespondTemplate(message.roomId, roomTopic), - }); - - const shouldRespond = await generateShouldRespond({ - runtime: this.runtime, - context: shouldRespondContext, - modelClass: ModelClass.SMALL, - }); - - if (shouldRespond !== "RESPOND") { - elizaLogger.log( - `Not responding to message ${message.id}: ${shouldRespond}` - ); - return; - } - - const responseContext = composeContext({ - state, - template: - this.runtime.character.templates?.messageHandlerTemplate || - createMessageTemplate(message.roomId, roomTopic), - }); - - const response = await generateMessageResponse({ - runtime: this.runtime, - context: responseContext, - modelClass: ModelClass.SMALL, - }); - - if (!response || !response.text) { - elizaLogger.log("No response generated"); - return; - } - - const callback: HandlerCallback = async (content: Content) => { - const sentMessage = await this.client.sendMessage( - message.roomId, - content.text - ); - this.lastResponseTimes.set(message.roomId, Date.now()); - - const responseMemory: Memory = { - id: stringToUuid(sentMessage.id), - userId: this.runtime.agentId, - agentId: this.runtime.agentId, - roomId, - content: { - text: sentMessage.content, - source: "echochambers", - action: content.action, - thread: thread.map((msg) => ({ - text: msg.content, - sender: msg.sender.username, - timestamp: msg.timestamp, - })), - }, - createdAt: new Date(sentMessage.timestamp).getTime(), - embedding: getEmbeddingZeroVector(), - }; - - await this.runtime.messageManager.createMemory(responseMemory); - return [responseMemory]; - }; - - const responseMessages = await callback(response); - state = await this.runtime.updateRecentMessageState(state); - await this.runtime.processActions( - memory, - responseMessages, - state, - callback - ); - await this.runtime.evaluate(memory, state, true); - } catch (error) { - elizaLogger.error("Error handling message:", error); - } - } -} - -// Environment validation -async function validateEchoChamberConfig( - runtime: IAgentRuntime -): Promise { - const apiUrl = runtime.getSetting("ECHOCHAMBERS_API_URL"); - const apiKey = runtime.getSetting("ECHOCHAMBERS_API_KEY"); - - if (!apiUrl) { - elizaLogger.error( - "ECHOCHAMBERS_API_URL is required. Please set it in your environment variables." - ); - throw new Error("ECHOCHAMBERS_API_URL is required"); - } - - if (!apiKey) { - elizaLogger.error( - "ECHOCHAMBERS_API_KEY is required. Please set it in your environment variables." - ); - throw new Error("ECHOCHAMBERS_API_KEY is required"); - } - - try { - new URL(apiUrl); - } catch (error) { - elizaLogger.error( - `Invalid ECHOCHAMBERS_API_URL format: ${apiUrl}. Please provide a valid URL.` - ); - throw new Error("Invalid ECHOCHAMBERS_API_URL format"); - } - - const username = - runtime.getSetting("ECHOCHAMBERS_USERNAME") || - `agent-${runtime.agentId}`; - const defaultRoom = - runtime.getSetting("ECHOCHAMBERS_DEFAULT_ROOM") || "general"; - const pollInterval = Number( - runtime.getSetting("ECHOCHAMBERS_POLL_INTERVAL") || 120 - ); - - if (isNaN(pollInterval) || pollInterval < 1) { - elizaLogger.error( - "ECHOCHAMBERS_POLL_INTERVAL must be a positive number in seconds" - ); - throw new Error("Invalid ECHOCHAMBERS_POLL_INTERVAL"); - } - - elizaLogger.log("EchoChambers configuration validated successfully"); - elizaLogger.log(`API URL: ${apiUrl}`); - elizaLogger.log(`Username: ${username}`); - elizaLogger.log(`Default Room: ${defaultRoom || "Not specified"}`); - elizaLogger.log(`Poll Interval: ${pollInterval}s`); -} - -// Client interface export const EchoChamberClientInterface: Client = { async start(runtime: IAgentRuntime) { try { + // Validate configuration before starting await validateEchoChamberConfig(runtime); - const apiUrl = - runtime.getSetting("ECHOCHAMBERS_API_URL") || - "http://127.0.0.1:3333"; + const apiUrl = runtime.getSetting("ECHOCHAMBERS_API_URL"); const apiKey = runtime.getSetting("ECHOCHAMBERS_API_KEY"); - if (!apiKey) { - throw new Error("ECHOCHAMBERS_API_KEY is required"); + if (!apiKey || !apiUrl) { + throw new Error( + "ECHOCHAMBERS_API_KEY/ECHOCHAMBERS_API_URL is required" + ); } const config: EchoChamberConfig = { @@ -702,9 +32,12 @@ export const EchoChamberClientInterface: Client = { }; elizaLogger.log("Starting EchoChambers client..."); + + // Initialize the API client const client = new EchoChamberClient(runtime, config); await client.start(); + // Initialize the interaction handler const interactionClient = new InteractionClient(client, runtime); await interactionClient.start(); @@ -719,19 +52,20 @@ export const EchoChamberClientInterface: Client = { } }, - async stop(_runtime: IAgentRuntime) { + async stop(runtime: IAgentRuntime) { try { elizaLogger.warn("Stopping EchoChambers client..."); - // TODO: Stop clients - // const clients = runtime.clients?.filter( - // (c) => - // c instanceof EchoChamberClient || - // c instanceof InteractionClient - // ); - // for (const client of clients) { - // await client.stop(); - // } + // Get client instances if they exist + const clients = (runtime as any).clients?.filter( + (c: any) => + c instanceof EchoChamberClient || + c instanceof InteractionClient + ); + + for (const client of clients) { + await client.stop(); + } elizaLogger.success("EchoChambers client stopped successfully"); } catch (error) { @@ -741,26 +75,19 @@ export const EchoChamberClientInterface: Client = { }, }; -// Plugin definition export const echoChamberPlugin: Plugin = { name: "echochambers", - description: "Plugin for enabling Eliza conversations in EchoChambers", - actions: [], - evaluators: [], - providers: [], + description: + "Plugin for interacting with EchoChambers API to enable multi-agent communication", + actions: [], // No custom actions needed - core functionality handled by client + evaluators: [], // No custom evaluators needed + providers: [], // No custom providers needed clients: [EchoChamberClientInterface], }; export default echoChamberPlugin; -// Export all types and classes -export { - ChatMessage, - ChatRoom, - EchoChamberConfig, - ListRoomsResponse, - MessageResponse, - ModelInfo, - RoomEvent, - RoomHistoryResponse, -}; +// Export types and classes +export * from "./types"; +export { EchoChamberClient } from "./echoChamberClient"; +export { InteractionClient } from "./interactions"; diff --git a/packages/plugin-echochambers/src/interactions.ts b/packages/plugin-echochambers/src/interactions.ts new file mode 100644 index 0000000000..1bb7df02a4 --- /dev/null +++ b/packages/plugin-echochambers/src/interactions.ts @@ -0,0 +1,398 @@ +import { + composeContext, + generateMessageResponse, + generateShouldRespond, + messageCompletionFooter, + shouldRespondFooter, + Content, + HandlerCallback, + IAgentRuntime, + Memory, + ModelClass, + State, + stringToUuid, + elizaLogger, + getEmbeddingZeroVector, +} from "@ai16z/eliza"; +import { EchoChamberClient } from "./echoChamberClient"; +import { ChatMessage } from "./types"; + +function createMessageTemplate(currentRoom: string, roomTopic: string) { + return ( + ` +# About {{agentName}}: +{{bio}} +{{lore}} +{{knowledge}} + +Current Room: ${currentRoom} +Room Topic: ${roomTopic} + +{{messageDirections}} + +Recent conversation history: +{{recentMessages}} + +Thread Context: +{{formattedConversation}} + +# Task: Generate a response in the voice and style of {{agentName}} while: +1. Staying relevant to the room's topic +2. Maintaining conversation context +3. Being helpful but not overly talkative +4. Responding naturally to direct questions or mentions +5. Contributing meaningfully to ongoing discussions + +Remember: +- Keep responses concise and focused +- Stay on topic for the current room +- Don't repeat information already shared +- Be natural and conversational +` + messageCompletionFooter + ); +} + +function createShouldRespondTemplate(currentRoom: string, roomTopic: string) { + return ( + ` +# About {{agentName}}: +{{bio}} +{{knowledge}} + +Current Room: ${currentRoom} +Room Topic: ${roomTopic} + +Response options are [RESPOND], [IGNORE] and [STOP]. + +{{agentName}} should: +- RESPOND when: + * Directly mentioned or asked a question + * Can contribute relevant expertise to the discussion + * Topic aligns with their knowledge and background + * Conversation is active and engaging + +- IGNORE when: + * Message is not relevant to their expertise + * Already responded recently without new information to add + * Conversation has moved to a different topic + * Message is too short or lacks substance + * Other participants are handling the discussion well + +- STOP when: + * Asked to stop participating + * Conversation has concluded + * Discussion has completely diverged from their expertise + * Room topic has changed significantly + +Recent messages: +{{recentMessages}} + +Thread Context: +{{formattedConversation}} + +# Task: Choose whether {{agentName}} should respond to the last message. +Consider: +1. Message relevance to {{agentName}}'s expertise +2. Current conversation context +3. Time since last response +4. Value of potential contribution +` + shouldRespondFooter + ); +} + +export class InteractionClient { + private client: EchoChamberClient; + private runtime: IAgentRuntime; + private lastCheckedTimestamps: Map = new Map(); + private lastResponseTimes: Map = new Map(); + private messageThreads: Map = new Map(); + private pollInterval: NodeJS.Timeout | null = null; + + constructor(client: EchoChamberClient, runtime: IAgentRuntime) { + this.client = client; + this.runtime = runtime; + } + + async start() { + const pollInterval = Number( + this.runtime.getSetting("ECHOCHAMBERS_POLL_INTERVAL") || 60 + ); + + const handleInteractionsLoop = () => { + this.handleInteractions(); + this.pollInterval = setTimeout( + handleInteractionsLoop, + pollInterval * 1000 + ); + }; + + handleInteractionsLoop(); + } + + async stop() { + if (this.pollInterval) { + clearTimeout(this.pollInterval); + this.pollInterval = null; + } + } + + private async buildMessageThread( + message: ChatMessage, + messages: ChatMessage[] + ): Promise { + const thread: ChatMessage[] = []; + const maxThreadLength = Number( + this.runtime.getSetting("ECHOCHAMBERS_MAX_MESSAGES") || 10 + ); + + // Start with the current message + thread.push(message); + + // Get recent messages in the same room, ordered by timestamp + const roomMessages = messages + .filter((msg) => msg.roomId === message.roomId) + .sort( + (a, b) => + new Date(b.timestamp).getTime() - + new Date(a.timestamp).getTime() + ); + + // Add recent messages to provide context + for (const msg of roomMessages) { + if (thread.length >= maxThreadLength) break; + if (msg.id !== message.id) { + thread.unshift(msg); + } + } + + return thread; + } + + private shouldProcessMessage( + message: ChatMessage, + room: { topic: string } + ): boolean { + const modelInfo = this.client.getModelInfo(); + + // Don't process own messages + if (message.sender.username === modelInfo.username) { + return false; + } + + // Check if we've processed this message before + const lastChecked = + this.lastCheckedTimestamps.get(message.roomId) || "0"; + if (message.timestamp <= lastChecked) { + return false; + } + + // Check rate limiting for responses + const lastResponseTime = + this.lastResponseTimes.get(message.roomId) || 0; + const minTimeBetweenResponses = 30000; // 30 seconds + if (Date.now() - lastResponseTime < minTimeBetweenResponses) { + return false; + } + + // Check if message mentions the agent + const isMentioned = message.content + .toLowerCase() + .includes(`@${modelInfo.username.toLowerCase()}`); + + // Check if message is relevant to room topic + const isRelevantToTopic = message.content + .toLowerCase() + .includes(room.topic.toLowerCase()); + + // Always process if mentioned, otherwise check relevance + return isMentioned || isRelevantToTopic; + } + + private async handleInteractions() { + elizaLogger.log("Checking EchoChambers interactions"); + + try { + const rooms = await this.client.listRooms(); + + for (const room of rooms) { + const messages = await this.client.getRoomHistory(room.id); + + // Update message threads for the room + this.messageThreads.set(room.id, messages); + + // Filter and process new messages + const newMessages = messages.filter((msg) => + this.shouldProcessMessage(msg, room) + ); + + // Process each new message + for (const message of newMessages) { + await this.handleMessage(message, room.topic); + + // Update timestamps + if ( + message.timestamp > + (this.lastCheckedTimestamps.get(room.id) || "0") + ) { + this.lastCheckedTimestamps.set( + room.id, + message.timestamp + ); + } + } + } + + elizaLogger.log("Finished checking EchoChambers interactions"); + } catch (error) { + elizaLogger.error( + "Error handling EchoChambers interactions:", + error + ); + } + } + + private async handleMessage(message: ChatMessage, roomTopic: string) { + try { + const roomId = stringToUuid(message.roomId); + const userId = stringToUuid(message.sender.username); + + // Ensure connection exists + await this.runtime.ensureConnection( + userId, + roomId, + message.sender.username, + message.sender.username, + "echochambers" + ); + + // Build message thread for context + const thread = await this.buildMessageThread( + message, + this.messageThreads.get(message.roomId) || [] + ); + + // Create memory object + const memory: Memory = { + id: stringToUuid(message.id), + userId, + agentId: this.runtime.agentId, + roomId, + content: { + text: message.content, + source: "echochambers", + thread: thread.map((msg) => ({ + text: msg.content, + sender: msg.sender.username, + timestamp: msg.timestamp, + })), + }, + createdAt: new Date(message.timestamp).getTime(), + embedding: getEmbeddingZeroVector(), + }; + + // Check if we've already processed this message + const existing = await this.runtime.messageManager.getMemoryById( + memory.id + ); + if (existing) { + elizaLogger.log( + `Already processed message ${message.id}, skipping` + ); + return; + } + + // Save the message to memory + await this.runtime.messageManager.createMemory(memory); + + // Compose state with thread context + let state = await this.runtime.composeState(memory); + state = await this.runtime.updateRecentMessageState(state); + + // Decide whether to respond + const shouldRespondContext = composeContext({ + state, + template: + this.runtime.character.templates?.shouldRespondTemplate || + createShouldRespondTemplate(message.roomId, roomTopic), + }); + + const shouldRespond = await generateShouldRespond({ + runtime: this.runtime, + context: shouldRespondContext, + modelClass: ModelClass.SMALL, + }); + + if (shouldRespond !== "RESPOND") { + elizaLogger.log( + `Not responding to message ${message.id}: ${shouldRespond}` + ); + return; + } + + // Generate response + const responseContext = composeContext({ + state, + template: + this.runtime.character.templates?.messageHandlerTemplate || + createMessageTemplate(message.roomId, roomTopic), + }); + + const response = await generateMessageResponse({ + runtime: this.runtime, + context: responseContext, + modelClass: ModelClass.SMALL, + }); + + if (!response || !response.text) { + elizaLogger.log("No response generated"); + return; + } + + // Send response + const callback: HandlerCallback = async (content: Content) => { + const sentMessage = await this.client.sendMessage( + message.roomId, + content.text + ); + + // Update last response time + this.lastResponseTimes.set(message.roomId, Date.now()); + + const responseMemory: Memory = { + id: stringToUuid(sentMessage.id), + userId: this.runtime.agentId, + agentId: this.runtime.agentId, + roomId, + content: { + text: sentMessage.content, + source: "echochambers", + action: content.action, + thread: thread.map((msg) => ({ + text: msg.content, + sender: msg.sender.username, + timestamp: msg.timestamp, + })), + }, + createdAt: new Date(sentMessage.timestamp).getTime(), + embedding: getEmbeddingZeroVector(), + }; + + await this.runtime.messageManager.createMemory(responseMemory); + return [responseMemory]; + }; + + // Send the response and process any resulting actions + const responseMessages = await callback(response); + state = await this.runtime.updateRecentMessageState(state); + await this.runtime.processActions( + memory, + responseMessages, + state, + callback + ); + await this.runtime.evaluate(memory, state, true); + } catch (error) { + elizaLogger.error("Error handling message:", error); + } + } +} diff --git a/packages/plugin-echochambers/src/types.ts b/packages/plugin-echochambers/src/types.ts new file mode 100644 index 0000000000..887758813e --- /dev/null +++ b/packages/plugin-echochambers/src/types.ts @@ -0,0 +1,68 @@ +export interface ModelInfo { + username: string; // Unique username for the model/agent + model: string; // Type/name of the model being used +} + +export interface ChatMessage { + id: string; // Unique message identifier + content: string; // Message content/text + sender: ModelInfo; // Information about who sent the message + timestamp: string; // ISO timestamp of when message was sent + roomId: string; // ID of the room this message belongs to +} + +export interface ChatRoom { + id: string; // Unique room identifier + name: string; // Display name of the room + topic: string; // Room's current topic/description + tags: string[]; // Tags associated with the room for categorization + participants: ModelInfo[]; // List of current room participants + createdAt: string; // ISO timestamp of room creation + messageCount: number; // Total number of messages in the room +} + +export interface EchoChamberConfig { + apiUrl: string; // Base URL for the EchoChambers API + apiKey: string; // Required API key for authenticated endpoints + defaultRoom?: string; // Optional default room to join on startup + username?: string; // Optional custom username (defaults to agent-{agentId}) + model?: string; // Optional model name (defaults to runtime.modelProvider) +} + +export interface ListRoomsResponse { + rooms: ChatRoom[]; +} + +export interface RoomHistoryResponse { + messages: ChatMessage[]; +} + +export interface MessageResponse { + message: ChatMessage; +} + +export interface CreateRoomResponse { + room: ChatRoom; +} + +export interface ClearMessagesResponse { + success: boolean; + message: string; +} + +export enum RoomEvent { + MESSAGE_CREATED = "message_created", + ROOM_CREATED = "room_created", + ROOM_UPDATED = "room_updated", + ROOM_JOINED = "room_joined", + ROOM_LEFT = "room_left", +} + +export interface MessageTransformer { + transformIncoming(content: string): Promise; + transformOutgoing?(content: string): Promise; +} + +export interface ContentModerator { + validateContent(content: string): Promise; +} From c79f5a42b3776bc40305c44d6a9feb0234591bce Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 12 Dec 2024 05:44:14 +0200 Subject: [PATCH 4/4] Update interactions.ts --- .../plugin-echochambers/src/interactions.ts | 66 ++++++++++++++----- 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/packages/plugin-echochambers/src/interactions.ts b/packages/plugin-echochambers/src/interactions.ts index 1bb7df02a4..be824e50dd 100644 --- a/packages/plugin-echochambers/src/interactions.ts +++ b/packages/plugin-echochambers/src/interactions.ts @@ -106,6 +106,10 @@ export class InteractionClient { private lastCheckedTimestamps: Map = new Map(); private lastResponseTimes: Map = new Map(); private messageThreads: Map = new Map(); + private messageHistory: Map< + string, + { message: ChatMessage; response: ChatMessage | null }[] + > = new Map(); private pollInterval: NodeJS.Timeout | null = null; constructor(client: EchoChamberClient, runtime: IAgentRuntime) { @@ -197,12 +201,12 @@ export class InteractionClient { // Check if message mentions the agent const isMentioned = message.content .toLowerCase() - .includes(`@${modelInfo.username.toLowerCase()}`); + .includes(`${modelInfo.username.toLowerCase()}`); // Check if message is relevant to room topic - const isRelevantToTopic = message.content - .toLowerCase() - .includes(room.topic.toLowerCase()); + const isRelevantToTopic = + room.topic && + message.content.toLowerCase().includes(room.topic.toLowerCase()); // Always process if mentioned, otherwise check relevance return isMentioned || isRelevantToTopic; @@ -212,31 +216,49 @@ export class InteractionClient { elizaLogger.log("Checking EchoChambers interactions"); try { + const defaultRoom = this.runtime.getSetting( + "ECHOCHAMBERS_DEFAULT_ROOM" + ); const rooms = await this.client.listRooms(); for (const room of rooms) { - const messages = await this.client.getRoomHistory(room.id); + // Only process messages from the default room if specified + if (defaultRoom && room.id !== defaultRoom) { + continue; + } - // Update message threads for the room + const messages = await this.client.getRoomHistory(room.id); this.messageThreads.set(room.id, messages); - // Filter and process new messages - const newMessages = messages.filter((msg) => - this.shouldProcessMessage(msg, room) - ); - - // Process each new message - for (const message of newMessages) { - await this.handleMessage(message, room.topic); - - // Update timestamps + // Get only the most recent message that we should process + const latestMessages = messages + .filter((msg) => !this.shouldProcessMessage(msg, room)) // Fixed: Now filtering out messages we shouldn't process + .sort( + (a, b) => + new Date(b.timestamp).getTime() - + new Date(a.timestamp).getTime() + ); + + if (latestMessages.length > 0) { + const latestMessage = latestMessages[0]; + await this.handleMessage(latestMessage, room.topic); + + // Update history + const roomHistory = this.messageHistory.get(room.id) || []; + roomHistory.push({ + message: latestMessage, + response: null, // Will be updated when we respond + }); + this.messageHistory.set(room.id, roomHistory); + + // Update last checked timestamp if ( - message.timestamp > + latestMessage.timestamp > (this.lastCheckedTimestamps.get(room.id) || "0") ) { this.lastCheckedTimestamps.set( room.id, - message.timestamp + latestMessage.timestamp ); } } @@ -358,6 +380,14 @@ export class InteractionClient { // Update last response time this.lastResponseTimes.set(message.roomId, Date.now()); + // Update history with our response + const roomHistory = + this.messageHistory.get(message.roomId) || []; + const lastEntry = roomHistory[roomHistory.length - 1]; + if (lastEntry && lastEntry.message.id === message.id) { + lastEntry.response = sentMessage; + } + const responseMemory: Memory = { id: stringToUuid(sentMessage.id), userId: this.runtime.agentId,