@elizaos/plugin-xai 2.0.0-alpha.1 → 2.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shaw Walters and elizaOS Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -106,7 +106,7 @@
106
106
  "import {\n createUniqueUuid,\n type IAgentRuntime,\n logger,\n type Memory,\n ModelType,\n} from \"@elizaos/core\";\nimport type { ClientBase } from \"./base\";\nimport type { Client, Post } from \"./client/index\";\nimport { SearchMode } from \"./client/index\";\nimport { getRandomInterval } from \"./environment\";\nimport { createMemorySafe, ensureXContext } from \"./utils/memory\";\nimport { getSetting } from \"./utils/settings\";\n\ninterface DiscoveryConfig {\n // Topics from character configuration\n topics: string[];\n // Minimum follower count for accounts to consider\n minFollowerCount: number;\n // Maximum accounts to follow per cycle\n maxFollowsPerCycle: number;\n // Maximum engagements per cycle\n maxEngagementsPerCycle: number;\n // Engagement probability thresholds\n likeThreshold: number;\n replyThreshold: number;\n quoteThreshold: number;\n}\n\ninterface ScoredPost {\n post: Post;\n relevanceScore: number;\n engagementType: \"like\" | \"reply\" | \"quote\" | \"skip\";\n}\n\ninterface ScoredAccount {\n user: {\n id: string;\n username: string;\n name: string;\n followersCount: number;\n };\n qualityScore: number;\n relevanceScore: number;\n}\n\nexport class XDiscoveryClient {\n private xClient: Client;\n private runtime: IAgentRuntime;\n private config: DiscoveryConfig;\n private isRunning: boolean = false;\n private isDryRun: boolean;\n\n constructor(client: ClientBase, runtime: IAgentRuntime, state: Record<string, unknown>) {\n this.xClient = client.xClient;\n this.runtime = runtime;\n\n // Check dry run mode\n const dryRunSetting =\n state?.X_DRY_RUN ?? getSetting(this.runtime, \"X_DRY_RUN\") ?? process.env.X_DRY_RUN;\n this.isDryRun =\n dryRunSetting === true ||\n dryRunSetting === \"true\" ||\n (typeof dryRunSetting === \"string\" && dryRunSetting.toLowerCase() === \"true\");\n\n // Build config from character settings\n this.config = this.buildDiscoveryConfig();\n\n logger.info(\n `X Discovery Config: topics=${this.config.topics.join(\", \")}, isDryRun=${this.isDryRun}, minFollowerCount=${this.config.minFollowerCount}, maxFollowsPerCycle=${this.config.maxFollowsPerCycle}, maxEngagementsPerCycle=${this.config.maxEngagementsPerCycle}`\n );\n }\n\n /**\n * Sanitizes a topic for use in X search queries\n * - Removes common stop words that might be interpreted as operators\n * - Handles special characters\n * - Simplifies complex phrases\n */\n private sanitizeTopic(topic: string): string {\n // Remove common conjunctions that might be interpreted as operators\n let sanitized = topic\n .replace(/\\band\\b/gi, \" \")\n .replace(/\\bor\\b/gi, \" \")\n .replace(/\\bnot\\b/gi, \" \")\n .trim();\n\n // Remove extra spaces\n sanitized = sanitized.replace(/\\s+/g, \" \");\n\n // If the topic is still multi-word, wrap in quotes\n return sanitized.includes(\" \") ? `\"${sanitized}\"` : sanitized;\n }\n\n private buildDiscoveryConfig(): DiscoveryConfig {\n const character = this.runtime?.character;\n\n // Default topics if character is not available\n const defaultTopics = [\n \"ai\",\n \"technology\",\n \"blockchain\",\n \"web3\",\n \"crypto\",\n \"programming\",\n \"innovation\",\n ];\n\n // Use character topics, extract from bio, or use defaults\n let topics: string[] = defaultTopics;\n\n if (character) {\n if (character.topics && Array.isArray(character.topics) && character.topics.length > 0) {\n topics = character.topics;\n } else if (character.bio) {\n topics = this.extractTopicsFromBio(character.bio);\n }\n } else {\n logger.warn(\"Character not available in runtime, using default topics for discovery\");\n }\n\n return {\n topics,\n minFollowerCount: parseInt(\n (getSetting(this.runtime, \"X_MIN_FOLLOWER_COUNT\") as string) ||\n process.env.X_MIN_FOLLOWER_COUNT ||\n \"100\",\n 10\n ),\n maxFollowsPerCycle: parseInt(\n (getSetting(this.runtime, \"X_MAX_FOLLOWS_PER_CYCLE\") as string) ||\n process.env.X_MAX_FOLLOWS_PER_CYCLE ||\n \"5\",\n 10\n ),\n maxEngagementsPerCycle: parseInt(\n (getSetting(this.runtime, \"X_MAX_ENGAGEMENTS_PER_RUN\") as string) ||\n process.env.X_MAX_ENGAGEMENTS_PER_RUN ||\n \"5\",\n 10\n ),\n likeThreshold: 0.5, // Increased from 0.3 (be more selective)\n replyThreshold: 0.7, // Increased from 0.5 (be more selective)\n quoteThreshold: 0.85, // Increased from 0.7 (be more selective)\n };\n }\n\n private extractTopicsFromBio(bio: string | string[] | undefined): string[] {\n if (!bio) {\n return [];\n }\n\n const bioText = Array.isArray(bio) ? bio.join(\" \") : bio;\n // Extract meaningful words as potential topics\n const words = bioText\n .toLowerCase()\n .split(/\\s+/)\n .filter((word) => word.length > 4)\n .filter(\n (word) => ![\"about\", \"helping\", \"working\", \"people\", \"making\", \"building\"].includes(word)\n );\n return [...new Set(words)].slice(0, 5); // Limit to 5 topics\n }\n\n async start() {\n logger.info(\"Starting X Discovery Client...\");\n this.isRunning = true;\n\n const discoveryLoop = async () => {\n if (!this.isRunning) {\n logger.info(\"Discovery client stopped, exiting loop\");\n return;\n }\n\n try {\n await this.runDiscoveryCycle();\n } catch (error: unknown) {\n const errorMsg = error instanceof Error ? error.message : String(error);\n logger.error(\"Discovery cycle error:\", errorMsg);\n }\n\n // Run discovery every 20-40 minutes (with variance)\n const discoveryIntervalMinutes = getRandomInterval(this.runtime, \"discovery\");\n const nextInterval = discoveryIntervalMinutes * 60 * 1000;\n\n logger.log(`Next discovery cycle in ${discoveryIntervalMinutes.toFixed(1)} minutes`);\n\n // Schedule next discovery\n setTimeout(discoveryLoop, nextInterval);\n };\n\n // Start after a short delay\n setTimeout(discoveryLoop, 5000);\n }\n\n async stop() {\n logger.info(\"Stopping X Discovery Client...\");\n this.isRunning = false;\n }\n\n private async runDiscoveryCycle() {\n logger.info(\"Starting X discovery cycle...\");\n\n const discoveries = await this.discoverContent();\n const { posts, accounts } = discoveries;\n\n logger.info(`Discovered ${posts.length} posts and ${accounts.length} accounts`);\n\n // Process discovered accounts (follow high-quality ones)\n const followedCount = await this.processAccounts(accounts);\n\n // Process discovered posts (engage with relevant ones)\n const engagementCount = await this.processPosts(posts);\n\n logger.info(\n `Discovery cycle complete: ${followedCount} follows, ${engagementCount} engagements`\n );\n }\n\n private async discoverContent(): Promise<{\n posts: ScoredPost[];\n accounts: ScoredAccount[];\n }> {\n const allPosts: ScoredPost[] = [];\n const allAccounts = new Map<string, ScoredAccount>();\n\n // X API v2 doesn't support trends - using topic-based discovery only\n\n // 1. Discover from topic searches (primary discovery method)\n try {\n const topicContent = await this.discoverFromTopics();\n allPosts.push(...topicContent.posts);\n for (const acc of topicContent.accounts) {\n allAccounts.set(acc.user.id, acc);\n }\n } catch (error: unknown) {\n const errorMsg = error instanceof Error ? error.message : String(error);\n logger.error(\"Failed to discover from topics:\", errorMsg);\n }\n\n // 2. Discover from conversation threads\n try {\n const threadContent = await this.discoverFromThreads();\n allPosts.push(...threadContent.posts);\n for (const acc of threadContent.accounts) {\n allAccounts.set(acc.user.id, acc);\n }\n } catch (error: unknown) {\n const errorMsg = error instanceof Error ? error.message : String(error);\n logger.error(\"Failed to discover from threads:\", errorMsg);\n }\n\n // 3. Discover from popular accounts in our topics\n try {\n const popularContent = await this.discoverFromPopularAccounts();\n allPosts.push(...popularContent.posts);\n for (const acc of popularContent.accounts) {\n allAccounts.set(acc.user.id, acc);\n }\n } catch (error) {\n logger.error(\n \"Failed to discover from popular accounts:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n\n // Sort by relevance score\n const sortedPosts = allPosts.sort((a, b) => b.relevanceScore - a.relevanceScore).slice(0, 50); // Top 50 posts\n\n const sortedAccounts = Array.from(allAccounts.values())\n .sort((a, b) => b.qualityScore * b.relevanceScore - a.qualityScore * a.relevanceScore)\n .slice(0, 20); // Top 20 accounts\n\n return { posts: sortedPosts, accounts: sortedAccounts };\n }\n\n private async discoverFromTopics(): Promise<{\n posts: ScoredPost[];\n accounts: ScoredAccount[];\n }> {\n logger.debug(\"Discovering from character topics...\");\n\n const posts: ScoredPost[] = [];\n const accounts = new Map<string, ScoredAccount>();\n\n // Search for each topic with different query strategies\n for (const topic of this.config.topics.slice(0, 5)) {\n try {\n // Sanitize topic for search query\n const searchTopic = this.sanitizeTopic(topic);\n\n // Strategy 1: Popular posts in topic (min_faves filter applied post-retrieval)\n const popularQuery = `${searchTopic} -is:repost -is:reply lang:en`;\n\n logger.debug(`Searching popular posts for topic: ${topic}`);\n const popularResults = await this.xClient.fetchSearchPosts(\n popularQuery,\n 20,\n SearchMode.Top\n );\n\n for (const post of popularResults.posts) {\n // Filter by engagement after retrieval\n if ((post.likes || 0) < 10) continue;\n\n const scored = this.scorePost(post, \"topic\");\n posts.push(scored);\n\n // Extract account info from popular post authors\n if (!post.userId || !post.username) {\n continue;\n }\n const authorUsername = post.username;\n const authorName = post.name || post.username;\n\n // Estimate follower count based on post engagement\n // Popular posts often come from accounts with decent followings\n const estimatedFollowers = Math.max(\n 1000, // minimum estimate\n (post.likes || 0) * 100 // rough estimate: 100 followers per like\n );\n\n const account = this.scoreAccount({\n id: post.userId,\n username: authorUsername,\n name: authorName,\n followersCount: estimatedFollowers,\n });\n\n if (account.qualityScore > 0.3) {\n // Lower threshold to discover more accounts\n accounts.set(post.userId, account);\n }\n }\n\n // Strategy 2: Latest posts with good engagement (not just verified)\n const engagedQuery = `${searchTopic} -is:repost lang:en`;\n\n logger.debug(`Searching engaged posts for topic: ${topic}`);\n const engagedResults = await this.xClient.fetchSearchPosts(\n engagedQuery,\n 15,\n SearchMode.Latest\n );\n\n for (const post of engagedResults.posts) {\n // Only include posts with some engagement\n if ((post.likes || 0) < 5) continue;\n\n const scored = this.scorePost(post, \"topic\");\n posts.push(scored);\n\n // Extract account info from post author\n if (!post.userId || !post.username) {\n continue;\n }\n const authorUsername = post.username;\n const authorName = post.name || post.username;\n\n // Estimate follower count based on engagement\n const estimatedFollowers = Math.max(\n 500, // minimum for engaged posts\n (post.likes || 0) * 50\n );\n\n const account = this.scoreAccount({\n id: post.userId,\n username: authorUsername,\n name: authorName,\n followersCount: estimatedFollowers,\n });\n\n if (account.qualityScore > 0.2) {\n // Even lower threshold for engaged content\n accounts.set(post.userId, account);\n }\n }\n } catch (error) {\n logger.error(\n `Failed to search topic ${topic}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n return { posts, accounts: Array.from(accounts.values()) };\n }\n\n private async discoverFromThreads(): Promise<{\n posts: ScoredPost[];\n accounts: ScoredAccount[];\n }> {\n logger.debug(\"Discovering from conversation threads...\");\n\n const posts: ScoredPost[] = [];\n const accounts = new Map<string, ScoredAccount>();\n\n // Search for viral conversations in our topics\n // X API v2 doesn't support min_replies/min_faves - filter by engagement in scoring\n const topicQuery = this.config.topics\n .slice(0, 3)\n .map((t) => this.sanitizeTopic(t))\n .join(\" OR \");\n\n try {\n // Search for conversations (posts with engagement)\n const viralQuery = `(${topicQuery}) -is:repost has:mentions`;\n\n logger.debug(`Searching viral threads with query: ${viralQuery}`);\n const searchResults = await this.xClient.fetchSearchPosts(viralQuery, 15, SearchMode.Top);\n\n for (const post of searchResults.posts) {\n // Filter for posts with good engagement (proxy for viral threads)\n const engagementScore = (post.likes || 0) + (post.reposts || 0) * 2;\n if (engagementScore < 10) continue; // Lowered from 50 - more inclusive\n\n const scored = this.scorePost(post, \"thread\");\n posts.push(scored);\n\n // Viral thread authors are likely high-quality accounts\n if (!post.userId || !post.username) {\n continue;\n }\n const account = this.scoreAccount({\n id: post.userId,\n username: post.username,\n name: post.name || post.username,\n followersCount: 1000, // Reasonable estimate for engaged users\n });\n\n if (account.qualityScore > 0.5) {\n // Lowered from 0.6\n accounts.set(post.userId, account);\n }\n }\n } catch (error) {\n logger.error(\n \"Failed to discover threads:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n\n return { posts, accounts: Array.from(accounts.values()) };\n }\n\n private async discoverFromPopularAccounts(): Promise<{\n posts: ScoredPost[];\n accounts: ScoredAccount[];\n }> {\n logger.debug(\"Discovering from popular accounts in topics...\");\n\n const posts: ScoredPost[] = [];\n const accounts = new Map<string, ScoredAccount>();\n\n // Search for users who frequently post about our topics\n for (const topic of this.config.topics.slice(0, 3)) {\n try {\n // Sanitize topic for search query\n const searchTopic = this.sanitizeTopic(topic);\n\n // Find posts from accounts with high engagement\n // X API v2 doesn't support min_faves/min_reposts - filter post-retrieval\n const influencerQuery = `${searchTopic} -is:repost lang:en`;\n\n logger.debug(`Searching for influencers in topic: ${topic}`);\n const results = await this.xClient.fetchSearchPosts(influencerQuery, 10, SearchMode.Top);\n\n for (const post of results.posts) {\n // Filter by engagement metrics after retrieval\n const engagement = (post.likes || 0) + (post.reposts || 0) * 2;\n if (engagement < 5) continue; // Lowered from 20 - more inclusive\n\n const scored = this.scorePost(post, \"topic\");\n posts.push(scored);\n\n // High engagement suggests a quality account\n const estimatedFollowers = Math.max(\n (post.likes || 0) * 100,\n (post.reposts || 0) * 200,\n 10000\n );\n\n if (!post.userId || !post.username) {\n continue;\n }\n const account = this.scoreAccount({\n id: post.userId,\n username: post.username,\n name: post.name || post.username,\n followersCount: estimatedFollowers,\n });\n\n if (account.qualityScore > 0.7) {\n accounts.set(post.userId, account);\n }\n }\n } catch (error) {\n logger.error(\n `Failed to discover popular accounts for ${topic}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n return { posts, accounts: Array.from(accounts.values()) };\n }\n\n // Remove the discoverFromTrends method since API v2 doesn't support it\n // Remove the isTrendRelevant method since we're not using trends\n\n private scorePost(post: Post, source: string): ScoredPost {\n // Skip reposts - we want original content\n if (post.isRepost) {\n return {\n post,\n relevanceScore: 0,\n engagementType: \"skip\",\n };\n }\n\n let relevanceScore = 0;\n\n // Base score by source\n const sourceScores: Record<string, number> = {\n topic: 0.4,\n thread: 0.35,\n };\n relevanceScore += sourceScores[source] || 0;\n\n // Score by engagement metrics - much more realistic thresholds\n const engagementScore = Math.min(\n (post.likes || 0) / 100 + // 100 likes = 0.1 points (was 1000)\n (post.reposts || 0) / 50 + // 50 reposts = 0.1 points (was 500)\n (post.replies || 0) / 20, // 20 replies = 0.1 points (was 100)\n 0.3\n );\n relevanceScore += engagementScore;\n\n // Score by text relevance if text exists\n if (post.text) {\n // Additional scoring based on text content can go here\n }\n\n // Score by content relevance to topics\n if (post.text) {\n const textLower = post.text.toLowerCase();\n const topicMatches = this.config.topics.filter((topic) =>\n textLower.includes(topic.toLowerCase())\n ).length;\n relevanceScore += Math.min(topicMatches * 0.15, 0.3); // Increased from 0.1\n }\n\n // Bonus for verified accounts (isBlueVerified may not be in all responses)\n\n // Normalize score\n relevanceScore = Math.min(relevanceScore, 1);\n\n // Determine engagement type based on score\n let engagementType: ScoredPost[\"engagementType\"] = \"skip\";\n if (relevanceScore >= this.config.quoteThreshold) {\n engagementType = \"quote\";\n } else if (relevanceScore >= this.config.replyThreshold) {\n engagementType = \"reply\";\n } else if (relevanceScore >= this.config.likeThreshold) {\n engagementType = \"like\";\n }\n\n return {\n post,\n relevanceScore,\n engagementType,\n };\n }\n\n private scoreAccount(user: ScoredAccount[\"user\"]): ScoredAccount {\n let qualityScore = 0;\n let relevanceScore = 0;\n\n // Quality based on follower count\n if (user.followersCount > 10000) qualityScore += 0.4;\n else if (user.followersCount > 1000) qualityScore += 0.3;\n else if (user.followersCount > 100) qualityScore += 0.2;\n\n // Relevance based on username/name matching topics\n const userText = `${user.username} ${user.name}`.toLowerCase();\n const topicMatches = this.config.topics.filter((topic) =>\n userText.includes(topic.toLowerCase())\n ).length;\n relevanceScore = Math.min(topicMatches * 0.3, 1);\n\n return {\n user,\n qualityScore: Math.min(qualityScore, 1),\n relevanceScore,\n };\n }\n\n private async processAccounts(accounts: ScoredAccount[]): Promise<number> {\n let followedCount = 0;\n\n // Sort accounts by combined quality and relevance score\n const sortedAccounts = accounts.sort((a, b) => {\n const scoreA = a.qualityScore + a.relevanceScore;\n const scoreB = b.qualityScore + b.relevanceScore;\n return scoreB - scoreA;\n });\n\n for (const scoredAccount of sortedAccounts) {\n if (followedCount >= this.config.maxFollowsPerCycle) break;\n\n // Skip accounts with too few followers\n if (scoredAccount.user.followersCount < this.config.minFollowerCount) {\n logger.debug(\n `Skipping @${scoredAccount.user.username} - below minimum follower count (${scoredAccount.user.followersCount} < ${this.config.minFollowerCount})`\n );\n continue;\n }\n\n // Skip low-quality accounts\n if (scoredAccount.qualityScore < 0.2) {\n logger.debug(\n `Skipping @${scoredAccount.user.username} - quality score too low (${scoredAccount.qualityScore.toFixed(2)})`\n );\n continue;\n }\n\n try {\n // Check if already following (via memory)\n const isFollowing = await this.checkIfFollowing(scoredAccount.user.id);\n if (isFollowing) continue;\n\n if (this.isDryRun) {\n logger.info(\n `[DRY RUN] Would follow @${scoredAccount.user.username} ` +\n `(quality: ${scoredAccount.qualityScore.toFixed(2)}, ` +\n `relevance: ${scoredAccount.relevanceScore.toFixed(2)})`\n );\n } else {\n // Follow the account\n await this.xClient.followUser(scoredAccount.user.id);\n\n logger.info(\n `Followed @${scoredAccount.user.username} ` +\n `(quality: ${scoredAccount.qualityScore.toFixed(2)}, ` +\n `relevance: ${scoredAccount.relevanceScore.toFixed(2)})`\n );\n\n // Save follow action to memory\n await this.saveFollowMemory(scoredAccount.user);\n }\n\n followedCount++;\n\n // Add a delay to avoid rate limits\n await this.delay(2000 + Math.random() * 3000);\n } catch (error) {\n logger.error(\n `Failed to follow @${scoredAccount.user.username}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n return followedCount;\n }\n\n private async processPosts(posts: ScoredPost[]): Promise<number> {\n let engagementCount = 0;\n\n for (const scoredPost of posts) {\n if (engagementCount >= this.config.maxEngagementsPerCycle) break;\n if (scoredPost.engagementType === \"skip\") continue;\n\n try {\n // Check if already engaged\n if (!scoredPost.post.id) {\n continue;\n }\n const postMemoryId = createUniqueUuid(this.runtime, scoredPost.post.id);\n const existingMemory = await this.runtime.getMemoryById(postMemoryId);\n if (existingMemory) {\n logger.debug(`Already engaged with post ${scoredPost.post.id}, skipping`);\n continue;\n }\n\n // Perform engagement\n switch (scoredPost.engagementType) {\n case \"like\":\n if (this.isDryRun) {\n logger.info(\n `[DRY RUN] Would like post: ${scoredPost.post.id} (score: ${scoredPost.relevanceScore.toFixed(2)})`\n );\n } else {\n if (!scoredPost.post.id) {\n continue;\n }\n await this.xClient.likePost(scoredPost.post.id);\n logger.info(\n `Liked post: ${scoredPost.post.id} (score: ${scoredPost.relevanceScore.toFixed(2)})`\n );\n }\n break;\n\n case \"reply\": {\n const replyText = await this.generateReply(scoredPost.post);\n if (this.isDryRun) {\n logger.info(\n `[DRY RUN] Would reply to post ${scoredPost.post.id} with: \"${replyText}\"`\n );\n } else {\n await this.xClient.sendPost(replyText, scoredPost.post.id);\n logger.info(`Replied to post: ${scoredPost.post.id}`);\n }\n break;\n }\n\n case \"quote\": {\n if (!scoredPost.post.id) {\n continue;\n }\n const quoteText = await this.generateQuote(scoredPost.post);\n if (this.isDryRun) {\n logger.info(`[DRY RUN] Would quote post ${scoredPost.post.id} with: \"${quoteText}\"`);\n } else {\n await this.xClient.sendQuotePost(quoteText, scoredPost.post.id);\n logger.info(`Quoted post: ${scoredPost.post.id}`);\n }\n break;\n }\n }\n\n // Save engagement to memory (even in dry run for tracking)\n await this.saveEngagementMemory(scoredPost.post, scoredPost.engagementType);\n\n engagementCount++;\n\n // Add delay to avoid rate limits\n await this.delay(3000 + Math.random() * 5000);\n } catch (error: unknown) {\n // Check if it's a 403 error\n const errorMessage = (error as { message?: string })?.message;\n if (errorMessage?.includes(\"403\")) {\n logger.warn(\n `Permission denied (403) for post ${scoredPost.post.id}. ` +\n `This might be a protected account or restricted post. Skipping.`\n );\n // Still save to memory to avoid retrying\n await this.saveEngagementMemory(scoredPost.post, \"skip\");\n } else if (errorMessage?.includes(\"429\")) {\n logger.warn(\n `Rate limit (429) hit while engaging with post ${scoredPost.post.id}. ` +\n `Pausing engagement cycle.`\n );\n // Break out of the loop on rate limit\n break;\n } else {\n logger.error(\n `Failed to engage with post ${scoredPost.post.id}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n }\n\n return engagementCount;\n }\n\n private async checkIfFollowing(userId: string): Promise<boolean> {\n // Check our memory to see if we've followed them\n const embedding = await this.runtime.useModel(ModelType.TEXT_EMBEDDING, {\n text: `followed X user ${userId}`,\n });\n\n const followMemories = await this.runtime.searchMemories({\n tableName: \"messages\",\n embedding,\n match_threshold: 0.8,\n count: 1,\n });\n return followMemories.length > 0;\n }\n\n private async generateReply(post: Post): Promise<string> {\n // Handle case where runtime.character might be undefined\n const characterName = this.runtime?.character?.name || \"AI Assistant\";\n let characterBio = \"\";\n\n if (this.runtime?.character?.bio) {\n if (Array.isArray(this.runtime.character.bio)) {\n characterBio = this.runtime.character.bio.join(\" \");\n } else {\n characterBio = this.runtime.character.bio;\n }\n }\n\n const prompt = `You are ${characterName}. Generate a thoughtful reply to this post:\n\nPost by @${post.username || \"unknown\"}: \"${post.text || \"\"}\"\n\nYour interests: ${this.config.topics.join(\", \")}\nCharacter bio: ${characterBio}\n\nKeep the reply:\n- Relevant and adding value to the conversation\n- Under 280 characters\n- Natural and conversational\n- Related to your expertise and interests\n- Respectful and constructive\n\nReply:`;\n\n const response = await this.runtime.useModel(ModelType.TEXT_SMALL, {\n prompt,\n maxTokens: 100,\n temperature: 0.8,\n });\n\n return response.trim();\n }\n\n private async generateQuote(post: Post): Promise<string> {\n // Handle case where runtime.character might be undefined\n const characterName = this.runtime?.character?.name || \"AI Assistant\";\n let characterBio = \"\";\n\n if (this.runtime?.character?.bio) {\n if (Array.isArray(this.runtime.character.bio)) {\n characterBio = this.runtime.character.bio.join(\" \");\n } else {\n characterBio = this.runtime.character.bio;\n }\n }\n\n const prompt = `You are ${characterName}. Add your perspective to this post with a quote post:\n\nOriginal post by @${post.username || \"unknown\"}: \"${post.text || \"\"}\"\n\nYour interests: ${this.config.topics.join(\", \")}\nCharacter bio: ${characterBio}\n\nCreate a quote post that:\n- Adds unique insight or perspective\n- Is under 280 characters\n- Respectfully builds on the original idea\n- Showcases your expertise\n- Encourages further discussion\n\nQuote post:`;\n\n const response = await this.runtime.useModel(ModelType.TEXT_SMALL, {\n prompt,\n maxTokens: 100,\n temperature: 0.8,\n });\n\n return response.trim();\n }\n\n private async saveEngagementMemory(post: Post, engagementType: string) {\n try {\n // Ensure context exists before saving memory\n if (!post.userId || !post.username) {\n logger.warn(\"Cannot ensure context: missing userId or username\");\n return;\n }\n const context = await ensureXContext(this.runtime, {\n userId: post.userId,\n username: post.username,\n conversationId: post.conversationId || post.id || \"\",\n });\n\n const memory: Memory = {\n id: createUniqueUuid(this.runtime, `${post.id}-${engagementType}`),\n entityId: context.entityId,\n content: {\n text: `${engagementType} post from @${post.username}: ${post.text}`,\n metadata: {\n postId: post.id,\n engagementType,\n source: \"discovery\",\n isDryRun: this.isDryRun,\n },\n },\n roomId: context.roomId,\n agentId: this.runtime.agentId,\n createdAt: Date.now(),\n };\n\n await createMemorySafe(this.runtime, memory, \"messages\");\n logger.debug(`[Discovery] Saved ${engagementType} memory for post ${post.id}`);\n } catch (error) {\n logger.error(\n `[Discovery] Failed to save engagement memory:`,\n error instanceof Error ? error.message : String(error)\n );\n // Don't throw - just log the error\n }\n }\n\n private async saveFollowMemory(user: ScoredAccount[\"user\"]) {\n try {\n // Create a simple context for follows\n const context = await ensureXContext(this.runtime, {\n userId: user.id,\n username: user.username,\n name: user.name,\n conversationId: `x-follows`,\n });\n\n const memory: Memory = {\n id: createUniqueUuid(this.runtime, `follow-${user.id}`),\n entityId: context.entityId,\n content: {\n text: `followed X user ${user.id} @${user.username}`,\n metadata: {\n userId: user.id,\n username: user.username,\n name: user.name,\n followersCount: user.followersCount,\n source: \"discovery\",\n isDryRun: this.isDryRun,\n },\n },\n roomId: context.roomId,\n agentId: this.runtime.agentId,\n createdAt: Date.now(),\n };\n\n await createMemorySafe(this.runtime, memory, \"messages\");\n logger.debug(`[Discovery] Saved follow memory for @${user.username}`);\n } catch (error) {\n logger.error(\n `[Discovery] Failed to save follow memory:`,\n error instanceof Error ? error.message : String(error)\n );\n // Don't throw - just log the error\n }\n }\n\n private delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n }\n}\n",
