codeep 1.2.14 → 1.2.16

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/dist/api/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { config, getApiKey } from '../config/index.js';
2
- import { withRetry, isNetworkError, isTimeoutError } from '../utils/retry.js';
2
+ import { withRetry, isNetworkError } from '../utils/retry.js';
3
3
  import { getProvider, getProviderBaseUrl, getProviderAuthHeader } from '../config/providers.js';
4
4
  import { logApiRequest, logApiResponse } from '../utils/logger.js';
5
5
  import { loadProjectIntelligence, generateContextFromIntelligence } from '../utils/projectIntelligence.js';
@@ -92,6 +92,30 @@ export function setProjectContext(ctx) {
92
92
  cachedIntelligence = null;
93
93
  }
94
94
  }
95
+ /**
96
+ * Parse API error response body into a human-readable message.
97
+ * Handles JSON error responses from OpenAI, Anthropic, and other providers.
98
+ */
99
+ function parseApiError(status, body) {
100
+ try {
101
+ const json = JSON.parse(body);
102
+ // OpenAI format: { error: { message: "..." } }
103
+ if (json.error?.message)
104
+ return `${status} - ${json.error.message}`;
105
+ // Anthropic format: { error: { type: "...", message: "..." } }
106
+ if (json.message)
107
+ return `${status} - ${json.message}`;
108
+ // Other: { detail: "..." }
109
+ if (json.detail)
110
+ return `${status} - ${json.detail}`;
111
+ }
112
+ catch {
113
+ // Not JSON — use raw body but truncate
114
+ }
115
+ // Truncate long raw error bodies
116
+ const truncated = body.length > 200 ? body.slice(0, 200) + '...' : body;
117
+ return `${status} - ${truncated}`;
118
+ }
95
119
  export async function chat(message, history = [], onChunk, onRetry, projectContext, abortSignal) {
96
120
  // Update project context if provided
97
121
  if (projectContext !== undefined) {
@@ -119,13 +143,15 @@ export async function chat(message, history = [], onChunk, onRetry, projectConte
119
143
  }
120
144
  },
121
145
  shouldRetry: (error) => {
122
- // Don't retry on user abort
146
+ // Don't retry on user abort or timeout
123
147
  if (error.name === 'AbortError')
124
148
  return false;
149
+ if (error.isTimeout)
150
+ return false;
125
151
  // Don't retry on 4xx client errors (except 429 rate limit)
126
152
  if (error.status >= 400 && error.status < 500 && error.status !== 429)
127
153
  return false;
128
- // Retry on network errors, timeouts, 5xx, and rate limits
154
+ // Retry on network errors, 5xx, and rate limits
129
155
  return true;
130
156
  },
131
157
  });
@@ -135,9 +161,14 @@ export async function chat(message, history = [], onChunk, onRetry, projectConte
135
161
  }
136
162
  catch (error) {
137
163
  const err = error;
138
- // Don't log or throw if request was aborted by user
164
+ // Timeout errors (from chatOpenAI/chatAnthropic) show user-friendly message
165
+ if (err.isTimeout) {
166
+ logApiResponse(providerId, false, undefined, 'timeout');
167
+ throw error;
168
+ }
169
+ // User cancel (Escape key) — re-throw silently without logging
139
170
  if (err.name === 'AbortError' || err.message?.includes('aborted')) {
140
- throw error; // Re-throw abort errors silently
171
+ throw error;
141
172
  }
142
173
  // Log error
143
174
  logApiResponse(providerId, false, undefined, err.message);
@@ -145,9 +176,6 @@ export async function chat(message, history = [], onChunk, onRetry, projectConte
145
176
  if (isNetworkError(error)) {
146
177
  throw new Error(getErrorMessage('noInternet'));
147
178
  }
148
- if (isTimeoutError(error)) {
149
- throw new Error(getErrorMessage('timeout'));
150
- }
151
179
  throw error;
152
180
  }
153
181
  }
@@ -247,10 +275,11 @@ async function chatOpenAI(message, history, model, apiKey, onChunk, abortSignal)
247
275
  if (!baseUrl) {
248
276
  throw new Error(`Provider ${providerId} does not support OpenAI protocol`);
249
277
  }
250
- // Create abort controller for timeout or use provided one
251
- const controller = abortSignal ? new AbortController() : new AbortController();
252
- const timeoutId = setTimeout(() => controller.abort(), timeout);
253
- // Listen to external abort signal if provided
278
+ // Create abort controller with timeout flag to distinguish from user cancel
279
+ const controller = new AbortController();
280
+ let timedOut = false;
281
+ const timeoutId = setTimeout(() => { timedOut = true; controller.abort(); }, timeout);
282
+ // Listen to external abort signal if provided (user cancel)
254
283
  if (abortSignal) {
255
284
  abortSignal.addEventListener('abort', () => controller.abort());
256
285
  }
