diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..14fdfac --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,22 @@ +documentation: + - changed-files: + - any-glob-to-any-file: + - '**/*.md' + - 'docs/**' + +source: + - changed-files: + - any-glob-to-any-file: + - 'src/**' + +config: + - changed-files: + - any-glob-to-any-file: + - '**/*.json' + - '**/.env*' + +workflows: + - changed-files: + - any-glob-to-any-file: + - '.github/**/*.yml' + - '.github/**/*.yaml' diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml new file mode 100644 index 0000000..b3f76f4 --- /dev/null +++ b/.github/workflows/auto-label.yml @@ -0,0 +1,15 @@ +name: Auto Label PRs +on: + pull_request_target: + types: [opened, synchronize] +permissions: + pull-requests: write + contents: read +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Label PR based on files changed + uses: actions/labeler@v5 + with: + sync-labels: true diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 0000000..3ed0836 --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,39 @@ +name: Issue Triage +on: + issues: + types: [opened] +permissions: + issues: write +jobs: + triage: + runs-on: ubuntu-latest + steps: + - name: Add triage label + uses: actions/github-script@v7 + with: + script: | + const labelName = 'needs-triage'; + // Ensure label exists + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: labelName, + }); + } catch (e) { + if (e?.status === 404) { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: labelName, + color: 'FBCA04', + description: 'Issue needs triage', + }); + } + } + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: [labelName], + }); diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..b3c4a5c --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,20 @@ +name: PR Checks +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main] +permissions: + pull-requests: write + contents: read +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci + - run: npm run build diff --git a/src/examples.ts b/src/examples.ts index aaf1598..3da6efe 100644 --- a/src/examples.ts +++ b/src/examples.ts @@ -33,7 +33,7 @@ async function example1_fetchAndAnalyzeMention() { console.log(`\nThread has ${thread.replies.length + 1} posts`); // Analyze with Grok - const analysis = await grok.analyzeAndDecide(mention.post.text, thread); + const analysis = await grok.analyzeAndDecide(mention.post.text, thread, mention.post.id); console.log(`\nGrok's Decision:`); console.log(` Action: ${analysis.action.type}`); @@ -139,7 +139,7 @@ async function example5_batchProcessMentions() { const thread = await xClient.fetchThread(conversationId); if (thread) { - const analysis = await grok.analyzeAndDecide(mention.post.text, thread); + const analysis = await grok.analyzeAndDecide(mention.post.text, thread, mention.post.id); console.log(` → Action: ${analysis.action.type} (${(analysis.confidence * 100).toFixed(0)}% confidence)`); // In a real scenario, you might execute the action here diff --git a/src/index.ts b/src/index.ts index 6dd2dfe..9ae06a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,10 @@ import { AutonomousAgent } from './services/agent.js'; import { XMCPServer } from './mcp/server.js'; async function main() { + // Redirect console.log to stderr so it doesn't conflict with + // MCP StdioServerTransport which uses stdout for protocol messages + console.log = (...args: unknown[]) => console.error(...args); + console.log('═══════════════════════════════════════════════════'); console.log(' MyXstack - Autonomous AI Agent on X (Twitter)'); console.log('═══════════════════════════════════════════════════\n'); diff --git a/src/services/agent.ts b/src/services/agent.ts index ab40033..6635cad 100644 --- a/src/services/agent.ts +++ b/src/services/agent.ts @@ -11,6 +11,7 @@ export class AutonomousAgent { private grokService: GrokService; private config: AgentConfig; private processedMentions: Set = new Set(); + private static readonly MAX_PROCESSED_MENTIONS = 10000; private isRunning: boolean = false; private pollingIntervalId: NodeJS.Timeout | null = null; private isProcessing: boolean = false; @@ -90,10 +91,24 @@ export class AutonomousAgent { console.log(`\n📬 [${new Date().toLocaleTimeString()}] Found ${newMentions.length} new mention(s)!\n`); - // Process each mention - for (const mention of newMentions) { - await this.processMention(mention); - this.processedMentions.add(mention.post.id); + // Process in reverse (oldest-first) since API returns newest-first + for (let i = newMentions.length - 1; i >= 0; i--) { + await this.processMention(newMentions[i]); + this.processedMentions.add(newMentions[i].post.id); + } + + // Prune oldest entries to prevent unbounded memory growth + // Set iteration order is insertion order (ES2015+), so oldest entries come first + if (this.processedMentions.size > AutonomousAgent.MAX_PROCESSED_MENTIONS) { + const excess = this.processedMentions.size - AutonomousAgent.MAX_PROCESSED_MENTIONS; + const iter = this.processedMentions.values(); + for (let i = 0; i < excess; i++) { + const { value, done } = iter.next(); + if (done) { + break; + } + this.processedMentions.delete(value); + } } } catch (error) { console.error('❌ Error in processing loop:', error); @@ -129,7 +144,8 @@ export class AutonomousAgent { console.log('\n🤖 Analyzing with Grok AI...'); const analysis = await this.grokService.analyzeAndDecide( mention.post.text, - thread + thread, + mention.post.id ); console.log(` Action: ${analysis.action.type.toUpperCase()}`); diff --git a/src/services/grok.ts b/src/services/grok.ts index 0fd6b2c..32cf58f 100644 --- a/src/services/grok.ts +++ b/src/services/grok.ts @@ -21,11 +21,12 @@ export class GrokService { * Analyze a mention and thread context to determine appropriate action * @param mention - The text content of the mention to analyze * @param thread - The thread context including root post and replies + * @param mentionPostId - The ID of the post where the agent was mentioned * @returns Analysis with recommended action */ - async analyzeAndDecide(mention: string, thread: XThread): Promise { + async analyzeAndDecide(mention: string, thread: XThread, mentionPostId: string): Promise { if (this.simulationMode) { - return this.simulateAnalysis(mention, thread); + return this.simulateAnalysis(mention, thread, mentionPostId); } try { @@ -58,15 +59,14 @@ export class GrokService { throw new Error(`Grok API error: ${response.status}`); } - const data: any = await response.json(); + const data = await response.json() as { choices: Array<{ message?: { content?: string } }> }; const analysisText = data.choices[0]?.message?.content || ''; - // Use the root post ID from the thread, not the mention text - return this.parseGrokResponse(analysisText, thread.root_post.id); + return this.parseGrokResponse(analysisText, mentionPostId); } catch (error) { console.error('Error calling Grok API:', error); // Fallback to simulation - return this.simulateAnalysis(mention, thread); + return this.simulateAnalysis(mention, thread, mentionPostId); } } @@ -145,7 +145,7 @@ export class GrokService { /** * Simulate Grok analysis for testing */ - private simulateAnalysis(mention: string, thread: XThread): GrokAnalysis { + private simulateAnalysis(mention: string, thread: XThread, mentionPostId: string): GrokAnalysis { console.log('🤖 Simulated Grok Analysis:'); console.log(` Analyzing: "${mention}"`); @@ -159,7 +159,7 @@ export class GrokService { const analysis: GrokAnalysis = { action: { type: 'reply', - target_post_id: thread.root_post.id, + target_post_id: mentionPostId, content: 'Thanks for reaching out! I\'ve analyzed your question and here\'s my insight: Based on the context, I\'d recommend exploring this topic further. Let me know if you need more specific information!', reasoning: 'Detected a question, providing helpful response', }, @@ -174,7 +174,7 @@ export class GrokService { const analysis: GrokAnalysis = { action: { type: 'analyze', - target_post_id: thread.root_post.id, + target_post_id: mentionPostId, reasoning: 'No clear action needed, just acknowledgment', }, confidence: 0.7, diff --git a/src/services/xapi.ts b/src/services/xapi.ts index 1f62056..052a4c0 100644 --- a/src/services/xapi.ts +++ b/src/services/xapi.ts @@ -8,6 +8,7 @@ export class XAPIClient { private config: XAPIConfig; private lastMentionId: string | null = null; private simulationMode: boolean = false; + private static readonly MAX_MENTIONS_PER_FETCH = 10; constructor(config: XAPIConfig) { this.config = config; @@ -44,17 +45,31 @@ export class XAPIClient { throw new Error('Failed to get user ID from response'); } - const mentionsResponse = await this.makeXAPIRequest( - `https://api.twitter.com/2/users/${userId}/mentions?max_results=10&expansions=author_id&tweet.fields=created_at,conversation_id,in_reply_to_user_id,referenced_tweets`, - 'GET' - ); + const params = new URLSearchParams({ + max_results: String(XAPIClient.MAX_MENTIONS_PER_FETCH), + expansions: 'author_id', + 'tweet.fields': 'created_at,conversation_id,in_reply_to_user_id,referenced_tweets', + }); + if (this.lastMentionId) { + params.set('since_id', this.lastMentionId); + } + const mentionsUrl = `https://api.twitter.com/2/users/${userId}/mentions?${params.toString()}`; + + const mentionsResponse = await this.makeXAPIRequest(mentionsUrl, 'GET'); if (!mentionsResponse || !Array.isArray(mentionsResponse.data)) { console.warn('Invalid response from X API (mentions)'); return []; } - return this.parseMentions(mentionsResponse.data); + const mentions = this.parseMentions(mentionsResponse.data); + + // Track the newest mention ID for pagination on the next poll + if (mentionsResponse.data.length > 0 && mentionsResponse.data[0]?.id) { + this.lastMentionId = mentionsResponse.data[0].id; + } + + return mentions; } catch (error) { console.error('Error fetching mentions:', error); return []; @@ -77,7 +92,12 @@ export class XAPIClient { 'GET' ); - return this.parseThread(response.data || []); + if (!response || !Array.isArray(response.data)) { + console.warn('Unexpected response shape from X API (thread): data is not an array'); + return null; + } + + return this.parseThread(response.data); } catch (error) { console.error('Error fetching thread:', error); return null; @@ -181,10 +201,10 @@ export class XAPIClient { }; } - private parseThread(tweets: any[]): XThread | null { + private parseThread(tweets: { created_at: string; [key: string]: unknown }[]): XThread | null { if (tweets.length === 0) return null; - const sorted = tweets.sort((a, b) => + const sorted = [...tweets].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() );