107
107
  "import type { IAgentRuntime } from \"@elizaos/core\";\nimport { z } from \"zod\";\nimport { getSetting } from \"./utils/settings\";\n\nfunction getXSetting(runtime: IAgentRuntime, key: string): string {\n return getSetting(runtime, key) || \"\";\n}\n\nexport const xEnvSchema = z.object({\n X_AUTH_MODE: z.enum([\"env\", \"oauth\", \"bearer\"]).default(\"env\"),\n X_API_KEY: z.string().default(\"\"),\n X_API_SECRET: z.string().default(\"\"),\n X_ACCESS_TOKEN: z.string().default(\"\"),\n X_ACCESS_TOKEN_SECRET: z.string().default(\"\"),\n X_BEARER_TOKEN: z.string().default(\"\"),\n X_CLIENT_ID: z.string().default(\"\"),\n X_REDIRECT_URI: z.string().default(\"\"),\n X_DRY_RUN: z.string().default(\"false\"),\n X_TARGET_USERS: z.string().default(\"\"),\n X_ENABLE_POST: z.string().default(\"false\"),\n X_ENABLE_REPLIES: z.string().default(\"true\"),\n X_ENABLE_ACTIONS: z.string().default(\"false\"),\n X_ENABLE_DISCOVERY: z.string().default(\"false\"),\n X_POST_INTERVAL_MIN: z.string().default(\"90\"),\n X_POST_INTERVAL_MAX: z.string().default(\"180\"),\n X_ENGAGEMENT_INTERVAL_MIN: z.string().default(\"20\"),\n X_ENGAGEMENT_INTERVAL_MAX: z.string().default(\"40\"),\n X_DISCOVERY_INTERVAL_MIN: z.string().default(\"15\"),\n X_DISCOVERY_INTERVAL_MAX: z.string().default(\"30\"),\n X_MAX_ENGAGEMENTS_PER_RUN: z.string().default(\"5\"),\n X_MAX_POST_LENGTH: z.string().default(\"280\"),\n X_RETRY_LIMIT: z.string().default(\"5\"),\n});\n\nexport type TwitterConfig = z.infer<typeof xEnvSchema>;\n\nfunction parseTargetUsers(str: string): string[] {\n if (!str.trim()) return [];\n return str\n .split(\",\")\n .map((s) => s.trim())\n .filter(Boolean);\n}\n\nexport function shouldTargetUser(username: string, targetConfig: string): boolean {\n if (!targetConfig.trim()) return true;\n const targets = parseTargetUsers(targetConfig);\n if (targets.includes(\"*\")) return true;\n const normalized = username.toLowerCase().replace(/^@/, \"\");\n return targets.some((t) => t.toLowerCase().replace(/^@/, \"\") === normalized);\n}\n\nexport function getTargetUsers(targetConfig: string): string[] {\n return parseTargetUsers(targetConfig).filter((u) => u !== \"*\");\n}\n\nexport async function validateXConfig(runtime: IAgentRuntime): Promise<TwitterConfig> {\n const mode = (getXSetting(runtime, \"X_AUTH_MODE\") || \"env\").toLowerCase();\n\n const config: TwitterConfig = {\n X_AUTH_MODE: mode as \"env\" | \"oauth\" | \"bearer\",\n X_API_KEY: getXSetting(runtime, \"X_API_KEY\"),\n X_API_SECRET: getXSetting(runtime, \"X_API_SECRET\") || getXSetting(runtime, \"X_API_SECRET_KEY\"),\n X_ACCESS_TOKEN: getXSetting(runtime, \"X_ACCESS_TOKEN\"),\n X_ACCESS_TOKEN_SECRET: getXSetting(runtime, \"X_ACCESS_TOKEN_SECRET\"),\n X_BEARER_TOKEN: getXSetting(runtime, \"X_BEARER_TOKEN\"),\n X_CLIENT_ID: getXSetting(runtime, \"X_CLIENT_ID\"),\n X_REDIRECT_URI: getXSetting(runtime, \"X_REDIRECT_URI\"),\n X_DRY_RUN: getXSetting(runtime, \"X_DRY_RUN\") || \"false\",\n X_TARGET_USERS: getXSetting(runtime, \"X_TARGET_USERS\"),\n X_ENABLE_POST: getXSetting(runtime, \"X_ENABLE_POST\") || \"false\",\n X_ENABLE_REPLIES: getXSetting(runtime, \"X_ENABLE_REPLIES\") || \"true\",\n X_ENABLE_ACTIONS: getXSetting(runtime, \"X_ENABLE_ACTIONS\") || \"false\",\n X_ENABLE_DISCOVERY: getXSetting(runtime, \"X_ENABLE_DISCOVERY\") || \"false\",\n X_POST_INTERVAL_MIN: getXSetting(runtime, \"X_POST_INTERVAL_MIN\") || \"90\",\n X_POST_INTERVAL_MAX: getXSetting(runtime, \"X_POST_INTERVAL_MAX\") || \"180\",\n X_ENGAGEMENT_INTERVAL_MIN: getXSetting(runtime, \"X_ENGAGEMENT_INTERVAL_MIN\") || \"20\",\n X_ENGAGEMENT_INTERVAL_MAX: getXSetting(runtime, \"X_ENGAGEMENT_INTERVAL_MAX\") || \"40\",\n X_DISCOVERY_INTERVAL_MIN: getXSetting(runtime, \"X_DISCOVERY_INTERVAL_MIN\") || \"15\",\n X_DISCOVERY_INTERVAL_MAX: getXSetting(runtime, \"X_DISCOVERY_INTERVAL_MAX\") || \"30\",\n X_MAX_ENGAGEMENTS_PER_RUN: getXSetting(runtime, \"X_MAX_ENGAGEMENTS_PER_RUN\") || \"5\",\n X_MAX_POST_LENGTH: getXSetting(runtime, \"X_MAX_POST_LENGTH\") || \"280\",\n X_RETRY_LIMIT: getXSetting(runtime, \"X_RETRY_LIMIT\") || \"5\",\n };\n\n if (mode === \"env\") {\n if (\n !config.X_API_KEY ||\n !config.X_API_SECRET ||\n !config.X_ACCESS_TOKEN ||\n !config.X_ACCESS_TOKEN_SECRET\n ) {\n throw new Error(\n \"X env auth requires X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET\"\n );\n }\n } else if (mode === \"bearer\") {\n if (!config.X_BEARER_TOKEN) {\n throw new Error(\"X bearer auth requires X_BEARER_TOKEN\");\n }\n } else if (mode === \"oauth\") {\n if (!config.X_CLIENT_ID || !config.X_REDIRECT_URI) {\n throw new Error(\"X OAuth requires X_CLIENT_ID and X_REDIRECT_URI\");\n }\n }\n\n return xEnvSchema.parse(config);\n}\n\nfunction parseInterval(value: string, fallback: number): number {\n const parsed = parseInt(value, 10);\n return Number.isNaN(parsed) ? fallback : parsed;\n}\n\nexport function getRandomInterval(\n runtime: IAgentRuntime,\n type: \"post\" | \"engagement\" | \"discovery\"\n): number {\n const intervals = {\n post: {\n min: \"X_POST_INTERVAL_MIN\",\n max: \"X_POST_INTERVAL_MAX\",\n defMin: 90,\n defMax: 180,\n },\n engagement: {\n min: \"X_ENGAGEMENT_INTERVAL_MIN\",\n max: \"X_ENGAGEMENT_INTERVAL_MAX\",\n defMin: 20,\n defMax: 40,\n },\n discovery: {\n min: \"X_DISCOVERY_INTERVAL_MIN\",\n max: \"X_DISCOVERY_INTERVAL_MAX\",\n defMin: 15,\n defMax: 30,\n },\n };\n\n const { min, max, defMin, defMax } = intervals[type];\n const minVal = parseInterval(getXSetting(runtime, min), defMin);\n const maxVal = parseInterval(getXSetting(runtime, max), defMax);\n\n return minVal < maxVal ? Math.random() * (maxVal - minVal) + minVal : defMin;\n}\n\nexport function loadConfig(): TwitterConfig {\n const get = (key: string): string => process.env[key] || \"\";\n return {\n X_AUTH_MODE: (get(\"X_AUTH_MODE\") || \"env\") as \"env\" | \"oauth\" | \"bearer\",\n X_API_KEY: get(\"X_API_KEY\"),\n X_API_SECRET: get(\"X_API_SECRET\"),\n X_ACCESS_TOKEN: get(\"X_ACCESS_TOKEN\"),\n X_ACCESS_TOKEN_SECRET: get(\"X_ACCESS_TOKEN_SECRET\"),\n X_BEARER_TOKEN: get(\"X_BEARER_TOKEN\"),\n X_CLIENT_ID: get(\"X_CLIENT_ID\"),\n X_REDIRECT_URI: get(\"X_REDIRECT_URI\"),\n X_DRY_RUN: get(\"X_DRY_RUN\") || \"false\",\n X_TARGET_USERS: get(\"X_TARGET_USERS\"),\n X_ENABLE_POST: get(\"X_ENABLE_POST\") || \"false\",\n X_ENABLE_REPLIES: get(\"X_ENABLE_REPLIES\") || \"true\",\n X_ENABLE_ACTIONS: get(\"X_ENABLE_ACTIONS\") || \"false\",\n X_ENABLE_DISCOVERY: get(\"X_ENABLE_DISCOVERY\") || \"false\",\n X_POST_INTERVAL_MIN: get(\"X_POST_INTERVAL_MIN\") || \"90\",\n X_POST_INTERVAL_MAX: get(\"X_POST_INTERVAL_MAX\") || \"180\",\n X_ENGAGEMENT_INTERVAL_MIN: get(\"X_ENGAGEMENT_INTERVAL_MIN\") || \"20\",\n X_ENGAGEMENT_INTERVAL_MAX: get(\"X_ENGAGEMENT_INTERVAL_MAX\") || \"40\",\n X_DISCOVERY_INTERVAL_MIN: get(\"X_DISCOVERY_INTERVAL_MIN\") || \"15\",\n X_DISCOVERY_INTERVAL_MAX: get(\"X_DISCOVERY_INTERVAL_MAX\") || \"30\",\n X_MAX_ENGAGEMENTS_PER_RUN: get(\"X_MAX_ENGAGEMENTS_PER_RUN\") || \"5\",\n X_MAX_POST_LENGTH: get(\"X_MAX_POST_LENGTH\") || \"280\",\n X_RETRY_LIMIT: get(\"X_RETRY_LIMIT\") || \"5\",\n };\n}\n\nexport function validateConfig(config: unknown): TwitterConfig {\n return xEnvSchema.parse(config);\n}\n\nexport function loadConfigFromFile(): Partial<TwitterConfig> {\n return {};\n}\n",
108
108
  "import {\n ChannelType,\n type Content,\n type ContentValue,\n createUniqueUuid,\n EventType,\n type HandlerCallback,\n type IAgentRuntime,\n logger,\n type Memory,\n type MemoryMetadata,\n MemoryType,\n type MessagePayload,\n ModelType,\n} from \"@elizaos/core\";\n\n// WorldOwnership type for world metadata\ntype WorldOwnership = { ownerId: string };\n\nimport type { ClientBase } from \"./base\";\nimport { SearchMode } from \"./client/index\";\nimport type { Post as ClientPost } from \"./client/posts\";\nimport { getRandomInterval, getTargetUsers, shouldTargetUser } from \"./environment\";\n/**\n * Template for generating dialog and actions for a X message handler.\n *\n * @type {string}\n */\n/**\n * Templates for XAI plugin interactions.\n * Auto-generated from prompts/*.txt\n * DO NOT EDIT - Generated from ./generated/prompts/typescript/prompts.ts\n */\nimport {\n messageHandlerTemplate,\n xMessageHandlerTemplate,\n} from \"./generated/prompts/typescript/prompts.js\";\nimport type {\n XInteractionMemory,\n XInteractionPayload,\n XLikeReceivedPayload,\n XMemory,\n XQuoteReceivedPayload,\n XRepostReceivedPayload,\n} from \"./types\";\nimport { XEventTypes } from \"./types\";\nimport { sendPost } from \"./utils\";\nimport { createMemorySafe, ensureXContext as ensureContext, isPostProcessed } from \"./utils/memory\";\nimport { getSetting } from \"./utils/settings\";\nimport { getEpochMs } from \"./utils/time\";\nexport { xMessageHandlerTemplate, messageHandlerTemplate };\n\n/**\n * The XInteractionClient class manages X interactions,\n * including handling mentions, managing timelines, and engaging with other users.\n * It extends the base X client functionality to provide mention handling,\n * user interaction, and follow change detection capabilities.\n *\n * @extends ClientBase\n */\nexport class XInteractionClient {\n client: ClientBase;\n runtime: IAgentRuntime;\n xUsername: string;\n xUserId: string;\n private isDryRun: boolean;\n private state: Record<string, unknown>;\n private isRunning: boolean = false;\n\n /**\n * Constructor to initialize the X interaction client with runtime and state management.\n *\n * @param {ClientBase} client - The client instance.\n * @param {IAgentRuntime} runtime - The runtime instance for agent operations.\n * @param {Record<string, unknown>} state - The state object containing configuration settings.\n */\n constructor(client: ClientBase, runtime: IAgentRuntime, state: Record<string, unknown>) {\n this.client = client;\n this.runtime = runtime;\n this.state = state;\n\n const dryRunSetting =\n this.state?.X_DRY_RUN ?? getSetting(this.runtime, \"X_DRY_RUN\") ?? process.env.X_DRY_RUN;\n this.isDryRun =\n dryRunSetting === true ||\n dryRunSetting === \"true\" ||\n (typeof dryRunSetting === \"string\" && dryRunSetting.toLowerCase() === \"true\");\n\n // Initialize X username and user ID from client profile\n const usernameSetting = getSetting(this.runtime, \"X_USERNAME\") || this.state?.X_USERNAME;\n this.xUsername =\n typeof usernameSetting === \"string\" ? usernameSetting : client.profile?.username || \"\";\n this.xUserId = client.profile?.id || \"\";\n }\n\n /**\n * Asynchronously starts the process of handling X interactions on a loop.\n * Uses the X_ENGAGEMENT_INTERVAL setting.\n */\n async start() {\n this.isRunning = true;\n\n const handleXInteractionsLoop = () => {\n if (!this.isRunning) {\n logger.info(\"X interaction client stopped, exiting loop\");\n return;\n }\n\n // Get random engagement interval in minutes\n const engagementIntervalMinutes = getRandomInterval(this.runtime, \"engagement\");\n\n const interactionInterval = engagementIntervalMinutes * 60 * 1000;\n\n logger.info(\n `X interaction client will check in ${engagementIntervalMinutes.toFixed(1)} minutes`\n );\n\n this.handleXInteractions();\n\n if (this.isRunning) {\n setTimeout(handleXInteractionsLoop, interactionInterval);\n }\n };\n handleXInteractionsLoop();\n }\n\n /**\n * Stops the X interaction client\n */\n async stop() {\n logger.log(\"Stopping X interaction client...\");\n this.isRunning = false;\n }\n\n /**\n * Asynchronously handles X interactions by checking for mentions and target user posts.\n */\n async handleXInteractions() {\n logger.log(\"Checking X interactions\");\n\n const xUsername = this.client.profile?.username;\n\n try {\n // Check for mentions first (replies enabled by default)\n const repliesEnabled =\n (getSetting(this.runtime, \"X_ENABLE_REPLIES\") ?? process.env.X_ENABLE_REPLIES) !== \"false\";\n\n if (repliesEnabled && xUsername) {\n await this.handleMentions(xUsername);\n }\n\n // Check target users' posts for autonomous engagement\n const targetUsersConfig =\n ((getSetting(this.runtime, \"X_TARGET_USERS\") ?? process.env.X_TARGET_USERS) as string) ||\n \"\";\n\n if (targetUsersConfig?.trim()) {\n await this.handleTargetUserPosts(targetUsersConfig);\n }\n\n // Save the latest checked post ID to the file\n await this.client.cacheLatestCheckedPostId();\n\n logger.log(\"Finished checking X interactions\");\n } catch (error) {\n logger.error(\n \"Error handling X interactions:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n /**\n * Handle mentions and replies\n */\n private async handleMentions(xUsername: string) {\n try {\n // Check for mentions\n const cursorKey = `x/${xUsername}/mention_cursor`;\n const cachedCursor: string | undefined = await this.runtime.getCache<string>(cursorKey);\n\n const searchResult = await this.client.fetchSearchPosts(\n `@${xUsername}`,\n 20,\n SearchMode.Latest,\n cachedCursor ?? undefined\n );\n\n const mentionCandidates = searchResult.posts;\n\n // If we got posts and there's a valid cursor, cache it\n if (mentionCandidates.length > 0 && searchResult.previous) {\n await this.runtime.setCache(cursorKey, searchResult.previous);\n } else if (!searchResult.previous && !searchResult.next) {\n // If both previous and next are missing, clear the outdated cursor\n await this.runtime.setCache(cursorKey, \"\");\n }\n\n await this.processMentionPosts(mentionCandidates);\n } catch (error) {\n logger.error(\n \"Error handling mentions:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n /**\n * Handle autonomous engagement with target users' posts\n */\n private async handleTargetUserPosts(targetUsersConfig: string) {\n try {\n const targetUsers = getTargetUsers(targetUsersConfig);\n\n if (targetUsers.length === 0 && !targetUsersConfig.includes(\"*\")) {\n return; // No target users configured\n }\n\n logger.info(`Checking posts from target users: ${targetUsers.join(\", \") || \"everyone (*)\"}`);\n\n // For each target user, search their recent posts\n for (const targetUser of targetUsers) {\n try {\n const normalizedUsername = targetUser.replace(/^@/, \"\");\n\n // Search for recent posts from this user\n const searchQuery = `from:${normalizedUsername} -is:reply -is:repost`;\n const searchResult = await this.client.fetchSearchPosts(\n searchQuery,\n 10, // Get up to 10 recent posts per user\n SearchMode.Latest\n );\n\n if (searchResult.posts.length > 0) {\n logger.info(`Found ${searchResult.posts.length} posts from @${normalizedUsername}`);\n\n // Process these posts for potential engagement\n await this.processTargetUserPosts(searchResult.posts, normalizedUsername);\n }\n } catch (error) {\n logger.error(\n `Error searching posts from @${targetUser}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n // If wildcard is configured, also check timeline for any interesting posts\n if (targetUsersConfig.includes(\"*\")) {\n await this.processTimelineForEngagement();\n }\n } catch (error) {\n logger.error(\n \"Error handling target user posts:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n /**\n * Process posts from target users for potential engagement\n */\n private async processTargetUserPosts(posts: ClientPost[], username: string) {\n const maxEngagementsPerRun = parseInt(\n (getSetting(this.runtime, \"X_MAX_ENGAGEMENTS_PER_RUN\") as string) ||\n process.env.X_MAX_ENGAGEMENTS_PER_RUN ||\n \"10\",\n 10\n );\n\n let engagementCount = 0;\n\n for (const post of posts) {\n if (engagementCount >= maxEngagementsPerRun) {\n logger.info(`Reached max engagements limit (${maxEngagementsPerRun})`);\n break;\n }\n\n // Skip if already processed\n if (!post.id) {\n continue;\n }\n const isProcessed = await isPostProcessed(this.runtime, post.id);\n if (isProcessed) {\n continue; // Already processed\n }\n\n // Skip if post is too old (older than 24 hours)\n const postAge = Date.now() - getEpochMs(post.timestamp);\n const maxAge = 24 * 60 * 60 * 1000; // 24 hours\n\n if (postAge > maxAge) {\n continue;\n }\n\n // Decide whether to engage with this post\n const shouldEngage = await this.shouldEngageWithPost(post);\n\n if (shouldEngage) {\n logger.info(\n `Engaging with post from @${username}: ${post.text?.substring(0, 50) || \"no text\"}...`\n );\n\n // Create necessary context for the post\n await this.ensurePostContext(post);\n\n // Handle the post (generate and send reply)\n const engaged = await this.engageWithPost(post);\n\n if (engaged) {\n engagementCount++;\n }\n }\n }\n }\n\n /**\n * Process timeline for engagement when wildcard is configured\n */\n private async processTimelineForEngagement() {\n try {\n // This would use the timeline client if available, but for now\n // we'll do a general search for recent popular posts\n const searchResult = await this.client.fetchSearchPosts(\n \"min_reposts:10 min_faves:20 -is:reply -is:repost lang:en\",\n 20,\n SearchMode.Latest\n );\n\n const relevantPosts = searchResult.posts.filter((post) => {\n // Filter for posts from the last 12 hours\n const postAge = Date.now() - getEpochMs(post.timestamp);\n return postAge < 12 * 60 * 60 * 1000;\n });\n\n if (relevantPosts.length > 0) {\n logger.info(`Found ${relevantPosts.length} relevant posts from timeline`);\n await this.processTargetUserPosts(relevantPosts, \"timeline\");\n }\n } catch (error) {\n logger.error(\n \"Error processing timeline for engagement:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n /**\n * Determine if the bot should engage with a specific post\n */\n private async shouldEngageWithPost(post: ClientPost): Promise<boolean> {\n try {\n // Create a simple evaluation prompt\n const evaluationContext = {\n post: post.text,\n author: post.username,\n metrics: {\n likes: post.likes || 0,\n reposts: post.reposts || 0,\n replies: post.replies || 0,\n },\n };\n\n if (!post.id) {\n return false;\n }\n const shouldEngageMemory: Memory = {\n id: createUniqueUuid(this.runtime, `eval-${post.id}`),\n entityId: this.runtime.agentId,\n agentId: this.runtime.agentId,\n roomId: createUniqueUuid(this.runtime, post.conversationId || post.id),\n content: {\n text: `Should I engage with this post? Post: \"${post.text}\" by @${post.username}`,\n evaluationContext,\n },\n createdAt: Date.now(),\n };\n\n const _state = await this.runtime.composeState(shouldEngageMemory);\n const characterName = this.runtime?.character?.name || \"AI Assistant\";\n const context = `You are ${characterName}. Should you reply to this post based on your interests and expertise?\n \nPost by @${post.username}: \"${post.text}\"\n\nReply with YES if:\n- The topic relates to your interests or expertise\n- You can add valuable insights or perspective\n- The conversation seems constructive\n\nReply with NO if:\n- The topic is outside your knowledge\n- The post is inflammatory or controversial\n- You have nothing meaningful to add\n\nResponse (YES/NO):`;\n\n const response = await this.runtime.useModel(ModelType.TEXT_SMALL, {\n prompt: context,\n temperature: 0.3,\n maxTokens: 10,\n });\n\n return response.trim().toUpperCase().includes(\"YES\");\n } catch (error) {\n logger.error(\n \"Error determining engagement:\",\n error instanceof Error ? error.message : String(error)\n );\n return false;\n }\n }\n\n /**\n * Ensure post context exists (world, room, entity)\n */\n private async ensurePostContext(post: ClientPost) {\n try {\n if (!post.userId || !post.username) {\n logger.warn(\"Cannot ensure context: missing userId or username\");\n return;\n }\n const context = await ensureContext(this.runtime, {\n userId: post.userId,\n username: post.username,\n name: post.name,\n conversationId: post.conversationId || post.id,\n });\n\n // Save post as memory with error handling\n // Convert Post to ContentValue-compatible format\n // Post properties are JSON-serializable and compatible with ContentValue\n const postContentValue: Record<string, ContentValue> = {\n id: post.id ?? null,\n text: post.text ?? null,\n userId: post.userId ?? null,\n username: post.username ?? null,\n timestamp: post.timestamp ?? null,\n conversationId: post.conversationId ?? null,\n likes: post.likes ?? null,\n reposts: post.reposts ?? null,\n replies: post.replies ?? null,\n quotes: post.quotes ?? null,\n permanentUrl: post.permanentUrl ?? null,\n };\n\n const postMemory: Memory = {\n id: createUniqueUuid(this.runtime, post.id || \"\"),\n entityId: context.entityId,\n content: {\n text: post.text,\n url: post.permanentUrl,\n source: \"x\",\n post: postContentValue,\n },\n agentId: this.runtime.agentId,\n roomId: context.roomId,\n createdAt: getEpochMs(post.timestamp),\n };\n\n await createMemorySafe(this.runtime, postMemory, \"messages\");\n } catch (error) {\n logger.error(\n `Failed to ensure context for post ${post.id}:`,\n error instanceof Error ? error.message : String(error)\n );\n throw error;\n }\n }\n\n /**\n * Engage with a post by generating and sending a reply\n */\n private async engageWithPost(post: ClientPost): Promise<boolean> {\n try {\n const message: Memory = {\n id: createUniqueUuid(this.runtime, post.id || \"\"),\n entityId: createUniqueUuid(this.runtime, post.userId || \"\"),\n content: {\n text: post.text,\n source: \"x\",\n post: {\n id: post.id,\n text: post.text,\n userId: post.userId,\n username: post.username,\n timestamp: post.timestamp,\n conversationId: post.conversationId,\n } as Record<string, string | number | boolean | null | undefined>,\n },\n agentId: this.runtime.agentId,\n roomId: createUniqueUuid(this.runtime, post.conversationId || post.id || \"\"),\n createdAt: getEpochMs(post.timestamp),\n };\n\n const result = await this.handlePost({\n post,\n message,\n thread: post.thread || [post],\n });\n\n return typeof result.text === \"string\" && result.text.length > 0;\n } catch (error) {\n logger.error(\n \"Error engaging with post:\",\n error instanceof Error ? error.message : String(error)\n );\n return false;\n }\n }\n\n /**\n * Processes all incoming posts that mention the bot.\n * For each new post:\n * - Ensures world, room, and connection exist\n * - Saves the post as memory\n * - Emits thread-related events (THREAD_CREATED / THREAD_UPDATED)\n * - Delegates post content to `handlePost` for reply generation\n */\n async processMentionPosts(mentionCandidates: ClientPost[]) {\n logger.log(\"Completed checking mentioned posts:\", mentionCandidates.length.toString());\n let uniquePostCandidates = [...mentionCandidates];\n\n // Sort post candidates by ID in ascending order\n uniquePostCandidates = uniquePostCandidates\n .sort((a, b) => (a.id || \"\").localeCompare(b.id || \"\"))\n .filter((post) => post.userId && post.userId !== this.client.profile?.id);\n\n // Get X_TARGET_USERS configuration\n const targetUsersConfig =\n ((getSetting(this.runtime, \"X_TARGET_USERS\") ?? process.env.X_TARGET_USERS) as string) || \"\";\n\n // Filter posts based on X_TARGET_USERS if configured\n if (targetUsersConfig?.trim()) {\n uniquePostCandidates = uniquePostCandidates.filter((post) => {\n const shouldTarget = shouldTargetUser(post.username || \"\", targetUsersConfig);\n if (!shouldTarget) {\n logger.log(`Skipping post from @${post.username} - not in target users list`);\n }\n return shouldTarget;\n });\n }\n\n // Get max interactions per run setting\n const maxInteractionsPerRun = parseInt(\n (getSetting(this.runtime, \"X_MAX_ENGAGEMENTS_PER_RUN\") as string) ||\n process.env.X_MAX_ENGAGEMENTS_PER_RUN ||\n \"10\",\n 10\n );\n\n // Limit the number of interactions per run\n const postsToProcess = uniquePostCandidates.slice(0, maxInteractionsPerRun);\n logger.info(\n `Processing ${postsToProcess.length} of ${uniquePostCandidates.length} mention posts (max: ${maxInteractionsPerRun})`\n );\n\n // for each post candidate, handle the post\n for (const post of postsToProcess) {\n if (\n !this.client.lastCheckedPostId ||\n (post.id && BigInt(post.id) > this.client.lastCheckedPostId)\n ) {\n // Generate the postId UUID the same way it's done in handlePost\n const postId = createUniqueUuid(this.runtime, post.id || \"\");\n\n // Check if we've already processed this post\n const existingResponse = await this.runtime.getMemoryById(postId);\n\n if (existingResponse) {\n logger.log(`Already responded to post ${post.id}, skipping`);\n continue;\n }\n\n // Also check if we've already responded to this post (for chunked responses)\n // by looking for any memory with inReplyTo pointing to this post\n const conversationRoomId = createUniqueUuid(\n this.runtime,\n post.conversationId || post.id || \"\"\n );\n const existingReplies = await this.runtime.getMemories({\n tableName: \"messages\",\n roomId: conversationRoomId,\n count: 10, // Check recent messages in this room\n });\n\n // Check if any of the found memories is a reply to this specific post\n const hasExistingReply = existingReplies.some(\n (memory) => memory.content.inReplyTo === postId || memory.content.inReplyTo === post.id\n );\n\n if (hasExistingReply) {\n logger.log(\n `Already responded to post ${post.id} (found in conversation history), skipping`\n );\n continue;\n }\n\n logger.log(\"New Post found\", post.id);\n\n const userId = post.userId;\n if (!userId || !post.id) {\n logger.warn(\"Skipping post with missing required fields\", post.id);\n continue;\n }\n const conversationId = post.conversationId || post.id;\n if (!userId || !post.id) {\n logger.warn(\"Skipping post with missing required fields\", post.id);\n continue;\n }\n const roomId = createUniqueUuid(this.runtime, conversationId || \"\");\n const username = post.username;\n\n logger.log(\"----\");\n logger.log(`User: ${username} (${userId})`);\n logger.log(`Post: ${post.id}`);\n logger.log(`Conversation: ${conversationId}`);\n logger.log(`Room: ${roomId}`);\n logger.log(\"----\");\n\n // 1. Ensure world exists for the user\n const worldId = createUniqueUuid(this.runtime, userId || \"\");\n await this.runtime.ensureWorldExists({\n id: worldId,\n name: `${username}'s X`,\n agentId: this.runtime.agentId,\n messageServerId: userId as `${string}-${string}-${string}-${string}-${string}`,\n metadata: {\n ownership: { ownerId: userId || \"\" } as unknown as WorldOwnership,\n extra: {\n x: {\n username: username,\n id: userId,\n },\n },\n },\n });\n\n // 2. Ensure entity connection\n const entityId = createUniqueUuid(this.runtime, userId);\n await this.runtime.ensureConnection({\n entityId,\n roomId,\n userName: username,\n name: post.name,\n source: \"x\",\n type: ChannelType.FEED,\n worldId: worldId,\n });\n\n // 2.5. Ensure room exists\n await this.runtime.ensureRoomExists({\n id: roomId,\n name: `X conversation ${conversationId}`,\n source: \"x\",\n type: ChannelType.FEED,\n channelId: conversationId,\n messageServerId: createUniqueUuid(this.runtime, userId),\n worldId: worldId,\n });\n\n // 3. Create a memory for the post\n // Convert Post to ContentValue-compatible format\n const postContentValue: Record<string, ContentValue> = {\n id: post.id ?? null,\n text: post.text ?? null,\n userId: post.userId ?? null,\n username: post.username ?? null,\n timestamp: post.timestamp ?? null,\n conversationId: post.conversationId ?? null,\n };\n\n const memory: Memory = {\n id: postId,\n entityId,\n content: {\n text: post.text,\n url: post.permanentUrl,\n source: \"x\",\n post: postContentValue,\n },\n agentId: this.runtime.agentId,\n roomId,\n createdAt: getEpochMs(post.timestamp),\n };\n\n logger.log(\"Saving post memory...\");\n await createMemorySafe(this.runtime, memory, \"messages\");\n\n // 4. Handle thread-specific events\n if (post.thread && post.thread.length > 0) {\n const threadStartId = post.thread[0].id;\n const threadMemoryId = createUniqueUuid(this.runtime, `thread-${threadStartId}`);\n\n const threadPayload = {\n runtime: this.runtime,\n source: \"x\",\n entityId,\n conversationId: threadStartId,\n roomId: roomId,\n memory: memory,\n post: post,\n threadId: threadStartId,\n threadMemoryId: threadMemoryId,\n };\n\n // Check if this is a reply to an existing thread\n const previousThreadMemory = await this.runtime.getMemoryById(threadMemoryId);\n if (previousThreadMemory) {\n // This is a reply to an existing thread\n this.runtime.emitEvent(XEventTypes.THREAD_UPDATED, threadPayload);\n } else if (post.thread[0].id === post.id) {\n // This is the start of a new thread\n this.runtime.emitEvent(XEventTypes.THREAD_CREATED, threadPayload);\n }\n }\n\n await this.handlePost({\n post,\n message: memory,\n thread: post.thread,\n });\n\n // Update the last checked post ID after processing each post\n this.client.lastCheckedPostId = BigInt(post.id);\n }\n }\n }\n\n /**\n * Handles X interactions such as likes, reposts, and quotes.\n * For each interaction:\n * - Creates a memory object\n * - Emits platform-specific events (LIKE_RECEIVED, REPOST_RECEIVED, QUOTE_RECEIVED)\n * - Emits a generic REACTION_RECEIVED event with metadata\n */\n async handleInteraction(interaction: XInteractionPayload) {\n if (interaction?.targetPost?.conversationId) {\n const memory = this.createMemoryObject(\n interaction.type,\n `${interaction.id}-${interaction.type}`,\n interaction.userId,\n interaction.targetPost.conversationId\n );\n\n await createMemorySafe(this.runtime, memory, \"messages\");\n\n // Create message for reaction\n const reactionMessage: XMemory = {\n id: createUniqueUuid(this.runtime, interaction.targetPostId || \"\"),\n content: {\n text: interaction.targetPost.text || \"\",\n source: \"x\",\n },\n entityId: createUniqueUuid(this.runtime, interaction.userId || \"\"),\n roomId: createUniqueUuid(this.runtime, interaction.targetPost.conversationId || \"\"),\n agentId: this.runtime.agentId,\n createdAt: Date.now(),\n };\n\n // Emit specific event for each type of interaction\n switch (interaction.type) {\n case \"like\": {\n const payload: XLikeReceivedPayload = {\n runtime: this.runtime,\n post: interaction.targetPost,\n user: {\n id: interaction.userId,\n username: interaction.username,\n name: interaction.name,\n },\n source: \"x\",\n };\n this.runtime.emitEvent(XEventTypes.LIKE_RECEIVED, payload);\n break;\n }\n case \"repost\": {\n const payload: XRepostReceivedPayload = {\n runtime: this.runtime,\n post: interaction.targetPost,\n repostId: interaction.repostId || interaction.id,\n user: {\n id: interaction.userId,\n username: interaction.username,\n name: interaction.name,\n },\n source: \"x\",\n };\n this.runtime.emitEvent(XEventTypes.REPOST_RECEIVED, payload);\n break;\n }\n case \"quote\": {\n const payload: XQuoteReceivedPayload = {\n runtime: this.runtime,\n quotedPost: interaction.targetPost,\n quotePost: interaction.quotePost || interaction.targetPost,\n user: {\n id: interaction.userId,\n username: interaction.username,\n name: interaction.name,\n },\n message: reactionMessage,\n callback: async () => [],\n reaction: {\n type: \"quote\",\n entityId: createUniqueUuid(this.runtime, interaction.userId),\n },\n source: \"x\",\n };\n this.runtime.emitEvent(XEventTypes.QUOTE_RECEIVED, payload);\n break;\n }\n }\n\n // Also emit generic REACTION_RECEIVED event\n this.runtime.emitEvent(EventType.REACTION_RECEIVED, {\n runtime: this.runtime,\n entityId: createUniqueUuid(this.runtime, interaction.userId),\n roomId: createUniqueUuid(this.runtime, interaction.targetPost.conversationId),\n world: createUniqueUuid(this.runtime, interaction.userId),\n message: reactionMessage,\n source: \"x\",\n metadata: {\n type: interaction.type,\n targetPostId: interaction.targetPostId,\n username: interaction.username,\n userId: interaction.userId,\n timestamp: Date.now(),\n quoteText: interaction.type === \"quote\" ? interaction.quotePost?.text || \"\" : undefined,\n },\n callback: async () => [],\n } as MessagePayload);\n }\n }\n\n /**\n * Creates a memory object for a given X interaction.\n *\n * @param {string} type - The type of interaction (e.g., 'like', 'repost', 'quote').\n * @param {string} id - The unique identifier for the interaction.\n * @param {string} userId - The ID of the user who initiated the interaction.\n * @param {string} conversationId - The ID of the conversation context.\n * @returns {XInteractionMemory} The constructed memory object.\n */\n createMemoryObject(\n type: string,\n id: string,\n userId: string,\n conversationId: string\n ): XInteractionMemory {\n return {\n id: createUniqueUuid(this.runtime, id),\n agentId: this.runtime.agentId,\n entityId: createUniqueUuid(this.runtime, userId),\n roomId: createUniqueUuid(this.runtime, conversationId),\n content: {\n type,\n source: \"x\",\n },\n createdAt: Date.now(),\n };\n }\n\n /**\n * Asynchronously handles a post by generating a response and sending it.\n * This method processes the post content, determines if a response is needed,\n * generates appropriate response text, and sends the post reply.\n *\n * @param {object} params - The parameters object containing the post, message, and thread.\n * @param {Post} params.post - The post object to handle.\n * @param {Memory} params.message - The memory object associated with the post.\n * @param {Post[]} params.thread - The array of posts in the thread.\n * @returns {object} - An object containing the text of the response and any relevant actions.\n */\n async handlePost({\n post,\n message,\n thread,\n }: {\n post: ClientPost;\n message: Memory;\n thread: ClientPost[];\n }) {\n if (!message.content.text) {\n logger.log(\"Skipping Post with no text\", post.id);\n return { text: \"\", actions: [\"IGNORE\"] };\n }\n\n // Create a callback for handling the response\n const callback: HandlerCallback = async (response: Content, postId?: string) => {\n try {\n if (!response.text) {\n logger.warn(\"No text content in response, skipping post reply\");\n return [];\n }\n\n const postToReplyTo = postId || post.id;\n\n if (this.isDryRun) {\n logger.info(`[DRY RUN] Would have replied to ${post.username} with: ${response.text}`);\n return [];\n }\n\n logger.info(`Replying to post ${postToReplyTo}`);\n\n // Create the actual post using the X API through the client\n const postResult = await sendPost(this.client, response.text, [], postToReplyTo);\n\n if (!postResult) {\n throw new Error(\"Failed to get post result from response\");\n }\n\n // Create memory for our response\n const responsePostId = postResult.id || postResult.data?.id || Date.now().toString();\n const responseId = createUniqueUuid(this.runtime, responsePostId);\n const responseMemory: Memory = {\n id: responseId,\n entityId: this.runtime.agentId,\n agentId: this.runtime.agentId,\n roomId: message.roomId,\n content: {\n text: response.text,\n source: \"x\",\n inReplyTo: message.id,\n },\n createdAt: Date.now(),\n };\n\n await createMemorySafe(this.runtime, responseMemory, \"messages\");\n\n // Return the created memory\n return [responseMemory];\n } catch (error) {\n logger.error(\n \"Error in post reply callback:\",\n error instanceof Error ? error.message : String(error)\n );\n return [];\n }\n };\n\n const xUserId = post.userId || \"\";\n const entityId = createUniqueUuid(this.runtime, xUserId);\n const xUsername = post.username || \"\";\n\n // Add X-specific metadata to message\n if (!message.metadata || Array.isArray(message.metadata)) {\n message.metadata = { type: MemoryType.CUSTOM };\n }\n const metadataObj =\n typeof message.metadata === \"object\" && !Array.isArray(message.metadata)\n ? message.metadata\n : { type: MemoryType.CUSTOM };\n\n // Create properly typed CustomMetadata with X-specific properties\n // CustomMetadata allows additional properties via index signature\n message.metadata = {\n ...metadataObj,\n type: (metadataObj.type as MemoryType) || MemoryType.CUSTOM,\n x: {\n entityId: entityId as string,\n xUserId: xUserId as string,\n xUsername: xUsername as string,\n thread: thread,\n },\n } as unknown as MemoryMetadata;\n\n // Process message through message service\n const result = await this.runtime.messageService?.handleMessage(\n this.runtime,\n message,\n callback\n );\n\n // Extract response for X posting\n const response = result?.responseMessages || [];\n\n // Check if response is an array of memories and extract the text\n let responseText = \"\";\n if (Array.isArray(response) && response.length > 0) {\n const firstResponse = response[0];\n if (firstResponse?.content?.text) {\n responseText = firstResponse.content.text;\n }\n }\n\n return {\n text: responseText,\n actions: responseText ? [\"REPLY\"] : [\"IGNORE\"],\n };\n }\n}\n",