@@ -279,8 +308,8 @@ async function chatOpenAI(message, history, model, apiKey, onChunk, abortSignal)
279
308
  signal: controller.signal,
280
309
  });
281
310
  if (!response.ok) {
282
- const error = await response.text();
283
- const err = new Error(`${getErrorMessage('apiError')}: ${response.status} - ${error}`);
311
+ const body = await response.text();
312
+ const err = new Error(`${getErrorMessage('apiError')}: ${parseApiError(response.status, body)}`);
284
313
  err.status = response.status;
285
314
  throw err;
286
315
  }
@@ -296,6 +325,14 @@ async function chatOpenAI(message, history, model, apiKey, onChunk, abortSignal)
296
325
  return stripThinkTags(content);
297
326
  }
298
327
  }
328
+ catch (error) {
329
+ if (timedOut) {
330
+ const err = new Error(getErrorMessage('timeout'));
331
+ err.isTimeout = true;
332
+ throw err;
333
+ }
334
+ throw error;
335
+ }
299
336
  finally {
300
337
  clearTimeout(timeoutId);
301
338
  }
@@ -342,27 +379,31 @@ async function handleOpenAIStream(body, onChunk) {
342
379
  }
343
380
  async function chatAnthropic(message, history, model, apiKey, onChunk, abortSignal) {
344
381
  const systemPrompt = getSystemPrompt();
345
- const messages = [
346
- { role: 'user', content: systemPrompt },
347
- { role: 'assistant', content: 'Understood. I will follow your language instructions.' },
348
- ...history,
349
- { role: 'user', content: message },
350
- ];
382
+ const providerId = config.get('provider');
383
+ // Use native system parameter for Anthropic, fake turns for other providers
384
+ const useNativeSystem = providerId === 'anthropic';
385
+ const messages = useNativeSystem
386
+ ? [...history, { role: 'user', content: message }]
387
+ : [
388
+ { role: 'user', content: systemPrompt },
389
+ { role: 'assistant', content: 'Understood.' },
390
+ ...history,
391
+ { role: 'user', content: message },
392
+ ];
351
393
  const stream = Boolean(onChunk);
352
394
  const timeout = config.get('apiTimeout');
353
395
  const temperature = config.get('temperature');
354
396
  const maxTokens = config.get('maxTokens');
355
- // Get provider-specific URL and auth
356
- const providerId = config.get('provider');
357
397
  const baseUrl = getProviderBaseUrl(providerId, 'anthropic');
358
398
  const authHeader = getProviderAuthHeader(providerId, 'anthropic');
359
399
  if (!baseUrl) {
360
400
  throw new Error(`Provider ${providerId} does not support Anthropic protocol`);
361
401
  }
362
- // Create abort controller for timeout or use provided one
402
+ // Create abort controller with timeout flag to distinguish from user cancel
363
403
  const controller = new AbortController();
364
- const timeoutId = setTimeout(() => controller.abort(), timeout);
365
- // Listen to external abort signal if provided
404
+ let timedOut = false;
405
+ const timeoutId = setTimeout(() => { timedOut = true; controller.abort(); }, timeout);
406
+ // Listen to external abort signal if provided (user cancel)
366
407
  if (abortSignal) {
367
408
  abortSignal.addEventListener('abort', () => controller.abort());
368
409
  }
@@ -387,12 +428,13 @@ async function chatAnthropic(message, history, model, apiKey, onChunk, abortSign
387
428
  max_tokens: maxTokens,
388
429
  temperature,
389
430
  stream,
431
+ ...(useNativeSystem ? { system: systemPrompt } : {}),
390
432
  }),
391
433
  signal: controller.signal,
392
434
  });
393
435
  if (!response.ok) {
394
- const error = await response.text();
395
- const err = new Error(`${getErrorMessage('apiError')}: ${response.status} - ${error}`);
436
+ const body = await response.text();
437
+ const err = new Error(`${getErrorMessage('apiError')}: ${parseApiError(response.status, body)}`);
396
438
  err.status = response.status;
397
439
  throw err;
398
440
  }
@@ -408,6 +450,14 @@ async function chatAnthropic(message, history, model, apiKey, onChunk, abortSign
408
450
  return stripThinkTags(content);
409
451
  }
410
452
  }
453
+ catch (error) {
454
+ if (timedOut) {
455
+ const err = new Error(getErrorMessage('timeout'));
456
+ err.isTimeout = true;
457
+ throw err;
458
+ }
459
+ throw error;
460
+ }
411
461
  finally {
412
462
  clearTimeout(timeoutId);
413
463
  }
