diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 472f6b6..52eca53 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,13 +1,13 @@ # CODEOWNERS - Automatically assigns reviewers based on file paths # Default owner for everything -* @codex +* @groupthinking # Frontend -*.tsx @Vercel -*.jsx @Vercel -*.css @Vercel -/frontend/ @Vercel +*.tsx @groupthinking +*.jsx @groupthinking +*.css @groupthinking +/frontend/ @groupthinking # Backend *.py @groupthinking @@ -16,11 +16,11 @@ /api/ @groupthinking # Infrastructure -/.github/ @Claude -*.yml @Claude -*.yaml @Claude -Dockerfile @Claude +/.github/ @groupthinking +*.yml @groupthinking +*.yaml @groupthinking +Dockerfile @groupthinking # Documentation -*.md @Copilot -/docs/ @Copilot +*.md @groupthinking +/docs/ @groupthinking diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index f0934ef..3f4ea69 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -2,6 +2,11 @@ name: Auto Label on: pull_request: types: [opened, reopened, synchronized] + +permissions: + contents: read + pull-requests: write + jobs: label: runs-on: ubuntu-latest diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index c8649bf..15134f8 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -2,6 +2,10 @@ name: Issue Triage on: issues: types: [opened] + +permissions: + issues: write + jobs: triage: runs-on: ubuntu-latest @@ -22,12 +26,23 @@ jobs: labels.push('needs-triage'); if (labels.length > 0) { - await github.rest.issues.addLabels({ + // Fetch existing labels to avoid "Label does not exist" errors + const { data: repoLabels } = await github.rest.issues.listLabelsForRepo({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: issue.number, - labels: labels + per_page: 100, }); + const existingLabelNames = new Set(repoLabels.map(l => l.name)); + const labelsToAdd = labels.filter(l => existingLabelNames.has(l)); + + if (labelsToAdd.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: labelsToAdd + }); + } } await github.rest.issues.createComment({ diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 00f340a..22ed3c2 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -2,6 +2,11 @@ name: PR Checks on: pull_request: types: [opened, reopened, synchronize, edited] + +permissions: + contents: read + pull-requests: write + jobs: validate: runs-on: ubuntu-latest diff --git a/src/index.ts b/src/index.ts index c29abb7..a12c45d 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 326fb38..e48bbb0 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,19 @@ 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 mentions oldest-first (API returns newest first) to maintain + // chronological insertion order in the Set for proper pruning behavior + for (let i = newMentions.length - 1; i >= 0; i--) { + await this.processMention(newMentions[i]); + this.processedMentions.add(newMentions[i].post.id); + } + + // Prune oldest entries (from beginning of Set) to prevent unbounded memory growth + // Sets maintain insertion order, so slice(0, excess) removes oldest entries + if (this.processedMentions.size > AutonomousAgent.MAX_PROCESSED_MENTIONS) { + const excess = this.processedMentions.size - AutonomousAgent.MAX_PROCESSED_MENTIONS; + const toDelete = Array.from(this.processedMentions).slice(0, excess); + toDelete.forEach(id => this.processedMentions.delete(id)); } } catch (error) { console.error('❌ Error in processing loop:', error); diff --git a/src/services/grok.ts b/src/services/grok.ts index d71ffdc..60d50f2 100644 --- a/src/services/grok.ts +++ b/src/services/grok.ts @@ -4,6 +4,14 @@ */ import { XThread, GrokAnalysis, AgentAction } from '../types/index.js'; +interface GrokApiResponse { + choices: Array<{ + message?: { + content?: string; + }; + }>; +} + export class GrokService { private apiKey: string; private simulationMode: boolean = false; @@ -59,7 +67,7 @@ export class GrokService { throw new Error(`Grok API error: ${response.status}`); } - const data: any = await response.json(); + const data = await response.json() as GrokApiResponse; const analysisText = data.choices[0]?.message?.content || ''; // Use the mention post ID so replies target the specific post where the agent was mentioned diff --git a/src/services/xapi.ts b/src/services/xapi.ts index f80be1e..0044205 100644 --- a/src/services/xapi.ts +++ b/src/services/xapi.ts @@ -6,6 +6,7 @@ import { XPost, XThread, Mention, XAPIConfig } from '../types/index.js'; export class XAPIClient { private config: XAPIConfig; + // Track the most recent mention ID to enable pagination (avoid re-fetching) private lastMentionId: string | null = null; private simulationMode: boolean = false; @@ -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: '10', + 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 && typeof mentionsResponse.data[0].id === 'string') { + this.lastMentionId = mentionsResponse.data[0].id; + } + + return mentions; } catch (error) { console.error('Error fetching mentions:', error); return []; @@ -77,7 +92,17 @@ export class XAPIClient { 'GET' ); - return this.parseThread(response.data || []); + if (!response || !response.data) { + console.warn('Invalid response from X API (thread)'); + return null; + } + + if (!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 +206,10 @@ export class XAPIClient { }; } - private parseThread(tweets: { created_at: string; [key: string]: 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() );