109
- "/**\n * Auto-generated prompt templates\n * DO NOT EDIT - Generated from ../../../../prompts/*.txt\n *\n * These prompts use Handlebars-style template syntax:\n * - {{variableName}} for simple substitution\n * - {{#each items}}...{{/each}} for iteration\n * - {{#if condition}}...{{/if}} for conditionals\n */\n\nexport const generatePostTemplate = `You are {{agentName}}.\n{{bio}}\n\nGenerate a post based on: {{request}}\n\nStyle:\n- Be specific, opinionated, authentic\n- No generic content or platitudes\n- Share insights, hot takes, unique perspectives\n- Conversational and punchy\n- Under 280 characters\n- Skip hashtags unless essential\n\nTopics: {{topics}}\n\nPost:`;\n\nexport const GENERATE_POST_TEMPLATE = generatePostTemplate;\n\nexport const messageHandlerTemplate = `{{agentName}} is replying to you:\n{{senderName}}: {{userMessage}}\n\n# Task: Generate a reply for {{agentName}}.\n{{providers}}\n\n# Instructions: Write a thoughtful response to {{senderName}} that is appropriate and relevant to their message. Do not including any thinking, self-reflection or internal dialog in your response.`;\n\nexport const MESSAGE_HANDLER_TEMPLATE = messageHandlerTemplate;\n\nexport const quoteTweetTemplate = `# Task: Write a quote post in the voice, style, and perspective of {{agentName}} @{{xUserName}}.\n\n{{bio}}\n{{postDirections}}\n\n<response>\n <thought>Your thought here, explaining why the quote post is meaningful or how it connects to what {{agentName}} cares about</thought>\n <post>The quote post content here, under 280 characters, without emojis, no questions</post>\n</response>\n\nYour quote post should be:\n- A reaction, agreement, disagreement, or expansion of the original post\n- Personal and unique to {{agentName}}'s style and point of view\n- 1 to 3 sentences long, chosen at random\n- No questions, no emojis, concise\n- Use \"\\\\n\\\\n\" (double spaces) between multiple sentences\n- Max 280 characters including line breaks\n\nYour output must ONLY contain the XML block.`;\n\nexport const QUOTE_TWEET_TEMPLATE = quoteTweetTemplate;\n\nexport const replyTweetTemplate = `# Task: Write a reply post in the voice, style, and perspective of {{agentName}} @{{xUserName}}.\n\n{{bio}}\n{{postDirections}}\n\n<response>\n <thought>Your thought here, explaining why this reply is meaningful or how it connects to what {{agentName}} cares about</thought>\n <post>The reply post content here, under 280 characters, without emojis, no questions</post>\n</response>\n\nYour reply should be:\n- A direct response, agreement, disagreement, or personal take on the original post\n- Reflective of {{agentName}}'s unique voice and values\n- 1 to 2 sentences long, chosen at random\n- No questions, no emojis, concise\n- Use \"\\\\n\\\\n\" (double spaces) between multiple sentences if needed\n- Max 280 characters including line breaks\n\nYour output must ONLY contain the XML block.`;\n\nexport const REPLY_TWEET_TEMPLATE = replyTweetTemplate;\n\nexport const xActionTemplate = `# INSTRUCTIONS: Determine actions for {{agentName}} (@{{xUserName}}) based on:\n{{bio}}\n{{postDirections}}\n\nGuidelines:\n- Engage with content that relates to character's interests and expertise\n- Direct mentions should be prioritized when relevant\n- Consider engaging with:\n - Content directly related to your topics\n - Interesting discussions you can contribute to\n - Questions you can help answer\n - Content from users you've interacted with before\n- Skip content that is:\n - Completely off-topic or spam\n - Inflammatory or highly controversial (unless it's your area)\n - Pure marketing/promotional with no value\n\nActions (respond only with tags):\n[LIKE] - Content is relevant and interesting (7/10 or higher)\n[REPOST] - Content is valuable and worth sharing (8/10 or higher)\n[QUOTE] - You can add meaningful commentary (7.5/10 or higher)\n[REPLY] - You can contribute helpful insights (7/10 or higher)`;\n\nexport const X_ACTION_TEMPLATE = xActionTemplate;\n\nexport const xMessageHandlerTemplate = `# Task: Generate dialog and actions for {{agentName}}.\n{{providers}}\nHere is the current post text again. Remember to include an action if the current post text includes a prompt that asks for one of the available actions mentioned above (does not need to be exact)\n{{currentPost}}\n{{imageDescriptions}}\n\n# Instructions: Write the next message for {{agentName}}. Include the appropriate action from the list: {{actionNames}}\nResponse format should be formatted in a valid JSON block like this:\n\\`\\`\\`json\n{ \"thought\": \"<string>\", \"name\": \"{{agentName}}\", \"text\": \"<string>\", \"action\": \"<string>\" }\n\\`\\`\\`\n\nThe \"action\" field should be one of the options in [Available Actions] and the \"text\" field should be the response you want to send. Do not including any thinking or internal reflection in the \"text\" field. \"thought\" should be a short description of what the agent is thinking about before responding, inlcuding a brief justification for the response.`;\n\nexport const X_MESSAGE_HANDLER_TEMPLATE = xMessageHandlerTemplate;\n\n",
109
+ "/**\n * Auto-generated prompt templates\n * DO NOT EDIT - Generated from ../../../../prompts/*.txt\n *\n * These prompts use Handlebars-style template syntax:\n * - {{variableName}} for simple substitution\n * - {{#each items}}...{{/each}} for iteration\n * - {{#if condition}}...{{/if}} for conditionals\n */\n\nexport const generatePostTemplate = `You are {{agentName}}.\n{{bio}}\n\nGenerate a post based on: {{request}}\n\nStyle:\n- Be specific, opinionated, authentic\n- No generic content or platitudes\n- Share insights, hot takes, unique perspectives\n- Conversational and punchy\n- Under 280 characters\n- Skip hashtags unless essential\n\nTopics: {{topics}}\n\nPost:`;\n\nexport const GENERATE_POST_TEMPLATE = generatePostTemplate;\n\nexport const messageHandlerTemplate = `{{agentName}} is replying to you:\n{{senderName}}: {{userMessage}}\n\n# Task: Generate a reply for {{agentName}}.\n{{providers}}\n\n# Instructions: Write a thoughtful response to {{senderName}} that is appropriate and relevant to their message. Do not including any thinking, self-reflection or internal dialog in your response.`;\n\nexport const MESSAGE_HANDLER_TEMPLATE = messageHandlerTemplate;\n\nexport const quoteTweetTemplate = `# Task: Write a quote post in the voice, style, and perspective of {{agentName}} @{{xUserName}}.\n\n{{bio}}\n{{postDirections}}\n\n<response>\n <thought>Your thought here, explaining why the quote post is meaningful or how it connects to what {{agentName}} cares about</thought>\n <post>The quote post content here, under 280 characters, without emojis, no questions</post>\n</response>\n\nYour quote post should be:\n- A reaction, agreement, disagreement, or expansion of the original post\n- Personal and unique to {{agentName}}'s style and point of view\n- 1 to 3 sentences long, chosen at random\n- No questions, no emojis, concise\n- Use \"\\\\n\\\\n\" (double spaces) between multiple sentences\n- Max 280 characters including line breaks\n\nYour output must ONLY contain the XML block.`;\n\nexport const QUOTE_TWEET_TEMPLATE = quoteTweetTemplate;\n\nexport const replyTweetTemplate = `# Task: Write a reply post in the voice, style, and perspective of {{agentName}} @{{xUserName}}.\n\n{{bio}}\n{{postDirections}}\n\n<response>\n <thought>Your thought here, explaining why this reply is meaningful or how it connects to what {{agentName}} cares about</thought>\n <post>The reply post content here, under 280 characters, without emojis, no questions</post>\n</response>\n\nYour reply should be:\n- A direct response, agreement, disagreement, or personal take on the original post\n- Reflective of {{agentName}}'s unique voice and values\n- 1 to 2 sentences long, chosen at random\n- No questions, no emojis, concise\n- Use \"\\\\n\\\\n\" (double spaces) between multiple sentences if needed\n- Max 280 characters including line breaks\n\nYour output must ONLY contain the XML block.`;\n\nexport const REPLY_TWEET_TEMPLATE = replyTweetTemplate;\n\nexport const xActionTemplate = `# INSTRUCTIONS: Determine actions for {{agentName}} (@{{xUserName}}) based on:\n{{bio}}\n{{postDirections}}\n\nGuidelines:\n- Engage with content that relates to character's interests and expertise\n- Direct mentions should be prioritized when relevant\n- Consider engaging with:\n - Content directly related to your topics\n - Interesting discussions you can contribute to\n - Questions you can help answer\n - Content from users you've interacted with before\n- Skip content that is:\n - Completely off-topic or spam\n - Inflammatory or highly controversial (unless it's your area)\n - Pure marketing/promotional with no value\n\nActions (respond only with tags):\n[LIKE] - Content is relevant and interesting (7/10 or higher)\n[REPOST] - Content is valuable and worth sharing (8/10 or higher)\n[QUOTE] - You can add meaningful commentary (7.5/10 or higher)\n[REPLY] - You can contribute helpful insights (7/10 or higher)`;\n\nexport const X_ACTION_TEMPLATE = xActionTemplate;\n\nexport const xMessageHandlerTemplate = `# Task: Generate dialog and actions for {{agentName}}.\n{{providers}}\nHere is the current post text again. Remember to include an action if the current post text includes a prompt that asks for one of the available actions mentioned above (does not need to be exact)\n{{currentPost}}\n{{imageDescriptions}}\n\n# Instructions: Write the next message for {{agentName}}. Include the appropriate action from the list: {{actionNames}}\nResponse format should be formatted in a valid JSON block like this:\n\\`\\`\\`json\n{ \"thought\": \"<string>\", \"name\": \"{{agentName}}\", \"text\": \"<string>\", \"action\": \"<string>\" }\n\\`\\`\\`\n\nThe \"action\" field should be one of the options in [Available Actions] and the \"text\" field should be the response you want to send. Do not including any thinking or internal reflection in the \"text\" field. \"thought\" should be a short description of what the agent is thinking about before responding, inlcuding a brief justification for the response.`;\n\nexport const X_MESSAGE_HANDLER_TEMPLATE = xMessageHandlerTemplate;\n",
110
110
  "import {\n ChannelType,\n createUniqueUuid,\n type IAgentRuntime,\n logger,\n type Memory,\n ModelType,\n parseBooleanFromText,\n type UUID,\n} from \"@elizaos/core\";\nimport type { ClientBase } from \"./base\";\nimport { getRandomInterval } from \"./environment\";\nimport type { MediaData, PostResponse } from \"./types\";\nimport { sendPost } from \"./utils\";\nimport {\n addToRecentPosts,\n createMemorySafe,\n ensureXContext,\n isDuplicatePost,\n} from \"./utils/memory\";\nimport { getSetting } from \"./utils/settings\";\n/**\n * Class representing an X post client for generating and posting.\n */\nexport class XPostClient {\n client: ClientBase;\n runtime: IAgentRuntime;\n xUsername: string;\n private isDryRun: boolean;\n private state: Record<string, unknown>;\n private isRunning: boolean = false;\n private isPosting: boolean = false; // Add lock to prevent concurrent posting\n\n /**\n * Creates an instance of XPostClient.\n * @param {ClientBase} client - The client instance.\n * @param {IAgentRuntime} runtime - The runtime instance.\n * @param {Record<string, unknown>} state - The state object containing configuration settings\n */\n constructor(client: ClientBase, runtime: IAgentRuntime, state: Record<string, unknown>) {\n this.client = client;\n this.state = state;\n this.runtime = runtime;\n const dryRunSetting =\n typeof this.state?.X_DRY_RUN === \"string\" ||\n typeof this.state?.X_DRY_RUN === \"boolean\" ||\n this.state?.X_DRY_RUN === true ||\n this.state?.X_DRY_RUN === false\n ? this.state.X_DRY_RUN\n : getSetting(this.runtime, \"X_DRY_RUN\");\n this.isDryRun = parseBooleanFromText(dryRunSetting);\n\n // Get X username from settings\n const usernameSetting = getSetting(this.runtime, \"X_USERNAME\") || this.state?.X_USERNAME;\n this.xUsername = typeof usernameSetting === \"string\" ? usernameSetting : \"\";\n\n // Log configuration on initialization\n logger.log(\"X Post Client Configuration:\");\n logger.log(`- Dry Run Mode: ${this.isDryRun ? \"Enabled\" : \"Disabled\"}`);\n\n const postIntervalMin = parseInt(\n (typeof this.state?.X_POST_INTERVAL_MIN === \"string\"\n ? this.state.X_POST_INTERVAL_MIN\n : null) ||\n (getSetting(this.runtime, \"X_POST_INTERVAL_MIN\") as string) ||\n \"90\",\n 10\n );\n const postIntervalMax = parseInt(\n (typeof this.state?.X_POST_INTERVAL_MAX === \"string\"\n ? this.state.X_POST_INTERVAL_MAX\n : null) ||\n (getSetting(this.runtime, \"X_POST_INTERVAL_MAX\") as string) ||\n \"150\",\n 10\n );\n logger.log(`- Post Interval: ${postIntervalMin}-${postIntervalMax} minutes (randomized)`);\n }\n\n /**\n * Stops the X post client\n */\n async stop() {\n logger.log(\"Stopping X post client...\");\n this.isRunning = false;\n }\n\n /**\n * Starts the X post client, setting up a loop to periodically generate new posts.\n */\n async start() {\n logger.log(\"Starting X post client...\");\n this.isRunning = true;\n\n const generateNewPostLoop = async () => {\n if (!this.isRunning) {\n logger.log(\"X post client stopped, exiting loop\");\n return;\n }\n\n await this.generateNewPost();\n\n if (!this.isRunning) {\n logger.log(\"X post client stopped after post, exiting loop\");\n return;\n }\n\n // Get random post interval in minutes\n const postIntervalMinutes = getRandomInterval(this.runtime, \"post\");\n\n // Convert to milliseconds\n const interval = postIntervalMinutes * 60 * 1000;\n\n logger.info(`Next post scheduled in ${postIntervalMinutes.toFixed(1)} minutes`);\n\n // Wait for the interval AFTER generating the post\n await new Promise((resolve) => setTimeout(resolve, interval));\n\n if (this.isRunning) {\n // Schedule the next iteration\n generateNewPostLoop();\n }\n };\n\n // Wait a bit longer to ensure profile is loaded\n await new Promise((resolve) => setTimeout(resolve, 5000));\n\n // Check if we should generate a post immediately\n const postImmediately =\n typeof this.state?.X_POST_IMMEDIATELY === \"string\" ||\n typeof this.state?.X_POST_IMMEDIATELY === \"boolean\"\n ? this.state.X_POST_IMMEDIATELY\n : (getSetting(this.runtime, \"X_POST_IMMEDIATELY\") as string);\n\n if (parseBooleanFromText(postImmediately)) {\n logger.info(\"X_POST_IMMEDIATELY is true, generating initial post now\");\n // Try multiple times in case profile isn't ready\n let retries = 0;\n while (retries < 5) {\n const success = await this.generateNewPost();\n if (success) break;\n\n retries++;\n logger.info(`Retrying immediate post (attempt ${retries}/5)...`);\n await new Promise((resolve) => setTimeout(resolve, 3000));\n }\n }\n\n // Start the regular generation loop\n generateNewPostLoop();\n }\n\n /**\n * Handles the creation and posting of a post by emitting standardized events.\n * This approach aligns with our platform-independent architecture.\n * @returns {Promise<boolean>} true if post was posted successfully\n */\n async generateNewPost(): Promise<boolean> {\n logger.info(\"Attempting to generate new post...\");\n\n // Prevent concurrent posting\n if (this.isPosting) {\n logger.info(\"Already posting, skipping concurrent attempt\");\n return false;\n }\n\n this.isPosting = true;\n\n try {\n // Create the timeline room ID for storing the post\n const userId = this.client.profile?.id;\n if (!userId) {\n logger.error(\"Cannot generate post: X profile not available\");\n this.isPosting = false; // Reset flag\n return false;\n }\n\n logger.info(`Generating post for user: ${this.client.profile?.username} (${userId})`);\n\n // Create standardized world and room IDs\n const _worldId = createUniqueUuid(this.runtime, userId) as UUID;\n const roomId = createUniqueUuid(this.runtime, `${userId}-home`) as UUID;\n\n // Generate post content using the runtime's model\n const state = await this.runtime\n .composeState({\n agentId: this.runtime.agentId,\n entityId: this.runtime.agentId,\n roomId,\n content: { text: \"\", type: \"post\" },\n createdAt: Date.now(),\n } as Memory)\n .catch((error) => {\n logger.warn(\"Error composing state, using minimal state:\", error);\n // Return minimal state if composition fails\n return {\n agentId: this.runtime.agentId,\n recentMemories: [],\n values: {},\n };\n });\n\n // Create a prompt for post generation\n const postPrompt = `You are ${this.runtime.character.name}.\n${this.runtime.character.bio}\n\nCRITICAL: Generate a post that sounds like YOU, not a generic motivational poster or LinkedIn influencer.\n\n${\n this.runtime.character.messageExamples && this.runtime.character.messageExamples.length > 0\n ? `\nExample posts that capture your voice:\n${(\n this.runtime.character.messageExamples as Array<{\n examples?: Array<{ content?: { text?: string } }>;\n }>\n)\n .flatMap((group) => group.examples ?? [])\n .map((example) => example.content?.text ?? \"\")\n .filter((text) => text.length > 0)\n .slice(0, 5)\n .join(\"\\n\")}\n`\n : \"\"\n}\n\nStyle guidelines:\n- Be authentic, opinionated, and specific - no generic platitudes\n- Use your unique voice and perspective\n- Share hot takes, unpopular opinions, or specific insights\n- Be conversational, not preachy\n- If you use emojis, use them sparingly and purposefully\n- Length: 50-280 characters (keep it punchy)\n- NO hashtags unless absolutely essential\n- NO generic motivational content\n\nYour interests: ${this.runtime.character.topics?.join(\", \") || \"technology, crypto, AI\"}\n\n${\n this.runtime.character.style\n ? `Your style: ${\n typeof this.runtime.character.style === \"object\"\n ? this.runtime.character.style.all?.join(\", \") ||\n JSON.stringify(this.runtime.character.style)\n : this.runtime.character.style\n }`\n : \"\"\n}\n\nRecent context:\n${\n Array.isArray(state.recentMemories) && state.recentMemories.length > 0\n ? state.recentMemories\n .slice(0, 3)\n .map((m: Memory) => m.content?.text || \"\")\n .join(\"\\n\") || \"No recent context\"\n : \"No recent context\"\n}\n\nGenerate a single post that sounds like YOU would actually write it:`;\n\n // Use the runtime's model to generate post content\n const generatedContent = await this.runtime.useModel(ModelType.TEXT_SMALL, {\n prompt: postPrompt,\n temperature: 0.9, // Increased for more creativity\n maxTokens: 100,\n });\n\n const postText = generatedContent.trim();\n\n if (!postText || postText.length === 0) {\n logger.error(\"Generated empty post content\");\n return false;\n }\n\n if (postText.includes(\"Error: Missing\")) {\n logger.error(\"Error in generated content:\", postText);\n return false;\n }\n\n // Validate post length\n if (postText.length > 280) {\n logger.warn(`Generated post too long (${postText.length} chars), truncating...`);\n // Truncate to the last complete sentence within 280 chars\n const sentences = postText.match(/[^.!?]+[.!?]+/g) || [postText];\n let truncated = \"\";\n for (const sentence of sentences) {\n if ((truncated + sentence).length <= 280) {\n truncated += sentence;\n } else {\n break;\n }\n }\n const finalPost = truncated.trim() || `${postText.substring(0, 277)}...`;\n logger.info(`Truncated post: ${finalPost}`);\n\n // Post the truncated post\n if (this.isDryRun) {\n logger.info(`[DRY RUN] Would post: ${finalPost}`);\n return false;\n }\n\n const result = await this.postToX(finalPost, []);\n\n if (result === null) {\n logger.info(\"Skipped posting duplicate post\");\n return false;\n }\n\n const postId = result.id ?? result.data?.id ?? result.data?.data?.id;\n logger.info(`Post created successfully! ID: ${postId}`);\n\n // Don't save to memory if room creation might fail\n logger.info(\"Post created successfully (memory saving disabled due to room constraints)\");\n return true;\n }\n\n logger.info(`Generated post: ${postText}`);\n\n // Post the post\n if (this.isDryRun) {\n logger.info(`[DRY RUN] Would post: ${postText}`);\n return false;\n }\n\n const result = await this.postToX(postText, []);\n\n // If result is null, it means we detected a duplicate post and skipped posting\n if (result === null) {\n logger.info(\"Skipped posting duplicate post\");\n return false;\n }\n\n const postId = result.id ?? result.data?.id ?? result.data?.data?.id;\n logger.info(`Post created successfully! ID: ${postId}`);\n\n if (result && postId) {\n const postedPostId = createUniqueUuid(this.runtime, postId);\n\n try {\n // Ensure context exists with error handling\n const context = await ensureXContext(this.runtime, {\n userId,\n username: this.client.profile?.username || \"unknown\",\n conversationId: `${userId}-home`,\n });\n\n // Create memory for the posted post with retry logic\n const postedMemory: Memory = {\n id: postedPostId,\n entityId: this.runtime.agentId,\n agentId: this.runtime.agentId,\n roomId: context.roomId,\n content: {\n text: postText,\n source: \"x\",\n channelType: ChannelType.FEED,\n type: \"post\",\n metadata: {\n postId,\n postedAt: Date.now(),\n },\n },\n createdAt: Date.now(),\n };\n\n await createMemorySafe(this.runtime, postedMemory, \"messages\");\n logger.info(\"Post created and saved to memory successfully\");\n } catch (error) {\n logger.error(\n \"Failed to save post memory:\",\n error instanceof Error ? error.message : String(error)\n );\n // Don't fail the post creation if memory creation fails\n }\n\n return true;\n } else {\n logger.warn(\"Post generation returned no result\");\n return false;\n }\n } catch (error) {\n logger.error(\n \"Error generating post:\",\n error instanceof Error ? error.message : String(error)\n );\n return false;\n } finally {\n this.isPosting = false;\n }\n }\n\n /**\n * Posts content to X\n * @param {string} text The post text to create\n * @param {MediaData[]} mediaData Optional media to attach to the post\n * @returns {Promise<PostResponse | null>} The result from the X API\n */\n private async postToX(text: string, mediaData: MediaData[] = []): Promise<PostResponse | null> {\n // Check if this post is a duplicate of recent posts\n const username = this.client.profile?.username;\n if (!username) {\n logger.error(\"No profile username available\");\n return null;\n }\n\n // Check for duplicates in recent posts\n const isDuplicate = await isDuplicatePost(this.runtime, username, text);\n if (isDuplicate) {\n logger.warn(\"Post is a duplicate of a recent post. Skipping to avoid duplicate.\");\n return null;\n }\n\n // Handle media uploads if needed\n const _mediaIds: string[] = [];\n\n if (mediaData && mediaData.length > 0) {\n logger.warn(\"Media upload not currently supported with the modern X API\");\n }\n\n const result = await sendPost(this.client, text, mediaData);\n\n // Add to recent posts cache to prevent future duplicates\n await addToRecentPosts(this.runtime, username, text);\n\n return result;\n }\n}\n",