@@ -85,6 +85,29 @@ export const PROVIDERS = {
85
85
  envKey: 'MINIMAX_API_KEY',
86
86
  subscribeUrl: 'https://platform.minimax.io/subscribe/coding-plan?code=2lWvoWUhrp&source=link',
87
87
  },
88
+ 'minimax-cn': {
89
+ name: 'MiniMax China',
90
+ description: 'MiniMax Coding Plan (China)',
91
+ protocols: {
92
+ openai: {
93
+ baseUrl: 'https://api.minimaxi.com/v1',
94
+ authHeader: 'Bearer',
95
+ supportsNativeTools: true,
96
+ },
97
+ anthropic: {
98
+ baseUrl: 'https://api.minimaxi.com/anthropic',
99
+ authHeader: 'x-api-key',
100
+ supportsNativeTools: false,
101
+ },
102
+ },
103
+ models: [
104
+ { id: 'MiniMax-M2.5', name: 'MiniMax M2.5', description: 'Latest MiniMax coding model' },
105
+ ],
106
+ defaultModel: 'MiniMax-M2.5',
107
+ defaultProtocol: 'anthropic',
108
+ envKey: 'MINIMAX_CN_API_KEY',
109
+ subscribeUrl: 'https://platform.minimaxi.com',
110
+ },
88
111
  'deepseek': {
89
112
  name: 'DeepSeek',
90
113
  description: 'DeepSeek AI models',
@@ -115,11 +138,12 @@ export const PROVIDERS = {
115
138
  },
116
139
  },
