Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Tools for running agents in parallel are too complex. `dmux` makes running paral
- **Node.js** 18 or higher
- **Git** 2.20 or higher (with worktree support)
- **Agent CLI**: Claude Code (`claude`) or opencode (`opencode`)
- **OpenRouter API Key** (optional but recommended for AI features)
- **AI API Key** (optional but recommended for AI features): OpenRouter or Mistral AI

## Installation

Expand All @@ -37,7 +37,9 @@ npm install -g dmux

### 2. Enable AI Features

For AI-powered branch naming and commit messages:
For AI-powered branch naming and commit messages, you can use either OpenRouter or Mistral AI:

#### Option A: OpenRouter (default, multiple models with fallback)

```bash
# Add to your ~/.bashrc or ~/.zshrc
Expand All @@ -46,6 +48,22 @@ export OPENROUTER_API_KEY="your-api-key-here"

Get your API key from [OpenRouter](https://openrouter.ai/).

#### Option B: Mistral AI (direct API, faster for some features)

```bash
# Add to your ~/.bashrc or ~/.zshrc
export MISTRAL_API_KEY="your-api-key-here"

# Optional: Configure specific models for different features
export MISTRAL_SLUG_MODELS="mistral-small-latest"
export MISTRAL_COMMIT_MODELS="mistral-small-latest"
export MISTRAL_ANALYSIS_MODELS="codestral-2501"
```

Get your API key from [Mistral AI](https://mistral.ai/).

If both `OPENROUTER_API_KEY` and `MISTRAL_API_KEY` are set, dmux will use OpenRouter as the default but can use Mistral for specific features.

## Quick Start

### Basic Usage
Expand Down
123 changes: 35 additions & 88 deletions src/services/PaneAnalyzer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash } from 'crypto';
import { capturePaneContent } from '../utils/paneCapture.js';
import { LogService } from './LogService.js';
import { createProviderManagerFromEnv } from '../utils/aiProvider.js';

// State types for agent status
export type PaneState = 'option_dialog' | 'open_prompt' | 'in_progress';
Expand All @@ -27,12 +28,7 @@ interface CacheEntry {
}

export class PaneAnalyzer {
private apiKey: string;
private modelStack: string[] = [
'google/gemini-2.5-flash',
'x-ai/grok-4-fast:free',
'openai/gpt-4o-mini'
];
private providerManager: ReturnType<typeof createProviderManagerFromEnv>;

// Content-hash based cache to avoid repeated API calls for identical content
private cache = new Map<string, CacheEntry>();
Expand All @@ -43,7 +39,7 @@ export class PaneAnalyzer {
private pendingRequests = new Map<string, Promise<PaneAnalysis>>();

constructor() {
this.apiKey = process.env.OPENROUTER_API_KEY || '';
this.providerManager = createProviderManagerFromEnv();
}

/**
Expand Down Expand Up @@ -87,95 +83,46 @@ export class PaneAnalyzer {
this.cache.clear();
}


/**
* Make a single API request to a specific model
*/
private async tryModel(
model: string,
systemPrompt: string,
userPrompt: string,
maxTokens: number,
signal?: AbortSignal
): Promise<any> {
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://github.com/dmux/dmux',
'X-Title': 'dmux',
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
temperature: 0.1,
max_tokens: maxTokens,
response_format: { type: 'json_object' },
}),
signal
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`API error (${model}): ${response.status} ${errorText}`);
}

return response.json();
}

/**
* Makes a request to OpenRouter API with PARALLEL model fallback
* Uses Promise.any to race all models - first success wins
*
* Performance improvement: Previously could take 6+ seconds if models failed sequentially.
* Now returns as soon as ANY model responds successfully (typically <1s).
* Makes a request to AI provider with fallback support
*/
private async makeRequestWithFallback(
systemPrompt: string,
userPrompt: string,
maxTokens: number,
signal?: AbortSignal
): Promise<any> {
if (!this.apiKey) {
throw new Error('API key not available');
if (!this.providerManager.hasProviders()) {
throw new Error('No AI providers available');
}

const logService = LogService.getInstance();

// Create an AbortController with timeout
// Create controller for abort signal if provided
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s total timeout

// Combine external signal with our timeout
const combinedSignal = signal
? AbortSignal.any([signal, controller.signal])
: controller.signal;
if (signal) {
signal.addEventListener('abort', () => controller.abort());
}

try {
// Race all models in parallel - first success wins
const result = await Promise.any(
this.modelStack.map(model =>
this.tryModel(model, systemPrompt, userPrompt, maxTokens, combinedSignal)
.then(data => {
logService.debug(`PaneAnalyzer: Model ${model} succeeded`, 'paneAnalyzer');
return data;
})
)
);
const response = await this.providerManager.generateText(userPrompt, {
feature: 'analysis',
maxTokens,
temperature: 0.1,
systemPrompt
});

return result;
} catch (error) {
if (error instanceof AggregateError) {
// All models failed - throw the first error for context
throw error.errors[0] || new Error('All models in fallback stack failed');
if (!response) {
throw new Error('AI provider returned no response');
}
throw error;
} finally {
clearTimeout(timeoutId);

// Parse JSON response (for structured outputs)
try {
return JSON.parse(response);
} catch {
// If not JSON, wrap in expected format
return { choices: [{ message: { content: response } }] };
}
} catch (error) {
throw error instanceof Error ? error : new Error('AI provider request failed');
}
}

Expand All @@ -188,9 +135,9 @@ export class PaneAnalyzer {
async determineState(content: string, signal?: AbortSignal, paneName?: string): Promise<PaneState> {
const logService = LogService.getInstance();

if (!this.apiKey) {
// API key not set
logService.debug(`PaneAnalyzer: No API key set, defaulting to in_progress state${paneName ? ` for "${paneName}"` : ''}`, 'paneAnalyzer');
if (!this.providerManager.hasProviders()) {
// No AI providers available
logService.debug(`PaneAnalyzer: No AI providers available, defaulting to in_progress state${paneName ? ` for "${paneName}"` : ''}`, 'paneAnalyzer');
return 'in_progress';
}

Expand Down Expand Up @@ -268,8 +215,8 @@ CRITICAL:
async extractOptions(content: string, signal?: AbortSignal, paneName?: string): Promise<Omit<PaneAnalysis, 'state'>> {
const logService = LogService.getInstance();

if (!this.apiKey) {
logService.debug(`PaneAnalyzer: No API key set, cannot extract options${paneName ? ` for "${paneName}"` : ''}`, 'paneAnalyzer');
if (!this.providerManager.hasProviders()) {
logService.debug(`PaneAnalyzer: No AI providers available, cannot extract options${paneName ? ` for "${paneName}"` : ''}`, 'paneAnalyzer');
return {};
}

Expand Down Expand Up @@ -361,7 +308,7 @@ Output: {
* Stage 3: Extract summary when state is open_prompt (idle)
*/
async extractSummary(content: string, signal?: AbortSignal): Promise<string | undefined> {
if (!this.apiKey) {
if (!this.providerManager.hasProviders()) {
return undefined;
}

Expand Down Expand Up @@ -519,4 +466,4 @@ If there's no meaningful content or the output is unclear, return an empty summa
throw error;
}
}
}
}
77 changes: 20 additions & 57 deletions src/utils/aiMerge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,72 +8,35 @@ import { execSync } from 'child_process';
import fs from 'fs/promises';
import path from 'path';
import { LogService } from '../services/LogService.js';
import { createProviderManagerFromEnv } from './aiProvider.js';

/**
* Fetch with timeout wrapper
* Call AI provider for assistance with fallback support
*/
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise<Response> {
async function callAIProvider(prompt: string, maxTokens: number = 1000, timeoutMs: number = 12000): Promise<string | null> {
const providerManager = createProviderManagerFromEnv();

if (!providerManager.hasProviders()) {
return null;
}

// Create an AbortController for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

try {
const response = await fetch(url, {
...options,
signal: controller.signal,
const response = await providerManager.generateText(prompt, {
feature: 'commit',
maxTokens,
temperature: 0.3
});

clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}

/**
* Call OpenRouter API for AI assistance with model fallback
*/
async function callOpenRouter(prompt: string, maxTokens: number = 1000, timeoutMs: number = 12000): Promise<string | null> {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) return null;

const models = ['google/gemini-2.5-flash', 'x-ai/grok-4-fast:free', 'openai/gpt-4o-mini'];

for (const model of models) {
try {
const response = await fetchWithTimeout(
'https://openrouter.ai/api/v1/chat/completions',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: [
{
role: 'user',
content: prompt,
},
],
max_tokens: maxTokens,
temperature: 0.3,
}),
},
timeoutMs
);

if (response.ok) {
const data = (await response.json()) as any;
return data.choices[0].message.content.trim();
}
} catch {
// Try next model
continue;
}
return null;
}

return null;
}

/**
Expand Down Expand Up @@ -156,8 +119,8 @@ export async function generateCommitMessage(repoPath: string): Promise<string |

const prompt = `Generate a concise conventional commit message (e.g., "feat: add feature", "fix: bug") for these changes. Respond with ONLY the commit message, nothing else:\n\nFile changes:\n${summary}\n\nDiff:\n${contextDiff}`;

// Try OpenRouter first
let message = await callOpenRouter(prompt, 50);
// Try AI provider first
let message = await callAIProvider(prompt, 50);
if (message) {
// Clean up the response
message = message.replace(/^["']|["']$/g, '').trim();
Expand Down Expand Up @@ -294,8 +257,8 @@ ${content}

Respond with ONLY the complete resolved file content, no explanations:`;

// Try OpenRouter with longer timeout for conflict resolution
let resolved = await callOpenRouter(prompt, 2000, 20000);
// Try AI provider with longer timeout for conflict resolution
let resolved = await callAIProvider(prompt, 2000, 20000);
if (!resolved) {
// Try Claude Code with longer timeout for conflict resolution
resolved = await callClaudeCode(prompt, 20000);
Expand Down
Loading