111
111
  "import {\n ChannelType,\n composePromptFromState,\n createUniqueUuid,\n type IAgentRuntime,\n logger,\n type Memory,\n ModelType,\n parseKeyValueXml,\n type State,\n type UUID,\n} from \"@elizaos/core\";\nimport type { ClientBase } from \"./base\";\nimport type { Client, Post } from \"./client/index\";\nimport { quotePostTemplate, replyPostTemplate, xActionTemplate } from \"./templates\";\nimport type { ActionResponse } from \"./types\";\nimport { parseActionResponseFromText, sendPost } from \"./utils\";\nimport { createMemorySafe, ensureXContext, isPostProcessed } from \"./utils/memory\";\nimport { getSetting } from \"./utils/settings\";\nimport { getEpochMs } from \"./utils/time\";\n\nenum TIMELINE_TYPE {\n ForYou = \"foryou\",\n Following = \"following\",\n}\n\nexport class XTimelineClient {\n client: ClientBase;\n xClient: Client;\n runtime: IAgentRuntime;\n isDryRun: boolean;\n timelineType: TIMELINE_TYPE;\n private state: Record<string, unknown>;\n private isRunning: boolean = false;\n\n constructor(client: ClientBase, runtime: IAgentRuntime, state: Record<string, unknown>) {\n this.client = client;\n this.xClient = client.xClient;\n this.runtime = runtime;\n this.state = state;\n\n const dryRunSetting =\n this.state?.X_DRY_RUN ?? getSetting(this.runtime, \"X_DRY_RUN\") ?? process.env.X_DRY_RUN;\n this.isDryRun =\n dryRunSetting === true ||\n dryRunSetting === \"true\" ||\n (typeof dryRunSetting === \"string\" && dryRunSetting.toLowerCase() === \"true\");\n\n // Load timeline mode from runtime settings or use default\n const timelineMode = getSetting(this.runtime, \"X_TIMELINE_MODE\") ?? process.env.X_TIMELINE_MODE;\n this.timelineType =\n timelineMode === TIMELINE_TYPE.Following ? TIMELINE_TYPE.Following : TIMELINE_TYPE.ForYou;\n }\n\n async start() {\n logger.info(\"Starting X timeline client...\");\n this.isRunning = true;\n\n const handleXTimelineLoop = () => {\n if (!this.isRunning) {\n logger.info(\"X timeline client stopped, exiting loop\");\n return;\n }\n\n // Use standard engagement interval\n const engagementIntervalMinutes = parseInt(\n (typeof this.state?.X_ENGAGEMENT_INTERVAL === \"string\"\n ? this.state.X_ENGAGEMENT_INTERVAL\n : null) ||\n (getSetting(this.runtime, \"X_ENGAGEMENT_INTERVAL\") as string) ||\n process.env.X_ENGAGEMENT_INTERVAL ||\n \"30\",\n 10\n );\n const actionInterval = engagementIntervalMinutes * 60 * 1000;\n\n logger.info(`Timeline client will check every ${engagementIntervalMinutes} minutes`);\n\n this.handleTimeline();\n\n if (this.isRunning) {\n setTimeout(handleXTimelineLoop, actionInterval);\n }\n };\n handleXTimelineLoop();\n }\n\n async stop() {\n logger.info(\"Stopping X timeline client...\");\n this.isRunning = false;\n }\n\n async getTimeline(count: number): Promise<Post[]> {\n const xUsername = this.client.profile?.username;\n const homeTimeline =\n this.timelineType === TIMELINE_TYPE.Following\n ? await this.xClient.fetchFollowingTimeline(count, [])\n : await this.xClient.fetchHomeTimeline(count, []);\n\n // The timeline methods now return Post objects directly from v2 API\n return homeTimeline.filter((post) => post.username !== xUsername); // do not perform action on self-posts\n }\n\n createPostId(runtime: IAgentRuntime, post: Post) {\n if (!post.id) {\n throw new Error(\"Post ID is required\");\n }\n return createUniqueUuid(runtime, post.id);\n }\n\n formMessage(runtime: IAgentRuntime, post: Post) {\n if (!post.id || !post.userId || !post.conversationId) {\n throw new Error(\"Post missing required fields: id, userId, or conversationId\");\n }\n return {\n id: this.createPostId(runtime, post),\n agentId: runtime.agentId,\n content: {\n text: post.text,\n url: post.permanentUrl,\n imageUrls: post.photos?.map((photo) => photo.url) || [],\n inReplyTo: post.inReplyToStatusId\n ? createUniqueUuid(runtime, post.inReplyToStatusId)\n : undefined,\n source: \"x\",\n channelType: ChannelType.GROUP,\n post: {\n id: post.id,\n text: post.text,\n userId: post.userId,\n username: post.username,\n timestamp: post.timestamp,\n conversationId: post.conversationId,\n } as Record<string, string | number | boolean | null | undefined>,\n },\n entityId: createUniqueUuid(runtime, post.userId),\n roomId: createUniqueUuid(runtime, post.conversationId),\n createdAt: getEpochMs(post.timestamp),\n };\n }\n\n async handleTimeline() {\n logger.info(\"Starting X timeline processing...\");\n\n const posts = await this.getTimeline(20);\n logger.info(`Fetched ${posts.length} posts from timeline`);\n\n // Use max engagements per run from environment\n const maxActionsPerCycle = parseInt(\n (getSetting(this.runtime, \"X_MAX_ENGAGEMENTS_PER_RUN\") as string) ||\n process.env.X_MAX_ENGAGEMENTS_PER_RUN ||\n \"10\",\n 10\n );\n\n const postDecisions: Array<{\n post: Post;\n actionResponse: ActionResponse;\n postState: State;\n roomId: UUID;\n }> = [];\n for (const post of posts) {\n try {\n // Check if already processed using utility\n if (!post.id) {\n logger.warn(\"Skipping post with no ID\");\n continue;\n }\n const isProcessed = await isPostProcessed(this.runtime, post.id);\n if (isProcessed) {\n logger.log(`Already processed post ID: ${post.id}`);\n continue;\n }\n\n if (!post.conversationId) {\n logger.warn(\"Skipping post with no conversationId\");\n continue;\n }\n const roomId = createUniqueUuid(this.runtime, post.conversationId);\n\n const message = this.formMessage(this.runtime, post);\n\n const state = await this.runtime.composeState(message);\n\n const actionRespondPrompt =\n composePromptFromState({\n state,\n template: this.runtime.character.templates?.xActionTemplate || xActionTemplate,\n }) +\n `\nPost:\n${post.text}\n\n# Respond with qualifying action tags only.\n\nChoose any combination of [LIKE], [REPOST], [QUOTE], and [REPLY] that are appropriate. Each action must be on its own line. Your response must only include the chosen actions.`;\n\n const actionResponse = await this.runtime.useModel(ModelType.TEXT_SMALL, {\n prompt: actionRespondPrompt,\n });\n const parsedResponse = parseActionResponseFromText(actionResponse);\n\n // Ensure a valid action response was generated\n if (!parsedResponse || !parsedResponse.actions) {\n logger.debug(`No action response generated for post ${post.id}`);\n continue;\n }\n\n postDecisions.push({\n post,\n actionResponse: parsedResponse.actions,\n postState: state,\n roomId,\n });\n\n // Limit the number of actions per cycle\n if (postDecisions.length >= maxActionsPerCycle) break;\n } catch (error) {\n logger.error(\n `Error processing post ${post.id}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n // Rank by the quality of the response\n const rankByActionRelevance = (arr: typeof postDecisions) => {\n return arr.sort((a, b) => {\n const countTrue = (obj: typeof a.actionResponse) =>\n Object.values(obj).filter(Boolean).length;\n\n const countA = countTrue(a.actionResponse);\n const countB = countTrue(b.actionResponse);\n\n // Primary sort by number of true values\n if (countA !== countB) {\n return countB - countA;\n }\n\n // Secondary sort by the \"like\" property\n if (a.actionResponse.like !== b.actionResponse.like) {\n return a.actionResponse.like ? -1 : 1;\n }\n\n // Tertiary sort keeps the remaining objects with equal weight\n return 0;\n });\n };\n // Sort the timeline based on the action decision score,\n const prioritizedPosts = rankByActionRelevance(postDecisions);\n\n logger.info(`Processing ${prioritizedPosts.length} posts with actions`);\n if (prioritizedPosts.length > 0) {\n const actionSummary = prioritizedPosts.map((td: (typeof postDecisions)[0]) => {\n const actions = [];\n if (td.actionResponse.like) actions.push(\"LIKE\");\n if (td.actionResponse.repost) actions.push(\"REPOST\");\n if (td.actionResponse.quote) actions.push(\"QUOTE\");\n if (td.actionResponse.reply) actions.push(\"REPLY\");\n return `Post ${td.post.id}: ${actions.join(\", \")}`;\n });\n logger.info(`Actions to execute:\\n${actionSummary.join(\"\\n\")}`);\n }\n\n await this.processTimelineActions(prioritizedPosts);\n logger.info(\"Timeline processing complete\");\n }\n\n private async processTimelineActions(\n postDecisions: {\n post: Post;\n actionResponse: ActionResponse;\n postState: State;\n roomId: UUID;\n }[]\n ): Promise<\n {\n postId: string;\n actionResponse: ActionResponse;\n executedActions: string[];\n }[]\n > {\n const results = [];\n\n for (const { post, actionResponse, postState: _postState, roomId } of postDecisions) {\n const postId = this.createPostId(this.runtime, post);\n const executedActions = [];\n\n // Ensure room exists before creating memory\n await this.runtime.ensureRoomExists({\n id: roomId,\n name: `X conversation ${post.conversationId}`,\n source: \"x\",\n type: ChannelType.GROUP,\n channelId: post.conversationId,\n messageServerId: createUniqueUuid(this.runtime, post.userId || \"\"),\n worldId: createUniqueUuid(this.runtime, post.userId || \"\"),\n });\n\n // Update memory with processed post using safe method\n if (!post.userId) {\n logger.warn(\"Skipping post with no userId\");\n continue;\n }\n const postMemory: Memory = {\n id: postId,\n entityId: createUniqueUuid(this.runtime, post.userId),\n content: {\n text: post.text,\n url: post.permanentUrl,\n source: \"x\",\n channelType: ChannelType.GROUP,\n post: {\n id: post.id,\n text: post.text,\n userId: post.userId,\n username: post.username,\n timestamp: post.timestamp,\n conversationId: post.conversationId,\n } as Record<string, string | number | boolean | null | undefined>,\n },\n agentId: this.runtime.agentId,\n roomId,\n createdAt: getEpochMs(post.timestamp),\n };\n\n await createMemorySafe(this.runtime, postMemory, \"messages\");\n\n try {\n // ensure world and rooms, connections, and worlds are created\n const userId = post.userId;\n if (!userId) {\n logger.warn(\"Cannot create world/entity: userId is undefined\");\n continue;\n }\n const worldId = createUniqueUuid(this.runtime, userId);\n const entityId = createUniqueUuid(this.runtime, userId);\n\n await this.ensurePostWorldContext(post, roomId, worldId, entityId);\n\n if (actionResponse.like) {\n await this.handleLikeAction(post);\n executedActions.push(\"like\");\n }\n\n if (actionResponse.repost) {\n await this.handleRepostAction(post);\n executedActions.push(\"repost\");\n }\n\n if (actionResponse.quote) {\n await this.handleQuoteAction(post);\n executedActions.push(\"quote\");\n }\n\n if (actionResponse.reply) {\n await this.handleReplyAction(post);\n executedActions.push(\"reply\");\n }\n\n if (post.id) {\n results.push({ postId: post.id, actionResponse, executedActions });\n }\n } catch (error) {\n logger.error(\n `Error processing actions for post ${post.id}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n return results;\n }\n\n private async ensurePostWorldContext(post: Post, _roomId: UUID, _worldId: UUID, _entityId: UUID) {\n try {\n // Use the utility function for consistency\n if (!post.userId || !post.username || !post.conversationId) {\n logger.warn(\"Cannot ensure context: missing required post fields\");\n return;\n }\n await ensureXContext(this.runtime, {\n userId: post.userId,\n username: post.username,\n name: post.name,\n conversationId: post.conversationId,\n });\n } catch (error) {\n logger.error(\n `Failed to ensure context for post ${post.id}:`,\n error instanceof Error ? error.message : String(error)\n );\n // Don't fail the entire timeline processing\n }\n }\n\n async handleLikeAction(post: Post) {\n try {\n if (this.isDryRun) {\n logger.log(`[DRY RUN] Would have liked post ${post.id}`);\n return;\n }\n if (!post.id) {\n logger.warn(\"Cannot like post: missing post ID\");\n return;\n }\n await this.xClient.likePost(post.id);\n logger.log(`Liked post ${post.id}`);\n } catch (error) {\n logger.error(\n `Error liking post ${post.id}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n async handleRepostAction(post: Post) {\n try {\n if (this.isDryRun) {\n logger.log(`[DRY RUN] Would have reposted post ${post.id}`);\n return;\n }\n if (!post.id) {\n logger.warn(\"Cannot repost: missing post ID\");\n return;\n }\n await this.xClient.repost(post.id);\n logger.log(`Reposted post ${post.id}`);\n } catch (error) {\n logger.error(\n `Error reposting post ${post.id}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n async handleQuoteAction(post: Post) {\n try {\n const message = this.formMessage(this.runtime, post);\n\n const state = await this.runtime.composeState(message);\n\n const quotePrompt =\n composePromptFromState({\n state,\n template: this.runtime.character.templates?.quotePostTemplate || quotePostTemplate,\n }) +\n `\nYou are responding to this post:\n${post.text}`;\n\n const quoteResponse = await this.runtime.useModel(ModelType.TEXT_SMALL, {\n prompt: quotePrompt,\n });\n const responseObject = parseKeyValueXml(quoteResponse);\n\n const postText = responseObject?.post;\n if (postText && typeof postText === \"string\") {\n if (this.isDryRun) {\n logger.log(`[DRY RUN] Would have quoted post ${post.id} with: ${postText}`);\n return;\n }\n\n if (!post.id) {\n logger.error(\"Cannot send quote post: post.id is undefined\");\n return;\n }\n const postTextValue = postText; // Capture for closure\n const postIdValue = post.id; // Capture for closure\n const result = await this.client.requestQueue.add(\n async () => await this.xClient.sendQuotePost(postTextValue, postIdValue)\n );\n\n const body = (await result.json()) as {\n data?: {\n create_post?: { post_results?: { result?: { id?: string } } };\n };\n id?: string;\n };\n\n const postResult = body?.data?.create_post?.post_results?.result || body?.data || body;\n if (postResult) {\n logger.log(\"Successfully posted quote\");\n } else {\n logger.error(\"Quote post creation failed:\", body);\n }\n\n // Create memory for our response\n const postResultWithId = postResult as { id?: string };\n const postId = postResultWithId?.id || Date.now().toString();\n const responseId = createUniqueUuid(this.runtime, postId);\n const responseMemory: Memory = {\n id: responseId,\n entityId: this.runtime.agentId,\n agentId: this.runtime.agentId,\n roomId: message.roomId,\n content: {\n text: responseObject.post as string,\n inReplyTo: message.id,\n },\n createdAt: Date.now(),\n };\n\n // Save the response to memory with error handling\n await createMemorySafe(this.runtime, responseMemory, \"messages\");\n }\n } catch (error) {\n logger.error(\n \"Error in quote post generation:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n async handleReplyAction(post: Post) {\n try {\n const message = this.formMessage(this.runtime, post);\n\n const state = await this.runtime.composeState(message);\n\n const replyPrompt =\n composePromptFromState({\n state,\n template: this.runtime.character.templates?.replyPostTemplate || replyPostTemplate,\n }) +\n `\nYou are replying to this post:\n${post.text}`;\n\n const replyResponse = await this.runtime.useModel(ModelType.TEXT_SMALL, {\n prompt: replyPrompt,\n });\n const responseObject = parseKeyValueXml(replyResponse);\n\n if (responseObject?.post && typeof responseObject.post === \"string\") {\n if (this.isDryRun) {\n logger.log(\n `[DRY RUN] Would have replied to post ${post.id} with: ${responseObject.post}`\n );\n return;\n }\n\n const result = await sendPost(this.client, responseObject.post as string, [], post.id);\n\n if (result) {\n logger.log(\"Successfully posted reply\");\n\n // Create memory for our response\n const replyPostId = result.id || result.data?.id || Date.now().toString();\n const responseId = createUniqueUuid(this.runtime, replyPostId);\n const responseMemory: Memory = {\n id: responseId,\n entityId: this.runtime.agentId,\n agentId: this.runtime.agentId,\n roomId: message.roomId,\n content: {\n ...responseObject,\n inReplyTo: message.id,\n },\n createdAt: Date.now(),\n };\n\n // Save the response to memory with error handling\n await createMemorySafe(this.runtime, responseMemory, \"messages\");\n }\n }\n } catch (error) {\n logger.error(\n \"Error in reply generation:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n}\n"
112
112
  ],