117
140
  models: [
118
- { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', description: 'Best balance of speed and intelligence' },
119
- { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', description: 'Fastest and most affordable' },
141
+ { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', description: 'Latest Sonnet — best balance of speed and intelligence' },
120
142
  { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', description: 'Most capable model' },
143
+ { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', description: 'Previous generation Sonnet' },
144
+ { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', description: 'Fastest and most affordable' },
121
145
  ],
122
- defaultModel: 'claude-sonnet-4-5-20250929',
146
+ defaultModel: 'claude-sonnet-4-6',
123
147
  defaultProtocol: 'anthropic',
124
148
  envKey: 'ANTHROPIC_API_KEY',
125
149
  },
@@ -72,12 +72,14 @@ describe('providers', () => {
72
72
  expect(item.description).toBeDefined();
73
73
  }
74
74
  });
75
- it('should include z.ai, z.ai-cn, and minimax', () => {
75
+ it('should include z.ai, z.ai-cn, minimax, minimax-cn, and anthropic', () => {
76
76
  const list = getProviderList();
77
77
  const ids = list.map(p => p.id);
78
78
  expect(ids).toContain('z.ai');
79
79
  expect(ids).toContain('z.ai-cn');
80
80
  expect(ids).toContain('minimax');
81
+ expect(ids).toContain('minimax-cn');
82
+ expect(ids).toContain('anthropic');
81
83
  });
82
84
  });
83
85
  describe('getProviderModels', () => {
@@ -136,6 +138,30 @@ describe('providers', () => {
136
138
  it('should have env key for minimax', () => {
137
139
  expect(PROVIDERS['minimax'].envKey).toBe('MINIMAX_API_KEY');
138
140
  });
141
+ it('should have env key for minimax-cn', () => {
142
+ expect(PROVIDERS['minimax-cn'].envKey).toBe('MINIMAX_CN_API_KEY');
143
+ });
144
+ it('should have env key for anthropic', () => {
145
+ expect(PROVIDERS['anthropic'].envKey).toBe('ANTHROPIC_API_KEY');
146
+ });
147
+ });
148
+ describe('minimax-cn provider', () => {
149
+ it('should have correct name and endpoints', () => {
150
+ expect(PROVIDERS['minimax-cn']).toBeDefined();
151
+ expect(PROVIDERS['minimax-cn'].name).toBe('MiniMax China');
152
+ expect(getProviderBaseUrl('minimax-cn', 'openai')).toContain('api.minimaxi.com');
153
+ expect(getProviderBaseUrl('minimax-cn', 'anthropic')).toContain('api.minimaxi.com');
154
+ });
155
+ });
156
+ describe('anthropic provider', () => {
157
+ it('should include Claude Sonnet 4.6 as default model', () => {
158
+ expect(PROVIDERS['anthropic'].defaultModel).toBe('claude-sonnet-4-6');
159
+ const modelIds = PROVIDERS['anthropic'].models.map(m => m.id);
160
+ expect(modelIds).toContain('claude-sonnet-4-6');
161
+ expect(modelIds).toContain('claude-opus-4-6');
162
+ expect(modelIds).toContain('claude-sonnet-4-5-20250929');
163
+ expect(modelIds).toContain('claude-haiku-4-5-20251001');
164
+ });
139
165
  });
140
166
  describe('MCP endpoints', () => {
141
167
  it('should have MCP endpoints for z.ai', () => {
@@ -358,6 +358,7 @@ export class App {
358
358
  this.screen.init();
359
359
  this.input.start();
360
360
  this.input.onKey((event) => this.handleKey(event));
361
+ this.screen.onResize(() => this.render());
361
362
  this.render();
362
363
  }
363
364
  /**
@@ -1733,7 +1734,7 @@ export class App {
1733
1734
  if (this.pasteInfoOpen && this.pasteInfo) {
1734
1735
  this.renderInlinePasteInfo(statusLine + 1, width);
1735
1736
  }
1736
- this.screen.fullRender();
1737
+ this.screen.render();
1737
1738
  }
1738
1739
  /**
1739
1740
  * Render inline confirmation dialog below status bar
@@ -58,7 +58,19 @@ export declare class LineEditor {
58
58
  */
59
59
  setCursorPos(pos: number): void;
60
60
  /**
61
- * Delete word backward (Ctrl+W)
61
+ * Check if character is a word boundary (space, path separator, punctuation)
62
+ */
63
+ private isWordBoundary;
64
+ /**
65
+ * Move cursor to previous word boundary (Ctrl+Left)
66
+ */
67
+ wordLeft(): void;
68
+ /**
69
+ * Move cursor to next word boundary (Ctrl+Right)
70
+ */
71
+ wordRight(): void;
72
+ /**
73
+ * Delete word backward (Ctrl+W) — respects path separators
62
74
  */
63
75
  deleteWordBackward(): void;
64
76
  /**
@@ -19,7 +19,8 @@ export class Input {
19
19
  // Enable mouse tracking (SGR mode for better compatibility)
20
20
  // \x1b[?1000h - enable basic mouse tracking
21
21
  // \x1b[?1006h - enable SGR extended mouse mode
22
- process.stdout.write('\x1b[?1000h\x1b[?1006h');
22
+ // \x1b[?2004h - enable bracketed paste mode (wraps pastes in \x1b[200~ ... \x1b[201~)
23
+ process.stdout.write('\x1b[?1000h\x1b[?1006h\x1b[?2004h');
23
24
  this.dataHandler = (data) => {
24
25
  const event = this.parseKey(data);
25
26
  this.emit(event);
@@ -35,8 +36,8 @@ export class Input {
35
36
  process.stdin.removeListener('data', this.dataHandler);
36
37
  this.dataHandler = null;
37
38
  }
38
- // Disable mouse tracking
39
- process.stdout.write('\x1b[?1006l\x1b[?1000l');
39
+ // Disable mouse tracking and bracketed paste mode
40
+ process.stdout.write('\x1b[?2004l\x1b[?1006l\x1b[?1000l');
40
41
  if (process.stdin.isTTY) {
41
42
  process.stdin.setRawMode(false);
42
43
  }
@@ -239,6 +240,16 @@ export class Input {
239
240
  case '6~':
240
241
  event.key = 'pagedown';
241
242
  break;
243
+ // Ctrl+Right (word jump forward)
244
+ case '1;5C':
245
+ event.key = 'ctrl-right';
246
+ event.ctrl = true;
247
+ break;
248
+ // Ctrl+Left (word jump backward)
249
+ case '1;5D':
250
+ event.key = 'ctrl-left';
251
+ event.ctrl = true;
252
+ break;
242
253
  default:
243
254
  event.key = 'unknown';
244
255
  }
@@ -300,20 +311,57 @@ export class LineEditor {
300
311
  this.cursorPos = Math.max(0, Math.min(pos, this.value.length));
301
312
  }
302
313
  /**
303
- * Delete word backward (Ctrl+W)
314
+ * Check if character is a word boundary (space, path separator, punctuation)
315
+ */
316
+ isWordBoundary(ch) {
317
+ return ' \t/\\.-_:'.includes(ch);
318
+ }
319
+ /**
320
+ * Move cursor to previous word boundary (Ctrl+Left)
321
+ */
322
+ wordLeft() {
323
+ if (this.cursorPos === 0)
324
+ return;
325
+ let i = this.cursorPos - 1;
326
+ // Skip current boundary chars
327
+ while (i > 0 && this.isWordBoundary(this.value[i]))
328
+ i--;
329
+ // Move to start of word
330
+ while (i > 0 && !this.isWordBoundary(this.value[i - 1]))
331
+ i--;
332
+ this.cursorPos = i;
333
+ }
334
+ /**
335
+ * Move cursor to next word boundary (Ctrl+Right)
336
+ */
337
+ wordRight() {
338
+ const len = this.value.length;
339
+ if (this.cursorPos >= len)
340
+ return;
341
+ let i = this.cursorPos;
342
+ // Skip current word chars
343
+ while (i < len && !this.isWordBoundary(this.value[i]))
344
+ i++;
345
+ // Skip boundary chars
346
+ while (i < len && this.isWordBoundary(this.value[i]))
347
+ i++;
348
+ this.cursorPos = i;
349
+ }
350
+ /**
351
+ * Delete word backward (Ctrl+W) — respects path separators
304
352
  */
305
353
  deleteWordBackward() {
306
354
  if (this.cursorPos === 0)
307
355
  return;
308
- const beforeCursor = this.value.slice(0, this.cursorPos);
309
356
  const afterCursor = this.value.slice(this.cursorPos);
310
- // Find last word boundary (skip trailing spaces, then find space)
311
- let i = beforeCursor.length - 1;
312
- while (i >= 0 && beforeCursor[i] === ' ')
357
+ let i = this.cursorPos - 1;
358
+ // Skip trailing spaces
359
+ while (i >= 0 && this.value[i] === ' ')
313
360
  i--;
314
- while (i >= 0 && beforeCursor[i] !== ' ')
361
+ // Delete back to next word boundary
362
+ while (i >= 0 && !this.isWordBoundary(this.value[i]))
315
363
  i--;
316
- const newBefore = beforeCursor.slice(0, i + 1);
364
+ const newBefore = this.value.slice(0, i + 1);
317
365
  this.value = newBefore + afterCursor;
318
366
  this.cursorPos = newBefore.length;
319
367
  }
@@ -361,6 +409,12 @@ export class LineEditor {
361
409
  this.cursorPos++;
362
410
  }
363
411
  break;
412
+ case 'ctrl-left':
413
+ this.wordLeft();
414
+ break;
415
+ case 'ctrl-right':
416
+ this.wordRight();
417
+ break;
364
418
  case 'home':
365
419
  this.cursorPos = 0;
366
420
  break;
@@ -14,7 +14,12 @@ export declare class Screen {
14
14
  private cursorX;
15
15
  private cursorY;
16
16
  private cursorVisible;
17
+ private resizeCallback;
17
18
  constructor();
19
+ /**
20
+ * Register a callback to be called on terminal resize
21
+ */
22
+ onResize(callback: () => void): void;
18
23
  private createEmptyBuffer;
19
24
  /**
20
25
  * Get terminal dimensions
@@ -11,6 +11,7 @@ export class Screen {
11
11
  cursorX = 0;
12
12
  cursorY = 0;
13
13
  cursorVisible = true;
14
+ resizeCallback = null;
14
15
  constructor() {
15
16
  this.width = process.stdout.columns || 80;
16
17
  this.height = process.stdout.rows || 24;
@@ -22,9 +23,18 @@ export class Screen {
22
23
  this.height = process.stdout.rows || 24;
23
24
  this.buffer = this.createEmptyBuffer();
24
25
  this.rendered = this.createEmptyBuffer();
25
- this.fullRender();
26
+ // Notify the app to re-render with new dimensions
27
+ if (this.resizeCallback) {
28
+ this.resizeCallback();
29
+ }
26
30
  });
27
31
  }
32
+ /**
33
+ * Register a callback to be called on terminal resize
34
+ */
35
+ onResize(callback) {
36
+ this.resizeCallback = callback;
37
+ }
28
38
  createEmptyBuffer() {
29
39
  const buffer = [];
30
40
  for (let y = 0; y < this.height; y++) {
@@ -16,6 +16,7 @@ import { isProjectDirectory, getProjectContext } from '../utils/project.js';
16
16
  import { getCurrentVersion } from '../utils/update.js';
17
17
  import { getProviderList, getProvider } from '../config/providers.js';
18
18
  import { getSessionStats } from '../utils/tokenTracker.js';
19
+ import { checkApiRateLimit } from '../utils/ratelimit.js';
19
20
  // State
20
21
  let projectPath = process.cwd();
21
22
  let projectContext = null;
@@ -127,6 +128,12 @@ async function handleSubmit(message) {
127
128
  runAgentTask(message, false);
128
129
  return;
129
130
  }
131
+ // Check API rate limit
132
+ const rateCheck = checkApiRateLimit();
133
+ if (!rateCheck.allowed) {
134
+ app.notify(rateCheck.message || 'Rate limit exceeded', 5000);
135
+ return;
136
+ }
130
137
  try {
131
138
  app.startStreaming();
132
139
  // Get conversation history for context
@@ -144,6 +151,9 @@ async function handleSubmit(message) {
144
151
  catch (error) {
145
152
  app.endStreaming();
146
153
  const err = error;
154
+ // Don't show error for user-cancelled requests
155
+ if (err.name === 'AbortError')
156
+ return;
147
157
  app.notify(`Error: ${err.message}`, 5000);
148
158
  }
149
159
  }
@@ -966,12 +976,23 @@ function handleCommand(command, args) {
966
976
  for (const p of configuredProviders) {
967
977
  clearApiKey(p.id);
968
978
  }
969
- app.notify('Logged out from all providers');
979
+ app.notify('Logged out from all providers. Use /login to sign in.');
970
980
  }
971
981
  else {
972
982
  clearApiKey(result);
973
983
  const provider = configuredProviders.find(p => p.id === result);
974
984
  app.notify(`Logged out from ${provider?.name || result}`);
985
+ // If we logged out from the active provider, switch to another configured one
986
+ if (result === currentProvider.id) {
987
+ const remaining = configuredProviders.filter(p => p.id !== result);
988
+ if (remaining.length > 0) {
989
+ setProvider(remaining[0].id);
990
+ app.notify(`Switched to ${remaining[0].name}`);
991
+ }
992
+ else {
993
+ app.notify('No providers configured. Use /login to sign in.');
994
+ }
995
+ }
975
996
  }
976
997
  });
977
998
  break;
@@ -1057,12 +1078,24 @@ function handleCommand(command, args) {
1057
1078
  app.notify('No assistant response to apply');
1058
1079
  return;
1059
1080
  }
1060
- // Find file changes in the response
1061
- const filePattern = /```(\w+)?\s*\n\/\/\s*(?:File:|Path:)\s*([^\n]+)\n([\s\S]*?)```/g;
1081
+ // Find file changes in the response using multiple patterns
1062
1082
  const changes = [];
1083
+ // Pattern 1: ```lang filepath\n...\n``` (most common AI format)
1084
+ const fenceFilePattern = /```\w*\s+([\w./\\-]+(?:\.\w+))\n([\s\S]*?)```/g;
1063
1085
  let match;
1064
- while ((match = filePattern.exec(lastAssistant.content)) !== null) {
1065
- changes.push({ path: match[2].trim(), content: match[3] });
1086
+ while ((match = fenceFilePattern.exec(lastAssistant.content)) !== null) {
1087
+ const path = match[1].trim();
1088
+ // Must look like a file path (has extension, no spaces)
1089
+ if (path.includes('.') && !path.includes(' ')) {
1090
+ changes.push({ path, content: match[2] });
1091
+ }
1092
+ }
1093
+ // Pattern 2: // File: or // Path: comment on first line of code block
1094
+ if (changes.length === 0) {
1095
+ const commentPattern = /```(\w+)?\s*\n(?:\/\/|#|--|\/\*)\s*(?:File|Path|file|path):\s*([^\n*]+)\n([\s\S]*?)```/g;
1096
+ while ((match = commentPattern.exec(lastAssistant.content)) !== null) {
1097
+ changes.push({ path: match[2].trim(), content: match[3] });
1098
+ }
1066
1099
  }
1067
1100
  if (changes.length === 0) {
1068
1101
  app.notify('No file changes found in response');
@@ -80,7 +80,7 @@ export function loadProjectRules(projectRoot) {
80
80
  * Keeps the most recent messages within a character budget so the agent
81
81
  * has conversational context without overwhelming the context window.
82
82
  */
83
- export function formatChatHistoryForAgent(history, maxChars = 8000) {
83
+ export function formatChatHistoryForAgent(history, maxChars = 16000) {
84
84
  if (!history || history.length === 0)
85
85
  return '';
86
86
  // Filter out agent execution messages
@@ -262,6 +262,17 @@ export declare const AGENT_TOOLS: {
262
262
  };
263
263
  };
264
264
  };
265
+ minimax_web_search: {
266
+ name: string;
267
+ description: string;
268
+ parameters: {
269
+ query: {
270
+ type: string;
271
+ description: string;
272
+ required: boolean;
273
+ };
274
+ };
275
+ };
265
276
  };
266
277
  export declare function formatToolDefinitions(): string;
267
278
  /**
@@ -13,11 +13,15 @@ import { executeCommand } from './shell.js';
13
13
  import { recordWrite, recordEdit, recordDelete, recordMkdir, recordCommand } from './history.js';
14
14
  import { loadIgnoreRules, isIgnored } from './gitignore.js';
15
15
  import { config, getApiKey } from '../config/index.js';
16
- import { getProviderMcpEndpoints } from '../config/providers.js';
16
+ import { getProviderMcpEndpoints, PROVIDERS } from '../config/providers.js';
17
17
  // Z.AI MCP tool names (available when user has any Z.AI API key)
18
18
  const ZAI_MCP_TOOLS = ['web_search', 'web_read', 'github_read'];
19
19
  // Z.AI provider IDs that have MCP endpoints
20
20
  const ZAI_PROVIDER_IDS = ['z.ai', 'z.ai-cn'];
21
+ // MiniMax MCP tool names (available when user has any MiniMax API key)
22
+ const MINIMAX_MCP_TOOLS = ['minimax_web_search'];
23
+ // MiniMax provider IDs
24
+ const MINIMAX_PROVIDER_IDS = ['minimax', 'minimax-cn'];
21
25
  /**
22
26
  * Find a Z.AI provider that has an API key configured.
23
27
  * Returns the provider ID and API key, or null if none found.
@@ -49,6 +53,77 @@ function getZaiMcpConfig() {
49
53
  function hasZaiMcpAccess() {
50
54
  return getZaiMcpConfig() !== null;
51
55
  }
56
+ /**
57
+ * Find a MiniMax provider that has an API key configured.
58
+ * Returns the base host URL and API key, or null if none found.
59
+ */
60
+ function getMinimaxMcpConfig() {
61
+ // First check if active provider is MiniMax
62
+ const activeProvider = config.get('provider');
63
+ if (MINIMAX_PROVIDER_IDS.includes(activeProvider)) {
64
+ const key = getApiKey(activeProvider);
65
+ if (key) {
66
+ const provider = PROVIDERS[activeProvider];
67
+ const baseUrl = provider?.protocols?.openai?.baseUrl;
68
+ if (baseUrl) {
69
+ // Extract host from baseUrl (e.g. https://api.minimax.io/v1 -> https://api.minimax.io)
70
+ const host = baseUrl.replace(/\/v1\/?$/, '');
71
+ return { host, apiKey: key };
72
+ }
73
+ }
74
+ }
75
+ // Otherwise check all MiniMax providers
76
+ for (const pid of MINIMAX_PROVIDER_IDS) {
77
+ const key = getApiKey(pid);
78
+ if (key) {
79
+ const provider = PROVIDERS[pid];
80
+ const baseUrl = provider?.protocols?.openai?.baseUrl;
81
+ if (baseUrl) {
82
+ const host = baseUrl.replace(/\/v1\/?$/, '');
83
+ return { host, apiKey: key };
84
+ }
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+ /**
90
+ * Check if MiniMax MCP tools are available
91
+ */
92
+ function hasMinimaxMcpAccess() {
93
+ return getMinimaxMcpConfig() !== null;
94
+ }
95
+ /**
96
+ * Call a MiniMax MCP REST API endpoint
97
+ */
98
+ async function callMinimaxApi(host, path, body, apiKey) {
99
+ const controller = new AbortController();
100
+ const timeout = setTimeout(() => controller.abort(), 60000);
101
+ try {
102
+ const response = await fetch(`${host}${path}`, {
103
+ method: 'POST',
104
+ headers: {
105
+ 'Content-Type': 'application/json',
106
+ 'Authorization': `Bearer ${apiKey}`,
107
+ },
108
+ body: JSON.stringify(body),
109
+ signal: controller.signal,
110
+ });
111
+ if (!response.ok) {
112
+ const errorText = await response.text().catch(() => '');
113
+ throw new Error(`MiniMax API error ${response.status}: ${errorText || response.statusText}`);
114
+ }
115
+ const data = await response.json();
116
+ // MiniMax returns content array like MCP
117
+ if (data.content && Array.isArray(data.content)) {
118
+ return data.content.map((c) => c.text || '').join('\n');
119
+ }
120
+ // Fallback: return raw result
121
+ return typeof data === 'string' ? data : JSON.stringify(data);
122
+ }
123
+ finally {
124
+ clearTimeout(timeout);
125
+ }
126
+ }
52
127
  /**
53
128
  * Call a Z.AI MCP endpoint via JSON-RPC 2.0
54
129
  */
@@ -196,18 +271,28 @@ export const AGENT_TOOLS = {
196
271
  path: { type: 'string', description: 'File path (for action=read_file) or directory path (for action=tree)', required: false },
197
272
  },
198
273
  },
274
+ minimax_web_search: {
275
+ name: 'minimax_web_search',
276
+ description: 'Search the web using MiniMax search engine. Returns relevant results with summaries. Requires a MiniMax API key.',
277
+ parameters: {
278
+ query: { type: 'string', description: 'Search query', required: true },
279
+ },
280
+ },
199
281
  };
200
282
  /**
201
283
  * Format tool definitions for system prompt (text-based fallback)
202
284
  */
203
285
  /**
204
- * Get filtered tool entries (excludes Z.AI-only tools when not using Z.AI provider)
286
+ * Get filtered tool entries (excludes provider-specific tools when API key not available)
205
287
  */
206
288
  function getFilteredToolEntries() {
207
289
  const hasMcp = hasZaiMcpAccess();
290
+ const hasMinimaxMcp = hasMinimaxMcpAccess();
208
291
  return Object.entries(AGENT_TOOLS).filter(([name]) => {
209
292
  if (ZAI_MCP_TOOLS.includes(name))
210
293
  return hasMcp;
294
+ if (MINIMAX_MCP_TOOLS.includes(name))
295
+ return hasMinimaxMcp;
211
296
  return true;
212
297
  });
213
298
  }
@@ -1099,6 +1184,20 @@ export async function executeTool(toolCall, projectRoot) {
1099
1184
  const output = result.length > 15000 ? result.substring(0, 15000) + '\n\n... (truncated)' : result;
1100
1185
  return { success: true, output, tool, parameters };
1101
1186
  }
1187
+ // === MiniMax MCP Tools ===
1188
+ case 'minimax_web_search': {
1189
+ const mmConfig = getMinimaxMcpConfig();
1190
+ if (!mmConfig) {
1191
+ return { success: false, output: '', error: 'minimax_web_search requires a MiniMax API key. Configure one via /provider minimax', tool, parameters };
1192
+ }
1193
+ const query = parameters.query;
1194
+ if (!query) {
1195
+ return { success: false, output: '', error: 'Missing required parameter: query', tool, parameters };
1196
+ }
1197
+ const result = await callMinimaxApi(mmConfig.host, '/v1/coding_plan/search', { q: query }, mmConfig.apiKey);
1198
+ const output = result.length > 15000 ? result.substring(0, 15000) + '\n\n... (truncated)' : result;
1199
+ return { success: true, output, tool, parameters };
1200
+ }
1102
1201
  default:
1103
1202
  return { success: false, output: '', error: `Unknown tool: ${tool}`, tool, parameters };
1104
1203
  }
@@ -1217,6 +1316,7 @@ export function createActionLog(toolCall, result) {
1217
1316
  web_search: 'fetch',
1218
1317
  web_read: 'fetch',
1219
1318
  github_read: 'fetch',
1319
+ minimax_web_search: 'fetch',
1220
1320
  };
1221
1321
  const target = toolCall.parameters.path ||
1222
1322
  toolCall.parameters.command ||
@@ -2,9 +2,11 @@ import { describe, it, expect } from 'vitest';
2
2
  import { getOpenAITools, getAnthropicTools, parseToolCalls, parseOpenAIToolCalls, parseAnthropicToolCalls, createActionLog, AGENT_TOOLS, } from './tools.js';
3
3
  // ─── CONSTANTS ───────────────────────────────────────────────────────────────
4
4
  const ALL_TOOL_NAMES = Object.keys(AGENT_TOOLS);
5
- // MCP tools are filtered out when no Z.AI API key is configured (e.g. in tests)
5
+ // MCP tools are filtered out when no API key is configured (e.g. in tests)
6
6
  const ZAI_MCP_TOOLS = ['web_search', 'web_read', 'github_read'];
7
- const CORE_TOOL_NAMES = ALL_TOOL_NAMES.filter(n => !ZAI_MCP_TOOLS.includes(n));
7
+ const MINIMAX_MCP_TOOLS = ['minimax_web_search'];
8
+ const MCP_TOOLS = [...ZAI_MCP_TOOLS, ...MINIMAX_MCP_TOOLS];
9
+ const CORE_TOOL_NAMES = ALL_TOOL_NAMES.filter(n => !MCP_TOOLS.includes(n));
8
10
  // ─── getOpenAITools ──────────────────────────────────────────────────────────
9
11
  describe('getOpenAITools', () => {
10
12
  it('should return one entry per AGENT_TOOLS definition', () => {
@@ -578,6 +580,11 @@ describe('createActionLog', () => {
578
580
  const log = createActionLog(tc, makeResult({ tool: 'fetch_url' }));
579
581
  expect(log.type).toBe('fetch');
580
582
  });
583
+ it('should map minimax_web_search to type "fetch"', () => {
584
+ const tc = { tool: 'minimax_web_search', parameters: { query: 'test' } };
585
+ const log = createActionLog(tc, makeResult({ tool: 'minimax_web_search' }));
586
+ expect(log.type).toBe('fetch');
587
+ });
581
588
  it('should default to type "command" for unknown tools', () => {
582
589
  const tc = { tool: 'unknown_tool', parameters: {} };
583
590
  const log = createActionLog(tc, makeResult({ tool: 'unknown_tool' }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeep",
3
- "version": "1.2.14",
3
+ "version": "1.2.16",
4
4
  "description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",