diff --git a/src/services/worker-service.ts b/src/services/worker-service.ts index 02dd4f328..55cc9688f 100644 --- a/src/services/worker-service.ts +++ b/src/services/worker-service.ts @@ -108,6 +108,7 @@ import { SSEBroadcaster } from './worker/SSEBroadcaster.js'; import { SDKAgent } from './worker/SDKAgent.js'; import { GeminiAgent, isGeminiSelected, isGeminiAvailable } from './worker/GeminiAgent.js'; import { OpenRouterAgent, isOpenRouterSelected, isOpenRouterAvailable } from './worker/OpenRouterAgent.js'; +import { MiniMaxAgent, isMiniMaxSelected, isMiniMaxAvailable } from './worker/MiniMaxAgent.js'; import { PaginationHelper } from './worker/PaginationHelper.js'; import { SettingsManager } from './worker/SettingsManager.js'; import { SearchManager } from './worker/SearchManager.js'; @@ -168,6 +169,7 @@ export class WorkerService { private sdkAgent: SDKAgent; private geminiAgent: GeminiAgent; private openRouterAgent: OpenRouterAgent; + private miniMaxAgent: MiniMaxAgent; private paginationHelper: PaginationHelper; private settingsManager: SettingsManager; private sessionEventBroadcaster: SessionEventBroadcaster; @@ -209,6 +211,7 @@ export class WorkerService { this.sdkAgent = new SDKAgent(this.dbManager, this.sessionManager); this.geminiAgent = new GeminiAgent(this.dbManager, this.sessionManager); this.openRouterAgent = new OpenRouterAgent(this.dbManager, this.sessionManager); + this.miniMaxAgent = new MiniMaxAgent(this.dbManager, this.sessionManager); this.paginationHelper = new PaginationHelper(this.dbManager); this.settingsManager = new SettingsManager(this.dbManager); @@ -337,7 +340,7 @@ export class WorkerService { // Standard routes (registered AFTER guard middleware) this.server.registerRoutes(new ViewerRoutes(this.sseBroadcaster, this.dbManager, this.sessionManager)); - this.server.registerRoutes(new SessionRoutes(this.sessionManager, this.dbManager, this.sdkAgent, this.geminiAgent, this.openRouterAgent, this.sessionEventBroadcaster, this)); + this.server.registerRoutes(new SessionRoutes(this.sessionManager, this.dbManager, this.sdkAgent, this.geminiAgent, this.openRouterAgent, this.miniMaxAgent, this.sessionEventBroadcaster, this)); this.server.registerRoutes(new DataRoutes(this.paginationHelper, this.dbManager, this.sessionManager, this.sseBroadcaster, this, this.startTime)); this.server.registerRoutes(new SettingsRoutes(this.settingsManager)); this.server.registerRoutes(new LogsRoutes()); @@ -506,7 +509,10 @@ export class WorkerService { * Get the appropriate agent based on provider settings. * Same logic as SessionRoutes.getActiveAgent() for consistency. */ - private getActiveAgent(): SDKAgent | GeminiAgent | OpenRouterAgent { + private getActiveAgent(): SDKAgent | GeminiAgent | OpenRouterAgent | MiniMaxAgent { + if (isMiniMaxSelected() && isMiniMaxAvailable()) { + return this.miniMaxAgent; + } if (isOpenRouterSelected() && isOpenRouterAvailable()) { return this.openRouterAgent; } @@ -722,6 +728,18 @@ export class WorkerService { } } + if (isMiniMaxAvailable()) { + try { + await this.miniMaxAgent.startSession(session, this); + return; + } catch (e) { + logger.warn('SDK', 'Fallback MiniMax failed, trying OpenRouter', { + sessionId: sessionDbId, + error: e instanceof Error ? e.message : String(e) + }); + } + } + if (isOpenRouterAvailable()) { try { await this.openRouterAgent.startSession(session, this); diff --git a/src/services/worker-types.ts b/src/services/worker-types.ts index c4fd89eb1..7a6b6c182 100644 --- a/src/services/worker-types.ts +++ b/src/services/worker-types.ts @@ -32,7 +32,7 @@ export interface ActiveSession { cumulativeOutputTokens: number; // Track output tokens for discovery cost earliestPendingTimestamp: number | null; // Original timestamp of earliest pending message (for accurate observation timestamps) conversationHistory: ConversationMessage[]; // Shared conversation history for provider switching - currentProvider: 'claude' | 'gemini' | 'openrouter' | null; // Track which provider is currently running + currentProvider: 'claude' | 'gemini' | 'openrouter' | 'minimax' | null; // Track which provider is currently running consecutiveRestarts: number; // Track consecutive restart attempts to prevent infinite loops forceInit?: boolean; // Force fresh SDK session (skip resume) idleTimedOut?: boolean; // Set when session exits due to idle timeout (prevents restart loop) diff --git a/src/services/worker/MiniMaxAgent.ts b/src/services/worker/MiniMaxAgent.ts new file mode 100644 index 000000000..65de10f18 --- /dev/null +++ b/src/services/worker/MiniMaxAgent.ts @@ -0,0 +1,458 @@ +/** + * MiniMaxAgent: MiniMax-based observation extraction + * + * Alternative to SDKAgent that uses MiniMax's OpenAI-compatible API + * for accessing MiniMax M2.7 models. + * + * Responsibility: + * - Call MiniMax REST API for observation extraction + * - Parse XML responses (same format as Claude/Gemini/OpenRouter) + * - Sync to database and Chroma + * - Support MiniMax-M2.7, MiniMax-M2.7-highspeed, and legacy M2.5 models + */ + +import { buildContinuationPrompt, buildInitPrompt, buildObservationPrompt, buildSummaryPrompt } from '../../sdk/prompts.js'; +import { getCredential } from '../../shared/EnvManager.js'; +import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js'; +import { USER_SETTINGS_PATH } from '../../shared/paths.js'; +import { logger } from '../../utils/logger.js'; +import { ModeManager } from '../domain/ModeManager.js'; +import type { ActiveSession, ConversationMessage } from '../worker-types.js'; +import { DatabaseManager } from './DatabaseManager.js'; +import { SessionManager } from './SessionManager.js'; +import { + isAbortError, + processAgentResponse, + shouldFallbackToClaude, + type FallbackAgent, + type WorkerRef +} from './agents/index.js'; + +// MiniMax API endpoint (OpenAI-compatible) +const MINIMAX_API_URL = 'https://api.minimax.io/v1/chat/completions'; + +// Context window management constants (defaults, overridable via settings) +const DEFAULT_MAX_CONTEXT_MESSAGES = 20; // Maximum messages to keep in conversation history +const DEFAULT_MAX_ESTIMATED_TOKENS = 100000; // ~100k tokens max context (safety limit) +const CHARS_PER_TOKEN_ESTIMATE = 4; // Conservative estimate: 1 token = 4 chars + +// OpenAI-compatible message format +interface OpenAIMessage { + role: 'user' | 'assistant' | 'system'; + content: string; +} + +interface MiniMaxResponse { + choices?: Array<{ + message?: { + role?: string; + content?: string; + }; + finish_reason?: string; + }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + error?: { + message?: string; + code?: string; + }; +} + +export class MiniMaxAgent { + private dbManager: DatabaseManager; + private sessionManager: SessionManager; + private fallbackAgent: FallbackAgent | null = null; + + constructor(dbManager: DatabaseManager, sessionManager: SessionManager) { + this.dbManager = dbManager; + this.sessionManager = sessionManager; + } + + /** + * Set the fallback agent (Claude SDK) for when MiniMax API fails + * Must be set after construction to avoid circular dependency + */ + setFallbackAgent(agent: FallbackAgent): void { + this.fallbackAgent = agent; + } + + /** + * Start MiniMax agent for a session + * Uses multi-turn conversation to maintain context across messages + */ + async startSession(session: ActiveSession, worker?: WorkerRef): Promise { + try { + // Get MiniMax configuration + const { apiKey, model, baseURL } = this.getMiniMaxConfig(); + + if (!apiKey) { + throw new Error('MiniMax API key not configured. Set CLAUDE_MEM_MINIMAX_API_KEY in settings or MINIMAX_API_KEY environment variable.'); + } + + // Generate synthetic memorySessionId (MiniMax is stateless, doesn't return session IDs) + if (!session.memorySessionId) { + const syntheticMemorySessionId = `minimax-${session.contentSessionId}-${Date.now()}`; + session.memorySessionId = syntheticMemorySessionId; + this.dbManager.getSessionStore().updateMemorySessionId(session.sessionDbId, syntheticMemorySessionId); + logger.info('SESSION', `MEMORY_ID_GENERATED | sessionDbId=${session.sessionDbId} | provider=MiniMax`); + } + + // Load active mode + const mode = ModeManager.getInstance().getActiveMode(); + + // Build initial prompt + const initPrompt = session.lastPromptNumber === 1 + ? buildInitPrompt(session.project, session.contentSessionId, session.userPrompt, mode) + : buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.contentSessionId, mode); + + // Add to conversation history and query MiniMax with full context + session.conversationHistory.push({ role: 'user', content: initPrompt }); + const initResponse = await this.queryMiniMaxMultiTurn(session.conversationHistory, apiKey, model, baseURL); + + if (initResponse.content) { + // Track token usage + const tokensUsed = initResponse.tokensUsed || 0; + session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); // Rough estimate + session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); + + // Process response using shared ResponseProcessor (no original timestamp for init - not from queue) + await processAgentResponse( + initResponse.content, + session, + this.dbManager, + this.sessionManager, + worker, + tokensUsed, + null, + 'MiniMax', + undefined // No lastCwd yet - before message processing + ); + } else { + logger.error('SDK', 'Empty MiniMax init response - session may lack context', { + sessionId: session.sessionDbId, + model + }); + } + + // Track lastCwd from messages for CLAUDE.md generation + let lastCwd: string | undefined; + + // Process pending messages + for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) { + // CLAIM-CONFIRM: Track message ID for confirmProcessed() after successful storage + session.processingMessageIds.push(message._persistentId); + + // Capture cwd from messages for proper worktree support + if (message.cwd) { + lastCwd = message.cwd; + } + // Capture earliest timestamp BEFORE processing (will be cleared after) + const originalTimestamp = session.earliestPendingTimestamp; + + if (message.type === 'observation') { + // Update last prompt number + if (message.prompt_number !== undefined) { + session.lastPromptNumber = message.prompt_number; + } + + // CRITICAL: Check memorySessionId BEFORE making expensive LLM call + if (!session.memorySessionId) { + throw new Error('Cannot process observations: memorySessionId not yet captured. This session may need to be reinitialized.'); + } + + // Build observation prompt + const obsPrompt = buildObservationPrompt({ + id: 0, + tool_name: message.tool_name!, + tool_input: JSON.stringify(message.tool_input), + tool_output: JSON.stringify(message.tool_response), + created_at_epoch: originalTimestamp ?? Date.now(), + cwd: message.cwd + }); + + // Add to conversation history and query MiniMax with full context + session.conversationHistory.push({ role: 'user', content: obsPrompt }); + const obsResponse = await this.queryMiniMaxMultiTurn(session.conversationHistory, apiKey, model, baseURL); + + let tokensUsed = 0; + if (obsResponse.content) { + tokensUsed = obsResponse.tokensUsed || 0; + session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); + session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); + } + + // Process response using shared ResponseProcessor + await processAgentResponse( + obsResponse.content || '', + session, + this.dbManager, + this.sessionManager, + worker, + tokensUsed, + originalTimestamp, + 'MiniMax', + lastCwd + ); + + } else if (message.type === 'summarize') { + // CRITICAL: Check memorySessionId BEFORE making expensive LLM call + if (!session.memorySessionId) { + throw new Error('Cannot process summary: memorySessionId not yet captured. This session may need to be reinitialized.'); + } + + // Build summary prompt + const summaryPrompt = buildSummaryPrompt({ + id: session.sessionDbId, + memory_session_id: session.memorySessionId, + project: session.project, + user_prompt: session.userPrompt, + last_assistant_message: message.last_assistant_message || '' + }, mode); + + // Add to conversation history and query MiniMax with full context + session.conversationHistory.push({ role: 'user', content: summaryPrompt }); + const summaryResponse = await this.queryMiniMaxMultiTurn(session.conversationHistory, apiKey, model, baseURL); + + let tokensUsed = 0; + if (summaryResponse.content) { + tokensUsed = summaryResponse.tokensUsed || 0; + session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); + session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3); + } + + // Process response using shared ResponseProcessor + await processAgentResponse( + summaryResponse.content || '', + session, + this.dbManager, + this.sessionManager, + worker, + tokensUsed, + originalTimestamp, + 'MiniMax', + lastCwd + ); + } + } + + // Mark session complete + const sessionDuration = Date.now() - session.startTime; + logger.success('SDK', 'MiniMax agent completed', { + sessionId: session.sessionDbId, + duration: `${(sessionDuration / 1000).toFixed(1)}s`, + historyLength: session.conversationHistory.length, + model + }); + + } catch (error: unknown) { + if (isAbortError(error)) { + logger.warn('SDK', 'MiniMax agent aborted', { sessionId: session.sessionDbId }); + throw error; + } + + // Check if we should fall back to Claude + if (shouldFallbackToClaude(error) && this.fallbackAgent) { + logger.warn('SDK', 'MiniMax API failed, falling back to Claude SDK', { + sessionDbId: session.sessionDbId, + error: error instanceof Error ? error.message : String(error), + historyLength: session.conversationHistory.length + }); + + return this.fallbackAgent.startSession(session, worker); + } + + logger.failure('SDK', 'MiniMax agent error', { sessionDbId: session.sessionDbId }, error as Error); + throw error; + } + } + + /** + * Estimate token count from text (conservative estimate) + */ + private estimateTokens(text: string): number { + return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE); + } + + /** + * Truncate conversation history to prevent runaway context costs + * Keeps most recent messages within token budget + */ + private truncateHistory(history: ConversationMessage[]): ConversationMessage[] { + const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH); + + const MAX_CONTEXT_MESSAGES = parseInt(settings.CLAUDE_MEM_MINIMAX_MAX_CONTEXT_MESSAGES) || DEFAULT_MAX_CONTEXT_MESSAGES; + const MAX_ESTIMATED_TOKENS = parseInt(settings.CLAUDE_MEM_MINIMAX_MAX_TOKENS) || DEFAULT_MAX_ESTIMATED_TOKENS; + + if (history.length <= MAX_CONTEXT_MESSAGES) { + // Check token count even if message count is ok + const totalTokens = history.reduce((sum, m) => sum + this.estimateTokens(m.content), 0); + if (totalTokens <= MAX_ESTIMATED_TOKENS) { + return history; + } + } + + // Sliding window: keep most recent messages within limits + const truncated: ConversationMessage[] = []; + let tokenCount = 0; + + // Process messages in reverse (most recent first) + for (let i = history.length - 1; i >= 0; i--) { + const msg = history[i]; + const msgTokens = this.estimateTokens(msg.content); + + if (truncated.length >= MAX_CONTEXT_MESSAGES || tokenCount + msgTokens > MAX_ESTIMATED_TOKENS) { + logger.warn('SDK', 'Context window truncated to prevent runaway costs', { + originalMessages: history.length, + keptMessages: truncated.length, + droppedMessages: i + 1, + estimatedTokens: tokenCount, + tokenLimit: MAX_ESTIMATED_TOKENS + }); + break; + } + + truncated.unshift(msg); // Add to beginning + tokenCount += msgTokens; + } + + return truncated; + } + + /** + * Convert shared ConversationMessage array to OpenAI-compatible message format + */ + private conversationToOpenAIMessages(history: ConversationMessage[]): OpenAIMessage[] { + return history.map(msg => ({ + role: msg.role === 'assistant' ? 'assistant' : 'user', + content: msg.content + })); + } + + /** + * Query MiniMax via REST API with full conversation history (multi-turn) + * Sends the entire conversation context for coherent responses + */ + private async queryMiniMaxMultiTurn( + history: ConversationMessage[], + apiKey: string, + model: string, + baseURL: string + ): Promise<{ content: string; tokensUsed?: number }> { + // Truncate history to prevent runaway costs + const truncatedHistory = this.truncateHistory(history); + const messages = this.conversationToOpenAIMessages(truncatedHistory); + const totalChars = truncatedHistory.reduce((sum, m) => sum + m.content.length, 0); + const estimatedTokens = this.estimateTokens(truncatedHistory.map(m => m.content).join('')); + + logger.debug('SDK', `Querying MiniMax multi-turn (${model})`, { + turns: truncatedHistory.length, + totalChars, + estimatedTokens + }); + + const apiUrl = `${baseURL}/chat/completions`; + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + messages, + temperature: 1.0, // MiniMax requires temperature in (0.0, 1.0], cannot be 0 + max_tokens: 4096, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`MiniMax API error: ${response.status} - ${errorText}`); + } + + const data = await response.json() as MiniMaxResponse; + + // Check for API error in response body + if (data.error) { + throw new Error(`MiniMax API error: ${data.error.code} - ${data.error.message}`); + } + + if (!data.choices?.[0]?.message?.content) { + logger.error('SDK', 'Empty response from MiniMax'); + return { content: '' }; + } + + const content = data.choices[0].message.content; + const tokensUsed = data.usage?.total_tokens; + + // Log actual token usage for cost tracking + if (tokensUsed) { + const inputTokens = data.usage?.prompt_tokens || 0; + const outputTokens = data.usage?.completion_tokens || 0; + // MiniMax pricing: $0.3/M input, $1.2/M output (M2.5) + const estimatedCost = (inputTokens / 1000000 * 0.3) + (outputTokens / 1000000 * 1.2); + + logger.info('SDK', 'MiniMax API usage', { + model, + inputTokens, + outputTokens, + totalTokens: tokensUsed, + estimatedCostUSD: estimatedCost.toFixed(4), + messagesInContext: truncatedHistory.length + }); + + // Warn if costs are getting high + if (tokensUsed > 50000) { + logger.warn('SDK', 'High token usage detected - consider reducing context', { + totalTokens: tokensUsed, + estimatedCost: estimatedCost.toFixed(4) + }); + } + } + + return { content, tokensUsed }; + } + + /** + * Get MiniMax configuration from settings or environment + * Issue #733: Uses centralized ~/.claude-mem/.env for credentials, not random project .env files + */ + private getMiniMaxConfig(): { apiKey: string; model: string; baseURL: string } { + const settingsPath = USER_SETTINGS_PATH; + const settings = SettingsDefaultsManager.loadFromFile(settingsPath); + + // API key: check settings first, then centralized claude-mem .env (NOT process.env) + const apiKey = settings.CLAUDE_MEM_MINIMAX_API_KEY || getCredential('MINIMAX_API_KEY') || ''; + + // Model: from settings or default + const model = settings.CLAUDE_MEM_MINIMAX_MODEL || 'MiniMax-M2.7'; + + // Base URL: from settings or default (overseas endpoint) + const baseURL = settings.CLAUDE_MEM_MINIMAX_BASE_URL || 'https://api.minimax.io/v1'; + + return { apiKey, model, baseURL }; + } +} + +/** + * Check if MiniMax is available (has API key configured) + * Issue #733: Uses centralized ~/.claude-mem/.env, not random project .env files + */ +export function isMiniMaxAvailable(): boolean { + const settingsPath = USER_SETTINGS_PATH; + const settings = SettingsDefaultsManager.loadFromFile(settingsPath); + return !!(settings.CLAUDE_MEM_MINIMAX_API_KEY || getCredential('MINIMAX_API_KEY')); +} + +/** + * Check if MiniMax is the selected provider + */ +export function isMiniMaxSelected(): boolean { + const settingsPath = USER_SETTINGS_PATH; + const settings = SettingsDefaultsManager.loadFromFile(settingsPath); + return settings.CLAUDE_MEM_PROVIDER === 'minimax'; +} diff --git a/src/services/worker/http/routes/SessionRoutes.ts b/src/services/worker/http/routes/SessionRoutes.ts index 7b33c8c3f..71997b422 100644 --- a/src/services/worker/http/routes/SessionRoutes.ts +++ b/src/services/worker/http/routes/SessionRoutes.ts @@ -14,6 +14,7 @@ import { DatabaseManager } from '../../DatabaseManager.js'; import { SDKAgent } from '../../SDKAgent.js'; import { GeminiAgent, isGeminiSelected, isGeminiAvailable } from '../../GeminiAgent.js'; import { OpenRouterAgent, isOpenRouterSelected, isOpenRouterAvailable } from '../../OpenRouterAgent.js'; +import { MiniMaxAgent, isMiniMaxSelected, isMiniMaxAvailable } from '../../MiniMaxAgent.js'; import type { WorkerService } from '../../../worker-service.js'; import { BaseRouteHandler } from '../BaseRouteHandler.js'; import { SessionEventBroadcaster } from '../../events/SessionEventBroadcaster.js'; @@ -34,6 +35,7 @@ export class SessionRoutes extends BaseRouteHandler { private sdkAgent: SDKAgent, private geminiAgent: GeminiAgent, private openRouterAgent: OpenRouterAgent, + private miniMaxAgent: MiniMaxAgent, private eventBroadcaster: SessionEventBroadcaster, private workerService: WorkerService ) { @@ -51,7 +53,15 @@ export class SessionRoutes extends BaseRouteHandler { * Note: Session linking via contentSessionId allows provider switching mid-session. * The conversationHistory on ActiveSession maintains context across providers. */ - private getActiveAgent(): SDKAgent | GeminiAgent | OpenRouterAgent { + private getActiveAgent(): SDKAgent | GeminiAgent | OpenRouterAgent | MiniMaxAgent { + if (isMiniMaxSelected()) { + if (isMiniMaxAvailable()) { + logger.debug('SESSION', 'Using MiniMax agent'); + return this.miniMaxAgent; + } else { + throw new Error('MiniMax provider selected but no API key configured. Set CLAUDE_MEM_MINIMAX_API_KEY in settings or MINIMAX_API_KEY environment variable.'); + } + } if (isOpenRouterSelected()) { if (isOpenRouterAvailable()) { logger.debug('SESSION', 'Using OpenRouter agent'); @@ -74,7 +84,10 @@ export class SessionRoutes extends BaseRouteHandler { /** * Get the currently selected provider name */ - private getSelectedProvider(): 'claude' | 'gemini' | 'openrouter' { + private getSelectedProvider(): 'claude' | 'gemini' | 'openrouter' | 'minimax' { + if (isMiniMaxSelected() && isMiniMaxAvailable()) { + return 'minimax'; + } if (isOpenRouterSelected() && isOpenRouterAvailable()) { return 'openrouter'; } @@ -149,7 +162,7 @@ export class SessionRoutes extends BaseRouteHandler { */ private startGeneratorWithProvider( session: ReturnType, - provider: 'claude' | 'gemini' | 'openrouter', + provider: 'claude' | 'gemini' | 'openrouter' | 'minimax', source: string ): void { if (!session) return; @@ -164,8 +177,8 @@ export class SessionRoutes extends BaseRouteHandler { session.abortController = new AbortController(); } - const agent = provider === 'openrouter' ? this.openRouterAgent : (provider === 'gemini' ? this.geminiAgent : this.sdkAgent); - const agentName = provider === 'openrouter' ? 'OpenRouter' : (provider === 'gemini' ? 'Gemini' : 'Claude SDK'); + const agent = provider === 'minimax' ? this.miniMaxAgent : (provider === 'openrouter' ? this.openRouterAgent : (provider === 'gemini' ? this.geminiAgent : this.sdkAgent)); + const agentName = provider === 'minimax' ? 'MiniMax' : (provider === 'openrouter' ? 'OpenRouter' : (provider === 'gemini' ? 'Gemini' : 'Claude SDK')); // Use database count for accurate telemetry (in-memory array is always empty due to FK constraint fix) const pendingStore = this.sessionManager.getPendingMessageStore(); diff --git a/src/services/worker/http/routes/SettingsRoutes.ts b/src/services/worker/http/routes/SettingsRoutes.ts index d32a39a39..f4834cc3a 100644 --- a/src/services/worker/http/routes/SettingsRoutes.ts +++ b/src/services/worker/http/routes/SettingsRoutes.ts @@ -234,9 +234,9 @@ export class SettingsRoutes extends BaseRouteHandler { private validateSettings(settings: any): { valid: boolean; error?: string } { // Validate CLAUDE_MEM_PROVIDER if (settings.CLAUDE_MEM_PROVIDER) { - const validProviders = ['claude', 'gemini', 'openrouter']; + const validProviders = ['claude', 'gemini', 'openrouter', 'minimax']; if (!validProviders.includes(settings.CLAUDE_MEM_PROVIDER)) { - return { valid: false, error: 'CLAUDE_MEM_PROVIDER must be "claude", "gemini", or "openrouter"' }; + return { valid: false, error: 'CLAUDE_MEM_PROVIDER must be "claude", "gemini", "openrouter", or "minimax"' }; } } diff --git a/src/shared/EnvManager.ts b/src/shared/EnvManager.ts index ed9159a66..6a7d22221 100644 --- a/src/shared/EnvManager.ts +++ b/src/shared/EnvManager.ts @@ -35,6 +35,7 @@ export const MANAGED_CREDENTIAL_KEYS = [ 'ANTHROPIC_API_KEY', 'GEMINI_API_KEY', 'OPENROUTER_API_KEY', + 'MINIMAX_API_KEY', ]; export interface ClaudeMemEnv { @@ -42,6 +43,7 @@ export interface ClaudeMemEnv { ANTHROPIC_API_KEY?: string; GEMINI_API_KEY?: string; OPENROUTER_API_KEY?: string; + MINIMAX_API_KEY?: string; } /** @@ -117,6 +119,7 @@ export function loadClaudeMemEnv(): ClaudeMemEnv { if (parsed.ANTHROPIC_API_KEY) result.ANTHROPIC_API_KEY = parsed.ANTHROPIC_API_KEY; if (parsed.GEMINI_API_KEY) result.GEMINI_API_KEY = parsed.GEMINI_API_KEY; if (parsed.OPENROUTER_API_KEY) result.OPENROUTER_API_KEY = parsed.OPENROUTER_API_KEY; + if (parsed.MINIMAX_API_KEY) result.MINIMAX_API_KEY = parsed.MINIMAX_API_KEY; return result; } catch (error) { @@ -165,6 +168,13 @@ export function saveClaudeMemEnv(env: ClaudeMemEnv): void { delete updated.OPENROUTER_API_KEY; } } + if (env.MINIMAX_API_KEY !== undefined) { + if (env.MINIMAX_API_KEY) { + updated.MINIMAX_API_KEY = env.MINIMAX_API_KEY; + } else { + delete updated.MINIMAX_API_KEY; + } + } writeFileSync(ENV_FILE_PATH, serializeEnvFile(updated), 'utf-8'); } catch (error) { @@ -218,6 +228,9 @@ export function buildIsolatedEnv(includeCredentials: boolean = true): Record; +let getSpy: ReturnType; +let modeManagerSpy: ReturnType; + +describe('MiniMaxAgent', () => { + let agent: MiniMaxAgent; + let originalFetch: typeof global.fetch; + + // Mocks + let mockStoreObservation: any; + let mockStoreObservations: any; + let mockStoreSummary: any; + let mockMarkSessionCompleted: any; + let mockSyncObservation: any; + let mockSyncSummary: any; + let mockMarkProcessed: any; + let mockCleanupProcessed: any; + let mockResetStuckMessages: any; + let mockDbManager: DatabaseManager; + let mockSessionManager: SessionManager; + + beforeEach(() => { + // Mock ModeManager using spyOn (restores properly) + modeManagerSpy = spyOn(ModeManager, 'getInstance').mockImplementation(() => ({ + getActiveMode: () => mockMode, + loadMode: () => {}, + } as any)); + + // Mock SettingsDefaultsManager methods using spyOn (restores properly) + loadFromFileSpy = spyOn(SettingsDefaultsManager, 'loadFromFile').mockImplementation(() => ({ + ...SettingsDefaultsManager.getAllDefaults(), + CLAUDE_MEM_MINIMAX_API_KEY: 'test-minimax-key', + CLAUDE_MEM_MINIMAX_MODEL: 'MiniMax-M2.7', + CLAUDE_MEM_MINIMAX_BASE_URL: 'https://api.minimax.io/v1', + CLAUDE_MEM_DATA_DIR: '/tmp/claude-mem-test', + })); + + getSpy = spyOn(SettingsDefaultsManager, 'get').mockImplementation((key: string) => { + if (key === 'CLAUDE_MEM_MINIMAX_API_KEY') return 'test-minimax-key'; + if (key === 'CLAUDE_MEM_MINIMAX_MODEL') return 'MiniMax-M2.7'; + if (key === 'CLAUDE_MEM_MINIMAX_BASE_URL') return 'https://api.minimax.io/v1'; + if (key === 'CLAUDE_MEM_DATA_DIR') return '/tmp/claude-mem-test'; + return SettingsDefaultsManager.getAllDefaults()[key as keyof ReturnType] ?? ''; + }); + + // Initialize mocks + mockStoreObservation = mock(() => ({ id: 1, createdAtEpoch: Date.now() })); + mockStoreSummary = mock(() => ({ id: 1, createdAtEpoch: Date.now() })); + mockMarkSessionCompleted = mock(() => {}); + mockSyncObservation = mock(() => Promise.resolve()); + mockSyncSummary = mock(() => Promise.resolve()); + mockMarkProcessed = mock(() => {}); + mockCleanupProcessed = mock(() => 0); + mockResetStuckMessages = mock(() => 0); + + mockStoreObservations = mock(() => ({ + observationIds: [1], + summaryId: 1, + createdAtEpoch: Date.now() + })); + + const mockSessionStore = { + storeObservation: mockStoreObservation, + storeObservations: mockStoreObservations, + storeSummary: mockStoreSummary, + markSessionCompleted: mockMarkSessionCompleted, + getSessionById: mock(() => ({ memory_session_id: 'mem-session-123' })), + ensureMemorySessionIdRegistered: mock(() => {}), + updateMemorySessionId: mock(() => {}), + }; + + const mockChromaSync = { + syncObservation: mockSyncObservation, + syncSummary: mockSyncSummary + }; + + mockDbManager = { + getSessionStore: () => mockSessionStore, + getChromaSync: () => mockChromaSync + } as unknown as DatabaseManager; + + const mockPendingMessageStore = { + markProcessed: mockMarkProcessed, + confirmProcessed: mock(() => {}), + cleanupProcessed: mockCleanupProcessed, + resetStuckMessages: mockResetStuckMessages + }; + + mockSessionManager = { + getMessageIterator: async function* () { yield* []; }, + getPendingMessageStore: () => mockPendingMessageStore + } as unknown as SessionManager; + + agent = new MiniMaxAgent(mockDbManager, mockSessionManager); + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + if (modeManagerSpy) modeManagerSpy.mockRestore(); + if (loadFromFileSpy) loadFromFileSpy.mockRestore(); + if (getSpy) getSpy.mockRestore(); + mock.restore(); + }); + + it('should initialize with correct config and call MiniMax API', async () => { + const session = { + sessionDbId: 1, + contentSessionId: 'test-session', + memorySessionId: 'mem-session-123', + project: 'test-project', + userPrompt: 'test prompt', + conversationHistory: [], + lastPromptNumber: 1, + cumulativeInputTokens: 0, + cumulativeOutputTokens: 0, + pendingMessages: [], + abortController: new AbortController(), + generatorPromise: null, + earliestPendingTimestamp: null, + currentProvider: null, + startTime: Date.now(), + processingMessageIds: [] + } as any; + + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({ + choices: [{ + message: { + role: 'assistant', + content: 'discoveryTest' + } + }], + usage: { prompt_tokens: 50, completion_tokens: 50, total_tokens: 100 } + })))); + + await agent.startSession(session); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const url = (global.fetch as any).mock.calls[0][0]; + expect(url).toBe('https://api.minimax.io/v1/chat/completions'); + + // Verify request body + const body = JSON.parse((global.fetch as any).mock.calls[0][1].body); + expect(body.model).toBe('MiniMax-M2.7'); + expect(body.temperature).toBe(1.0); // MiniMax requires temperature > 0 + + // Verify auth header + const headers = (global.fetch as any).mock.calls[0][1].headers; + expect(headers['Authorization']).toBe('Bearer test-minimax-key'); + }); + + it('should handle multi-turn conversation', async () => { + const session = { + sessionDbId: 1, + contentSessionId: 'test-session', + memorySessionId: 'mem-session-123', + project: 'test-project', + userPrompt: 'test prompt', + conversationHistory: [{ role: 'user', content: 'prev context' }, { role: 'assistant', content: 'prev response' }], + lastPromptNumber: 2, + cumulativeInputTokens: 0, + cumulativeOutputTokens: 0, + pendingMessages: [], + abortController: new AbortController(), + generatorPromise: null, + earliestPendingTimestamp: null, + currentProvider: null, + startTime: Date.now(), + processingMessageIds: [] + } as any; + + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({ + choices: [{ message: { role: 'assistant', content: 'response' } }] + })))); + + await agent.startSession(session); + + const body = JSON.parse((global.fetch as any).mock.calls[0][1].body); + expect(body.messages).toHaveLength(3); + expect(body.messages[0].role).toBe('user'); + expect(body.messages[1].role).toBe('assistant'); + expect(body.messages[2].role).toBe('user'); + }); + + it('should process observations and store them', async () => { + const session = { + sessionDbId: 1, + contentSessionId: 'test-session', + memorySessionId: 'mem-session-123', + project: 'test-project', + userPrompt: 'test prompt', + conversationHistory: [], + lastPromptNumber: 1, + cumulativeInputTokens: 0, + cumulativeOutputTokens: 0, + pendingMessages: [], + abortController: new AbortController(), + generatorPromise: null, + earliestPendingTimestamp: null, + currentProvider: null, + startTime: Date.now(), + processingMessageIds: [] + } as any; + + const observationXml = ` + + discovery + Found bug + Null pointer + Found a null pointer in the code + Null check missing + bug + src/main.ts + + + `; + + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({ + choices: [{ message: { role: 'assistant', content: observationXml } }], + usage: { prompt_tokens: 30, completion_tokens: 20, total_tokens: 50 } + })))); + + await agent.startSession(session); + + expect(mockStoreObservations).toHaveBeenCalled(); + expect(mockSyncObservation).toHaveBeenCalled(); + expect(session.cumulativeInputTokens).toBeGreaterThan(0); + }); + + it('should fallback to Claude on API error', async () => { + const session = { + sessionDbId: 1, + contentSessionId: 'test-session', + memorySessionId: 'mem-session-123', + project: 'test-project', + userPrompt: 'test prompt', + conversationHistory: [], + lastPromptNumber: 1, + cumulativeInputTokens: 0, + cumulativeOutputTokens: 0, + pendingMessages: [], + abortController: new AbortController(), + generatorPromise: null, + earliestPendingTimestamp: null, + currentProvider: null, + startTime: Date.now(), + processingMessageIds: [] + } as any; + + global.fetch = mock(() => Promise.resolve(new Response('Rate limit exceeded', { status: 429 }))); + + const fallbackAgent = { + startSession: mock(() => Promise.resolve()) + }; + agent.setFallbackAgent(fallbackAgent); + + await agent.startSession(session); + + // Verify fallback to Claude was triggered + expect(fallbackAgent.startSession).toHaveBeenCalledWith(session, undefined); + }); + + it('should NOT fallback on non-retriable errors', async () => { + const session = { + sessionDbId: 1, + contentSessionId: 'test-session', + memorySessionId: 'mem-session-123', + project: 'test-project', + userPrompt: 'test prompt', + conversationHistory: [], + lastPromptNumber: 1, + cumulativeInputTokens: 0, + cumulativeOutputTokens: 0, + pendingMessages: [], + abortController: new AbortController(), + generatorPromise: null, + earliestPendingTimestamp: null, + currentProvider: null, + startTime: Date.now(), + processingMessageIds: [] + } as any; + + global.fetch = mock(() => Promise.resolve(new Response('Invalid API key', { status: 401 }))); + + const fallbackAgent = { + startSession: mock(() => Promise.resolve()) + }; + agent.setFallbackAgent(fallbackAgent); + + await expect(agent.startSession(session)).rejects.toThrow('MiniMax API error: 401 - Invalid API key'); + expect(fallbackAgent.startSession).not.toHaveBeenCalled(); + }); + + it('should use correct default base URL', async () => { + const session = { + sessionDbId: 1, + contentSessionId: 'test-session', + memorySessionId: 'mem-session-123', + project: 'test-project', + userPrompt: 'test prompt', + conversationHistory: [], + lastPromptNumber: 1, + cumulativeInputTokens: 0, + cumulativeOutputTokens: 0, + pendingMessages: [], + abortController: new AbortController(), + generatorPromise: null, + earliestPendingTimestamp: null, + currentProvider: null, + startTime: Date.now(), + processingMessageIds: [] + } as any; + + global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({ + choices: [{ message: { role: 'assistant', content: 'ok' } }] + })))); + + await agent.startSession(session); + + const url = (global.fetch as any).mock.calls[0][0]; + // Verify it uses the overseas MiniMax API URL + expect(url).toContain('api.minimax.io'); + expect(url).not.toContain('api.minimax.chat'); + }); +});