@@ -106,7 +106,7 @@
106
106
  "import {\n createUniqueUuid,\n type IAgentRuntime,\n logger,\n type Memory,\n ModelType,\n} from \"@elizaos/core\";\nimport type { ClientBase } from \"./base\";\nimport type { Client, Post } from \"./client/index\";\nimport { SearchMode } from \"./client/index\";\nimport { getRandomInterval } from \"./environment\";\nimport { createMemorySafe, ensureXContext } from \"./utils/memory\";\nimport { getSetting } from \"./utils/settings\";\n\ninterface DiscoveryConfig {\n // Topics from character configuration\n topics: string[];\n // Minimum follower count for accounts to consider\n minFollowerCount: number;\n // Maximum accounts to follow per cycle\n maxFollowsPerCycle: number;\n // Maximum engagements per cycle\n maxEngagementsPerCycle: number;\n // Engagement probability thresholds\n likeThreshold: number;\n replyThreshold: number;\n quoteThreshold: number;\n}\n\ninterface ScoredPost {\n post: Post;\n relevanceScore: number;\n engagementType: \"like\" | \"reply\" | \"quote\" | \"skip\";\n}\n\ninterface ScoredAccount {\n user: {\n id: string;\n username: string;\n name: string;\n followersCount: number;\n };\n qualityScore: number;\n relevanceScore: number;\n}\n\nexport class XDiscoveryClient {\n private xClient: Client;\n private runtime: IAgentRuntime;\n private config: DiscoveryConfig;\n private isRunning: boolean = false;\n private isDryRun: boolean;\n\n constructor(client: ClientBase, runtime: IAgentRuntime, state: Record<string, unknown>) {\n this.xClient = client.xClient;\n this.runtime = runtime;\n\n // Check dry run mode\n const dryRunSetting =\n state?.X_DRY_RUN ?? getSetting(this.runtime, \"X_DRY_RUN\") ?? process.env.X_DRY_RUN;\n this.isDryRun =\n dryRunSetting === true ||\n dryRunSetting === \"true\" ||\n (typeof dryRunSetting === \"string\" && dryRunSetting.toLowerCase() === \"true\");\n\n // Build config from character settings\n this.config = this.buildDiscoveryConfig();\n\n logger.info(\n `X Discovery Config: topics=${this.config.topics.join(\", \")}, isDryRun=${this.isDryRun}, minFollowerCount=${this.config.minFollowerCount}, maxFollowsPerCycle=${this.config.maxFollowsPerCycle}, maxEngagementsPerCycle=${this.config.maxEngagementsPerCycle}`\n );\n }\n\n /**\n * Sanitizes a topic for use in X search queries\n * - Removes common stop words that might be interpreted as operators\n * - Handles special characters\n * - Simplifies complex phrases\n */\n private sanitizeTopic(topic: string): string {\n // Remove common conjunctions that might be interpreted as operators\n let sanitized = topic\n .replace(/\\band\\b/gi, \" \")\n .replace(/\\bor\\b/gi, \" \")\n .replace(/\\bnot\\b/gi, \" \")\n .trim();\n\n // Remove extra spaces\n sanitized = sanitized.replace(/\\s+/g, \" \");\n\n // If the topic is still multi-word, wrap in quotes\n return sanitized.includes(\" \") ? `\"${sanitized}\"` : sanitized;\n }\n\n private buildDiscoveryConfig(): DiscoveryConfig {\n const character = this.runtime?.character;\n\n // Default topics if character is not available\n const defaultTopics = [\n \"ai\",\n \"technology\",\n \"blockchain\",\n \"web3\",\n \"crypto\",\n \"programming\",\n \"innovation\",\n ];\n\n // Use character topics, extract from bio, or use defaults\n let topics: string[] = defaultTopics;\n\n if (character) {\n if (character.topics && Array.isArray(character.topics) && character.topics.length > 0) {\n topics = character.topics;\n } else if (character.bio) {\n topics = this.extractTopicsFromBio(character.bio);\n }\n } else {\n logger.warn(\"Character not available in runtime, using default topics for discovery\");\n }\n\n return {\n topics,\n minFollowerCount: parseInt(\n (getSetting(this.runtime, \"X_MIN_FOLLOWER_COUNT\") as string) ||\n process.env.X_MIN_FOLLOWER_COUNT ||\n \"100\",\n 10\n ),\n maxFollowsPerCycle: parseInt(\n (getSetting(this.runtime, \"X_MAX_FOLLOWS_PER_CYCLE\") as string) ||\n process.env.X_MAX_FOLLOWS_PER_CYCLE ||\n \"5\",\n 10\n ),\n maxEngagementsPerCycle: parseInt(\n (getSetting(this.runtime, \"X_MAX_ENGAGEMENTS_PER_RUN\") as string) ||\n process.env.X_MAX_ENGAGEMENTS_PER_RUN ||\n \"5\",\n 10\n ),\n likeThreshold: 0.5, // Increased from 0.3 (be more selective)\n replyThreshold: 0.7, // Increased from 0.5 (be more selective)\n quoteThreshold: 0.85, // Increased from 0.7 (be more selective)\n };\n }\n\n private extractTopicsFromBio(bio: string | string[] | undefined): string[] {\n if (!bio) {\n return [];\n }\n\n const bioText = Array.isArray(bio) ? bio.join(\" \") : bio;\n // Extract meaningful words as potential topics\n const words = bioText\n .toLowerCase()\n .split(/\\s+/)\n .filter((word) => word.length > 4)\n .filter(\n (word) => ![\"about\", \"helping\", \"working\", \"people\", \"making\", \"building\"].includes(word)\n );\n return [...new Set(words)].slice(0, 5); // Limit to 5 topics\n }\n\n async start() {\n logger.info(\"Starting X Discovery Client...\");\n this.isRunning = true;\n\n const discoveryLoop = async () => {\n if (!this.isRunning) {\n logger.info(\"Discovery client stopped, exiting loop\");\n return;\n }\n\n try {\n await this.runDiscoveryCycle();\n } catch (error: unknown) {\n const errorMsg = error instanceof Error ? error.message : String(error);\n logger.error(\"Discovery cycle error:\", errorMsg);\n }\n\n // Run discovery every 20-40 minutes (with variance)\n const discoveryIntervalMinutes = getRandomInterval(this.runtime, \"discovery\");\n const nextInterval = discoveryIntervalMinutes * 60 * 1000;\n\n logger.log(`Next discovery cycle in ${discoveryIntervalMinutes.toFixed(1)} minutes`);\n\n // Schedule next discovery\n setTimeout(discoveryLoop, nextInterval);\n };\n\n // Start after a short delay\n setTimeout(discoveryLoop, 5000);\n }\n\n async stop() {\n logger.info(\"Stopping X Discovery Client...\");\n this.isRunning = false;\n }\n\n private async runDiscoveryCycle() {\n logger.info(\"Starting X discovery cycle...\");\n\n const discoveries = await this.discoverContent();\n const { posts, accounts } = discoveries;\n\n logger.info(`Discovered ${posts.length} posts and ${accounts.length} accounts`);\n\n // Process discovered accounts (follow high-quality ones)\n const followedCount = await this.processAccounts(accounts);\n\n // Process discovered posts (engage with relevant ones)\n const engagementCount = await this.processPosts(posts);\n\n logger.info(\n `Discovery cycle complete: ${followedCount} follows, ${engagementCount} engagements`\n );\n }\n\n private async discoverContent(): Promise<{\n posts: ScoredPost[];\n accounts: ScoredAccount[];\n }> {\n const allPosts: ScoredPost[] = [];\n const allAccounts = new Map<string, ScoredAccount>();\n\n // X API v2 doesn't support trends - using topic-based discovery only\n\n // 1. Discover from topic searches (primary discovery method)\n try {\n const topicContent = await this.discoverFromTopics();\n allPosts.push(...topicContent.posts);\n for (const acc of topicContent.accounts) {\n allAccounts.set(acc.user.id, acc);\n }\n } catch (error: unknown) {\n const errorMsg = error instanceof Error ? error.message : String(error);\n logger.error(\"Failed to discover from topics:\", errorMsg);\n }\n\n // 2. Discover from conversation threads\n try {\n const threadContent = await this.discoverFromThreads();\n allPosts.push(...threadContent.posts);\n for (const acc of threadContent.accounts) {\n allAccounts.set(acc.user.id, acc);\n }\n } catch (error: unknown) {\n const errorMsg = error instanceof Error ? error.message : String(error);\n logger.error(\"Failed to discover from threads:\", errorMsg);\n }\n\n // 3. Discover from popular accounts in our topics\n try {\n const popularContent = await this.discoverFromPopularAccounts();\n allPosts.push(...popularContent.posts);\n for (const acc of popularContent.accounts) {\n allAccounts.set(acc.user.id, acc);\n }\n } catch (error) {\n logger.error(\n \"Failed to discover from popular accounts:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n\n // Sort by relevance score\n const sortedPosts = allPosts.sort((a, b) => b.relevanceScore - a.relevanceScore).slice(0, 50); // Top 50 posts\n\n const sortedAccounts = Array.from(allAccounts.values())\n .sort((a, b) => b.qualityScore * b.relevanceScore - a.qualityScore * a.relevanceScore)\n .slice(0, 20); // Top 20 accounts\n\n return { posts: sortedPosts, accounts: sortedAccounts };\n }\n\n private async discoverFromTopics(): Promise<{\n posts: ScoredPost[];\n accounts: ScoredAccount[];\n }> {\n logger.debug(\"Discovering from character topics...\");\n\n const posts: ScoredPost[] = [];\n const accounts = new Map<string, ScoredAccount>();\n\n // Search for each topic with different query strategies\n for (const topic of this.config.topics.slice(0, 5)) {\n try {\n // Sanitize topic for search query\n const searchTopic = this.sanitizeTopic(topic);\n\n // Strategy 1: Popular posts in topic (min_faves filter applied post-retrieval)\n const popularQuery = `${searchTopic} -is:repost -is:reply lang:en`;\n\n logger.debug(`Searching popular posts for topic: ${topic}`);\n const popularResults = await this.xClient.fetchSearchPosts(\n popularQuery,\n 20,\n SearchMode.Top\n );\n\n for (const post of popularResults.posts) {\n // Filter by engagement after retrieval\n if ((post.likes || 0) < 10) continue;\n\n const scored = this.scorePost(post, \"topic\");\n posts.push(scored);\n\n // Extract account info from popular post authors\n if (!post.userId || !post.username) {\n continue;\n }\n const authorUsername = post.username;\n const authorName = post.name || post.username;\n\n // Estimate follower count based on post engagement\n // Popular posts often come from accounts with decent followings\n const estimatedFollowers = Math.max(\n 1000, // minimum estimate\n (post.likes || 0) * 100 // rough estimate: 100 followers per like\n );\n\n const account = this.scoreAccount({\n id: post.userId,\n username: authorUsername,\n name: authorName,\n followersCount: estimatedFollowers,\n });\n\n if (account.qualityScore > 0.3) {\n // Lower threshold to discover more accounts\n accounts.set(post.userId, account);\n }\n }\n\n // Strategy 2: Latest posts with good engagement (not just verified)\n const engagedQuery = `${searchTopic} -is:repost lang:en`;\n\n logger.debug(`Searching engaged posts for topic: ${topic}`);\n const engagedResults = await this.xClient.fetchSearchPosts(\n engagedQuery,\n 15,\n SearchMode.Latest\n );\n\n for (const post of engagedResults.posts) {\n // Only include posts with some engagement\n if ((post.likes || 0) < 5) continue;\n\n const scored = this.scorePost(post, \"topic\");\n posts.push(scored);\n\n // Extract account info from post author\n if (!post.userId || !post.username) {\n continue;\n }\n const authorUsername = post.username;\n const authorName = post.name || post.username;\n\n // Estimate follower count based on engagement\n const estimatedFollowers = Math.max(\n 500, // minimum for engaged posts\n (post.likes || 0) * 50\n );\n\n const account = this.scoreAccount({\n id: post.userId,\n username: authorUsername,\n name: authorName,\n followersCount: estimatedFollowers,\n });\n\n if (account.qualityScore > 0.2) {\n // Even lower threshold for engaged content\n accounts.set(post.userId, account);\n }\n }\n } catch (error) {\n logger.error(\n `Failed to search topic ${topic}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n return { posts, accounts: Array.from(accounts.values()) };\n }\n\n private async discoverFromThreads(): Promise<{\n posts: ScoredPost[];\n accounts: ScoredAccount[];\n }> {\n logger.debug(\"Discovering from conversation threads...\");\n\n const posts: ScoredPost[] = [];\n const accounts = new Map<string, ScoredAccount>();\n\n // Search for viral conversations in our topics\n // X API v2 doesn't support min_replies/min_faves - filter by engagement in scoring\n const topicQuery = this.config.topics\n .slice(0, 3)\n .map((t) => this.sanitizeTopic(t))\n .join(\" OR \");\n\n try {\n // Search for conversations (posts with engagement)\n const viralQuery = `(${topicQuery}) -is:repost has:mentions`;\n\n logger.debug(`Searching viral threads with query: ${viralQuery}`);\n const searchResults = await this.xClient.fetchSearchPosts(viralQuery, 15, SearchMode.Top);\n\n for (const post of searchResults.posts) {\n // Filter for posts with good engagement (proxy for viral threads)\n const engagementScore = (post.likes || 0) + (post.reposts || 0) * 2;\n if (engagementScore < 10) continue; // Lowered from 50 - more inclusive\n\n const scored = this.scorePost(post, \"thread\");\n posts.push(scored);\n\n // Viral thread authors are likely high-quality accounts\n if (!post.userId || !post.username) {\n continue;\n }\n const account = this.scoreAccount({\n id: post.userId,\n username: post.username,\n name: post.name || post.username,\n followersCount: 1000, // Reasonable estimate for engaged users\n });\n\n if (account.qualityScore > 0.5) {\n // Lowered from 0.6\n accounts.set(post.userId, account);\n }\n }\n } catch (error) {\n logger.error(\n \"Failed to discover threads:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n\n return { posts, accounts: Array.from(accounts.values()) };\n }\n\n private async discoverFromPopularAccounts(): Promise<{\n posts: ScoredPost[];\n accounts: ScoredAccount[];\n }> {\n logger.debug(\"Discovering from popular accounts in topics...\");\n\n const posts: ScoredPost[] = [];\n const accounts = new Map<string, ScoredAccount>();\n\n // Search for users who frequently post about our topics\n for (const topic of this.config.topics.slice(0, 3)) {\n try {\n // Sanitize topic for search query\n const searchTopic = this.sanitizeTopic(topic);\n\n // Find posts from accounts with high engagement\n // X API v2 doesn't support min_faves/min_reposts - filter post-retrieval\n const influencerQuery = `${searchTopic} -is:repost lang:en`;\n\n logger.debug(`Searching for influencers in topic: ${topic}`);\n const results = await this.xClient.fetchSearchPosts(influencerQuery, 10, SearchMode.Top);\n\n for (const post of results.posts) {\n // Filter by engagement metrics after retrieval\n const engagement = (post.likes || 0) + (post.reposts || 0) * 2;\n if (engagement < 5) continue; // Lowered from 20 - more inclusive\n\n const scored = this.scorePost(post, \"topic\");\n posts.push(scored);\n\n // High engagement suggests a quality account\n const estimatedFollowers = Math.max(\n (post.likes || 0) * 100,\n (post.reposts || 0) * 200,\n 10000\n );\n\n if (!post.userId || !post.username) {\n continue;\n }\n const account = this.scoreAccount({\n id: post.userId,\n username: post.username,\n name: post.name || post.username,\n followersCount: estimatedFollowers,\n });\n\n if (account.qualityScore > 0.7) {\n accounts.set(post.userId, account);\n }\n }\n } catch (error) {\n logger.error(\n `Failed to discover popular accounts for ${topic}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n return { posts, accounts: Array.from(accounts.values()) };\n }\n\n // Remove the discoverFromTrends method since API v2 doesn't support it\n // Remove the isTrendRelevant method since we're not using trends\n\n private scorePost(post: Post, source: string): ScoredPost {\n // Skip reposts - we want original content\n if (post.isRepost) {\n return {\n post,\n relevanceScore: 0,\n engagementType: \"skip\",\n };\n }\n\n let relevanceScore = 0;\n\n // Base score by source\n const sourceScores: Record<string, number> = {\n topic: 0.4,\n thread: 0.35,\n };\n relevanceScore += sourceScores[source] || 0;\n\n // Score by engagement metrics - much more realistic thresholds\n const engagementScore = Math.min(\n (post.likes || 0) / 100 + // 100 likes = 0.1 points (was 1000)\n (post.reposts || 0) / 50 + // 50 reposts = 0.1 points (was 500)\n (post.replies || 0) / 20, // 20 replies = 0.1 points (was 100)\n 0.3\n );\n relevanceScore += engagementScore;\n\n // Score by text relevance if text exists\n if (post.text) {\n // Additional scoring based on text content can go here\n }\n\n // Score by content relevance to topics\n if (post.text) {\n const textLower = post.text.toLowerCase();\n const topicMatches = this.config.topics.filter((topic) =>\n textLower.includes(topic.toLowerCase())\n ).length;\n relevanceScore += Math.min(topicMatches * 0.15, 0.3); // Increased from 0.1\n }\n\n // Bonus for verified accounts (isBlueVerified may not be in all responses)\n\n // Normalize score\n relevanceScore = Math.min(relevanceScore, 1);\n\n // Determine engagement type based on score\n let engagementType: ScoredPost[\"engagementType\"] = \"skip\";\n if (relevanceScore >= this.config.quoteThreshold) {\n engagementType = \"quote\";\n } else if (relevanceScore >= this.config.replyThreshold) {\n engagementType = \"reply\";\n } else if (relevanceScore >= this.config.likeThreshold) {\n engagementType = \"like\";\n }\n\n return {\n post,\n relevanceScore,\n engagementType,\n };\n }\n\n private scoreAccount(user: ScoredAccount[\"user\"]): ScoredAccount {\n let qualityScore = 0;\n let relevanceScore = 0;\n\n // Quality based on follower count\n if (user.followersCount > 10000) qualityScore += 0.4;\n else if (user.followersCount > 1000) qualityScore += 0.3;\n else if (user.followersCount > 100) qualityScore += 0.2;\n\n // Relevance based on username/name matching topics\n const userText = `${user.username} ${user.name}`.toLowerCase();\n const topicMatches = this.config.topics.filter((topic) =>\n userText.includes(topic.toLowerCase())\n ).length;\n relevanceScore = Math.min(topicMatches * 0.3, 1);\n\n return {\n user,\n qualityScore: Math.min(qualityScore, 1),\n relevanceScore,\n };\n }\n\n private async processAccounts(accounts: ScoredAccount[]): Promise<number> {\n let followedCount = 0;\n\n // Sort accounts by combined quality and relevance score\n const sortedAccounts = accounts.sort((a, b) => {\n const scoreA = a.qualityScore + a.relevanceScore;\n const scoreB = b.qualityScore + b.relevanceScore;\n return scoreB - scoreA;\n });\n\n for (const scoredAccount of sortedAccounts) {\n if (followedCount >= this.config.maxFollowsPerCycle) break;\n\n // Skip accounts with too few followers\n if (scoredAccount.user.followersCount < this.config.minFollowerCount) {\n logger.debug(\n `Skipping @${scoredAccount.user.username} - below minimum follower count (${scoredAccount.user.followersCount} < ${this.config.minFollowerCount})`\n );\n continue;\n }\n\n // Skip low-quality accounts\n if (scoredAccount.qualityScore < 0.2) {\n logger.debug(\n `Skipping @${scoredAccount.user.username} - quality score too low (${scoredAccount.qualityScore.toFixed(2)})`\n );\n continue;\n }\n\n try {\n // Check if already following (via memory)\n const isFollowing = await this.checkIfFollowing(scoredAccount.user.id);\n if (isFollowing) continue;\n\n if (this.isDryRun) {\n logger.info(\n `[DRY RUN] Would follow @${scoredAccount.user.username} ` +\n `(quality: ${scoredAccount.qualityScore.toFixed(2)}, ` +\n `relevance: ${scoredAccount.relevanceScore.toFixed(2)})`\n );\n } else {\n // Follow the account\n await this.xClient.followUser(scoredAccount.user.id);\n\n logger.info(\n `Followed @${scoredAccount.user.username} ` +\n `(quality: ${scoredAccount.qualityScore.toFixed(2)}, ` +\n `relevance: ${scoredAccount.relevanceScore.toFixed(2)})`\n );\n\n // Save follow action to memory\n await this.saveFollowMemory(scoredAccount.user);\n }\n\n followedCount++;\n\n // Add a delay to avoid rate limits\n await this.delay(2000 + Math.random() * 3000);\n } catch (error) {\n logger.error(\n `Failed to follow @${scoredAccount.user.username}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n return followedCount;\n }\n\n private async processPosts(posts: ScoredPost[]): Promise<number> {\n let engagementCount = 0;\n\n for (const scoredPost of posts) {\n if (engagementCount >= this.config.maxEngagementsPerCycle) break;\n if (scoredPost.engagementType === \"skip\") continue;\n\n try {\n // Check if already engaged\n if (!scoredPost.post.id) {\n continue;\n }\n const postMemoryId = createUniqueUuid(this.runtime, scoredPost.post.id);\n const existingMemory = await this.runtime.getMemoryById(postMemoryId);\n if (existingMemory) {\n logger.debug(`Already engaged with post ${scoredPost.post.id}, skipping`);\n continue;\n }\n\n // Perform engagement\n switch (scoredPost.engagementType) {\n case \"like\":\n if (this.isDryRun) {\n logger.info(\n `[DRY RUN] Would like post: ${scoredPost.post.id} (score: ${scoredPost.relevanceScore.toFixed(2)})`\n );\n } else {\n if (!scoredPost.post.id) {\n continue;\n }\n await this.xClient.likePost(scoredPost.post.id);\n logger.info(\n `Liked post: ${scoredPost.post.id} (score: ${scoredPost.relevanceScore.toFixed(2)})`\n );\n }\n break;\n\n case \"reply\": {\n const replyText = await this.generateReply(scoredPost.post);\n if (this.isDryRun) {\n logger.info(\n `[DRY RUN] Would reply to post ${scoredPost.post.id} with: \"${replyText}\"`\n );\n } else {\n await this.xClient.sendPost(replyText, scoredPost.post.id);\n logger.info(`Replied to post: ${scoredPost.post.id}`);\n }\n break;\n }\n\n case \"quote\": {\n if (!scoredPost.post.id) {\n continue;\n }\n const quoteText = await this.generateQuote(scoredPost.post);\n if (this.isDryRun) {\n logger.info(`[DRY RUN] Would quote post ${scoredPost.post.id} with: \"${quoteText}\"`);\n } else {\n await this.xClient.sendQuotePost(quoteText, scoredPost.post.id);\n logger.info(`Quoted post: ${scoredPost.post.id}`);\n }\n break;\n }\n }\n\n // Save engagement to memory (even in dry run for tracking)\n await this.saveEngagementMemory(scoredPost.post, scoredPost.engagementType);\n\n engagementCount++;\n\n // Add delay to avoid rate limits\n await this.delay(3000 + Math.random() * 5000);\n } catch (error: unknown) {\n // Check if it's a 403 error\n const errorMessage = (error as { message?: string })?.message;\n if (errorMessage?.includes(\"403\")) {\n logger.warn(\n `Permission denied (403) for post ${scoredPost.post.id}. ` +\n `This might be a protected account or restricted post. Skipping.`\n );\n // Still save to memory to avoid retrying\n await this.saveEngagementMemory(scoredPost.post, \"skip\");\n } else if (errorMessage?.includes(\"429\")) {\n logger.warn(\n `Rate limit (429) hit while engaging with post ${scoredPost.post.id}. ` +\n `Pausing engagement cycle.`\n );\n // Break out of the loop on rate limit\n break;\n } else {\n logger.error(\n `Failed to engage with post ${scoredPost.post.id}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n }\n\n return engagementCount;\n }\n\n private async checkIfFollowing(userId: string): Promise<boolean> {\n // Check our memory to see if we've followed them\n const embedding = await this.runtime.useModel(ModelType.TEXT_EMBEDDING, {\n text: `followed X user ${userId}`,\n });\n\n const followMemories = await this.runtime.searchMemories({\n tableName: \"messages\",\n embedding,\n match_threshold: 0.8,\n count: 1,\n });\n return followMemories.length > 0;\n }\n\n private async generateReply(post: Post): Promise<string> {\n // Handle case where runtime.character might be undefined\n const characterName = this.runtime?.character?.name || \"AI Assistant\";\n let characterBio = \"\";\n\n if (this.runtime?.character?.bio) {\n if (Array.isArray(this.runtime.character.bio)) {\n characterBio = this.runtime.character.bio.join(\" \");\n } else {\n characterBio = this.runtime.character.bio;\n }\n }\n\n const prompt = `You are ${characterName}. Generate a thoughtful reply to this post:\n\nPost by @${post.username || \"unknown\"}: \"${post.text || \"\"}\"\n\nYour interests: ${this.config.topics.join(\", \")}\nCharacter bio: ${characterBio}\n\nKeep the reply:\n- Relevant and adding value to the conversation\n- Under 280 characters\n- Natural and conversational\n- Related to your expertise and interests\n- Respectful and constructive\n\nReply:`;\n\n const response = await this.runtime.useModel(ModelType.TEXT_SMALL, {\n prompt,\n maxTokens: 100,\n temperature: 0.8,\n });\n\n return response.trim();\n }\n\n private async generateQuote(post: Post): Promise<string> {\n // Handle case where runtime.character might be undefined\n const characterName = this.runtime?.character?.name || \"AI Assistant\";\n let characterBio = \"\";\n\n if (this.runtime?.character?.bio) {\n if (Array.isArray(this.runtime.character.bio)) {\n characterBio = this.runtime.character.bio.join(\" \");\n } else {\n characterBio = this.runtime.character.bio;\n }\n }\n\n const prompt = `You are ${characterName}. Add your perspective to this post with a quote post:\n\nOriginal post by @${post.username || \"unknown\"}: \"${post.text || \"\"}\"\n\nYour interests: ${this.config.topics.join(\", \")}\nCharacter bio: ${characterBio}\n\nCreate a quote post that:\n- Adds unique insight or perspective\n- Is under 280 characters\n- Respectfully builds on the original idea\n- Showcases your expertise\n- Encourages further discussion\n\nQuote post:`;\n\n const response = await this.runtime.useModel(ModelType.TEXT_SMALL, {\n prompt,\n maxTokens: 100,\n temperature: 0.8,\n });\n\n return response.trim();\n }\n\n private async saveEngagementMemory(post: Post, engagementType: string) {\n try {\n // Ensure context exists before saving memory\n if (!post.userId || !post.username) {\n logger.warn(\"Cannot ensure context: missing userId or username\");\n return;\n }\n const context = await ensureXContext(this.runtime, {\n userId: post.userId,\n username: post.username,\n conversationId: post.conversationId || post.id || \"\",\n });\n\n const memory: Memory = {\n id: createUniqueUuid(this.runtime, `${post.id}-${engagementType}`),\n entityId: context.entityId,\n content: {\n text: `${engagementType} post from @${post.username}: ${post.text}`,\n metadata: {\n postId: post.id,\n engagementType,\n source: \"discovery\",\n isDryRun: this.isDryRun,\n },\n },\n roomId: context.roomId,\n agentId: this.runtime.agentId,\n createdAt: Date.now(),\n };\n\n await createMemorySafe(this.runtime, memory, \"messages\");\n logger.debug(`[Discovery] Saved ${engagementType} memory for post ${post.id}`);\n } catch (error) {\n logger.error(\n `[Discovery] Failed to save engagement memory:`,\n error instanceof Error ? error.message : String(error)\n );\n // Don't throw - just log the error\n }\n }\n\n private async saveFollowMemory(user: ScoredAccount[\"user\"]) {\n try {\n // Create a simple context for follows\n const context = await ensureXContext(this.runtime, {\n userId: user.id,\n username: user.username,\n name: user.name,\n conversationId: `x-follows`,\n });\n\n const memory: Memory = {\n id: createUniqueUuid(this.runtime, `follow-${user.id}`),\n entityId: context.entityId,\n content: {\n text: `followed X user ${user.id} @${user.username}`,\n metadata: {\n userId: user.id,\n username: user.username,\n name: user.name,\n followersCount: user.followersCount,\n source: \"discovery\",\n isDryRun: this.isDryRun,\n },\n },\n roomId: context.roomId,\n agentId: this.runtime.agentId,\n createdAt: Date.now(),\n };\n\n await createMemorySafe(this.runtime, memory, \"messages\");\n logger.debug(`[Discovery] Saved follow memory for @${user.username}`);\n } catch (error) {\n logger.error(\n `[Discovery] Failed to save follow memory:`,\n error instanceof Error ? error.message : String(error)\n );\n // Don't throw - just log the error\n }\n }\n\n private delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n }\n}\n",
107
107
  "import type { IAgentRuntime } from \"@elizaos/core\";\nimport { z } from \"zod\";\nimport { getSetting } from \"./utils/settings\";\n\nfunction getXSetting(runtime: IAgentRuntime, key: string): string {\n return getSetting(runtime, key) || \"\";\n}\n\nexport const xEnvSchema = z.object({\n X_AUTH_MODE: z.enum([\"env\", \"oauth\", \"bearer\"]).default(\"env\"),\n X_API_KEY: z.string().default(\"\"),\n X_API_SECRET: z.string().default(\"\"),\n X_ACCESS_TOKEN: z.string().default(\"\"),\n X_ACCESS_TOKEN_SECRET: z.string().default(\"\"),\n X_BEARER_TOKEN: z.string().default(\"\"),\n X_CLIENT_ID: z.string().default(\"\"),\n X_REDIRECT_URI: z.string().default(\"\"),\n X_DRY_RUN: z.string().default(\"false\"),\n X_TARGET_USERS: z.string().default(\"\"),\n X_ENABLE_POST: z.string().default(\"false\"),\n X_ENABLE_REPLIES: z.string().default(\"true\"),\n X_ENABLE_ACTIONS: z.string().default(\"false\"),\n X_ENABLE_DISCOVERY: z.string().default(\"false\"),\n X_POST_INTERVAL_MIN: z.string().default(\"90\"),\n X_POST_INTERVAL_MAX: z.string().default(\"180\"),\n X_ENGAGEMENT_INTERVAL_MIN: z.string().default(\"20\"),\n X_ENGAGEMENT_INTERVAL_MAX: z.string().default(\"40\"),\n X_DISCOVERY_INTERVAL_MIN: z.string().default(\"15\"),\n X_DISCOVERY_INTERVAL_MAX: z.string().default(\"30\"),\n X_MAX_ENGAGEMENTS_PER_RUN: z.string().default(\"5\"),\n X_MAX_POST_LENGTH: z.string().default(\"280\"),\n X_RETRY_LIMIT: z.string().default(\"5\"),\n});\n\nexport type TwitterConfig = z.infer<typeof xEnvSchema>;\n\nfunction parseTargetUsers(str: string): string[] {\n if (!str.trim()) return [];\n return str\n .split(\",\")\n .map((s) => s.trim())\n .filter(Boolean);\n}\n\nexport function shouldTargetUser(username: string, targetConfig: string): boolean {\n if (!targetConfig.trim()) return true;\n const targets = parseTargetUsers(targetConfig);\n if (targets.includes(\"*\")) return true;\n const normalized = username.toLowerCase().replace(/^@/, \"\");\n return targets.some((t) => t.toLowerCase().replace(/^@/, \"\") === normalized);\n}\n\nexport function getTargetUsers(targetConfig: string): string[] {\n return parseTargetUsers(targetConfig).filter((u) => u !== \"*\");\n}\n\nexport async function validateXConfig(runtime: IAgentRuntime): Promise<TwitterConfig> {\n const mode = (getXSetting(runtime, \"X_AUTH_MODE\") || \"env\").toLowerCase();\n\n const config: TwitterConfig = {\n X_AUTH_MODE: mode as \"env\" | \"oauth\" | \"bearer\",\n X_API_KEY: getXSetting(runtime, \"X_API_KEY\"),\n X_API_SECRET: getXSetting(runtime, \"X_API_SECRET\") || getXSetting(runtime, \"X_API_SECRET_KEY\"),\n X_ACCESS_TOKEN: getXSetting(runtime, \"X_ACCESS_TOKEN\"),\n X_ACCESS_TOKEN_SECRET: getXSetting(runtime, \"X_ACCESS_TOKEN_SECRET\"),\n X_BEARER_TOKEN: getXSetting(runtime, \"X_BEARER_TOKEN\"),\n X_CLIENT_ID: getXSetting(runtime, \"X_CLIENT_ID\"),\n X_REDIRECT_URI: getXSetting(runtime, \"X_REDIRECT_URI\"),\n X_DRY_RUN: getXSetting(runtime, \"X_DRY_RUN\") || \"false\",\n X_TARGET_USERS: getXSetting(runtime, \"X_TARGET_USERS\"),\n X_ENABLE_POST: getXSetting(runtime, \"X_ENABLE_POST\") || \"false\",\n X_ENABLE_REPLIES: getXSetting(runtime, \"X_ENABLE_REPLIES\") || \"true\",\n X_ENABLE_ACTIONS: getXSetting(runtime, \"X_ENABLE_ACTIONS\") || \"false\",\n X_ENABLE_DISCOVERY: getXSetting(runtime, \"X_ENABLE_DISCOVERY\") || \"false\",\n X_POST_INTERVAL_MIN: getXSetting(runtime, \"X_POST_INTERVAL_MIN\") || \"90\",\n X_POST_INTERVAL_MAX: getXSetting(runtime, \"X_POST_INTERVAL_MAX\") || \"180\",\n X_ENGAGEMENT_INTERVAL_MIN: getXSetting(runtime, \"X_ENGAGEMENT_INTERVAL_MIN\") || \"20\",\n X_ENGAGEMENT_INTERVAL_MAX: getXSetting(runtime, \"X_ENGAGEMENT_INTERVAL_MAX\") || \"40\",\n X_DISCOVERY_INTERVAL_MIN: getXSetting(runtime, \"X_DISCOVERY_INTERVAL_MIN\") || \"15\",\n X_DISCOVERY_INTERVAL_MAX: getXSetting(runtime, \"X_DISCOVERY_INTERVAL_MAX\") || \"30\",\n X_MAX_ENGAGEMENTS_PER_RUN: getXSetting(runtime, \"X_MAX_ENGAGEMENTS_PER_RUN\") || \"5\",\n X_MAX_POST_LENGTH: getXSetting(runtime, \"X_MAX_POST_LENGTH\") || \"280\",\n X_RETRY_LIMIT: getXSetting(runtime, \"X_RETRY_LIMIT\") || \"5\",\n };\n\n if (mode === \"env\") {\n if (\n !config.X_API_KEY ||\n !config.X_API_SECRET ||\n !config.X_ACCESS_TOKEN ||\n !config.X_ACCESS_TOKEN_SECRET\n ) {\n throw new Error(\n \"X env auth requires X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET\"\n );\n }\n } else if (mode === \"bearer\") {\n if (!config.X_BEARER_TOKEN) {\n throw new Error(\"X bearer auth requires X_BEARER_TOKEN\");\n }\n } else if (mode === \"oauth\") {\n if (!config.X_CLIENT_ID || !config.X_REDIRECT_URI) {\n throw new Error(\"X OAuth requires X_CLIENT_ID and X_REDIRECT_URI\");\n }\n }\n\n return xEnvSchema.parse(config);\n}\n\nfunction parseInterval(value: string, fallback: number): number {\n const parsed = parseInt(value, 10);\n return Number.isNaN(parsed) ? fallback : parsed;\n}\n\nexport function getRandomInterval(\n runtime: IAgentRuntime,\n type: \"post\" | \"engagement\" | \"discovery\"\n): number {\n const intervals = {\n post: {\n min: \"X_POST_INTERVAL_MIN\",\n max: \"X_POST_INTERVAL_MAX\",\n defMin: 90,\n defMax: 180,\n },\n engagement: {\n min: \"X_ENGAGEMENT_INTERVAL_MIN\",\n max: \"X_ENGAGEMENT_INTERVAL_MAX\",\n defMin: 20,\n defMax: 40,\n },\n discovery: {\n min: \"X_DISCOVERY_INTERVAL_MIN\",\n max: \"X_DISCOVERY_INTERVAL_MAX\",\n defMin: 15,\n defMax: 30,\n },\n };\n\n const { min, max, defMin, defMax } = intervals[type];\n const minVal = parseInterval(getXSetting(runtime, min), defMin);\n const maxVal = parseInterval(getXSetting(runtime, max), defMax);\n\n return minVal < maxVal ? Math.random() * (maxVal - minVal) + minVal : defMin;\n}\n\nexport function loadConfig(): TwitterConfig {\n const get = (key: string): string => process.env[key] || \"\";\n return {\n X_AUTH_MODE: (get(\"X_AUTH_MODE\") || \"env\") as \"env\" | \"oauth\" | \"bearer\",\n X_API_KEY: get(\"X_API_KEY\"),\n X_API_SECRET: get(\"X_API_SECRET\"),\n X_ACCESS_TOKEN: get(\"X_ACCESS_TOKEN\"),\n X_ACCESS_TOKEN_SECRET: get(\"X_ACCESS_TOKEN_SECRET\"),\n X_BEARER_TOKEN: get(\"X_BEARER_TOKEN\"),\n X_CLIENT_ID: get(\"X_CLIENT_ID\"),\n X_REDIRECT_URI: get(\"X_REDIRECT_URI\"),\n X_DRY_RUN: get(\"X_DRY_RUN\") || \"false\",\n X_TARGET_USERS: get(\"X_TARGET_USERS\"),\n X_ENABLE_POST: get(\"X_ENABLE_POST\") || \"false\",\n X_ENABLE_REPLIES: get(\"X_ENABLE_REPLIES\") || \"true\",\n X_ENABLE_ACTIONS: get(\"X_ENABLE_ACTIONS\") || \"false\",\n X_ENABLE_DISCOVERY: get(\"X_ENABLE_DISCOVERY\") || \"false\",\n X_POST_INTERVAL_MIN: get(\"X_POST_INTERVAL_MIN\") || \"90\",\n X_POST_INTERVAL_MAX: get(\"X_POST_INTERVAL_MAX\") || \"180\",\n X_ENGAGEMENT_INTERVAL_MIN: get(\"X_ENGAGEMENT_INTERVAL_MIN\") || \"20\",\n X_ENGAGEMENT_INTERVAL_MAX: get(\"X_ENGAGEMENT_INTERVAL_MAX\") || \"40\",\n X_DISCOVERY_INTERVAL_MIN: get(\"X_DISCOVERY_INTERVAL_MIN\") || \"15\",\n X_DISCOVERY_INTERVAL_MAX: get(\"X_DISCOVERY_INTERVAL_MAX\") || \"30\",\n X_MAX_ENGAGEMENTS_PER_RUN: get(\"X_MAX_ENGAGEMENTS_PER_RUN\") || \"5\",\n X_MAX_POST_LENGTH: get(\"X_MAX_POST_LENGTH\") || \"280\",\n X_RETRY_LIMIT: get(\"X_RETRY_LIMIT\") || \"5\",\n };\n}\n\nexport function validateConfig(config: unknown): TwitterConfig {\n return xEnvSchema.parse(config);\n}\n\nexport function loadConfigFromFile(): Partial<TwitterConfig> {\n return {};\n}\n",
108
108
  "import {\n ChannelType,\n type Content,\n type ContentValue,\n createUniqueUuid,\n EventType,\n type HandlerCallback,\n type IAgentRuntime,\n logger,\n type Memory,\n type MemoryMetadata,\n MemoryType,\n type MessagePayload,\n ModelType,\n} from \"@elizaos/core\";\n\n// WorldOwnership type for world metadata\ntype WorldOwnership = { ownerId: string };\n\nimport type { ClientBase } from \"./base\";\nimport { SearchMode } from \"./client/index\";\nimport type { Post as ClientPost } from \"./client/posts\";\nimport { getRandomInterval, getTargetUsers, shouldTargetUser } from \"./environment\";\n/**\n * Template for generating dialog and actions for a X message handler.\n *\n * @type {string}\n */\n/**\n * Templates for XAI plugin interactions.\n * Auto-generated from prompts/*.txt\n * DO NOT EDIT - Generated from ./generated/prompts/typescript/prompts.ts\n */\nimport {\n messageHandlerTemplate,\n xMessageHandlerTemplate,\n} from \"./generated/prompts/typescript/prompts.js\";\nimport type {\n XInteractionMemory,\n XInteractionPayload,\n XLikeReceivedPayload,\n XMemory,\n XQuoteReceivedPayload,\n XRepostReceivedPayload,\n} from \"./types\";\nimport { XEventTypes } from \"./types\";\nimport { sendPost } from \"./utils\";\nimport { createMemorySafe, ensureXContext as ensureContext, isPostProcessed } from \"./utils/memory\";\nimport { getSetting } from \"./utils/settings\";\nimport { getEpochMs } from \"./utils/time\";\nexport { xMessageHandlerTemplate, messageHandlerTemplate };\n\n/**\n * The XInteractionClient class manages X interactions,\n * including handling mentions, managing timelines, and engaging with other users.\n * It extends the base X client functionality to provide mention handling,\n * user interaction, and follow change detection capabilities.\n *\n * @extends ClientBase\n */\nexport class XInteractionClient {\n client: ClientBase;\n runtime: IAgentRuntime;\n xUsername: string;\n xUserId: string;\n private isDryRun: boolean;\n private state: Record<string, unknown>;\n private isRunning: boolean = false;\n\n /**\n * Constructor to initialize the X interaction client with runtime and state management.\n *\n * @param {ClientBase} client - The client instance.\n * @param {IAgentRuntime} runtime - The runtime instance for agent operations.\n * @param {Record<string, unknown>} state - The state object containing configuration settings.\n */\n constructor(client: ClientBase, runtime: IAgentRuntime, state: Record<string, unknown>) {\n this.client = client;\n this.runtime = runtime;\n this.state = state;\n\n const dryRunSetting =\n this.state?.X_DRY_RUN ?? getSetting(this.runtime, \"X_DRY_RUN\") ?? process.env.X_DRY_RUN;\n this.isDryRun =\n dryRunSetting === true ||\n dryRunSetting === \"true\" ||\n (typeof dryRunSetting === \"string\" && dryRunSetting.toLowerCase() === \"true\");\n\n // Initialize X username and user ID from client profile\n const usernameSetting = getSetting(this.runtime, \"X_USERNAME\") || this.state?.X_USERNAME;\n this.xUsername =\n typeof usernameSetting === \"string\" ? usernameSetting : client.profile?.username || \"\";\n this.xUserId = client.profile?.id || \"\";\n }\n\n /**\n * Asynchronously starts the process of handling X interactions on a loop.\n * Uses the X_ENGAGEMENT_INTERVAL setting.\n */\n async start() {\n this.isRunning = true;\n\n const handleXInteractionsLoop = () => {\n if (!this.isRunning) {\n logger.info(\"X interaction client stopped, exiting loop\");\n return;\n }\n\n // Get random engagement interval in minutes\n const engagementIntervalMinutes = getRandomInterval(this.runtime, \"engagement\");\n\n const interactionInterval = engagementIntervalMinutes * 60 * 1000;\n\n logger.info(\n `X interaction client will check in ${engagementIntervalMinutes.toFixed(1)} minutes`\n );\n\n this.handleXInteractions();\n\n if (this.isRunning) {\n setTimeout(handleXInteractionsLoop, interactionInterval);\n }\n };\n handleXInteractionsLoop();\n }\n\n /**\n * Stops the X interaction client\n */\n async stop() {\n logger.log(\"Stopping X interaction client...\");\n this.isRunning = false;\n }\n\n /**\n * Asynchronously handles X interactions by checking for mentions and target user posts.\n */\n async handleXInteractions() {\n logger.log(\"Checking X interactions\");\n\n const xUsername = this.client.profile?.username;\n\n try {\n // Check for mentions first (replies enabled by default)\n const repliesEnabled =\n (getSetting(this.runtime, \"X_ENABLE_REPLIES\") ?? process.env.X_ENABLE_REPLIES) !== \"false\";\n\n if (repliesEnabled && xUsername) {\n await this.handleMentions(xUsername);\n }\n\n // Check target users' posts for autonomous engagement\n const targetUsersConfig =\n ((getSetting(this.runtime, \"X_TARGET_USERS\") ?? process.env.X_TARGET_USERS) as string) ||\n \"\";\n\n if (targetUsersConfig?.trim()) {\n await this.handleTargetUserPosts(targetUsersConfig);\n }\n\n // Save the latest checked post ID to the file\n await this.client.cacheLatestCheckedPostId();\n\n logger.log(\"Finished checking X interactions\");\n } catch (error) {\n logger.error(\n \"Error handling X interactions:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n /**\n * Handle mentions and replies\n */\n private async handleMentions(xUsername: string) {\n try {\n // Check for mentions\n const cursorKey = `x/${xUsername}/mention_cursor`;\n const cachedCursor: string | undefined = await this.runtime.getCache<string>(cursorKey);\n\n const searchResult = await this.client.fetchSearchPosts(\n `@${xUsername}`,\n 20,\n SearchMode.Latest,\n cachedCursor ?? undefined\n );\n\n const mentionCandidates = searchResult.posts;\n\n // If we got posts and there's a valid cursor, cache it\n if (mentionCandidates.length > 0 && searchResult.previous) {\n await this.runtime.setCache(cursorKey, searchResult.previous);\n } else if (!searchResult.previous && !searchResult.next) {\n // If both previous and next are missing, clear the outdated cursor\n await this.runtime.setCache(cursorKey, \"\");\n }\n\n await this.processMentionPosts(mentionCandidates);\n } catch (error) {\n logger.error(\n \"Error handling mentions:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n /**\n * Handle autonomous engagement with target users' posts\n */\n private async handleTargetUserPosts(targetUsersConfig: string) {\n try {\n const targetUsers = getTargetUsers(targetUsersConfig);\n\n if (targetUsers.length === 0 && !targetUsersConfig.includes(\"*\")) {\n return; // No target users configured\n }\n\n logger.info(`Checking posts from target users: ${targetUsers.join(\", \") || \"everyone (*)\"}`);\n\n // For each target user, search their recent posts\n for (const targetUser of targetUsers) {\n try {\n const normalizedUsername = targetUser.replace(/^@/, \"\");\n\n // Search for recent posts from this user\n const searchQuery = `from:${normalizedUsername} -is:reply -is:repost`;\n const searchResult = await this.client.fetchSearchPosts(\n searchQuery,\n 10, // Get up to 10 recent posts per user\n SearchMode.Latest\n );\n\n if (searchResult.posts.length > 0) {\n logger.info(`Found ${searchResult.posts.length} posts from @${normalizedUsername}`);\n\n // Process these posts for potential engagement\n await this.processTargetUserPosts(searchResult.posts, normalizedUsername);\n }\n } catch (error) {\n logger.error(\n `Error searching posts from @${targetUser}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n // If wildcard is configured, also check timeline for any interesting posts\n if (targetUsersConfig.includes(\"*\")) {\n await this.processTimelineForEngagement();\n }\n } catch (error) {\n logger.error(\n \"Error handling target user posts:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n /**\n * Process posts from target users for potential engagement\n */\n private async processTargetUserPosts(posts: ClientPost[], username: string) {\n const maxEngagementsPerRun = parseInt(\n (getSetting(this.runtime, \"X_MAX_ENGAGEMENTS_PER_RUN\") as string) ||\n process.env.X_MAX_ENGAGEMENTS_PER_RUN ||\n \"10\",\n 10\n );\n\n let engagementCount = 0;\n\n for (const post of posts) {\n if (engagementCount >= maxEngagementsPerRun) {\n logger.info(`Reached max engagements limit (${maxEngagementsPerRun})`);\n break;\n }\n\n // Skip if already processed\n if (!post.id) {\n continue;\n }\n const isProcessed = await isPostProcessed(this.runtime, post.id);\n if (isProcessed) {\n continue; // Already processed\n }\n\n // Skip if post is too old (older than 24 hours)\n const postAge = Date.now() - getEpochMs(post.timestamp);\n const maxAge = 24 * 60 * 60 * 1000; // 24 hours\n\n if (postAge > maxAge) {\n continue;\n }\n\n // Decide whether to engage with this post\n const shouldEngage = await this.shouldEngageWithPost(post);\n\n if (shouldEngage) {\n logger.info(\n `Engaging with post from @${username}: ${post.text?.substring(0, 50) || \"no text\"}...`\n );\n\n // Create necessary context for the post\n await this.ensurePostContext(post);\n\n // Handle the post (generate and send reply)\n const engaged = await this.engageWithPost(post);\n\n if (engaged) {\n engagementCount++;\n }\n }\n }\n }\n\n /**\n * Process timeline for engagement when wildcard is configured\n */\n private async processTimelineForEngagement() {\n try {\n // This would use the timeline client if available, but for now\n // we'll do a general search for recent popular posts\n const searchResult = await this.client.fetchSearchPosts(\n \"min_reposts:10 min_faves:20 -is:reply -is:repost lang:en\",\n 20,\n SearchMode.Latest\n );\n\n const relevantPosts = searchResult.posts.filter((post) => {\n // Filter for posts from the last 12 hours\n const postAge = Date.now() - getEpochMs(post.timestamp);\n return postAge < 12 * 60 * 60 * 1000;\n });\n\n if (relevantPosts.length > 0) {\n logger.info(`Found ${relevantPosts.length} relevant posts from timeline`);\n await this.processTargetUserPosts(relevantPosts, \"timeline\");\n }\n } catch (error) {\n logger.error(\n \"Error processing timeline for engagement:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n /**\n * Determine if the bot should engage with a specific post\n */\n private async shouldEngageWithPost(post: ClientPost): Promise<boolean> {\n try {\n // Create a simple evaluation prompt\n const evaluationContext = {\n post: post.text,\n author: post.username,\n metrics: {\n likes: post.likes || 0,\n reposts: post.reposts || 0,\n replies: post.replies || 0,\n },\n };\n\n if (!post.id) {\n return false;\n }\n const shouldEngageMemory: Memory = {\n id: createUniqueUuid(this.runtime, `eval-${post.id}`),\n entityId: this.runtime.agentId,\n agentId: this.runtime.agentId,\n roomId: createUniqueUuid(this.runtime, post.conversationId || post.id),\n content: {\n text: `Should I engage with this post? Post: \"${post.text}\" by @${post.username}`,\n evaluationContext,\n },\n createdAt: Date.now(),\n };\n\n const _state = await this.runtime.composeState(shouldEngageMemory);\n const characterName = this.runtime?.character?.name || \"AI Assistant\";\n const context = `You are ${characterName}. Should you reply to this post based on your interests and expertise?\n \nPost by @${post.username}: \"${post.text}\"\n\nReply with YES if:\n- The topic relates to your interests or expertise\n- You can add valuable insights or perspective\n- The conversation seems constructive\n\nReply with NO if:\n- The topic is outside your knowledge\n- The post is inflammatory or controversial\n- You have nothing meaningful to add\n\nResponse (YES/NO):`;\n\n const response = await this.runtime.useModel(ModelType.TEXT_SMALL, {\n prompt: context,\n temperature: 0.3,\n maxTokens: 10,\n });\n\n return response.trim().toUpperCase().includes(\"YES\");\n } catch (error) {\n logger.error(\n \"Error determining engagement:\",\n error instanceof Error ? error.message : String(error)\n );\n return false;\n }\n }\n\n /**\n * Ensure post context exists (world, room, entity)\n */\n private async ensurePostContext(post: ClientPost) {\n try {\n if (!post.userId || !post.username) {\n logger.warn(\"Cannot ensure context: missing userId or username\");\n return;\n }\n const context = await ensureContext(this.runtime, {\n userId: post.userId,\n username: post.username,\n name: post.name,\n conversationId: post.conversationId || post.id,\n });\n\n // Save post as memory with error handling\n // Convert Post to ContentValue-compatible format\n // Post properties are JSON-serializable and compatible with ContentValue\n const postContentValue: Record<string, ContentValue> = {\n id: post.id ?? null,\n text: post.text ?? null,\n userId: post.userId ?? null,\n username: post.username ?? null,\n timestamp: post.timestamp ?? null,\n conversationId: post.conversationId ?? null,\n likes: post.likes ?? null,\n reposts: post.reposts ?? null,\n replies: post.replies ?? null,\n quotes: post.quotes ?? null,\n permanentUrl: post.permanentUrl ?? null,\n };\n\n const postMemory: Memory = {\n id: createUniqueUuid(this.runtime, post.id || \"\"),\n entityId: context.entityId,\n content: {\n text: post.text,\n url: post.permanentUrl,\n source: \"x\",\n post: postContentValue,\n },\n agentId: this.runtime.agentId,\n roomId: context.roomId,\n createdAt: getEpochMs(post.timestamp),\n };\n\n await createMemorySafe(this.runtime, postMemory, \"messages\");\n } catch (error) {\n logger.error(\n `Failed to ensure context for post ${post.id}:`,\n error instanceof Error ? error.message : String(error)\n );\n throw error;\n }\n }\n\n /**\n * Engage with a post by generating and sending a reply\n */\n private async engageWithPost(post: ClientPost): Promise<boolean> {\n try {\n const message: Memory = {\n id: createUniqueUuid(this.runtime, post.id || \"\"),\n entityId: createUniqueUuid(this.runtime, post.userId || \"\"),\n content: {\n text: post.text,\n source: \"x\",\n post: {\n id: post.id,\n text: post.text,\n userId: post.userId,\n username: post.username,\n timestamp: post.timestamp,\n conversationId: post.conversationId,\n } as Record<string, string | number | boolean | null | undefined>,\n },\n agentId: this.runtime.agentId,\n roomId: createUniqueUuid(this.runtime, post.conversationId || post.id || \"\"),\n createdAt: getEpochMs(post.timestamp),\n };\n\n const result = await this.handlePost({\n post,\n message,\n thread: post.thread || [post],\n });\n\n return typeof result.text === \"string\" && result.text.length > 0;\n } catch (error) {\n logger.error(\n \"Error engaging with post:\",\n error instanceof Error ? error.message : String(error)\n );\n return false;\n }\n }\n\n /**\n * Processes all incoming posts that mention the bot.\n * For each new post:\n * - Ensures world, room, and connection exist\n * - Saves the post as memory\n * - Emits thread-related events (THREAD_CREATED / THREAD_UPDATED)\n * - Delegates post content to `handlePost` for reply generation\n */\n async processMentionPosts(mentionCandidates: ClientPost[]) {\n logger.log(\"Completed checking mentioned posts:\", mentionCandidates.length.toString());\n let uniquePostCandidates = [...mentionCandidates];\n\n // Sort post candidates by ID in ascending order\n uniquePostCandidates = uniquePostCandidates\n .sort((a, b) => (a.id || \"\").localeCompare(b.id || \"\"))\n .filter((post) => post.userId && post.userId !== this.client.profile?.id);\n\n // Get X_TARGET_USERS configuration\n const targetUsersConfig =\n ((getSetting(this.runtime, \"X_TARGET_USERS\") ?? process.env.X_TARGET_USERS) as string) || \"\";\n\n // Filter posts based on X_TARGET_USERS if configured\n if (targetUsersConfig?.trim()) {\n uniquePostCandidates = uniquePostCandidates.filter((post) => {\n const shouldTarget = shouldTargetUser(post.username || \"\", targetUsersConfig);\n if (!shouldTarget) {\n logger.log(`Skipping post from @${post.username} - not in target users list`);\n }\n return shouldTarget;\n });\n }\n\n // Get max interactions per run setting\n const maxInteractionsPerRun = parseInt(\n (getSetting(this.runtime, \"X_MAX_ENGAGEMENTS_PER_RUN\") as string) ||\n process.env.X_MAX_ENGAGEMENTS_PER_RUN ||\n \"10\",\n 10\n );\n\n // Limit the number of interactions per run\n const postsToProcess = uniquePostCandidates.slice(0, maxInteractionsPerRun);\n logger.info(\n `Processing ${postsToProcess.length} of ${uniquePostCandidates.length} mention posts (max: ${maxInteractionsPerRun})`\n );\n\n // for each post candidate, handle the post\n for (const post of postsToProcess) {\n if (\n !this.client.lastCheckedPostId ||\n (post.id && BigInt(post.id) > this.client.lastCheckedPostId)\n ) {\n // Generate the postId UUID the same way it's done in handlePost\n const postId = createUniqueUuid(this.runtime, post.id || \"\");\n\n // Check if we've already processed this post\n const existingResponse = await this.runtime.getMemoryById(postId);\n\n if (existingResponse) {\n logger.log(`Already responded to post ${post.id}, skipping`);\n continue;\n }\n\n // Also check if we've already responded to this post (for chunked responses)\n // by looking for any memory with inReplyTo pointing to this post\n const conversationRoomId = createUniqueUuid(\n this.runtime,\n post.conversationId || post.id || \"\"\n );\n const existingReplies = await this.runtime.getMemories({\n tableName: \"messages\",\n roomId: conversationRoomId,\n count: 10, // Check recent messages in this room\n });\n\n // Check if any of the found memories is a reply to this specific post\n const hasExistingReply = existingReplies.some(\n (memory) => memory.content.inReplyTo === postId || memory.content.inReplyTo === post.id\n );\n\n if (hasExistingReply) {\n logger.log(\n `Already responded to post ${post.id} (found in conversation history), skipping`\n );\n continue;\n }\n\n logger.log(\"New Post found\", post.id);\n\n const userId = post.userId;\n if (!userId || !post.id) {\n logger.warn(\"Skipping post with missing required fields\", post.id);\n continue;\n }\n const conversationId = post.conversationId || post.id;\n if (!userId || !post.id) {\n logger.warn(\"Skipping post with missing required fields\", post.id);\n continue;\n }\n const roomId = createUniqueUuid(this.runtime, conversationId || \"\");\n const username = post.username;\n\n logger.log(\"----\");\n logger.log(`User: ${username} (${userId})`);\n logger.log(`Post: ${post.id}`);\n logger.log(`Conversation: ${conversationId}`);\n logger.log(`Room: ${roomId}`);\n logger.log(\"----\");\n\n // 1. Ensure world exists for the user\n const worldId = createUniqueUuid(this.runtime, userId || \"\");\n await this.runtime.ensureWorldExists({\n id: worldId,\n name: `${username}'s X`,\n agentId: this.runtime.agentId,\n messageServerId: userId as `${string}-${string}-${string}-${string}-${string}`,\n metadata: {\n ownership: { ownerId: userId || \"\" } as unknown as WorldOwnership,\n extra: {\n x: {\n username: username,\n id: userId,\n },\n },\n },\n });\n\n // 2. Ensure entity connection\n const entityId = createUniqueUuid(this.runtime, userId);\n await this.runtime.ensureConnection({\n entityId,\n roomId,\n userName: username,\n name: post.name,\n source: \"x\",\n type: ChannelType.FEED,\n worldId: worldId,\n });\n\n // 2.5. Ensure room exists\n await this.runtime.ensureRoomExists({\n id: roomId,\n name: `X conversation ${conversationId}`,\n source: \"x\",\n type: ChannelType.FEED,\n channelId: conversationId,\n messageServerId: createUniqueUuid(this.runtime, userId),\n worldId: worldId,\n });\n\n // 3. Create a memory for the post\n // Convert Post to ContentValue-compatible format\n const postContentValue: Record<string, ContentValue> = {\n id: post.id ?? null,\n text: post.text ?? null,\n userId: post.userId ?? null,\n username: post.username ?? null,\n timestamp: post.timestamp ?? null,\n conversationId: post.conversationId ?? null,\n };\n\n const memory: Memory = {\n id: postId,\n entityId,\n content: {\n text: post.text,\n url: post.permanentUrl,\n source: \"x\",\n post: postContentValue,\n },\n agentId: this.runtime.agentId,\n roomId,\n createdAt: getEpochMs(post.timestamp),\n };\n\n logger.log(\"Saving post memory...\");\n await createMemorySafe(this.runtime, memory, \"messages\");\n\n // 4. Handle thread-specific events\n if (post.thread && post.thread.length > 0) {\n const threadStartId = post.thread[0].id;\n const threadMemoryId = createUniqueUuid(this.runtime, `thread-${threadStartId}`);\n\n const threadPayload = {\n runtime: this.runtime,\n source: \"x\",\n entityId,\n conversationId: threadStartId,\n roomId: roomId,\n memory: memory,\n post: post,\n threadId: threadStartId,\n threadMemoryId: threadMemoryId,\n };\n\n // Check if this is a reply to an existing thread\n const previousThreadMemory = await this.runtime.getMemoryById(threadMemoryId);\n if (previousThreadMemory) {\n // This is a reply to an existing thread\n this.runtime.emitEvent(XEventTypes.THREAD_UPDATED, threadPayload);\n } else if (post.thread[0].id === post.id) {\n // This is the start of a new thread\n this.runtime.emitEvent(XEventTypes.THREAD_CREATED, threadPayload);\n }\n }\n\n await this.handlePost({\n post,\n message: memory,\n thread: post.thread,\n });\n\n // Update the last checked post ID after processing each post\n this.client.lastCheckedPostId = BigInt(post.id);\n }\n }\n }\n\n /**\n * Handles X interactions such as likes, reposts, and quotes.\n * For each interaction:\n * - Creates a memory object\n * - Emits platform-specific events (LIKE_RECEIVED, REPOST_RECEIVED, QUOTE_RECEIVED)\n * - Emits a generic REACTION_RECEIVED event with metadata\n */\n async handleInteraction(interaction: XInteractionPayload) {\n if (interaction?.targetPost?.conversationId) {\n const memory = this.createMemoryObject(\n interaction.type,\n `${interaction.id}-${interaction.type}`,\n interaction.userId,\n interaction.targetPost.conversationId\n );\n\n await createMemorySafe(this.runtime, memory, \"messages\");\n\n // Create message for reaction\n const reactionMessage: XMemory = {\n id: createUniqueUuid(this.runtime, interaction.targetPostId || \"\"),\n content: {\n text: interaction.targetPost.text || \"\",\n source: \"x\",\n },\n entityId: createUniqueUuid(this.runtime, interaction.userId || \"\"),\n roomId: createUniqueUuid(this.runtime, interaction.targetPost.conversationId || \"\"),\n agentId: this.runtime.agentId,\n createdAt: Date.now(),\n };\n\n // Emit specific event for each type of interaction\n switch (interaction.type) {\n case \"like\": {\n const payload: XLikeReceivedPayload = {\n runtime: this.runtime,\n post: interaction.targetPost,\n user: {\n id: interaction.userId,\n username: interaction.username,\n name: interaction.name,\n },\n source: \"x\",\n };\n this.runtime.emitEvent(XEventTypes.LIKE_RECEIVED, payload);\n break;\n }\n case \"repost\": {\n const payload: XRepostReceivedPayload = {\n runtime: this.runtime,\n post: interaction.targetPost,\n repostId: interaction.repostId || interaction.id,\n user: {\n id: interaction.userId,\n username: interaction.username,\n name: interaction.name,\n },\n source: \"x\",\n };\n this.runtime.emitEvent(XEventTypes.REPOST_RECEIVED, payload);\n break;\n }\n case \"quote\": {\n const payload: XQuoteReceivedPayload = {\n runtime: this.runtime,\n quotedPost: interaction.targetPost,\n quotePost: interaction.quotePost || interaction.targetPost,\n user: {\n id: interaction.userId,\n username: interaction.username,\n name: interaction.name,\n },\n message: reactionMessage,\n callback: async () => [],\n reaction: {\n type: \"quote\",\n entityId: createUniqueUuid(this.runtime, interaction.userId),\n },\n source: \"x\",\n };\n this.runtime.emitEvent(XEventTypes.QUOTE_RECEIVED, payload);\n break;\n }\n }\n\n // Also emit generic REACTION_RECEIVED event\n this.runtime.emitEvent(EventType.REACTION_RECEIVED, {\n runtime: this.runtime,\n entityId: createUniqueUuid(this.runtime, interaction.userId),\n roomId: createUniqueUuid(this.runtime, interaction.targetPost.conversationId),\n world: createUniqueUuid(this.runtime, interaction.userId),\n message: reactionMessage,\n source: \"x\",\n metadata: {\n type: interaction.type,\n targetPostId: interaction.targetPostId,\n username: interaction.username,\n userId: interaction.userId,\n timestamp: Date.now(),\n quoteText: interaction.type === \"quote\" ? interaction.quotePost?.text || \"\" : undefined,\n },\n callback: async () => [],\n } as MessagePayload);\n }\n }\n\n /**\n * Creates a memory object for a given X interaction.\n *\n * @param {string} type - The type of interaction (e.g., 'like', 'repost', 'quote').\n * @param {string} id - The unique identifier for the interaction.\n * @param {string} userId - The ID of the user who initiated the interaction.\n * @param {string} conversationId - The ID of the conversation context.\n * @returns {XInteractionMemory} The constructed memory object.\n */\n createMemoryObject(\n type: string,\n id: string,\n userId: string,\n conversationId: string\n ): XInteractionMemory {\n return {\n id: createUniqueUuid(this.runtime, id),\n agentId: this.runtime.agentId,\n entityId: createUniqueUuid(this.runtime, userId),\n roomId: createUniqueUuid(this.runtime, conversationId),\n content: {\n type,\n source: \"x\",\n },\n createdAt: Date.now(),\n };\n }\n\n /**\n * Asynchronously handles a post by generating a response and sending it.\n * This method processes the post content, determines if a response is needed,\n * generates appropriate response text, and sends the post reply.\n *\n * @param {object} params - The parameters object containing the post, message, and thread.\n * @param {Post} params.post - The post object to handle.\n * @param {Memory} params.message - The memory object associated with the post.\n * @param {Post[]} params.thread - The array of posts in the thread.\n * @returns {object} - An object containing the text of the response and any relevant actions.\n */\n async handlePost({\n post,\n message,\n thread,\n }: {\n post: ClientPost;\n message: Memory;\n thread: ClientPost[];\n }) {\n if (!message.content.text) {\n logger.log(\"Skipping Post with no text\", post.id);\n return { text: \"\", actions: [\"IGNORE\"] };\n }\n\n // Create a callback for handling the response\n const callback: HandlerCallback = async (response: Content, postId?: string) => {\n try {\n if (!response.text) {\n logger.warn(\"No text content in response, skipping post reply\");\n return [];\n }\n\n const postToReplyTo = postId || post.id;\n\n if (this.isDryRun) {\n logger.info(`[DRY RUN] Would have replied to ${post.username} with: ${response.text}`);\n return [];\n }\n\n logger.info(`Replying to post ${postToReplyTo}`);\n\n // Create the actual post using the X API through the client\n const postResult = await sendPost(this.client, response.text, [], postToReplyTo);\n\n if (!postResult) {\n throw new Error(\"Failed to get post result from response\");\n }\n\n // Create memory for our response\n const responsePostId = postResult.id || postResult.data?.id || Date.now().toString();\n const responseId = createUniqueUuid(this.runtime, responsePostId);\n const responseMemory: Memory = {\n id: responseId,\n entityId: this.runtime.agentId,\n agentId: this.runtime.agentId,\n roomId: message.roomId,\n content: {\n text: response.text,\n source: \"x\",\n inReplyTo: message.id,\n },\n createdAt: Date.now(),\n };\n\n await createMemorySafe(this.runtime, responseMemory, \"messages\");\n\n // Return the created memory\n return [responseMemory];\n } catch (error) {\n logger.error(\n \"Error in post reply callback:\",\n error instanceof Error ? error.message : String(error)\n );\n return [];\n }\n };\n\n const xUserId = post.userId || \"\";\n const entityId = createUniqueUuid(this.runtime, xUserId);\n const xUsername = post.username || \"\";\n\n // Add X-specific metadata to message\n if (!message.metadata || Array.isArray(message.metadata)) {\n message.metadata = { type: MemoryType.CUSTOM };\n }\n const metadataObj =\n typeof message.metadata === \"object\" && !Array.isArray(message.metadata)\n ? message.metadata\n : { type: MemoryType.CUSTOM };\n\n // Create properly typed CustomMetadata with X-specific properties\n // CustomMetadata allows additional properties via index signature\n message.metadata = {\n ...metadataObj,\n type: (metadataObj.type as MemoryType) || MemoryType.CUSTOM,\n x: {\n entityId: entityId as string,\n xUserId: xUserId as string,\n xUsername: xUsername as string,\n thread: thread,\n },\n } as unknown as MemoryMetadata;\n\n // Process message through message service\n const result = await this.runtime.messageService?.handleMessage(\n this.runtime,\n message,\n callback\n );\n\n // Extract response for X posting\n const response = result?.responseMessages || [];\n\n // Check if response is an array of memories and extract the text\n let responseText = \"\";\n if (Array.isArray(response) && response.length > 0) {\n const firstResponse = response[0];\n if (firstResponse?.content?.text) {\n responseText = firstResponse.content.text;\n }\n }\n\n return {\n text: responseText,\n actions: responseText ? [\"REPLY\"] : [\"IGNORE\"],\n };\n }\n}\n",
109
- "/**\n * Auto-generated prompt templates\n * DO NOT EDIT - Generated from ../../../../prompts/*.txt\n *\n * These prompts use Handlebars-style template syntax:\n * - {{variableName}} for simple substitution\n * - {{#each items}}...{{/each}} for iteration\n * - {{#if condition}}...{{/if}} for conditionals\n */\n\nexport const generatePostTemplate = `You are {{agentName}}.\n{{bio}}\n\nGenerate a post based on: {{request}}\n\nStyle:\n- Be specific, opinionated, authentic\n- No generic content or platitudes\n- Share insights, hot takes, unique perspectives\n- Conversational and punchy\n- Under 280 characters\n- Skip hashtags unless essential\n\nTopics: {{topics}}\n\nPost:`;\n\nexport const GENERATE_POST_TEMPLATE = generatePostTemplate;\n\nexport const messageHandlerTemplate = `{{agentName}} is replying to you:\n{{senderName}}: {{userMessage}}\n\n# Task: Generate a reply for {{agentName}}.\n{{providers}}\n\n# Instructions: Write a thoughtful response to {{senderName}} that is appropriate and relevant to their message. Do not including any thinking, self-reflection or internal dialog in your response.`;\n\nexport const MESSAGE_HANDLER_TEMPLATE = messageHandlerTemplate;\n\nexport const quoteTweetTemplate = `# Task: Write a quote post in the voice, style, and perspective of {{agentName}} @{{xUserName}}.\n\n{{bio}}\n{{postDirections}}\n\n<response>\n <thought>Your thought here, explaining why the quote post is meaningful or how it connects to what {{agentName}} cares about</thought>\n <post>The quote post content here, under 280 characters, without emojis, no questions</post>\n</response>\n\nYour quote post should be:\n- A reaction, agreement, disagreement, or expansion of the original post\n- Personal and unique to {{agentName}}'s style and point of view\n- 1 to 3 sentences long, chosen at random\n- No questions, no emojis, concise\n- Use \"\\\\n\\\\n\" (double spaces) between multiple sentences\n- Max 280 characters including line breaks\n\nYour output must ONLY contain the XML block.`;\n\nexport const QUOTE_TWEET_TEMPLATE = quoteTweetTemplate;\n\nexport const replyTweetTemplate = `# Task: Write a reply post in the voice, style, and perspective of {{agentName}} @{{xUserName}}.\n\n{{bio}}\n{{postDirections}}\n\n<response>\n <thought>Your thought here, explaining why this reply is meaningful or how it connects to what {{agentName}} cares about</thought>\n <post>The reply post content here, under 280 characters, without emojis, no questions</post>\n</response>\n\nYour reply should be:\n- A direct response, agreement, disagreement, or personal take on the original post\n- Reflective of {{agentName}}'s unique voice and values\n- 1 to 2 sentences long, chosen at random\n- No questions, no emojis, concise\n- Use \"\\\\n\\\\n\" (double spaces) between multiple sentences if needed\n- Max 280 characters including line breaks\n\nYour output must ONLY contain the XML block.`;\n\nexport const REPLY_TWEET_TEMPLATE = replyTweetTemplate;\n\nexport const xActionTemplate = `# INSTRUCTIONS: Determine actions for {{agentName}} (@{{xUserName}}) based on:\n{{bio}}\n{{postDirections}}\n\nGuidelines:\n- Engage with content that relates to character's interests and expertise\n- Direct mentions should be prioritized when relevant\n- Consider engaging with:\n - Content directly related to your topics\n - Interesting discussions you can contribute to\n - Questions you can help answer\n - Content from users you've interacted with before\n- Skip content that is:\n - Completely off-topic or spam\n - Inflammatory or highly controversial (unless it's your area)\n - Pure marketing/promotional with no value\n\nActions (respond only with tags):\n[LIKE] - Content is relevant and interesting (7/10 or higher)\n[REPOST] - Content is valuable and worth sharing (8/10 or higher)\n[QUOTE] - You can add meaningful commentary (7.5/10 or higher)\n[REPLY] - You can contribute helpful insights (7/10 or higher)`;\n\nexport const X_ACTION_TEMPLATE = xActionTemplate;\n\nexport const xMessageHandlerTemplate = `# Task: Generate dialog and actions for {{agentName}}.\n{{providers}}\nHere is the current post text again. Remember to include an action if the current post text includes a prompt that asks for one of the available actions mentioned above (does not need to be exact)\n{{currentPost}}\n{{imageDescriptions}}\n\n# Instructions: Write the next message for {{agentName}}. Include the appropriate action from the list: {{actionNames}}\nResponse format should be formatted in a valid JSON block like this:\n\\`\\`\\`json\n{ \"thought\": \"<string>\", \"name\": \"{{agentName}}\", \"text\": \"<string>\", \"action\": \"<string>\" }\n\\`\\`\\`\n\nThe \"action\" field should be one of the options in [Available Actions] and the \"text\" field should be the response you want to send. Do not including any thinking or internal reflection in the \"text\" field. \"thought\" should be a short description of what the agent is thinking about before responding, inlcuding a brief justification for the response.`;\n\nexport const X_MESSAGE_HANDLER_TEMPLATE = xMessageHandlerTemplate;\n\n",
109
+ "/**\n * Auto-generated prompt templates\n * DO NOT EDIT - Generated from ../../../../prompts/*.txt\n *\n * These prompts use Handlebars-style template syntax:\n * - {{variableName}} for simple substitution\n * - {{#each items}}...{{/each}} for iteration\n * - {{#if condition}}...{{/if}} for conditionals\n */\n\nexport const generatePostTemplate = `You are {{agentName}}.\n{{bio}}\n\nGenerate a post based on: {{request}}\n\nStyle:\n- Be specific, opinionated, authentic\n- No generic content or platitudes\n- Share insights, hot takes, unique perspectives\n- Conversational and punchy\n- Under 280 characters\n- Skip hashtags unless essential\n\nTopics: {{topics}}\n\nPost:`;\n\nexport const GENERATE_POST_TEMPLATE = generatePostTemplate;\n\nexport const messageHandlerTemplate = `{{agentName}} is replying to you:\n{{senderName}}: {{userMessage}}\n\n# Task: Generate a reply for {{agentName}}.\n{{providers}}\n\n# Instructions: Write a thoughtful response to {{senderName}} that is appropriate and relevant to their message. Do not including any thinking, self-reflection or internal dialog in your response.`;\n\nexport const MESSAGE_HANDLER_TEMPLATE = messageHandlerTemplate;\n\nexport const quoteTweetTemplate = `# Task: Write a quote post in the voice, style, and perspective of {{agentName}} @{{xUserName}}.\n\n{{bio}}\n{{postDirections}}\n\n<response>\n <thought>Your thought here, explaining why the quote post is meaningful or how it connects to what {{agentName}} cares about</thought>\n <post>The quote post content here, under 280 characters, without emojis, no questions</post>\n</response>\n\nYour quote post should be:\n- A reaction, agreement, disagreement, or expansion of the original post\n- Personal and unique to {{agentName}}'s style and point of view\n- 1 to 3 sentences long, chosen at random\n- No questions, no emojis, concise\n- Use \"\\\\n\\\\n\" (double spaces) between multiple sentences\n- Max 280 characters including line breaks\n\nYour output must ONLY contain the XML block.`;\n\nexport const QUOTE_TWEET_TEMPLATE = quoteTweetTemplate;\n\nexport const replyTweetTemplate = `# Task: Write a reply post in the voice, style, and perspective of {{agentName}} @{{xUserName}}.\n\n{{bio}}\n{{postDirections}}\n\n<response>\n <thought>Your thought here, explaining why this reply is meaningful or how it connects to what {{agentName}} cares about</thought>\n <post>The reply post content here, under 280 characters, without emojis, no questions</post>\n</response>\n\nYour reply should be:\n- A direct response, agreement, disagreement, or personal take on the original post\n- Reflective of {{agentName}}'s unique voice and values\n- 1 to 2 sentences long, chosen at random\n- No questions, no emojis, concise\n- Use \"\\\\n\\\\n\" (double spaces) between multiple sentences if needed\n- Max 280 characters including line breaks\n\nYour output must ONLY contain the XML block.`;\n\nexport const REPLY_TWEET_TEMPLATE = replyTweetTemplate;\n\nexport const xActionTemplate = `# INSTRUCTIONS: Determine actions for {{agentName}} (@{{xUserName}}) based on:\n{{bio}}\n{{postDirections}}\n\nGuidelines:\n- Engage with content that relates to character's interests and expertise\n- Direct mentions should be prioritized when relevant\n- Consider engaging with:\n - Content directly related to your topics\n - Interesting discussions you can contribute to\n - Questions you can help answer\n - Content from users you've interacted with before\n- Skip content that is:\n - Completely off-topic or spam\n - Inflammatory or highly controversial (unless it's your area)\n - Pure marketing/promotional with no value\n\nActions (respond only with tags):\n[LIKE] - Content is relevant and interesting (7/10 or higher)\n[REPOST] - Content is valuable and worth sharing (8/10 or higher)\n[QUOTE] - You can add meaningful commentary (7.5/10 or higher)\n[REPLY] - You can contribute helpful insights (7/10 or higher)`;\n\nexport const X_ACTION_TEMPLATE = xActionTemplate;\n\nexport const xMessageHandlerTemplate = `# Task: Generate dialog and actions for {{agentName}}.\n{{providers}}\nHere is the current post text again. Remember to include an action if the current post text includes a prompt that asks for one of the available actions mentioned above (does not need to be exact)\n{{currentPost}}\n{{imageDescriptions}}\n\n# Instructions: Write the next message for {{agentName}}. Include the appropriate action from the list: {{actionNames}}\nResponse format should be formatted in a valid JSON block like this:\n\\`\\`\\`json\n{ \"thought\": \"<string>\", \"name\": \"{{agentName}}\", \"text\": \"<string>\", \"action\": \"<string>\" }\n\\`\\`\\`\n\nThe \"action\" field should be one of the options in [Available Actions] and the \"text\" field should be the response you want to send. Do not including any thinking or internal reflection in the \"text\" field. \"thought\" should be a short description of what the agent is thinking about before responding, inlcuding a brief justification for the response.`;\n\nexport const X_MESSAGE_HANDLER_TEMPLATE = xMessageHandlerTemplate;\n",
110
110
  "import {\n ChannelType,\n createUniqueUuid,\n type IAgentRuntime,\n logger,\n type Memory,\n ModelType,\n parseBooleanFromText,\n type UUID,\n} from \"@elizaos/core\";\nimport type { ClientBase } from \"./base\";\nimport { getRandomInterval } from \"./environment\";\nimport type { MediaData, PostResponse } from \"./types\";\nimport { sendPost } from \"./utils\";\nimport {\n addToRecentPosts,\n createMemorySafe,\n ensureXContext,\n isDuplicatePost,\n} from \"./utils/memory\";\nimport { getSetting } from \"./utils/settings\";\n/**\n * Class representing an X post client for generating and posting.\n */\nexport class XPostClient {\n client: ClientBase;\n runtime: IAgentRuntime;\n xUsername: string;\n private isDryRun: boolean;\n private state: Record<string, unknown>;\n private isRunning: boolean = false;\n private isPosting: boolean = false; // Add lock to prevent concurrent posting\n\n /**\n * Creates an instance of XPostClient.\n * @param {ClientBase} client - The client instance.\n * @param {IAgentRuntime} runtime - The runtime instance.\n * @param {Record<string, unknown>} state - The state object containing configuration settings\n */\n constructor(client: ClientBase, runtime: IAgentRuntime, state: Record<string, unknown>) {\n this.client = client;\n this.state = state;\n this.runtime = runtime;\n const dryRunSetting =\n typeof this.state?.X_DRY_RUN === \"string\" ||\n typeof this.state?.X_DRY_RUN === \"boolean\" ||\n this.state?.X_DRY_RUN === true ||\n this.state?.X_DRY_RUN === false\n ? this.state.X_DRY_RUN\n : getSetting(this.runtime, \"X_DRY_RUN\");\n this.isDryRun = parseBooleanFromText(dryRunSetting);\n\n // Get X username from settings\n const usernameSetting = getSetting(this.runtime, \"X_USERNAME\") || this.state?.X_USERNAME;\n this.xUsername = typeof usernameSetting === \"string\" ? usernameSetting : \"\";\n\n // Log configuration on initialization\n logger.log(\"X Post Client Configuration:\");\n logger.log(`- Dry Run Mode: ${this.isDryRun ? \"Enabled\" : \"Disabled\"}`);\n\n const postIntervalMin = parseInt(\n (typeof this.state?.X_POST_INTERVAL_MIN === \"string\"\n ? this.state.X_POST_INTERVAL_MIN\n : null) ||\n (getSetting(this.runtime, \"X_POST_INTERVAL_MIN\") as string) ||\n \"90\",\n 10\n );\n const postIntervalMax = parseInt(\n (typeof this.state?.X_POST_INTERVAL_MAX === \"string\"\n ? this.state.X_POST_INTERVAL_MAX\n : null) ||\n (getSetting(this.runtime, \"X_POST_INTERVAL_MAX\") as string) ||\n \"150\",\n 10\n );\n logger.log(`- Post Interval: ${postIntervalMin}-${postIntervalMax} minutes (randomized)`);\n }\n\n /**\n * Stops the X post client\n */\n async stop() {\n logger.log(\"Stopping X post client...\");\n this.isRunning = false;\n }\n\n /**\n * Starts the X post client, setting up a loop to periodically generate new posts.\n */\n async start() {\n logger.log(\"Starting X post client...\");\n this.isRunning = true;\n\n const generateNewPostLoop = async () => {\n if (!this.isRunning) {\n logger.log(\"X post client stopped, exiting loop\");\n return;\n }\n\n await this.generateNewPost();\n\n if (!this.isRunning) {\n logger.log(\"X post client stopped after post, exiting loop\");\n return;\n }\n\n // Get random post interval in minutes\n const postIntervalMinutes = getRandomInterval(this.runtime, \"post\");\n\n // Convert to milliseconds\n const interval = postIntervalMinutes * 60 * 1000;\n\n logger.info(`Next post scheduled in ${postIntervalMinutes.toFixed(1)} minutes`);\n\n // Wait for the interval AFTER generating the post\n await new Promise((resolve) => setTimeout(resolve, interval));\n\n if (this.isRunning) {\n // Schedule the next iteration\n generateNewPostLoop();\n }\n };\n\n // Wait a bit longer to ensure profile is loaded\n await new Promise((resolve) => setTimeout(resolve, 5000));\n\n // Check if we should generate a post immediately\n const postImmediately =\n typeof this.state?.X_POST_IMMEDIATELY === \"string\" ||\n typeof this.state?.X_POST_IMMEDIATELY === \"boolean\"\n ? this.state.X_POST_IMMEDIATELY\n : (getSetting(this.runtime, \"X_POST_IMMEDIATELY\") as string);\n\n if (parseBooleanFromText(postImmediately)) {\n logger.info(\"X_POST_IMMEDIATELY is true, generating initial post now\");\n // Try multiple times in case profile isn't ready\n let retries = 0;\n while (retries < 5) {\n const success = await this.generateNewPost();\n if (success) break;\n\n retries++;\n logger.info(`Retrying immediate post (attempt ${retries}/5)...`);\n await new Promise((resolve) => setTimeout(resolve, 3000));\n }\n }\n\n // Start the regular generation loop\n generateNewPostLoop();\n }\n\n /**\n * Handles the creation and posting of a post by emitting standardized events.\n * This approach aligns with our platform-independent architecture.\n * @returns {Promise<boolean>} true if post was posted successfully\n */\n async generateNewPost(): Promise<boolean> {\n logger.info(\"Attempting to generate new post...\");\n\n // Prevent concurrent posting\n if (this.isPosting) {\n logger.info(\"Already posting, skipping concurrent attempt\");\n return false;\n }\n\n this.isPosting = true;\n\n try {\n // Create the timeline room ID for storing the post\n const userId = this.client.profile?.id;\n if (!userId) {\n logger.error(\"Cannot generate post: X profile not available\");\n this.isPosting = false; // Reset flag\n return false;\n }\n\n logger.info(`Generating post for user: ${this.client.profile?.username} (${userId})`);\n\n // Create standardized world and room IDs\n const _worldId = createUniqueUuid(this.runtime, userId) as UUID;\n const roomId = createUniqueUuid(this.runtime, `${userId}-home`) as UUID;\n\n // Generate post content using the runtime's model\n const state = await this.runtime\n .composeState({\n agentId: this.runtime.agentId,\n entityId: this.runtime.agentId,\n roomId,\n content: { text: \"\", type: \"post\" },\n createdAt: Date.now(),\n } as Memory)\n .catch((error) => {\n logger.warn(\"Error composing state, using minimal state:\", error);\n // Return minimal state if composition fails\n return {\n agentId: this.runtime.agentId,\n recentMemories: [],\n values: {},\n };\n });\n\n // Create a prompt for post generation\n const postPrompt = `You are ${this.runtime.character.name}.\n${this.runtime.character.bio}\n\nCRITICAL: Generate a post that sounds like YOU, not a generic motivational poster or LinkedIn influencer.\n\n${\n this.runtime.character.messageExamples && this.runtime.character.messageExamples.length > 0\n ? `\nExample posts that capture your voice:\n${(\n this.runtime.character.messageExamples as Array<{\n examples?: Array<{ content?: { text?: string } }>;\n }>\n)\n .flatMap((group) => group.examples ?? [])\n .map((example) => example.content?.text ?? \"\")\n .filter((text) => text.length > 0)\n .slice(0, 5)\n .join(\"\\n\")}\n`\n : \"\"\n}\n\nStyle guidelines:\n- Be authentic, opinionated, and specific - no generic platitudes\n- Use your unique voice and perspective\n- Share hot takes, unpopular opinions, or specific insights\n- Be conversational, not preachy\n- If you use emojis, use them sparingly and purposefully\n- Length: 50-280 characters (keep it punchy)\n- NO hashtags unless absolutely essential\n- NO generic motivational content\n\nYour interests: ${this.runtime.character.topics?.join(\", \") || \"technology, crypto, AI\"}\n\n${\n this.runtime.character.style\n ? `Your style: ${\n typeof this.runtime.character.style === \"object\"\n ? this.runtime.character.style.all?.join(\", \") ||\n JSON.stringify(this.runtime.character.style)\n : this.runtime.character.style\n }`\n : \"\"\n}\n\nRecent context:\n${\n Array.isArray(state.recentMemories) && state.recentMemories.length > 0\n ? state.recentMemories\n .slice(0, 3)\n .map((m: Memory) => m.content?.text || \"\")\n .join(\"\\n\") || \"No recent context\"\n : \"No recent context\"\n}\n\nGenerate a single post that sounds like YOU would actually write it:`;\n\n // Use the runtime's model to generate post content\n const generatedContent = await this.runtime.useModel(ModelType.TEXT_SMALL, {\n prompt: postPrompt,\n temperature: 0.9, // Increased for more creativity\n maxTokens: 100,\n });\n\n const postText = generatedContent.trim();\n\n if (!postText || postText.length === 0) {\n logger.error(\"Generated empty post content\");\n return false;\n }\n\n if (postText.includes(\"Error: Missing\")) {\n logger.error(\"Error in generated content:\", postText);\n return false;\n }\n\n // Validate post length\n if (postText.length > 280) {\n logger.warn(`Generated post too long (${postText.length} chars), truncating...`);\n // Truncate to the last complete sentence within 280 chars\n const sentences = postText.match(/[^.!?]+[.!?]+/g) || [postText];\n let truncated = \"\";\n for (const sentence of sentences) {\n if ((truncated + sentence).length <= 280) {\n truncated += sentence;\n } else {\n break;\n }\n }\n const finalPost = truncated.trim() || `${postText.substring(0, 277)}...`;\n logger.info(`Truncated post: ${finalPost}`);\n\n // Post the truncated post\n if (this.isDryRun) {\n logger.info(`[DRY RUN] Would post: ${finalPost}`);\n return false;\n }\n\n const result = await this.postToX(finalPost, []);\n\n if (result === null) {\n logger.info(\"Skipped posting duplicate post\");\n return false;\n }\n\n const postId = result.id ?? result.data?.id ?? result.data?.data?.id;\n logger.info(`Post created successfully! ID: ${postId}`);\n\n // Don't save to memory if room creation might fail\n logger.info(\"Post created successfully (memory saving disabled due to room constraints)\");\n return true;\n }\n\n logger.info(`Generated post: ${postText}`);\n\n // Post the post\n if (this.isDryRun) {\n logger.info(`[DRY RUN] Would post: ${postText}`);\n return false;\n }\n\n const result = await this.postToX(postText, []);\n\n // If result is null, it means we detected a duplicate post and skipped posting\n if (result === null) {\n logger.info(\"Skipped posting duplicate post\");\n return false;\n }\n\n const postId = result.id ?? result.data?.id ?? result.data?.data?.id;\n logger.info(`Post created successfully! ID: ${postId}`);\n\n if (result && postId) {\n const postedPostId = createUniqueUuid(this.runtime, postId);\n\n try {\n // Ensure context exists with error handling\n const context = await ensureXContext(this.runtime, {\n userId,\n username: this.client.profile?.username || \"unknown\",\n conversationId: `${userId}-home`,\n });\n\n // Create memory for the posted post with retry logic\n const postedMemory: Memory = {\n id: postedPostId,\n entityId: this.runtime.agentId,\n agentId: this.runtime.agentId,\n roomId: context.roomId,\n content: {\n text: postText,\n source: \"x\",\n channelType: ChannelType.FEED,\n type: \"post\",\n metadata: {\n postId,\n postedAt: Date.now(),\n },\n },\n createdAt: Date.now(),\n };\n\n await createMemorySafe(this.runtime, postedMemory, \"messages\");\n logger.info(\"Post created and saved to memory successfully\");\n } catch (error) {\n logger.error(\n \"Failed to save post memory:\",\n error instanceof Error ? error.message : String(error)\n );\n // Don't fail the post creation if memory creation fails\n }\n\n return true;\n } else {\n logger.warn(\"Post generation returned no result\");\n return false;\n }\n } catch (error) {\n logger.error(\n \"Error generating post:\",\n error instanceof Error ? error.message : String(error)\n );\n return false;\n } finally {\n this.isPosting = false;\n }\n }\n\n /**\n * Posts content to X\n * @param {string} text The post text to create\n * @param {MediaData[]} mediaData Optional media to attach to the post\n * @returns {Promise<PostResponse | null>} The result from the X API\n */\n private async postToX(text: string, mediaData: MediaData[] = []): Promise<PostResponse | null> {\n // Check if this post is a duplicate of recent posts\n const username = this.client.profile?.username;\n if (!username) {\n logger.error(\"No profile username available\");\n return null;\n }\n\n // Check for duplicates in recent posts\n const isDuplicate = await isDuplicatePost(this.runtime, username, text);\n if (isDuplicate) {\n logger.warn(\"Post is a duplicate of a recent post. Skipping to avoid duplicate.\");\n return null;\n }\n\n // Handle media uploads if needed\n const _mediaIds: string[] = [];\n\n if (mediaData && mediaData.length > 0) {\n logger.warn(\"Media upload not currently supported with the modern X API\");\n }\n\n const result = await sendPost(this.client, text, mediaData);\n\n // Add to recent posts cache to prevent future duplicates\n await addToRecentPosts(this.runtime, username, text);\n\n return result;\n }\n}\n",
111
111
  "import {\n ChannelType,\n composePromptFromState,\n createUniqueUuid,\n type IAgentRuntime,\n logger,\n type Memory,\n ModelType,\n parseKeyValueXml,\n type State,\n type UUID,\n} from \"@elizaos/core\";\nimport type { ClientBase } from \"./base\";\nimport type { Client, Post } from \"./client/index\";\nimport { quotePostTemplate, replyPostTemplate, xActionTemplate } from \"./templates\";\nimport type { ActionResponse } from \"./types\";\nimport { parseActionResponseFromText, sendPost } from \"./utils\";\nimport { createMemorySafe, ensureXContext, isPostProcessed } from \"./utils/memory\";\nimport { getSetting } from \"./utils/settings\";\nimport { getEpochMs } from \"./utils/time\";\n\nenum TIMELINE_TYPE {\n ForYou = \"foryou\",\n Following = \"following\",\n}\n\nexport class XTimelineClient {\n client: ClientBase;\n xClient: Client;\n runtime: IAgentRuntime;\n isDryRun: boolean;\n timelineType: TIMELINE_TYPE;\n private state: Record<string, unknown>;\n private isRunning: boolean = false;\n\n constructor(client: ClientBase, runtime: IAgentRuntime, state: Record<string, unknown>) {\n this.client = client;\n this.xClient = client.xClient;\n this.runtime = runtime;\n this.state = state;\n\n const dryRunSetting =\n this.state?.X_DRY_RUN ?? getSetting(this.runtime, \"X_DRY_RUN\") ?? process.env.X_DRY_RUN;\n this.isDryRun =\n dryRunSetting === true ||\n dryRunSetting === \"true\" ||\n (typeof dryRunSetting === \"string\" && dryRunSetting.toLowerCase() === \"true\");\n\n // Load timeline mode from runtime settings or use default\n const timelineMode = getSetting(this.runtime, \"X_TIMELINE_MODE\") ?? process.env.X_TIMELINE_MODE;\n this.timelineType =\n timelineMode === TIMELINE_TYPE.Following ? TIMELINE_TYPE.Following : TIMELINE_TYPE.ForYou;\n }\n\n async start() {\n logger.info(\"Starting X timeline client...\");\n this.isRunning = true;\n\n const handleXTimelineLoop = () => {\n if (!this.isRunning) {\n logger.info(\"X timeline client stopped, exiting loop\");\n return;\n }\n\n // Use standard engagement interval\n const engagementIntervalMinutes = parseInt(\n (typeof this.state?.X_ENGAGEMENT_INTERVAL === \"string\"\n ? this.state.X_ENGAGEMENT_INTERVAL\n : null) ||\n (getSetting(this.runtime, \"X_ENGAGEMENT_INTERVAL\") as string) ||\n process.env.X_ENGAGEMENT_INTERVAL ||\n \"30\",\n 10\n );\n const actionInterval = engagementIntervalMinutes * 60 * 1000;\n\n logger.info(`Timeline client will check every ${engagementIntervalMinutes} minutes`);\n\n this.handleTimeline();\n\n if (this.isRunning) {\n setTimeout(handleXTimelineLoop, actionInterval);\n }\n };\n handleXTimelineLoop();\n }\n\n async stop() {\n logger.info(\"Stopping X timeline client...\");\n this.isRunning = false;\n }\n\n async getTimeline(count: number): Promise<Post[]> {\n const xUsername = this.client.profile?.username;\n const homeTimeline =\n this.timelineType === TIMELINE_TYPE.Following\n ? await this.xClient.fetchFollowingTimeline(count, [])\n : await this.xClient.fetchHomeTimeline(count, []);\n\n // The timeline methods now return Post objects directly from v2 API\n return homeTimeline.filter((post) => post.username !== xUsername); // do not perform action on self-posts\n }\n\n createPostId(runtime: IAgentRuntime, post: Post) {\n if (!post.id) {\n throw new Error(\"Post ID is required\");\n }\n return createUniqueUuid(runtime, post.id);\n }\n\n formMessage(runtime: IAgentRuntime, post: Post) {\n if (!post.id || !post.userId || !post.conversationId) {\n throw new Error(\"Post missing required fields: id, userId, or conversationId\");\n }\n return {\n id: this.createPostId(runtime, post),\n agentId: runtime.agentId,\n content: {\n text: post.text,\n url: post.permanentUrl,\n imageUrls: post.photos?.map((photo) => photo.url) || [],\n inReplyTo: post.inReplyToStatusId\n ? createUniqueUuid(runtime, post.inReplyToStatusId)\n : undefined,\n source: \"x\",\n channelType: ChannelType.GROUP,\n post: {\n id: post.id,\n text: post.text,\n userId: post.userId,\n username: post.username,\n timestamp: post.timestamp,\n conversationId: post.conversationId,\n } as Record<string, string | number | boolean | null | undefined>,\n },\n entityId: createUniqueUuid(runtime, post.userId),\n roomId: createUniqueUuid(runtime, post.conversationId),\n createdAt: getEpochMs(post.timestamp),\n };\n }\n\n async handleTimeline() {\n logger.info(\"Starting X timeline processing...\");\n\n const posts = await this.getTimeline(20);\n logger.info(`Fetched ${posts.length} posts from timeline`);\n\n // Use max engagements per run from environment\n const maxActionsPerCycle = parseInt(\n (getSetting(this.runtime, \"X_MAX_ENGAGEMENTS_PER_RUN\") as string) ||\n process.env.X_MAX_ENGAGEMENTS_PER_RUN ||\n \"10\",\n 10\n );\n\n const postDecisions: Array<{\n post: Post;\n actionResponse: ActionResponse;\n postState: State;\n roomId: UUID;\n }> = [];\n for (const post of posts) {\n try {\n // Check if already processed using utility\n if (!post.id) {\n logger.warn(\"Skipping post with no ID\");\n continue;\n }\n const isProcessed = await isPostProcessed(this.runtime, post.id);\n if (isProcessed) {\n logger.log(`Already processed post ID: ${post.id}`);\n continue;\n }\n\n if (!post.conversationId) {\n logger.warn(\"Skipping post with no conversationId\");\n continue;\n }\n const roomId = createUniqueUuid(this.runtime, post.conversationId);\n\n const message = this.formMessage(this.runtime, post);\n\n const state = await this.runtime.composeState(message);\n\n const actionRespondPrompt =\n composePromptFromState({\n state,\n template: this.runtime.character.templates?.xActionTemplate || xActionTemplate,\n }) +\n `\nPost:\n${post.text}\n\n# Respond with qualifying action tags only.\n\nChoose any combination of [LIKE], [REPOST], [QUOTE], and [REPLY] that are appropriate. Each action must be on its own line. Your response must only include the chosen actions.`;\n\n const actionResponse = await this.runtime.useModel(ModelType.TEXT_SMALL, {\n prompt: actionRespondPrompt,\n });\n const parsedResponse = parseActionResponseFromText(actionResponse);\n\n // Ensure a valid action response was generated\n if (!parsedResponse || !parsedResponse.actions) {\n logger.debug(`No action response generated for post ${post.id}`);\n continue;\n }\n\n postDecisions.push({\n post,\n actionResponse: parsedResponse.actions,\n postState: state,\n roomId,\n });\n\n // Limit the number of actions per cycle\n if (postDecisions.length >= maxActionsPerCycle) break;\n } catch (error) {\n logger.error(\n `Error processing post ${post.id}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n // Rank by the quality of the response\n const rankByActionRelevance = (arr: typeof postDecisions) => {\n return arr.sort((a, b) => {\n const countTrue = (obj: typeof a.actionResponse) =>\n Object.values(obj).filter(Boolean).length;\n\n const countA = countTrue(a.actionResponse);\n const countB = countTrue(b.actionResponse);\n\n // Primary sort by number of true values\n if (countA !== countB) {\n return countB - countA;\n }\n\n // Secondary sort by the \"like\" property\n if (a.actionResponse.like !== b.actionResponse.like) {\n return a.actionResponse.like ? -1 : 1;\n }\n\n // Tertiary sort keeps the remaining objects with equal weight\n return 0;\n });\n };\n // Sort the timeline based on the action decision score,\n const prioritizedPosts = rankByActionRelevance(postDecisions);\n\n logger.info(`Processing ${prioritizedPosts.length} posts with actions`);\n if (prioritizedPosts.length > 0) {\n const actionSummary = prioritizedPosts.map((td: (typeof postDecisions)[0]) => {\n const actions = [];\n if (td.actionResponse.like) actions.push(\"LIKE\");\n if (td.actionResponse.repost) actions.push(\"REPOST\");\n if (td.actionResponse.quote) actions.push(\"QUOTE\");\n if (td.actionResponse.reply) actions.push(\"REPLY\");\n return `Post ${td.post.id}: ${actions.join(\", \")}`;\n });\n logger.info(`Actions to execute:\\n${actionSummary.join(\"\\n\")}`);\n }\n\n await this.processTimelineActions(prioritizedPosts);\n logger.info(\"Timeline processing complete\");\n }\n\n private async processTimelineActions(\n postDecisions: {\n post: Post;\n actionResponse: ActionResponse;\n postState: State;\n roomId: UUID;\n }[]\n ): Promise<\n {\n postId: string;\n actionResponse: ActionResponse;\n executedActions: string[];\n }[]\n > {\n const results = [];\n\n for (const { post, actionResponse, postState: _postState, roomId } of postDecisions) {\n const postId = this.createPostId(this.runtime, post);\n const executedActions = [];\n\n // Ensure room exists before creating memory\n await this.runtime.ensureRoomExists({\n id: roomId,\n name: `X conversation ${post.conversationId}`,\n source: \"x\",\n type: ChannelType.GROUP,\n channelId: post.conversationId,\n messageServerId: createUniqueUuid(this.runtime, post.userId || \"\"),\n worldId: createUniqueUuid(this.runtime, post.userId || \"\"),\n });\n\n // Update memory with processed post using safe method\n if (!post.userId) {\n logger.warn(\"Skipping post with no userId\");\n continue;\n }\n const postMemory: Memory = {\n id: postId,\n entityId: createUniqueUuid(this.runtime, post.userId),\n content: {\n text: post.text,\n url: post.permanentUrl,\n source: \"x\",\n channelType: ChannelType.GROUP,\n post: {\n id: post.id,\n text: post.text,\n userId: post.userId,\n username: post.username,\n timestamp: post.timestamp,\n conversationId: post.conversationId,\n } as Record<string, string | number | boolean | null | undefined>,\n },\n agentId: this.runtime.agentId,\n roomId,\n createdAt: getEpochMs(post.timestamp),\n };\n\n await createMemorySafe(this.runtime, postMemory, \"messages\");\n\n try {\n // ensure world and rooms, connections, and worlds are created\n const userId = post.userId;\n if (!userId) {\n logger.warn(\"Cannot create world/entity: userId is undefined\");\n continue;\n }\n const worldId = createUniqueUuid(this.runtime, userId);\n const entityId = createUniqueUuid(this.runtime, userId);\n\n await this.ensurePostWorldContext(post, roomId, worldId, entityId);\n\n if (actionResponse.like) {\n await this.handleLikeAction(post);\n executedActions.push(\"like\");\n }\n\n if (actionResponse.repost) {\n await this.handleRepostAction(post);\n executedActions.push(\"repost\");\n }\n\n if (actionResponse.quote) {\n await this.handleQuoteAction(post);\n executedActions.push(\"quote\");\n }\n\n if (actionResponse.reply) {\n await this.handleReplyAction(post);\n executedActions.push(\"reply\");\n }\n\n if (post.id) {\n results.push({ postId: post.id, actionResponse, executedActions });\n }\n } catch (error) {\n logger.error(\n `Error processing actions for post ${post.id}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n return results;\n }\n\n private async ensurePostWorldContext(post: Post, _roomId: UUID, _worldId: UUID, _entityId: UUID) {\n try {\n // Use the utility function for consistency\n if (!post.userId || !post.username || !post.conversationId) {\n logger.warn(\"Cannot ensure context: missing required post fields\");\n return;\n }\n await ensureXContext(this.runtime, {\n userId: post.userId,\n username: post.username,\n name: post.name,\n conversationId: post.conversationId,\n });\n } catch (error) {\n logger.error(\n `Failed to ensure context for post ${post.id}:`,\n error instanceof Error ? error.message : String(error)\n );\n // Don't fail the entire timeline processing\n }\n }\n\n async handleLikeAction(post: Post) {\n try {\n if (this.isDryRun) {\n logger.log(`[DRY RUN] Would have liked post ${post.id}`);\n return;\n }\n if (!post.id) {\n logger.warn(\"Cannot like post: missing post ID\");\n return;\n }\n await this.xClient.likePost(post.id);\n logger.log(`Liked post ${post.id}`);\n } catch (error) {\n logger.error(\n `Error liking post ${post.id}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n async handleRepostAction(post: Post) {\n try {\n if (this.isDryRun) {\n logger.log(`[DRY RUN] Would have reposted post ${post.id}`);\n return;\n }\n if (!post.id) {\n logger.warn(\"Cannot repost: missing post ID\");\n return;\n }\n await this.xClient.repost(post.id);\n logger.log(`Reposted post ${post.id}`);\n } catch (error) {\n logger.error(\n `Error reposting post ${post.id}:`,\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n async handleQuoteAction(post: Post) {\n try {\n const message = this.formMessage(this.runtime, post);\n\n const state = await this.runtime.composeState(message);\n\n const quotePrompt =\n composePromptFromState({\n state,\n template: this.runtime.character.templates?.quotePostTemplate || quotePostTemplate,\n }) +\n `\nYou are responding to this post:\n${post.text}`;\n\n const quoteResponse = await this.runtime.useModel(ModelType.TEXT_SMALL, {\n prompt: quotePrompt,\n });\n const responseObject = parseKeyValueXml(quoteResponse);\n\n const postText = responseObject?.post;\n if (postText && typeof postText === \"string\") {\n if (this.isDryRun) {\n logger.log(`[DRY RUN] Would have quoted post ${post.id} with: ${postText}`);\n return;\n }\n\n if (!post.id) {\n logger.error(\"Cannot send quote post: post.id is undefined\");\n return;\n }\n const postTextValue = postText; // Capture for closure\n const postIdValue = post.id; // Capture for closure\n const result = await this.client.requestQueue.add(\n async () => await this.xClient.sendQuotePost(postTextValue, postIdValue)\n );\n\n const body = (await result.json()) as {\n data?: {\n create_post?: { post_results?: { result?: { id?: string } } };\n };\n id?: string;\n };\n\n const postResult = body?.data?.create_post?.post_results?.result || body?.data || body;\n if (postResult) {\n logger.log(\"Successfully posted quote\");\n } else {\n logger.error(\"Quote post creation failed:\", body);\n }\n\n // Create memory for our response\n const postResultWithId = postResult as { id?: string };\n const postId = postResultWithId?.id || Date.now().toString();\n const responseId = createUniqueUuid(this.runtime, postId);\n const responseMemory: Memory = {\n id: responseId,\n entityId: this.runtime.agentId,\n agentId: this.runtime.agentId,\n roomId: message.roomId,\n content: {\n text: responseObject.post as string,\n inReplyTo: message.id,\n },\n createdAt: Date.now(),\n };\n\n // Save the response to memory with error handling\n await createMemorySafe(this.runtime, responseMemory, \"messages\");\n }\n } catch (error) {\n logger.error(\n \"Error in quote post generation:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n\n async handleReplyAction(post: Post) {\n try {\n const message = this.formMessage(this.runtime, post);\n\n const state = await this.runtime.composeState(message);\n\n const replyPrompt =\n composePromptFromState({\n state,\n template: this.runtime.character.templates?.replyPostTemplate || replyPostTemplate,\n }) +\n `\nYou are replying to this post:\n${post.text}`;\n\n const replyResponse = await this.runtime.useModel(ModelType.TEXT_SMALL, {\n prompt: replyPrompt,\n });\n const responseObject = parseKeyValueXml(replyResponse);\n\n if (responseObject?.post && typeof responseObject.post === \"string\") {\n if (this.isDryRun) {\n logger.log(\n `[DRY RUN] Would have replied to post ${post.id} with: ${responseObject.post}`\n );\n return;\n }\n\n const result = await sendPost(this.client, responseObject.post as string, [], post.id);\n\n if (result) {\n logger.log(\"Successfully posted reply\");\n\n // Create memory for our response\n const replyPostId = result.id || result.data?.id || Date.now().toString();\n const responseId = createUniqueUuid(this.runtime, replyPostId);\n const responseMemory: Memory = {\n id: responseId,\n entityId: this.runtime.agentId,\n agentId: this.runtime.agentId,\n roomId: message.roomId,\n content: {\n ...responseObject,\n inReplyTo: message.id,\n },\n createdAt: Date.now(),\n };\n\n // Save the response to memory with error handling\n await createMemorySafe(this.runtime, responseMemory, \"messages\");\n }\n }\n } catch (error) {\n logger.error(\n \"Error in reply generation:\",\n error instanceof Error ? error.message : String(error)\n );\n }\n }\n}\n"
112
112
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elizaos/plugin-xai",
3
- "version": "2.0.0-alpha.1",
3
+ "version": "2.0.0-alpha.2",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "dist/node/index.node.js",
@@ -53,13 +53,13 @@
53
53
  "format:check": "bunx @biomejs/biome format ."
54
54
  },
55
55
  "dependencies": {
56
- "@elizaos/core": "workspace:*",
56
+ "@elizaos/core": "2.0.0-alpha.2",
57
57
  "twitter-api-v2": "^1.28.0"
58
58
  },
59
59
  "devDependencies": {
60
+ "@biomejs/biome": "^2.3.11",
60
61
  "@types/node": "^25.0.3",
61
- "typescript": "^5.9.3",
62
- "@biomejs/biome": "^2.3.11"
62
+ "typescript": "^5.9.3"
63
63
  },
64
64
  "peerDependencies": {
65
65
  "@elizaos/core": "workspace:*"
@@ -189,5 +189,6 @@
189
189
  "sensitive": false
190
190
  }
191
191
  }
192
- }
192
+ },
193
+ "gitHead": "bc6cac8d36845d7cbde51a64307c6a57c16378ad"
193
194
  }