diff --git a/assets/kimi.png b/assets/kimi.png
new file mode 100644
index 0000000..2839282
Binary files /dev/null and b/assets/kimi.png differ
diff --git a/cli/proxima-cli.cjs b/cli/proxima-cli.cjs
index 1934cf0..9e673e4 100644
--- a/cli/proxima-cli.cjs
+++ b/cli/proxima-cli.cjs
@@ -119,7 +119,7 @@ function startSpinner(text) {
const FALLBACK_INFO = {
mode: 'session',
- providers: ['chatgpt', 'claude', 'gemini', 'perplexity'],
+ providers: ['chatgpt', 'claude', 'gemini', 'perplexity', 'kimi'],
models: {},
firstProvider: 'claude'
};
diff --git a/electron/api/byok/keys.cjs b/electron/api/byok/keys.cjs
index 3e61b3f..50071e7 100644
--- a/electron/api/byok/keys.cjs
+++ b/electron/api/byok/keys.cjs
@@ -11,7 +11,7 @@ const ENC_VERSION = 1;
let _storeCache = null;
const KNOWN_PROVIDERS = Object.freeze([
- 'chatgpt', 'claude', 'gemini', 'perplexity',
+ 'chatgpt', 'claude', 'gemini', 'perplexity', 'kimi',
'deepseek', 'groq', 'xai', 'openrouter', 'together', 'fireworks', 'mistral', 'nvidia',
]);
diff --git a/electron/api/byok/model-fetcher.cjs b/electron/api/byok/model-fetcher.cjs
index e28e93f..09244ed 100644
--- a/electron/api/byok/model-fetcher.cjs
+++ b/electron/api/byok/model-fetcher.cjs
@@ -25,6 +25,10 @@ const MODELS_CONFIG = Object.freeze({
'sonar-deep-research', 'r1-1776',
],
},
+ kimi: {
+ url: 'https://api.moonshot.cn/v1/models',
+ auth: 'bearer',
+ },
deepseek: {
url: 'https://api.deepseek.com/v1/models',
auth: 'bearer',
diff --git a/electron/api/byok/models.cjs b/electron/api/byok/models.cjs
index 73461b2..3e4f481 100644
--- a/electron/api/byok/models.cjs
+++ b/electron/api/byok/models.cjs
@@ -8,6 +8,7 @@ const FALLBACK_MODELS = Object.freeze({
claude: ['claude-sonnet-5', 'claude-3-5-sonnet-latest', 'claude-3-haiku-20240307'],
gemini: ['gemini-3.5-flash', 'gemini-3.1-pro', 'gemini-2.0-flash-lite'],
perplexity: ['sonar-pro', 'sonar'],
+ kimi: ['moonshot-v1-32k', 'moonshot-v1-8k'],
deepseek: ['deepseek-chat'],
groq: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant'],
xai: ['grok-2-1212', 'grok-beta'],
@@ -23,6 +24,7 @@ const DEFAULT_MODELS = Object.freeze({
claude: FALLBACK_MODELS.claude[0],
gemini: FALLBACK_MODELS.gemini[0],
perplexity: FALLBACK_MODELS.perplexity[0],
+ kimi: FALLBACK_MODELS.kimi[0],
deepseek: FALLBACK_MODELS.deepseek[0],
groq: FALLBACK_MODELS.groq[0],
xai: FALLBACK_MODELS.xai[0],
@@ -44,6 +46,7 @@ const API_ENDPOINTS = Object.freeze({
claude: 'https://api.anthropic.com/v1/messages',
google: 'https://generativelanguage.googleapis.com/v1beta/models',
perplexity: 'https://api.perplexity.ai/chat/completions',
+ kimi: 'https://api.moonshot.cn/v1/chat/completions',
deepseek: 'https://api.deepseek.com/v1/chat/completions',
groq: 'https://api.groq.com/openai/v1/chat/completions',
xai: 'https://api.x.ai/v1/chat/completions',
@@ -61,6 +64,7 @@ const MAX_TOKENS = Object.freeze({
claude: 4096,
gemini: 8192,
perplexity: 4096,
+ kimi: 8192,
deepseek: 4096,
groq: 4096,
xai: 4096,
diff --git a/electron/api/byok/router.cjs b/electron/api/byok/router.cjs
index 2883a35..4655d1d 100644
--- a/electron/api/byok/router.cjs
+++ b/electron/api/byok/router.cjs
@@ -16,6 +16,7 @@ const CALLERS = Object.freeze({
claude: anthropic,
gemini: google,
perplexity: perplexity,
+ kimi: compatible,
deepseek: compatible,
groq: compatible,
xai: compatible,
diff --git a/electron/api/pages/widget.cjs b/electron/api/pages/widget.cjs
index e44a472..23d6f9d 100644
--- a/electron/api/pages/widget.cjs
+++ b/electron/api/pages/widget.cjs
@@ -74,7 +74,7 @@ function getChatJS() {
} catch (e) {
console.warn('[Widget] Could not fetch models, using defaults:', e.message);
_gatewayMode = 'session';
- _enabledProviders = ['chatgpt', 'claude', 'gemini', 'perplexity'];
+ _enabledProviders = ['chatgpt', 'claude', 'gemini', 'perplexity', 'kimi'];
}
populateDropdown();
populateBattle();
diff --git a/electron/api/rest-api.cjs b/electron/api/rest-api.cjs
index 94c7b8d..3b429d6 100644
--- a/electron/api/rest-api.cjs
+++ b/electron/api/rest-api.cjs
@@ -29,6 +29,7 @@ const MODEL_ALIASES = {
'gemini': 'gemini', 'gemini-pro': 'gemini', 'gemini-2': 'gemini', 'gemini-2.5': 'gemini',
'google': 'gemini', 'bard': 'gemini',
'perplexity': 'perplexity', 'pplx': 'perplexity', 'sonar': 'perplexity',
+ 'kimi': 'kimi', 'moonshot': 'kimi', 'moonshotai': 'kimi',
'auto': 'auto', 'all': 'all'
};
@@ -342,7 +343,7 @@ function pickBestProvider(preferred) {
return enabled.includes(base) ? base : null;
}
- const priorityList = ['claude', 'chatgpt', 'gemini', 'perplexity', 'deepseek', 'groq', 'xai', 'openrouter', 'together', 'fireworks', 'mistral', 'nvidia'];
+ const priorityList = ['claude', 'chatgpt', 'gemini', 'perplexity', 'kimi', 'deepseek', 'groq', 'xai', 'openrouter', 'together', 'fireworks', 'mistral', 'nvidia'];
const priority = priorityList.filter(p => enabled.includes(p));
if (priority.length === 0) {
return enabled.length > 0 ? enabled[0] : null;
@@ -566,7 +567,7 @@ function startRestAPI() {
}
const url = new URL(req.url, `http://localhost:${REST_PORT}`);
- const publicPaths = ['/', '/docs', '/cli', '/ws', '/websocket', '/openapi.json', '/v1/openapi.json', '/api-key', '/v1/byok/models', '/v1/models', '/v1/stats', '/v1/functions'];
+ const publicPaths = ['/', '/docs', '/cli', '/ws', '/websocket', '/openapi.json', '/v1/openapi.json', '/api-key', '/v1/byok/models', '/v1/models', '/v1/stats', '/v1/functions', '/v1/kimi/session'];
if (!publicPaths.includes(url.pathname) && !validateApiKey(req)) {
return sendError(res, 401, 'Invalid or missing API key', 'authentication_error');
}
@@ -574,7 +575,7 @@ function startRestAPI() {
const body = req.method === 'POST' ? await parseBody(req) : {};
const providerKey = (req.headers['x-provider-key'] || '').trim();
if (providerKey) body._byokKey = providerKey;
- await handleRoute(req.method, url.pathname, body, res);
+ await handleRoute(req.method, url.pathname, body, res, req.headers, url.searchParams);
} catch (err) { console.error('[API] Error:', err.message); if (!res.headersSent) sendError(res, err.statusCode || 500, err.message); }
});
diff --git a/electron/api/routes.cjs b/electron/api/routes.cjs
index 5fe239c..674f297 100644
--- a/electron/api/routes.cjs
+++ b/electron/api/routes.cjs
@@ -15,9 +15,11 @@ function createRouteHandler(deps) {
byok,
} = deps;
- return async function handleRoute(method, pathname, body, res) {
+ return async function handleRoute(method, pathname, body, res, headers, searchParams) {
const handleMCPRequest = getHandler();
- const conversationId = (body && (body.conversationId || body.conversation_id || body.sessionId || body.session_id)) || null;
+ const conversationId = (body && (body.conversationId || body.conversation_id || body.sessionId || body.session_id))
+ || (headers && (headers['x-proxima-conversation'] || headers['X-Proxima-Conversation']))
+ || null;
if (method === 'POST' && pathname === `${API_PREFIX}/chat/completions`) {
const fn = (body.function || '').toLowerCase().trim();
@@ -253,10 +255,17 @@ function createRouteHandler(deps) {
else sendJSON(res, 200, formatChatResponse(result, provider));
}
} else {
- const toolPrompt = buildToolCallingPrompt(body);
- if (!toolPrompt) return sendError(res, 400, 'No message provided');
+ // Kimi's engine needs the raw messages array (it reads the
+ // system prompt to detect a NEW chat and only forwards the
+ // latest user turn on follow-ups). Flattening to a string
+ // via buildToolCallingPrompt would lose the system prompt
+ // and make the engine reuse a stale/too-long chat_id.
+ const inputForEngine = provider === 'kimi'
+ ? (Array.isArray(body.messages) ? body.messages : message)
+ : buildToolCallingPrompt(body);
+ if (!inputForEngine) return sendError(res, 400, 'No message provided');
if (TOOL_DEBUG) console.log('[TOOL-CALL/Legacy] Provider:', provider, '| Tools:', body.tools.length);
- const result = await queryProvider(provider, toolPrompt, filePath, gemini, null, conversationId, body._byokKey, null, resolved.byokModelId);
+ const result = await queryProvider(provider, inputForEngine, filePath, gemini, null, conversationId, body._byokKey, null, resolved.byokModelId);
if (TOOL_DEBUG) console.log('[TOOL-CALL/Legacy] Response text (first 300):', JSON.stringify(result.text?.slice(0, 300)));
const parsed = parseToolCallResponse(result.text);
if (TOOL_DEBUG) console.log('[TOOL-CALL/Legacy] Parsed:', parsed.isToolCall, '| toolCalls:', parsed.toolCalls?.length || 0);
@@ -283,7 +292,12 @@ function createRouteHandler(deps) {
if (resolved.mode === 'single') {
const provider = resolved.providers[0];
const isByokActive = !!(body._byokKey || (byok.keys.isEnabled() ? byok.keys.getKey(provider) : null));
- const inputMessage = isByokActive ? (body.messages || message) : message;
+ // Kimi's engine needs the full messages array so it can send the
+ // system prompt + context once at chat start and only the new
+ // user turn afterwards (parent_id chain carries the rest).
+ const inputMessage = isByokActive
+ ? (body.messages || message)
+ : (provider === 'kimi' && Array.isArray(body.messages) ? body.messages : message);
let onChunk = null;
let streamedAny = false;
@@ -373,7 +387,7 @@ function createRouteHandler(deps) {
aliases: Object.entries(MODEL_ALIASES).filter(([_, v]) => v === p).map(([k]) => k).filter(k => k !== p),
});
}
- ['chatgpt', 'claude', 'gemini', 'perplexity'].filter(p => !enabled.includes(p)).forEach(p =>
+ ['chatgpt', 'claude', 'gemini', 'perplexity', 'kimi'].filter(p => !enabled.includes(p)).forEach(p =>
models.push({ id: p, object: 'model', owned_by: 'proxima', status: 'disabled' })
);
}
@@ -395,7 +409,7 @@ function createRouteHandler(deps) {
function: null,
name: 'chat',
description: 'Default chat completion. Omit the "function" field to chat with a model.',
- fields: { model: 'auto|claude|chatgpt|gemini|perplexity|all (or array of models)', message: 'string (or use messages[])', stream: 'boolean (optional)', filePath: 'local file path for multimodal/Gemini routing (optional)' }
+ fields: { model: 'auto|claude|chatgpt|gemini|perplexity|kimi|all (or array of models)', message: 'string (or use messages[])', stream: 'boolean (optional)', filePath: 'local file path for multimodal/Gemini routing (optional)' }
},
{
function: 'search',
@@ -472,7 +486,7 @@ function createRouteHandler(deps) {
if (method === 'POST' && pathname === `${API_PREFIX}/conversations/new`) {
const requested = (body && (body.provider || body.model)) || null;
if (!requested) {
- return sendError(res, 400, 'A "provider" (or "model") is required: chatgpt, claude, gemini, or perplexity.');
+ return sendError(res, 400, 'A "provider" (or "model") is required: chatgpt, claude, gemini, perplexity, or kimi.');
}
const target = pickBestProvider(requested);
if (!target) {
@@ -482,6 +496,20 @@ function createRouteHandler(deps) {
catch (e) { return sendError(res, 500, e.message); }
}
+ if (method === 'GET' && pathname === `${API_PREFIX}/kimi/session`) {
+ // Session-status endpoint for the opencode plugin. The plugin polls this
+ // before each request; when resetCount changes, it knows Kimi rolled to
+ // a NEW chat_id and must re-send the system prompt + handoff summary.
+ const cid = (searchParams && (searchParams.get('conversationId') || searchParams.get('conversation_id') || searchParams.get('sessionId')))
+ || (headers && headers['x-proxima-conversation'])
+ || null;
+ try {
+ const r = await handleMCPRequest({ action: 'getKimiSessionStatus', provider: 'kimi', data: { conversationId: cid } });
+ if (!r || r.success === false) return sendJSON(res, 200, { success: false, resetCount: 0, chatId: null, parentId: null, error: (r && r.error) || 'unavailable' });
+ return sendJSON(res, 200, { success: true, resetCount: r.resetCount || 0, chatId: r.chatId || null, parentId: r.parentId || null });
+ } catch (e) { return sendJSON(res, 200, { success: false, resetCount: 0, chatId: null, parentId: null, error: e.message }); }
+ }
+
if (method === 'GET' && (pathname === `${API_PREFIX}/openapi.json` || pathname === '/openapi.json')) {
try { return sendJSON(res, 200, JSON.parse(require('fs').readFileSync(require('path').join(__dirname, '..', '..', 'docs', 'openapi.json'), 'utf8'))); }
catch (e) { return sendError(res, 500, 'OpenAPI spec not found'); }
diff --git a/electron/api/ws-server.cjs b/electron/api/ws-server.cjs
index cf0156e..4b0fb9a 100644
--- a/electron/api/ws-server.cjs
+++ b/electron/api/ws-server.cjs
@@ -154,7 +154,7 @@ function closeWebSocket() {
}
}
-async function queryProvider(provider, message, filePath = null, engine = null, conversationId = null, modelId = null) {
+async function queryProvider(provider, message, filePath = null, engine = null, conversationId = null, modelId = null, think = true) {
const byokKey = byok.keys.isEnabled() ? byok.keys.getKey(provider) : null;
if (byokKey) {
const messages = [{ role: 'user', content: message }];
@@ -167,7 +167,7 @@ async function queryProvider(provider, message, filePath = null, engine = null,
const sendResult = await handleMCPRequest({
action: 'sendMessage',
provider,
- data: { message, filePath, engine, conversationId }
+ data: { message, filePath, engine, conversationId, think }
});
if (!sendResult.success) {
@@ -199,7 +199,7 @@ function pickBestProvider(preferred) {
if (found) return found;
return null;
}
- const priorityList = ['claude', 'chatgpt', 'gemini', 'perplexity', 'deepseek', 'groq', 'xai', 'openrouter', 'together', 'fireworks', 'mistral', 'nvidia'];
+ const priorityList = ['claude', 'chatgpt', 'gemini', 'perplexity', 'kimi', 'deepseek', 'groq', 'xai', 'openrouter', 'together', 'fireworks', 'mistral', 'nvidia'];
const priority = priorityList.find(p => enabled.includes(p));
if (priority) return priority;
return enabled.length > 0 ? enabled[0] : null;
@@ -265,9 +265,10 @@ async function handleWSMessage(ws, clientId, msg) {
sendJSON(ws, { type: 'status', id: requestId, status: 'processing', model: provider, timestamp: new Date().toISOString() });
const conversationId = msg.conversationId || msg.conversation_id || msg.sessionId || msg.session_id || null;
+ const think = msg.think !== false;
const startTime = Date.now();
try {
- const content = await queryProvider(provider, message, filePath, engine, conversationId, byokModelId);
+ const content = await queryProvider(provider, message, filePath, engine, conversationId, byokModelId, think);
const responseTimeMs = Date.now() - startTime;
sendJSON(ws, {
@@ -546,7 +547,7 @@ async function handleWSMessage(ws, clientId, msg) {
case 'reset': {
const requested = msg.model || null;
if (!requested) {
- sendJSON(ws, { type: 'error', id: requestId, error: 'A "model" (provider) is required to reset: chatgpt, claude, gemini, or perplexity.', timestamp: new Date().toISOString() });
+ sendJSON(ws, { type: 'error', id: requestId, error: 'A "model" (provider) is required to reset: chatgpt, claude, gemini, perplexity, or kimi.', timestamp: new Date().toISOString() });
break;
}
const target = pickBestProvider(requested);
diff --git a/electron/browser-manager.cjs b/electron/browser-manager.cjs
index ec228c6..4ff59c9 100644
--- a/electron/browser-manager.cjs
+++ b/electron/browser-manager.cjs
@@ -32,6 +32,11 @@ class BrowserManager {
url: 'https://gemini.google.com/app',
partition: 'persist:gemini',
color: '#4285f4'
+ },
+ kimi: {
+ url: 'https://www.kimi.com/',
+ partition: 'persist:kimi',
+ color: '#6b4fbb'
}
};
@@ -398,7 +403,7 @@ class BrowserManager {
};
view.webContents.on('will-navigate', (event, url) => {
- if (isAuthUrl(url)) {
+ if (isAuthUrl(url) && provider !== 'kimi') {
console.log(`[${provider}] Intercepting navigation to auth URL:`, url.substring(0, 80));
event.preventDefault();
this.openAuthPopup(provider, url);
@@ -406,7 +411,7 @@ class BrowserManager {
});
view.webContents.on('will-redirect', (event, url) => {
- if (isAuthUrl(url)) {
+ if (isAuthUrl(url) && provider !== 'kimi') {
console.log(`[${provider}] Intercepting redirect to auth URL:`, url.substring(0, 80));
event.preventDefault();
this.openAuthPopup(provider, url);
@@ -434,6 +439,26 @@ class BrowserManager {
view.webContents.setWindowOpenHandler(({ url, frameName, features }) => {
console.log(`[${provider}] Popup requested:`, url.substring(0, 80));
+ // Kimi's login is an in-page modal; its Google OAuth must stay in the
+ // same Chromium session (not be routed through the Firefox-UA auth
+ // window), otherwise the OAuth callback breaks. Allow all popups for
+ // Kimi with the normal Chromium UA/session.
+ if (provider === 'kimi') {
+ return {
+ action: 'allow',
+ overrideBrowserWindowOptions: {
+ width: 600,
+ height: 700,
+ webPreferences: {
+ session: ses,
+ sandbox: true,
+ contextIsolation: true,
+ nodeIntegration: false,
+ }
+ }
+ };
+ }
+
const lowerUrl = url.toLowerCase();
const isAuthPopup =
lowerUrl.includes('accounts.google.com') ||
@@ -608,7 +633,8 @@ class BrowserManager {
perplexity: 'perplexity.ai',
chatgpt: 'chatgpt.com',
claude: 'claude.ai',
- gemini: 'gemini.google.com'
+ gemini: 'gemini.google.com',
+ kimi: 'kimi.com'
};
const domain = providerDomains[provider];
@@ -801,6 +827,38 @@ class BrowserManager {
return hasInput && !hasSignIn;
})()
`);
+ case 'kimi':
+ // kimi.com renders a chat editor even when logged out, so DOM
+ // checks are unreliable. Kimi's web session is backed by a JWT
+ // in localStorage plus an HttpOnly cookie. Login = a valid
+ // access token is present in storage AND the subscription
+ // endpoint answers 2xx when called with that token.
+ return await webContents.executeJavaScript(`
+ (async function() {
+ function isJwt(v) {
+ return typeof v === 'string' && /^eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$/.test(v.trim());
+ }
+ var token = null;
+ try {
+ var at = localStorage.getItem('access_token');
+ if (isJwt(at)) token = at.trim();
+ } catch (e) { }
+ try {
+ var deviceId = localStorage.getItem('proxima_kimi_device_id') || '7' + String(Math.floor(Math.random() * 9e17)).padStart(18, '0');
+ var sessionId = localStorage.getItem('proxima_kimi_session_id') || '17' + String(Math.floor(Math.random() * 9e17)).padStart(17, '0');
+ var headers = { 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1', 'Origin': 'https://www.kimi.com', 'X-Msh-Platform': 'web', 'X-Msh-Device-Id': deviceId, 'X-Msh-Session-Id': sessionId };
+ if (token) headers['Authorization'] = 'Bearer ' + token;
+ var res = await fetch('/apiv2/kimi.gateway.order.v1.SubscriptionService/GetSubscription', {
+ method: 'POST', credentials: 'include', headers: headers, body: '{}'
+ });
+ if (res.status === 401 || res.status === 403) return false;
+ if (res.ok) return true;
+ return false;
+ } catch (e) {
+ return !!token;
+ }
+ })()
+ `);
default:
return false;
}
diff --git a/electron/index-v2.html b/electron/index-v2.html
index afc2123..c18724a 100644
--- a/electron/index-v2.html
+++ b/electron/index-v2.html
@@ -154,6 +154,10 @@
background: linear-gradient(135deg, #4285f4, #8ab4f8);
}
+ .tab-icon.kimi {
+ background: linear-gradient(135deg, #6b4fbb, #a78bfa);
+ }
+
.tab-icon.settings {
background: transparent;
border: none;
@@ -1328,6 +1332,11 @@
Gemini
+
+

+
Kimi
+
+
Active Providers
-
0 / 4
+
0 / 5
@@ -1668,6 +1677,28 @@ Account: Session
+
+
+
+
+
+

+
Kimi
+
+
+
+
+
Status: Checking...
+
Latency: --ms
+
Model: Auto
+
Account: Session
+
+
@@ -2456,7 +2487,7 @@
Platform Stats:
- 4 Providers • MCP Enabled • REST Server
+ 5 Providers • MCP Enabled • REST Server
@@ -2570,7 +2601,8 @@
Object.values(settings.providers || {}).forEach(p => {
if (p && p.enabled) activeCount++;
});
- document.getElementById('dash-active-count').textContent = `${activeCount} / 4`;
+ const totalProviders = Object.keys(settings.providers || {}).length;
+ document.getElementById('dash-active-count').textContent = `${activeCount} / ${totalProviders}`;
}
// Update Sidebar Footer Status Card top-row dynamically
@@ -2582,7 +2614,8 @@
if (isByok) {
sbText.textContent = `${activeCount} Active`;
} else {
- sbText.textContent = `${activeCount} / 4`;
+ const totalProviders = Object.keys(settings.providers || {}).length;
+ sbText.textContent = `${activeCount} / ${totalProviders}`;
}
if (activeCount > 0) {
@@ -3311,13 +3344,15 @@
perplexity: 'perplexity.ai',
chatgpt: 'chatgpt.com',
claude: 'claude.ai',
- gemini: 'gemini.google.com'
+ gemini: 'gemini.google.com',
+ kimi: 'kimi.com'
};
const brandColors = {
perplexity: '#20b2aa',
chatgpt: '#10a37f',
claude: '#cc785c',
- gemini: '#4285f4'
+ gemini: '#4285f4',
+ kimi: '#6b4fbb'
};
const linkElem = document.getElementById('cookie-modal-link');
if (linkElem) {
@@ -3358,7 +3393,8 @@
perplexity: 'https://www.perplexity.ai/',
chatgpt: 'https://chatgpt.com/',
claude: 'https://claude.ai/',
- gemini: 'https://gemini.google.com/'
+ gemini: 'https://gemini.google.com/',
+ kimi: 'https://www.kimi.com/'
};
const targetUrl = urls[cookieModalProvider] || 'https://google.com/';
const name = cookieModalProvider.charAt(0).toUpperCase() + cookieModalProvider.slice(1);
diff --git a/electron/ipc/core.cjs b/electron/ipc/core.cjs
index 027347b..48141d2 100644
--- a/electron/ipc/core.cjs
+++ b/electron/ipc/core.cjs
@@ -152,7 +152,8 @@ ipcMain.handle('open-in-system-browser', (event, provider) => {
perplexity: 'https://www.perplexity.ai/',
chatgpt: 'https://chat.openai.com/',
claude: 'https://claude.ai/',
- gemini: 'https://gemini.google.com/'
+ gemini: 'https://gemini.google.com/',
+ kimi: 'https://www.kimi.com/'
};
if (urls[provider]) {
shell.openExternal(urls[provider]);
diff --git a/electron/ipc/settings.cjs b/electron/ipc/settings.cjs
index 1b3a5d5..106c3f0 100644
--- a/electron/ipc/settings.cjs
+++ b/electron/ipc/settings.cjs
@@ -141,7 +141,8 @@ function registerSettingsHandlers(deps) {
perplexity: 'perplexity.ai',
chatgpt: 'openai.com',
claude: 'claude.ai',
- gemini: 'google.com'
+ gemini: 'google.com',
+ kimi: 'kimi.com'
};
const domain = providerDomains[provider];
diff --git a/electron/main-v2.cjs b/electron/main-v2.cjs
index 047f07c..337a076 100644
--- a/electron/main-v2.cjs
+++ b/electron/main-v2.cjs
@@ -49,6 +49,7 @@ const PROVIDER_PARTITIONS = [
'persist:chatgpt',
'persist:claude',
'persist:gemini',
+ 'persist:kimi',
];
async function clearProviderCachesOnStartup() {
@@ -99,7 +100,8 @@ const defaultSettings = {
perplexity: { enabled: true, loggedIn: false },
chatgpt: { enabled: true, loggedIn: false },
claude: { enabled: true, loggedIn: false },
- gemini: { enabled: true, loggedIn: false }
+ gemini: { enabled: true, loggedIn: false },
+ kimi: { enabled: true, loggedIn: false }
},
ipcPort: 19222,
theme: 'dark',
@@ -521,11 +523,28 @@ async function handleMCPRequest(request) {
return { success: true, provider, loggedIn };
}
+ case 'getKimiSessionStatus': {
+ const conversationId = data && (data.conversationId || data.conversation_id || data.sessionId || data.session_id);
+ const wc = browserManager.getWebContents('kimi');
+ if (!wc) return { success: false, error: 'Kimi not initialized' };
+ try {
+ const status = await wc.executeJavaScript(
+ `(window.__proximaKimi && window.__proximaKimi.getSessionStatus)
+ ? window.__proximaKimi.getSessionStatus(${JSON.stringify(conversationId || null)})
+ : { resetCount: 0, chatId: null, parentId: null }`
+ );
+ return { success: true, ...status };
+ } catch (err) {
+ return { success: false, error: err.message };
+ }
+ }
+
case 'sendMessage': {
const baseProviderName = provider.split(':')[0];
const engineOverride = data.gemini || data.engine || null;
const targetProvider = engineOverride ? `${provider}:${engineOverride}` : provider;
const conversationId = data.conversationId || data.conversation_id || data.sessionId || data.session_id || null;
+ const thinkMode = data.think !== false;
const byokKey = byok.keys.isEnabled() ? byok.keys.getKey(baseProviderName) : null;
if (byokKey) {
@@ -565,7 +584,8 @@ async function handleMCPRequest(request) {
fileSize: uploadResult.fileSize
} : null,
data.onChunk,
- conversationId
+ conversationId,
+ thinkMode
);
if (!result.response && result.error) {
return { success: false, provider, error: result.error, fileUploaded: uploadResult };
@@ -576,14 +596,14 @@ async function handleMCPRequest(request) {
return { success: true, provider, response: result.response, fileUploaded: uploadResult };
} catch (fileErr) {
console.error('[MCP] File upload failed:', fileErr.message);
- const result = await sendMessageToProvider(targetProvider, data.message, null, data.onChunk, conversationId);
+ const result = await sendMessageToProvider(targetProvider, data.message, null, data.onChunk, conversationId, thinkMode);
if (!result.response || result.response.length === 0) {
return { success: false, provider, error: result.error || `${provider} returned empty response`, fileError: fileErr.message };
}
return { success: true, provider, response: result.response, fileError: fileErr.message };
}
} else {
- const result = await sendMessageToProvider(targetProvider, data.message, null, data.onChunk, conversationId);
+ const result = await sendMessageToProvider(targetProvider, data.message, null, data.onChunk, conversationId, thinkMode);
if (!result.response && result.error) {
return { success: false, provider, error: result.error };
}
@@ -617,6 +637,7 @@ async function handleMCPRequest(request) {
const targetProviderWithFile = data.engine ? `${provider}:${data.engine}` : provider;
const conversationIdWithFile = data.conversationId || data.conversation_id || data.sessionId || data.session_id || null;
+ const thinkModeWithFile = data.think !== false;
const msgResult = await sendMessageToProvider(
targetProviderWithFile,
data.message,
@@ -627,7 +648,8 @@ async function handleMCPRequest(request) {
fileSize: fileResult.fileSize
} : null,
null,
- conversationIdWithFile
+ conversationIdWithFile,
+ thinkModeWithFile
);
const finalResponse = (msgResult && msgResult.response) || '';
return {
@@ -924,7 +946,29 @@ async function uploadFileToProvider(provider, filePath) {
}
}
- throw new Error(`File upload is not supported for ${provider} (API mode). Only Gemini, ChatGPT, Perplexity, and Claude support file attachments.`);
+ if (provider.startsWith('kimi')) {
+ console.log(`[Kimi API] Direct upload of ${fileName} via Web API...`);
+ try {
+ await providerAPI.ensureAPI(provider, webContents);
+ const token = await webContents.executeJavaScript(
+ `window.__proximaKimi.uploadFileToKimi(${JSON.stringify(fileBase64)}, ${JSON.stringify(fileName)}, ${JSON.stringify(fileMimeType)})`
+ );
+ return {
+ success: true,
+ fileName,
+ mimeType: fileMimeType,
+ imageToken: token,
+ fileAttached: true,
+ fileSize: fileStats.size,
+ method: 'kimi-api'
+ };
+ } catch (apiUploadErr) {
+ console.error(`[Kimi API] Direct upload failed:`, apiUploadErr.message);
+ throw apiUploadErr;
+ }
+ }
+
+ throw new Error(`File upload is not supported for ${provider} (API mode). Only Gemini, ChatGPT, Perplexity, Claude, and Kimi support file attachments.`);
}
function getMimeType(filePath) {
@@ -995,7 +1039,7 @@ app.on('activate', () => {
});
app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {
- const trustedDomains = ['perplexity.ai', 'openai.com', 'chatgpt.com', 'claude.ai', 'anthropic.com', 'gemini.google.com', 'accounts.google.com'];
+ const trustedDomains = ['perplexity.ai', 'openai.com', 'chatgpt.com', 'claude.ai', 'anthropic.com', 'gemini.google.com', 'accounts.google.com', 'kimi.com', 'moonshot.cn'];
let host;
try {
host = new URL(url).hostname.toLowerCase();
diff --git a/electron/providers/api.cjs b/electron/providers/api.cjs
index 1309db9..e7a6541 100644
--- a/electron/providers/api.cjs
+++ b/electron/providers/api.cjs
@@ -15,7 +15,8 @@ function _loadScript(provider) {
chatgpt: 'chatgpt-engine.js',
claude: 'claude-engine.js',
gemini: 'gemini-engine.js',
- perplexity: 'perplexity-engine.js'
+ perplexity: 'perplexity-engine.js',
+ kimi: 'kimi-engine.js'
};
const filename = scriptMap[provider];
@@ -66,7 +67,8 @@ async function isAPIReady(provider, webContents) {
chatgpt: 'typeof window.__proximaChatGPT !== "undefined"',
claude: 'typeof window.__proximaClaude !== "undefined"',
gemini: 'typeof window.__proximaGemini !== "undefined"',
- perplexity: 'typeof window.__proximaPerplexity !== "undefined"'
+ perplexity: 'typeof window.__proximaPerplexity !== "undefined"',
+ kimi: 'typeof window.__proximaKimi !== "undefined"'
};
const check = checkMap[provider];
@@ -122,7 +124,7 @@ async function ensureAPI(provider, webContents) {
return await injectAPI(provider, webContents);
}
-async function sendViaAPI(provider, webContents, message, attachments = null, onChunk = null, conversationId = null) {
+async function sendViaAPI(provider, webContents, message, attachments = null, onChunk = null, conversationId = null, think = true) {
let baseProvider = provider;
let engine = 'auto';
if (provider.indexOf(':') !== -1) {
@@ -141,7 +143,8 @@ async function sendViaAPI(provider, webContents, message, attachments = null, on
chatgpt: '__proximaChatGPT',
claude: '__proximaClaude',
gemini: '__proximaGemini',
- perplexity: '__proximaPerplexity'
+ perplexity: '__proximaPerplexity',
+ kimi: '__proximaKimi'
};
const apiObj = sendMap[baseProvider];
@@ -154,7 +157,10 @@ async function sendViaAPI(provider, webContents, message, attachments = null, on
const startTime = Date.now();
let executeStr;
- if (baseProvider === 'gemini' || baseProvider === 'chatgpt' || baseProvider === 'perplexity' || baseProvider === 'claude') {
+ if (baseProvider === 'chatgpt') {
+ const escapedAttachments = attachments ? JSON.stringify(attachments) : 'null';
+ executeStr = `window.${apiObj}.send(${escapedMessage}, ${JSON.stringify(engine)}, ${escapedAttachments}, ${JSON.stringify(conversationId)}, ${JSON.stringify({ think: think !== false })})`;
+ } else if (baseProvider === 'gemini' || baseProvider === 'perplexity' || baseProvider === 'claude' || baseProvider === 'kimi') {
const escapedAttachments = attachments ? JSON.stringify(attachments) : 'null';
executeStr = `window.${apiObj}.send(${escapedMessage}, ${JSON.stringify(engine)}, ${escapedAttachments}, ${JSON.stringify(conversationId)})`;
} else {
@@ -236,10 +242,11 @@ async function resetConversation(provider, webContentsGetter) {
chatgpt: '__proximaChatGPT',
claude: '__proximaClaude',
gemini: '__proximaGemini',
- perplexity: '__proximaPerplexity'
+ perplexity: '__proximaPerplexity',
+ kimi: '__proximaKimi'
};
- const providers = provider ? [provider] : ['chatgpt', 'claude', 'gemini', 'perplexity'];
+ const providers = provider ? [provider] : ['chatgpt', 'claude', 'gemini', 'perplexity', 'kimi'];
for (const p of providers) {
const apiObj = resetMap[p];
diff --git a/electron/providers/engines/chatgpt-engine.js b/electron/providers/engines/chatgpt-engine.js
index c593fd7..8596173 100644
--- a/electron/providers/engines/chatgpt-engine.js
+++ b/electron/providers/engines/chatgpt-engine.js
@@ -197,6 +197,139 @@
var _cachedScripts = null;
var _cachedDpl = null;
+ var _cachedModels = null;
+ var _cachedModelsAt = 0;
+
+ // Mirrors the web app's Think-toggle logic: thinking_effort is only sent for models
+ // that report configurableThinkingEffort, and only with a value present in their
+ // thinkingEfforts list. Otherwise the field is omitted entirely.
+ async function _getModelConfig(modelSlug) {
+ try {
+ if (!_cachedModels || Date.now() - _cachedModelsAt > 300000) {
+ var mres = await fetch('/backend-api/models', { credentials: 'include' });
+ if (mres.ok) {
+ _cachedModels = (await mres.json()).models || [];
+ _cachedModelsAt = Date.now();
+ }
+ }
+ if (!_cachedModels) return null;
+ for (var i = 0; i < _cachedModels.length; i++) {
+ if (_cachedModels[i].slug === modelSlug) return _cachedModels[i];
+ }
+ return null;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function _resolveThinkingEffort(modelConfig, think) {
+ if (!think || !modelConfig || !modelConfig.configurableThinkingEffort) return undefined;
+ var efforts = (modelConfig.thinkingEfforts || []).map(function (e) { return e.thinking_effort; }).filter(Boolean);
+ if (!efforts.length) return undefined;
+ if (efforts.indexOf('xhigh') !== -1) return 'xhigh';
+ if (efforts.indexOf('standard') !== -1) return 'standard';
+ return efforts[efforts.length - 1];
+ }
+
+ // Drives the actual composer "Think"/"Reason" toggle button in the page (the same button
+ // a user clicks on chatgpt.com), so the web app's own UI logic produces the backend payload.
+ // We only ever turn it ON; if it is already ON we never click (so it can never toggle OFF).
+ function _findThinkToggle() {
+ var selectors = [
+ '[data-testid="composer-intelligence-button"]',
+ '[data-testid="composer-intelligence-pro-thinking-effort-trigger"]',
+ 'button[aria-label*="Think" i]',
+ 'button[aria-label*="Reason" i]',
+ 'button[data-testid*="think" i]',
+ 'button[data-testid*="reason" i]'
+ ];
+ for (var i = 0; i < selectors.length; i++) {
+ var el = document.querySelector(selectors[i]);
+ if (el) return el;
+ }
+ var pills = document.querySelectorAll('button.__composer-pill');
+ for (var k = 0; k < pills.length; k++) {
+ var ptext = (pills[k].innerText || '').trim();
+ if (/^(Think|Reason|Deep Think)$/i.test(ptext)) return pills[k];
+ }
+ var buttons = document.querySelectorAll('button');
+ for (var j = 0; j < buttons.length; j++) {
+ var txt = (buttons[j].innerText || '').trim();
+ if (/^(Think|Reason|Deep Think)$/i.test(txt)) return buttons[j];
+ }
+ return null;
+ }
+
+ function _isToggleOn(btn) {
+ if (!btn) return false;
+ var ap = btn.getAttribute('aria-pressed');
+ if (ap === 'true') return true;
+ if (ap === 'false') return false;
+ var ds = btn.getAttribute('data-state');
+ if (ds === 'on') return true;
+ if (ds === 'off') return false;
+ var ariaChecked = btn.getAttribute('aria-checked');
+ if (ariaChecked === 'true') return true;
+ if (ariaChecked === 'false') return false;
+ var cls = (btn.className || '').toLowerCase();
+ if (cls.indexOf('active') !== -1 || cls.indexOf('selected') !== -1 || cls.indexOf('on') !== -1) return true;
+ return false;
+ }
+
+ function _clickButton(btn) {
+ var rect = btn.getBoundingClientRect();
+ var opts = {
+ bubbles: true,
+ cancelable: true,
+ view: window,
+ clientX: rect.left + (rect.width / 2),
+ clientY: rect.top + (rect.height / 2)
+ };
+ try { btn.dispatchEvent(new MouseEvent('pointerdown', Object.assign({}, opts, { pointerId: 1, isPrimary: true, button: 0 }))); } catch (e) { }
+ try { btn.dispatchEvent(new MouseEvent('mousedown', opts)); } catch (e) { }
+ try { btn.dispatchEvent(new MouseEvent('mouseup', opts)); } catch (e) { }
+ try { btn.dispatchEvent(new MouseEvent('click', opts)); } catch (e) { }
+ try { btn.click(); } catch (e) { }
+ }
+
+ function _ensureThinkToggleOn() {
+ return new Promise(function (resolve) {
+ try {
+ var btn = _findThinkToggle();
+ if (!btn) {
+ console.log('[Proxima ChatGPT] Think toggle button not found in DOM');
+ resolve({ found: false, on: false });
+ return;
+ }
+ if (_isToggleOn(btn)) {
+ console.log('[Proxima ChatGPT] Think toggle already ON — leaving it alone (never toggling OFF)');
+ resolve({ found: true, on: true });
+ return;
+ }
+ _clickButton(btn);
+ console.log('[Proxima ChatGPT] Think toggle click dispatched (was off)');
+ var attempts = 0;
+ var iv = setInterval(function () {
+ attempts++;
+ var current = _findThinkToggle();
+ if (current && _isToggleOn(current)) {
+ clearInterval(iv);
+ console.log('[Proxima ChatGPT] Think toggle confirmed ON');
+ resolve({ found: true, on: true });
+ return;
+ }
+ if (attempts >= 12) {
+ clearInterval(iv);
+ console.log('[Proxima ChatGPT] Think toggle did NOT turn ON after click (still off)');
+ resolve({ found: true, on: false });
+ }
+ }, 200);
+ } catch (e) {
+ console.error('[Proxima ChatGPT] _ensureThinkToggleOn error:', e.message);
+ resolve({ found: false, on: false, error: e.message });
+ }
+ });
+ }
async function _getScriptsAndDpl() {
if (_cachedScripts) return { scripts: _cachedScripts, dpl: _cachedDpl };
@@ -371,9 +504,11 @@
return fileId;
}
- async function send(message, engine, attachments, sessionId) {
+ async function send(message, engine, attachments, sessionId, options) {
activateSession(sessionId);
+ var think = !(options && options.think === false);
+
var token = await _getToken();
var deviceId = '';
@@ -461,6 +596,31 @@
websocket_request_id: crypto.randomUUID()
};
+ var resolvedModel = (engine && engine !== 'auto') ? engine : 'auto';
+ payload.model = resolvedModel;
+
+ // "Think" mode = the composer's Think toggle (chatgpt.com). We drive the real UI
+ // button (never toggling it OFF) so the web app's own state reflects think mode,
+ // then mirror its backend behavior in our payload: thinking_effort is only sent
+ // when the selected model has configurableThinkingEffort and the value is in its
+ // thinkingEfforts list; otherwise the field is omitted (free accounts report
+ // configurableThinkingEffort:false, so nothing is sent — real think mode there
+ // means selecting a reasoning-variant model such as 'gpt-5-6-t-mini').
+ if (think) {
+ var toggle = await _ensureThinkToggleOn();
+ console.log('[Proxima ChatGPT] Think toggle ensure: found=' + toggle.found + ' on=' + toggle.on + (toggle.error ? ' error=' + toggle.error : ''));
+ }
+ var modelConfig = await _getModelConfig(resolvedModel);
+ var effort = _resolveThinkingEffort(modelConfig, think);
+ if (effort) {
+ payload.thinking_effort = effort;
+ console.log('[Proxima ChatGPT] Thinking mode ON (thinking_effort=' + effort + ')');
+ } else if (think && modelConfig && !modelConfig.configurableThinkingEffort) {
+ console.log('[Proxima ChatGPT] Thinking mode ON but not configurable for model ' + resolvedModel + ' — field omitted');
+ } else {
+ console.log('[Proxima ChatGPT] Thinking mode OFF');
+ }
+
if (_conversationId) {
payload.conversation_id = _conversationId;
console.log('[Proxima ChatGPT] Continuing conversation:', _conversationId);
diff --git a/electron/providers/engines/kimi-engine.js b/electron/providers/engines/kimi-engine.js
new file mode 100644
index 0000000..acaa9f5
--- /dev/null
+++ b/electron/providers/engines/kimi-engine.js
@@ -0,0 +1,643 @@
+/**
+ * Proxima — Kimi Engine
+ * Runs inside kimi.com BrowserView context. Sends queries via the internal
+ * Connect (CONNECT/gRPC-Web) protocol used by the Kimi web app:
+ *
+ * POST /apiv2/kimi.gateway.chat.v1.ChatService/Chat
+ *
+ * The request body is a Connect frame (1 flag byte + 4-byte big-endian length
+ * + JSON payload). The response is a stream of the same 5-byte-framed JSON
+ * events. Answer deltas live in event.block.text.content; reasoning lives in
+ * event.block.think.content.
+ *
+ * ⚠️ Uses the same non-official protocol the kimi.com web app itself uses.
+ * If Kimi changes their protocol this engine may need updating.
+ */
+(function () {
+ if (window.__proximaKimi) return;
+
+ var TIMEOUT = 360000;
+ var CHAT_PATH = '/apiv2/kimi.gateway.chat.v1.ChatService/Chat';
+ var REFRESH_PATH = '/api/auth/token/refresh';
+ var DEFAULT_SCENARIO = 'SCENARIO_K2D5';
+
+ var _accessToken = null;
+ var _refreshToken = null;
+ var _currentSessionId = null;
+ var _sessions = {};
+ try {
+ var saved = localStorage.getItem('proxima_kimi_sessions');
+ if (saved) _sessions = JSON.parse(saved);
+ } catch (e) { }
+
+ var MAX_SESSIONS = 200;
+ function _pruneSessions() {
+ var keys = Object.keys(_sessions);
+ for (var i = 0; i < keys.length && Object.keys(_sessions).length > MAX_SESSIONS; i++) {
+ if (keys[i] !== _currentSessionId) delete _sessions[keys[i]];
+ }
+ }
+
+ // Session change signal for the opencode plugin. The engine bumps this
+ // counter every time it rolls a Kimi chat over to a NEW chat_id (context
+ // limit recovery). The plugin polls Proxima's session-status endpoint and,
+ // on change, re-sends the system prompt + a comprehensive handoff summary.
+ function getSessionStatus(sessionId) {
+ var s = _sessions[sessionId || 'default'];
+ if (!s) return { resetCount: 0, chatId: null, parentId: null };
+ return {
+ resetCount: (s.resetCount || 0),
+ chatId: s.chatId || null,
+ parentId: s.parentId || null
+ };
+ }
+
+ function _deviceId() {
+ try {
+ var d = localStorage.getItem('proxima_kimi_device_id');
+ if (d && /^[0-9]{19}$/.test(d)) return d;
+ var nd = String(Math.floor(7000000000000000000 + Math.random() * 999999999999999999));
+ localStorage.setItem('proxima_kimi_device_id', nd);
+ return nd;
+ } catch (e) {
+ return '7' + String(Math.floor(Math.random() * 9e17)).padStart(18, '0');
+ }
+ }
+
+ function _sessionHeader() {
+ try {
+ var s = localStorage.getItem('proxima_kimi_session_id');
+ if (s && /^[0-9]{19}$/.test(s)) return s;
+ var ns = String(Math.floor(1700000000000000000 + Math.random() * 99999999999999999));
+ localStorage.setItem('proxima_kimi_session_id', ns);
+ return ns;
+ } catch (e) {
+ return '17' + String(Math.floor(Math.random() * 9e17)).padStart(17, '0');
+ }
+ }
+
+ function _isJwt(v) {
+ return typeof v === 'string' && /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(v.trim());
+ }
+
+ // Parse the exp claim from a JWT (no signature verification needed for
+ // expiry math). Returns seconds-epoch, or 0 if unparsable.
+ function _jwtExp(jwt) {
+ try {
+ var parts = jwt.split('.');
+ if (parts.length !== 3) return 0;
+ var b64 = parts[1];
+ b64 += '='.repeat((4 - (b64.length % 4)) % 4);
+ var payload = JSON.parse(decodeURIComponent(escape(atob(b64))));
+ return typeof payload.exp === 'number' ? payload.exp : 0;
+ } catch (e) {
+ return 0;
+ }
+ }
+
+ // True if the token is missing or expired within 120s (proactive refresh
+ // window so Kimi never sees a stale token).
+ function _tokenNeedsRefresh(jwt) {
+ if (!_isJwt(jwt)) return true;
+ var exp = _jwtExp(jwt);
+ if (!exp) return false; // can't verify — assume it's fine
+ return (Date.now() / 1000) + 120 >= exp;
+ }
+
+ function _scanStorageToken() {
+ try {
+ var storages = [localStorage, sessionStorage];
+ for (var si = 0; si < storages.length; si++) {
+ var store = storages[si];
+ var access = null;
+ var refresh = null;
+ for (var i = 0; i < store.length; i++) {
+ var key = store.key(i);
+ var val = '';
+ try { val = store.getItem(key) || ''; } catch (e) { continue; }
+ var candidate = val.trim();
+ if (!_isJwt(candidate)) {
+ try {
+ var parsed = JSON.parse(val);
+ if (parsed && typeof parsed === 'object') {
+ var deep = _deepFindToken(parsed);
+ if (deep) candidate = deep;
+ }
+ } catch (e2) { }
+ }
+ if (!_isJwt(candidate)) continue;
+ var lower = key.toLowerCase();
+ if (lower.includes('refresh')) {
+ if (!refresh) refresh = candidate;
+ } else if (lower.includes('access') || lower.includes('token') || lower.includes('auth')) {
+ if (!access) access = candidate;
+ } else if (!access) {
+ access = candidate;
+ }
+ }
+ if (access) { _refreshToken = refresh || _refreshToken; return access; }
+ if (refresh) { _refreshToken = refresh; }
+ }
+ } catch (e) { }
+
+ // Fallback: check cookies for token-ish values.
+ try {
+ var cookies = document.cookie.split(';');
+ for (var ci = 0; ci < cookies.length; ci++) {
+ var cookie = cookies[ci].trim();
+ var eq = cookie.indexOf('=');
+ if (eq === -1) continue;
+ var cName = cookie.substring(0, eq).trim();
+ var cVal = decodeURIComponent(cookie.substring(eq + 1).trim());
+ if (_isJwt(cVal)) {
+ var lName = cName.toLowerCase();
+ if (lName.includes('refresh')) { if (!_refreshToken) _refreshToken = cVal; }
+ else if (lName.includes('access') || lName.includes('token')) { return cVal; }
+ else if (!_accessToken) { return cVal; }
+ }
+ }
+ } catch (e) { }
+
+ return null;
+ }
+
+ function _deepFindToken(obj, depth) {
+ if (!depth) depth = 0;
+ if (depth > 6 || !obj || typeof obj !== 'object') return null;
+ if (typeof obj === 'string' && _isJwt(obj)) return obj;
+ for (var key of Object.keys(obj)) {
+ var val = obj[key];
+ if (typeof val === 'string' && _isJwt(val)) return val;
+ if (val && typeof val === 'object') {
+ var found = _deepFindToken(val, depth + 1);
+ if (found) return found;
+ }
+ }
+ return null;
+ }
+
+ async function _refreshAccessToken() {
+ // Always rediscover the refresh token from storage first — Kimi rotates
+ // refresh tokens too, so a stale in-memory one will be rejected.
+ _scanStorageToken();
+ if (!_refreshToken) return null;
+ try {
+ var res = await fetch(REFRESH_PATH, {
+ method: 'GET',
+ credentials: 'include',
+ headers: {
+ 'Authorization': 'Bearer ' + _refreshToken,
+ 'Accept': 'application/json',
+ 'X-Msh-Platform': 'web',
+ 'X-Msh-Device-Id': _deviceId(),
+ 'X-Msh-Session-Id': _sessionHeader(),
+ 'R-Timezone': Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
+ 'Origin': 'https://www.kimi.com'
+ }
+ });
+ if (res.ok) {
+ var data = await res.json();
+ var token = data.access_token || data.token;
+ if (token && _isJwt(token)) {
+ _accessToken = token;
+ // Persist the fresh token so later engine loads reuse it
+ // instead of re-reading an expired one from storage.
+ try {
+ localStorage.setItem('access_token', token);
+ } catch (e) { }
+ return token;
+ }
+ }
+ } catch (e) { }
+ return null;
+ }
+
+ // Kimi revokes access tokens server-side (rotation) while the JWT exp claim
+ // stays ~30 days out, so expiry math alone can't detect a stale token. Always
+ // prefer the token the site currently has in storage over our cached copy.
+ function _getAccessToken(force) {
+ var storageToken = _scanStorageToken();
+ if (storageToken) {
+ _accessToken = storageToken;
+ return Promise.resolve(storageToken);
+ }
+ if (_accessToken && !force) {
+ return Promise.resolve(_accessToken);
+ }
+ return _refreshAccessToken();
+ }
+
+ function _encodeConnect(payload) {
+ var body = new TextEncoder().encode(JSON.stringify(payload));
+ var header = new Uint8Array(5);
+ header[0] = 0x00;
+ var len = body.length;
+ header[1] = (len >>> 24) & 0xff;
+ header[2] = (len >>> 16) & 0xff;
+ header[3] = (len >>> 8) & 0xff;
+ header[4] = len & 0xff;
+ var out = new Uint8Array(5 + body.length);
+ out.set(header, 0);
+ out.set(body, 5);
+ return out;
+ }
+
+ function _isAuthErrorMsg(msg) {
+ if (!msg) return false;
+ var lower = String(msg).toLowerCase();
+ return lower.indexOf('invalid user token') !== -1 ||
+ lower.indexOf('invalid token') !== -1 ||
+ lower.indexOf('unauthorized') !== -1 ||
+ lower.indexOf('unauthenticated') !== -1 ||
+ lower.indexOf('session expired') !== -1 ||
+ lower.indexOf('not logged in') !== -1 ||
+ lower.indexOf('authentication') !== -1 ||
+ lower.indexOf('expired') !== -1;
+ }
+
+ // Kimi's server-enforced conversation-context limit. No bypass exists
+ // (web K2.6 ≈128K tokens; K3 1M only on top-tier membership). The only
+ // recovery is a NEW chat_id. We detect it, then restart with the stored
+ // system prompt + the current user message (research-backed).
+ function _isTooLongMsg(msg) {
+ if (!msg) return false;
+ var lower = String(msg).toLowerCase();
+ return lower.indexOf('too long') !== -1 ||
+ lower.indexOf('start a new session') !== -1 ||
+ lower.indexOf('start a new chat') !== -1 ||
+ lower.indexOf('conversation') !== -1 && lower.indexOf('long') !== -1 ||
+ lower.indexOf('context') !== -1 && lower.indexOf('limit') !== -1;
+ }
+
+ function _AuthError(msg) {
+ var e = new Error(msg);
+ e.__kimiAuth = true;
+ return e;
+ }
+
+ function _TooLongError(msg) {
+ var e = new Error(msg);
+ e.__kimiTooLong = true;
+ return e;
+ }
+
+ // The engine may receive either a plain string or a JSON-encoded messages
+ // array (opencode / REST clients send [{role,content}, ...]). Normalize to
+ // an array and flatten content parts to plain text.
+ function _parseIncomingMessage(message) {
+ if (Array.isArray(message)) return message;
+ if (typeof message === 'string') {
+ var trimmed = message.trim();
+ if (trimmed.charAt(0) === '[') {
+ try {
+ var parsed = JSON.parse(trimmed);
+ if (Array.isArray(parsed)) return parsed;
+ } catch (e) { }
+ }
+ return [{ role: 'user', content: message }];
+ }
+ return [{ role: 'user', content: String(message || '') }];
+ }
+
+ function _contentText(content) {
+ if (content === null || content === undefined) return '';
+ if (typeof content === 'string') return content;
+ if (Array.isArray(content)) {
+ var parts = [];
+ for (var i = 0; i < content.length; i++) {
+ var item = content[i];
+ if (typeof item === 'string') parts.push(item);
+ else if (item && typeof item === 'object' && typeof item.text === 'string') parts.push(item.text);
+ }
+ return parts.join('\n');
+ }
+ return String(content);
+ }
+
+ async function _parseStream(response, session) {
+ var reader = response.body.getReader();
+ var buffer = new Uint8Array(0);
+ var decoder = new TextDecoder();
+ var answer = '';
+ var reasoning = '';
+ var done = false;
+
+ try {
+ while (!done) {
+ var chunk = await reader.read();
+ if (chunk.done) break;
+
+ var merged = new Uint8Array(buffer.length + chunk.value.length);
+ merged.set(buffer, 0);
+ merged.set(chunk.value, buffer.length);
+ buffer = merged;
+
+ var offset = 0;
+ while (offset + 5 <= buffer.length) {
+ var flag = buffer[offset];
+ var length = ((buffer[offset + 1] << 24) | (buffer[offset + 2] << 16) |
+ (buffer[offset + 3] << 8) | buffer[offset + 4]) >>> 0;
+ var frameEnd = offset + 5 + length;
+ if (frameEnd > buffer.length) break;
+
+ var payload = buffer.slice(offset + 5, frameEnd);
+ offset = frameEnd;
+
+ // Trailers (flag & 0x80) carry headers only — skip.
+ if (flag & 0x80) continue;
+
+ var text = decoder.decode(payload).trim();
+ if (!text) continue;
+
+ var event;
+ try { event = JSON.parse(text); } catch (e) { continue; }
+
+ if (event.error) {
+ var err = event.error;
+ var msg = err.message || JSON.stringify(err);
+ // Conversation hit the server-side context limit — recover
+ // by starting a NEW chat (no bypass exists).
+ if (_isTooLongMsg(msg)) throw _TooLongError(msg);
+ // Kimi reports expired/invalid sessions as a stream error
+ // inside an HTTP 200 body — flag it so send() can refresh
+ // the token and retry instead of surfacing a raw failure.
+ if (_isAuthErrorMsg(msg)) throw _AuthError(msg);
+ throw new Error(msg);
+ }
+ if (event.chat && event.chat.id) {
+ session.chatId = event.chat.id;
+ }
+ if (event.message && event.message.role === 'assistant' && event.message.id) {
+ session.parentId = event.message.id;
+ }
+ if (event.block) {
+ if (event.block.think && event.block.think.content) {
+ reasoning += event.block.think.content;
+ }
+ if (event.block.text && event.block.text.content) {
+ answer += event.block.text.content;
+ }
+ }
+ if ('done' in event) {
+ done = true;
+ break;
+ }
+ }
+
+ if (offset > 0) {
+ buffer = buffer.slice(offset);
+ }
+ }
+ } finally {
+ try { reader.releaseLock(); } catch (e) { }
+ }
+
+ return { answer: answer.trim(), reasoning: reasoning.trim() };
+ }
+
+ function activateSession(sessionId) {
+ if (!sessionId) sessionId = 'default';
+ if (sessionId !== _currentSessionId) {
+ _currentSessionId = sessionId;
+ if (!_sessions[sessionId]) {
+ _sessions[sessionId] = { chatId: null, parentId: null, systemPrompt: null, lastUser: null, resetCount: 0 };
+ }
+ }
+ return _sessions[_currentSessionId];
+ }
+
+ function saveSession() {
+ if (!_currentSessionId) return;
+ try {
+ _pruneSessions();
+ localStorage.setItem('proxima_kimi_sessions', JSON.stringify(_sessions));
+ } catch (e) { }
+ }
+
+ async function send(message, engine, attachments, sessionId) {
+ var session = activateSession(sessionId);
+
+ // Normalize the incoming payload. opencode / REST clients pass either a
+ // plain string or a JSON-encoded messages array. Extract the system
+ // prompt (sent ONCE at chat start) and the latest user turn (the only
+ // thing follow-ups need — the parent_id chain carries the rest).
+ var msgs = _parseIncomingMessage(message);
+ var systemParts = [];
+ var userParts = [];
+ for (var i = 0; i < msgs.length; i++) {
+ var m = msgs[i];
+ if (!m || typeof m !== 'object') continue;
+ if (m.role === 'system') systemParts.push(_contentText(m.content));
+ else if (m.role === 'user') userParts.push(_contentText(m.content));
+ }
+ var systemPrompt = systemParts.filter(Boolean).join('\n');
+
+ // Decide whether this request starts a NEW Kimi chat or continues the
+ // existing one. The incoming system prompt is the discriminator:
+ // * FIRST turn — no stored systemPrompt yet → new chat
+ // * ROLLOVER handoff — system prompt changed (summary appended by the
+ // plugin) → new chat (the old chat hit Kimi's context limit)
+ // * Follow-up — system prompt identical to stored (opencode can
+ // be restarted between turns; the plugin state that strips the prompt
+ // lives per-process, so the same prompt may legitimately reappear) →
+ // REUSE the existing chat_id so the conversation actually continues.
+ // A stale chat_id that is already too long self-heals: Kimi answers with
+ // the too-long error, the engine detects it below and rolls the session.
+ var isSameSystemPrompt = systemPrompt && session.systemPrompt === systemPrompt;
+ if (systemPrompt && !isSameSystemPrompt) {
+ if (session.chatId || session.parentId) {
+ console.log('[Proxima Kimi] System prompt changed on this request — starting a fresh chat (was chatId=' + (session.chatId || 'none') + ').');
+ }
+ session.chatId = null;
+ session.parentId = null;
+ }
+ // Only the LATEST user turn is the new message — everything earlier is
+ // already on Kimi's server via chat_id/parent_id (follow-up) or will be
+ // included as first-turn context (new chat).
+ var lastUser = userParts.filter(Boolean).pop() || (typeof message === 'string' ? message : '');
+
+ // Remember the system prompt for this conversation so a context-limit
+ // reset can re-establish it in the fresh chat.
+ if (systemPrompt) session.systemPrompt = systemPrompt;
+ session.lastUser = lastUser;
+
+ // First turn of a Kimi chat → send system prompt (which the opencode
+ // plugin augments with a comprehensive handoff summary after a rollover)
+ // + the current user turn. Do NOT re-send raw history here: after a
+ // context-limit rollover opencode re-sends the whole array and re-injecting
+ // it would instantly slam the new chat's limit again. Real context is
+ // carried by the summary inside the system prompt, not by replaying
+ // past turns.
+ var isNewChat = !session.chatId;
+ var content;
+ if (isNewChat) {
+ content = (systemPrompt ? 'system:' + systemPrompt + '\n' : '') + 'user:' + lastUser;
+ } else {
+ content = 'user:' + lastUser;
+ }
+
+ var token = await _getAccessToken(false);
+
+ var scenario = DEFAULT_SCENARIO;
+ if (engine && engine !== 'auto') {
+ var e = String(engine).toUpperCase();
+ if (e.indexOf('SCENARIO_') === 0) scenario = e;
+ }
+
+ var headers = {
+ 'Accept': '*/*',
+ 'Content-Type': 'application/connect+json',
+ 'Connect-Protocol-Version': '1',
+ 'Origin': 'https://www.kimi.com',
+ 'X-Msh-Platform': 'web',
+ 'X-Msh-Device-Id': _deviceId(),
+ 'X-Msh-Session-Id': _sessionHeader(),
+ 'R-Timezone': Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
+ 'Sec-Fetch-Dest': 'empty',
+ 'Sec-Fetch-Mode': 'cors',
+ 'Sec-Fetch-Site': 'same-origin'
+ };
+ if (token) headers['Authorization'] = 'Bearer ' + token;
+
+ var controller = new AbortController();
+ var timeoutId = setTimeout(function () { controller.abort(); }, TIMEOUT);
+
+ // Build the Connect payload for the current session state.
+ var buildPayload = function () {
+ var payload = {
+ scenario: scenario,
+ tools: [],
+ message: {
+ role: 'user',
+ blocks: [{
+ message_id: crypto.randomUUID ? crypto.randomUUID() : '',
+ text: { content: content }
+ }],
+ scenario: scenario
+ },
+ options: { thinking: false }
+ };
+ if (session.chatId) payload.chat_id = session.chatId;
+ if (session.parentId) payload.message.parent_id = session.parentId;
+ return payload;
+ };
+
+ try {
+ var res = await fetch(CHAT_PATH, {
+ method: 'POST',
+ credentials: 'include',
+ headers: headers,
+ body: _encodeConnect(buildPayload()),
+ signal: controller.signal
+ });
+
+ var needAuthRetry = (res.status === 401 || res.status === 403);
+
+ if (res.status === 429) throw new Error('Kimi rate limited');
+ if (!res.ok && !needAuthRetry) {
+ var errBody = await res.text().catch(function () { return ''; });
+ throw new Error('Kimi API error (' + res.status + '): ' + errBody.substring(0, 300));
+ }
+
+ var result;
+ var tooLong = false;
+ try {
+ result = needAuthRetry ? null : await _parseStream(res, session);
+ } catch (parseErr) {
+ if (parseErr && parseErr.__kimiAuth && !needAuthRetry) {
+ needAuthRetry = true;
+ } else if (parseErr && parseErr.__kimiTooLong) {
+ tooLong = true;
+ } else {
+ throw parseErr;
+ }
+ }
+
+ if (needAuthRetry) {
+ // Kimi revokes access tokens server-side even when the JWT exp is
+ // far in the future, so the stored token can be stale/rejected.
+ // Force a refresh-token EXCHANGE (not a storage re-read — that
+ // would return the same revoked token).
+ if (token) _accessToken = null;
+ var fresh = await _refreshAccessToken();
+ if (!fresh) {
+ fresh = await _getAccessToken(true);
+ }
+ if (fresh && fresh !== token) {
+ headers['Authorization'] = 'Bearer ' + fresh;
+ res = await fetch(CHAT_PATH, {
+ method: 'POST',
+ credentials: 'include',
+ headers: headers,
+ body: _encodeConnect(buildPayload()),
+ signal: controller.signal
+ });
+ if (res.status === 429) throw new Error('Kimi rate limited');
+ if (!res.ok) {
+ var errBody2 = await res.text().catch(function () { return ''; });
+ throw new Error('Kimi API error (' + res.status + '): ' + errBody2.substring(0, 300));
+ }
+ result = await _parseStream(res, session);
+ } else {
+ throw _AuthError('Not logged in to Kimi (no valid token found)');
+ }
+ }
+
+ if (tooLong) {
+ // No bypass exists for Kimi's context limit — the research-backed
+ // recovery is a NEW chat_id. The opencode plugin owns the context
+ // restoration: it polls Proxima's session status (resetCount),
+ // detects the rollover, and re-sends the system prompt + a
+ // comprehensive handoff summary on the next message. So we do NOT
+ // auto-recover here with the bare current question (the user
+ // explicitly wants a summary of recent work, not the question only).
+ console.log('[Proxima Kimi] Conversation too long — rolling session (resetCount++). Plugin will restore context.');
+ session.chatId = null;
+ session.parentId = null;
+ session.resetCount = (session.resetCount || 0) + 1;
+ saveSession();
+ result = {
+ answer: '**Conversation too long** — Kimi\'s context limit was reached, so I started a new session for you. Your next message will continue with a summary of our recent work (system prompt + context restored automatically).',
+ reasoning: ''
+ };
+ }
+
+ saveSession();
+
+ if (!result.answer) {
+ throw new Error('Kimi returned an empty response' +
+ (result.reasoning ? ' (thinking only)' : ''));
+ }
+ return result.answer;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+ }
+
+ function newConversation(sessionId) {
+ if (sessionId) {
+ delete _sessions[sessionId];
+ if (_currentSessionId === sessionId) {
+ _currentSessionId = null;
+ }
+ } else if (_currentSessionId) {
+ delete _sessions[_currentSessionId];
+ _currentSessionId = null;
+ } else {
+ // Nothing active — reset the loose globals as a safety net.
+ _accessToken = null;
+ }
+ try {
+ localStorage.setItem('proxima_kimi_sessions', JSON.stringify(_sessions));
+ } catch (e) { }
+ console.log('[Proxima Kimi] Conversation reset:', sessionId || _currentSessionId || 'current');
+ }
+ // Kimi's web protocol does not expose a public upload endpoint from the
+ // page context; text files are inlined into the message upstream.
+ async function uploadFileToKimi() {
+ throw new Error('Kimi web API does not support direct file upload yet. Text files are inlined into the message instead.');
+ }
+
+ window.__proximaKimi = { send: send, newConversation: newConversation, uploadFileToKimi: uploadFileToKimi, getSessionStatus: getSessionStatus };
+ console.log('[Proxima] Kimi engine loaded');
+})();
diff --git a/electron/providers/sender.cjs b/electron/providers/sender.cjs
index cff251e..f879377 100644
--- a/electron/providers/sender.cjs
+++ b/electron/providers/sender.cjs
@@ -8,16 +8,16 @@ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
// Chain sends to the same provider sequentially to prevent BrowserView execution collisions.
const _sendQueues = {};
-function sendMessageToProvider(provider, message, attachments = null, onChunk = null, conversationId = null) {
+function sendMessageToProvider(provider, message, attachments = null, onChunk = null, conversationId = null, think = true) {
const baseProvider = (provider || '').split(':')[0];
- const run = () => _sendMessageToProviderImpl(provider, message, attachments, onChunk, conversationId);
+ const run = () => _sendMessageToProviderImpl(provider, message, attachments, onChunk, conversationId, think);
const prior = _sendQueues[baseProvider] || Promise.resolve();
const next = prior.then(run, run);
_sendQueues[baseProvider] = next.then(() => { }, () => { });
return next;
}
-async function _sendMessageToProviderImpl(provider, message, attachments = null, onChunk = null, conversationId = null) {
+async function _sendMessageToProviderImpl(provider, message, attachments = null, onChunk = null, conversationId = null, think = true) {
const baseProvider = provider.split(':')[0];
let webContents = browserManager.getWebContents(baseProvider);
if (!webContents) {
@@ -42,7 +42,7 @@ async function _sendMessageToProviderImpl(provider, message, attachments = null,
for (let attempt = 1; attempt <= MAX_TRANSIENT_RETRIES; attempt++) {
try {
console.log(`[${provider}] API attempt ${attempt}/${MAX_TRANSIENT_RETRIES}...`);
- const apiResponse = await providerAPI.sendViaAPI(provider, webContents, message, attachments, onChunk, conversationId);
+ const apiResponse = await providerAPI.sendViaAPI(provider, webContents, message, attachments, onChunk, conversationId, think);
if (apiResponse && apiResponse.length > 0) {
console.log(`[${provider}] [OK] API response captured (${apiResponse.length} chars) on attempt ${attempt}`);
diff --git a/src/agentic/smart-router.js b/src/agentic/smart-router.js
index eacea8c..6f6535f 100644
--- a/src/agentic/smart-router.js
+++ b/src/agentic/smart-router.js
@@ -25,6 +25,12 @@ const PROVIDER_PROFILES = {
weaknesses: ['code-gen', 'creative'],
speedTier: 3,
qualityTier: 2,
+ },
+ kimi: {
+ strengths: ['long-context', 'code-gen', 'reasoning', 'general', 'writing', 'translation'],
+ weaknesses: ['web-search', 'current-events'],
+ speedTier: 2,
+ qualityTier: 2,
}
};
diff --git a/src/agentic/task-orchestrator.js b/src/agentic/task-orchestrator.js
index 1e612e5..ef20b72 100644
--- a/src/agentic/task-orchestrator.js
+++ b/src/agentic/task-orchestrator.js
@@ -28,6 +28,11 @@ const PROVIDER_ROLES = {
strengths: ['analysis', 'multimodal', 'research', 'code-gen', 'data'],
weight: 7,
},
+ kimi: {
+ role: 'Scribe',
+ strengths: ['long-context', 'writing', 'translation', 'general', 'synthesis'],
+ weight: 6,
+ },
};
const TASK_PATTERNS = [
@@ -137,6 +142,10 @@ class TaskOrchestrator {
assignments.push({ provider: 'gemini', role: 'analyst', assignment: 'analyze' });
}
+ if (available.includes('kimi') && !assignments.find(a => a.provider === 'kimi')) {
+ assignments.push({ provider: 'kimi', role: 'scribe', assignment: 'synthesize' });
+ }
+
if (assignments.length === 0) {
for (const p of available) {
diff --git a/src/config/defaults.js b/src/config/defaults.js
index 1780142..7dec8ba 100644
--- a/src/config/defaults.js
+++ b/src/config/defaults.js
@@ -22,7 +22,7 @@ export const DEFAULTS = {
RPM_WINDOW_MS: 60000,
- PROVIDER_ORDER: ['chatgpt', 'claude', 'perplexity', 'gemini'],
+ PROVIDER_ORDER: ['chatgpt', 'claude', 'perplexity', 'gemini', 'kimi'],
PROVIDER_HEALTH_CHECK_INTERVAL_MS: 60000,
ROUTER_SCORE_THRESHOLD: 15,
@@ -45,6 +45,7 @@ export const DEFAULTS = {
claude: 800000,
gemini: 1500000,
perplexity: 1000000,
+ kimi: 900000,
},
@@ -58,4 +59,5 @@ export const PROVIDER_INFO = {
claude: { name: 'Claude', url: 'https://claude.ai/' },
gemini: { name: 'Gemini', url: 'https://gemini.google.com/app' },
perplexity: { name: 'Perplexity', url: 'https://www.perplexity.ai/' },
+ kimi: { name: 'Kimi', url: 'https://www.kimi.com/' },
};
diff --git a/src/cost/token-tracker.js b/src/cost/token-tracker.js
index 9c6d80d..ee29971 100644
--- a/src/cost/token-tracker.js
+++ b/src/cost/token-tracker.js
@@ -14,6 +14,7 @@ const MODEL_PRICING = {
'claude': { input: 4.00, output: 20.00 },
'gemini': { input: 2.50, output: 10.00 },
'perplexity': { input: 5.00, output: 20.00 },
+ 'kimi': { input: 3.00, output: 16.00 },
'default': { input: 5.00, output: 20.00 },
};
@@ -26,6 +27,7 @@ function _pricingKey(model) {
if (m.includes('claude') || m.includes('sonnet') || m.includes('opus') || m.includes('haiku')) return 'claude';
if (m.includes('gemini') || m.includes('bison') || m.includes('palm')) return 'gemini';
if (m.includes('sonar') || m.includes('perplexity') || m.includes('pplx')) return 'perplexity';
+ if (m.includes('kimi') || m.includes('moonshot')) return 'kimi';
return 'default';
}
diff --git a/src/mcp/helpers.js b/src/mcp/helpers.js
index 55eca2e..0d2a585 100644
--- a/src/mcp/helpers.js
+++ b/src/mcp/helpers.js
@@ -46,7 +46,7 @@ export function getEnabledProviders(dirname) {
console.error('[MCP] Error reading enabled providers:', e);
}
- return new Set(['chatgpt', 'claude', 'gemini', 'perplexity']);
+ return new Set(['chatgpt', 'claude', 'gemini', 'perplexity', 'kimi']);
}
diff --git a/src/mcp/index.js b/src/mcp/index.js
index c3b3667..f872f11 100644
--- a/src/mcp/index.js
+++ b/src/mcp/index.js
@@ -57,8 +57,9 @@ const perplexity = new AIProvider('perplexity', ipcClient, isEnabled);
const chatgpt = new AIProvider('chatgpt', ipcClient, isEnabled);
const claude = new AIProvider('claude', ipcClient, isEnabled);
const gemini = new AIProvider('gemini', ipcClient, isEnabled);
+const kimi = new AIProvider('kimi', ipcClient, isEnabled);
-const allProviders = { perplexity, chatgpt, claude, gemini };
+const allProviders = { perplexity, chatgpt, claude, gemini, kimi };
(function _initByokProviders() {
@@ -165,10 +166,10 @@ function pickBestProvider(taskType) {
}
const priorities = {
- coding: ['claude', 'chatgpt', 'gemini', 'perplexity'],
- research: ['perplexity', 'gemini', 'chatgpt', 'claude'],
- general: ['claude', 'chatgpt', 'gemini', 'perplexity'],
- review: ['claude', 'chatgpt', 'gemini', 'perplexity'],
+ coding: ['claude', 'chatgpt', 'gemini', 'perplexity', 'kimi'],
+ research: ['perplexity', 'gemini', 'chatgpt', 'claude', 'kimi'],
+ general: ['claude', 'chatgpt', 'gemini', 'perplexity', 'kimi'],
+ review: ['claude', 'chatgpt', 'gemini', 'perplexity', 'kimi'],
};
const order = priorities[taskType] || priorities.general;
for (const name of order) {
@@ -265,8 +266,8 @@ server.resource(
}
);
-// The 4 session tool names that map 1:1 to a BYOK provider.
-const SESSION_TOOL_MAP = { chatgpt: 'ask_chatgpt', claude: 'ask_claude', gemini: 'ask_gemini', perplexity: 'ask_perplexity' };
+// The 5 session tool names that map 1:1 to a BYOK provider.
+const SESSION_TOOL_MAP = { chatgpt: 'ask_chatgpt', claude: 'ask_claude', gemini: 'ask_gemini', perplexity: 'ask_perplexity', kimi: 'ask_kimi' };
server.resource(
'models', 'proxima://models',
@@ -302,7 +303,7 @@ server.resource(
model: `${name} (web session)`,
tool: SESSION_TOOL_MAP[name] || `ask_model('${name}', message)`,
}));
- hint = 'Session mode. Use ask_chatgpt, ask_claude, ask_gemini, ask_perplexity for browser-based providers.';
+ hint = 'Session mode. Use ask_chatgpt, ask_claude, ask_gemini, ask_perplexity, ask_kimi for browser-based providers.';
}
return {
diff --git a/src/mcp/ipc-bridge.js b/src/mcp/ipc-bridge.js
index 92b2001..e551ae0 100644
--- a/src/mcp/ipc-bridge.js
+++ b/src/mcp/ipc-bridge.js
@@ -283,13 +283,14 @@ export class AIProvider {
}
- async _doChat(message, filePath = null, engine = null) {
+ async _doChat(message, filePath = null, engine = null, think = true) {
await this.ensureInitialized();
console.error(`[${this.name}] Sending message...`);
const sendResult = await this.ipc.send('sendMessage', this.name, {
message, filePath, engine,
conversationId: 'mcp-session',
+ think,
});
if (sendResult.response && sendResult.response.length > 0) {
@@ -306,9 +307,9 @@ export class AIProvider {
});
}
- async chat(message, useCache = true, filePath = null, engine = null) {
+ async chat(message, useCache = true, filePath = null, engine = null, think = true) {
- const cacheKey = `${message}\u0000${filePath || ''}\u0000${engine || ''}`;
+ const cacheKey = `${message}\u0000${filePath || ''}\u0000${engine || ''}\u0000${think ? 'think' : 'nothink'}`;
if (useCache && this.cache.has(cacheKey)) {
@@ -327,7 +328,7 @@ export class AIProvider {
const responsePromise = this._queue.then(async () => {
console.error(`[${this.name}] Processing request (${position} of ${this._queueLength})...`);
- const response = await this._doChat(message, filePath, engine);
+ const response = await this._doChat(message, filePath, engine, think);
this.cache.set(cacheKey, { response, time: Date.now() });
this._queueLength--;
return response;
diff --git a/src/mcp/pipeline.js b/src/mcp/pipeline.js
index 1e222ef..4e2730e 100644
--- a/src/mcp/pipeline.js
+++ b/src/mcp/pipeline.js
@@ -74,7 +74,7 @@ export function createSmartChat(deps) {
let response;
try {
response = await withRetry(
- () => providerInstance.chat(processedMessage, true, options.filePath, engineOverride),
+ () => providerInstance.chat(processedMessage, true, options.filePath, engineOverride, options.think !== false),
{ maxRetries: 2, baseDelay: 1, label: providerName }
);
smartRouterV2.recordSuccess(providerName, Date.now() - span.startedAt);
diff --git a/src/mcp/tools-chat.js b/src/mcp/tools-chat.js
index 1e99d47..94119d6 100644
--- a/src/mcp/tools-chat.js
+++ b/src/mcp/tools-chat.js
@@ -46,19 +46,20 @@ export function register(server, deps) {
server.registerTool('ask_chatgpt', {
title: 'Ask ChatGPT',
- description: 'Send a message to ChatGPT specifically. Use ask_model for any other/BYOK provider, smart_query to auto-pick the best provider, or ask_all_ais to query several at once.',
+ description: 'Send a message to ChatGPT specifically (defaults to Thinking/think mode, like the "Think" toggle on chatgpt.com). Use ask_model for any other/BYOK provider, smart_query to auto-pick the best provider, or ask_all_ais to query several at once.',
inputSchema: {
message: z.string().describe('Message to send to ChatGPT'),
files: z.array(z.string()).optional().describe(FILES_DESC),
+ think: z.boolean().optional().describe('Enable ChatGPT think mode (deep reasoning). Default: true. Set to false for instant/low-effort mode.'),
},
annotations: CHAT,
- }, async ({ message, files }) => {
+ }, async ({ message, files, think }) => {
const disabled = checkDisabled('chatgpt');
if (disabled) return disabled;
try {
const { textFiles, uploadFilePath } = getFilesSetup(files);
const fullMessage = buildMessageWithFiles(message, textFiles);
- return toolResponse(await smartChat(fullMessage, 'chatgpt', { filePath: uploadFilePath }));
+ return toolResponse(await smartChat(fullMessage, 'chatgpt', { filePath: uploadFilePath, think: think !== false }));
} catch (err) {
return toolError(err);
}
@@ -124,18 +125,39 @@ export function register(server, deps) {
}
});
- const enabledList = [...getEnabledProviders()].join(', ') || 'gemini, chatgpt, claude, perplexity';
+ server.registerTool('ask_kimi', {
+ title: 'Ask Kimi',
+ description: 'Send a message to Kimi specifically (Moonshot AI — strong at long-context). Use ask_model for other/BYOK providers, smart_query to auto-pick, or ask_all_ais for several at once.',
+ inputSchema: {
+ message: z.string().describe('Message to send to Kimi'),
+ files: z.array(z.string()).optional().describe(FILES_DESC),
+ },
+ annotations: CHAT,
+ }, async ({ message, files }) => {
+ const disabled = checkDisabled('kimi');
+ if (disabled) return disabled;
+ try {
+ const { textFiles, uploadFilePath } = getFilesSetup(files);
+ const fullMessage = buildMessageWithFiles(message, textFiles);
+ return toolResponse(await smartChat(fullMessage, 'kimi', { filePath: uploadFilePath }));
+ } catch (err) {
+ return toolError(err);
+ }
+ });
+
+ const enabledList = [...getEnabledProviders()].join(', ') || 'gemini, chatgpt, claude, perplexity, kimi';
server.registerTool('ask_model', {
title: 'Ask Any Model',
- description: 'Universal chat: send a message to ANY enabled provider by name (the 4 session providers OR any configured BYOK provider). Use this when you need a provider other than the four dedicated ask_* tools.',
+ description: 'Universal chat: send a message to ANY enabled provider by name (the session providers OR any configured BYOK provider). Use this when you need a provider other than the dedicated ask_* tools.',
inputSchema: {
provider: z.string().describe(`Provider name. Currently available: ${enabledList}`),
message: z.string().describe('Message to send'),
model: z.string().optional().describe('Specific model ID override (uses provider default if omitted)'),
files: z.array(z.string()).optional().describe('Optional: file paths to include as context. Supports line ranges like "path/file.js:10-50".'),
+ think: z.boolean().optional().describe('Enable think/deep-reasoning mode (ChatGPT and compatible providers). Default: true.'),
},
annotations: CHAT,
- }, async ({ provider, message, model, files }) => {
+ }, async ({ provider, message, model, files, think }) => {
const providerName = provider.toLowerCase().trim();
const disabled = checkDisabled(providerName);
if (disabled) return disabled;
@@ -146,6 +168,7 @@ export function register(server, deps) {
return toolResponse(await smartChat(fullMessage, providerName, {
filePath: uploadFilePath,
engine: model || null,
+ think: think !== false,
}));
} catch (err) {
return toolError(err);
@@ -253,7 +276,7 @@ export function register(server, deps) {
title: 'New Conversation (reset)',
description: 'Reset conversation memory/context for a provider (or all enabled providers if none named). Use when you want a fresh thread with no prior context.',
inputSchema: {
- provider: z.string().optional().describe('Which provider to reset: chatgpt, claude, gemini, or perplexity. If omitted, resets all enabled providers.'),
+ provider: z.string().optional().describe('Which provider to reset: chatgpt, claude, gemini, perplexity, or kimi. If omitted, resets all enabled providers.'),
},
annotations: Object.freeze({ readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }),
}, async ({ provider }) => {
@@ -268,7 +291,7 @@ export function register(server, deps) {
return toolResponse({ success: true, provider: name, message: `Started new ${name} conversation` });
}
const reset = [];
- for (const p of ['perplexity', 'chatgpt', 'claude', 'gemini']) {
+ for (const p of ['perplexity', 'chatgpt', 'claude', 'gemini', 'kimi']) {
if (enabled.has(p)) {
await allProviders[p].newConversation();
reset.push(p);
diff --git a/src/mcp/tools-code.js b/src/mcp/tools-code.js
index a1c6c98..9a98f02 100644
--- a/src/mcp/tools-code.js
+++ b/src/mcp/tools-code.js
@@ -20,7 +20,7 @@ export function register(server, deps) {
});
- const PROVIDER_DESC = 'AI provider: chatgpt, claude, gemini, perplexity, or any configured BYOK provider. Default: auto-select best for coding.';
+ const PROVIDER_DESC = 'AI provider: chatgpt, claude, gemini, perplexity, kimi, or any configured BYOK provider. Default: auto-select best for coding.';
server.registerTool('verify_code', {
title: 'Verify Code',
@@ -186,7 +186,7 @@ export function register(server, deps) {
inputSchema: {
error: z.string().describe('Error message or stack trace to explain'),
context: z.string().optional().describe('What you were doing when the error occurred'),
- provider: z.string().optional().describe('AI provider: chatgpt, claude, gemini, perplexity, or any configured BYOK provider. Default: auto-select.'),
+ provider: z.string().optional().describe('AI provider: chatgpt, claude, gemini, perplexity, kimi, or any configured BYOK provider. Default: auto-select.'),
},
annotations: ANALYSIS,
}, async ({ error, context: ctx, provider: pn }) =>
diff --git a/src/mcp/tools-content.js b/src/mcp/tools-content.js
index 6ea0a08..c426ee6 100644
--- a/src/mcp/tools-content.js
+++ b/src/mcp/tools-content.js
@@ -24,7 +24,7 @@ export function register(server, deps) {
input: z.string().describe('Main input — URL, topic, text, or task description'),
detail: z.string().optional().describe('Extra context — focus area, writing style, data type to extract, specific question'),
body: z.string().optional().describe('Content body — text to improve, or content to analyze/extract from'),
- provider: z.string().optional().describe('AI provider: chatgpt, claude, gemini, perplexity, or any configured BYOK provider. Default: auto-select'),
+ provider: z.string().optional().describe('AI provider: chatgpt, claude, gemini, perplexity, kimi, or any configured BYOK provider. Default: auto-select'),
},
annotations: GEN,
}, async ({ action, input, detail, body, provider: pn }) => {
@@ -51,7 +51,7 @@ export function register(server, deps) {
item1: z.string().describe('First item to compare'),
item2: z.string().describe('Second item to compare'),
context: z.string().optional().describe('Context for comparison'),
- provider: z.string().optional().describe('AI provider: chatgpt, claude, gemini, perplexity, or any configured BYOK provider. Default: auto-select'),
+ provider: z.string().optional().describe('AI provider: chatgpt, claude, gemini, perplexity, kimi, or any configured BYOK provider. Default: auto-select'),
},
annotations: GEN,
}, async ({ item1, item2, context, provider: pn }) => {
diff --git a/src/mcp/tools-search.js b/src/mcp/tools-search.js
index 14b9822..e1f6b07 100644
--- a/src/mcp/tools-search.js
+++ b/src/mcp/tools-search.js
@@ -37,7 +37,7 @@ export function register(server, deps) {
timeframe: z.string().optional().describe('For news: timeframe like "today", "this week", "2024"'),
year: z.string().optional().describe('For stats: specific year'),
files: z.array(z.string()).optional().describe('Optional: file paths for context. Supports line ranges like "path/file.js:10-50"'),
- provider: z.string().optional().describe('AI provider: chatgpt, claude, gemini, perplexity, or any configured BYOK provider. Default: auto-select'),
+ provider: z.string().optional().describe('AI provider: chatgpt, claude, gemini, perplexity, kimi, or any configured BYOK provider. Default: auto-select'),
},
annotations: READ_OPEN,
}, async ({ query, type, language, timeframe, year, files, provider: pn }) => {
@@ -68,7 +68,7 @@ export function register(server, deps) {
code: z.string().optional().describe('Optional: existing code to analyze and apply design improvements on'),
files: z.array(z.string()).optional().describe('Optional: file paths of existing code to improve with better UI/UX. Supports line ranges like "path/file.js:10-50"'),
style: z.string().optional().describe('Design style preference: modern, minimal, glassmorphism, dark, corporate, playful, etc.'),
- provider: z.string().optional().describe('AI provider: chatgpt, claude, gemini, perplexity, or any configured BYOK provider. Default: auto-select best for coding'),
+ provider: z.string().optional().describe('AI provider: chatgpt, claude, gemini, perplexity, kimi, or any configured BYOK provider. Default: auto-select best for coding'),
},
annotations: READ_OPEN,
}, async ({ description, code, files, style, provider: pn }) => {
diff --git a/src/mcp/tools-utility.js b/src/mcp/tools-utility.js
index baef0be..8f5fbb4 100644
--- a/src/mcp/tools-utility.js
+++ b/src/mcp/tools-utility.js
@@ -33,7 +33,7 @@ export function register(server, deps) {
inputSchema: {
filePath: z.string().describe('Absolute path to the file or directory to analyze'),
question: z.string().optional().describe('Specific question about the file/codebase'),
- provider: z.string().optional().describe('Which AI to use (chatgpt, claude, gemini, perplexity, or any configured BYOK provider). Default: claude'),
+ provider: z.string().optional().describe('Which AI to use (chatgpt, claude, gemini, perplexity, kimi, or any configured BYOK provider). Default: claude'),
grep: z.string().optional().describe('Optional regex pattern to search within the file/codebase before analysis'),
symbols: z.string().optional().describe('Comma-separated function/class names to extract from file. E.g. "smartChat,toolResponse,guardrails". Auto-resolves dependencies.'),
},
@@ -127,7 +127,7 @@ export function register(server, deps) {
inputSchema: {
filePath: z.string().describe('Absolute path to the code file to review'),
focus: z.string().optional().describe('What to focus on (bugs, performance, security, style)'),
- provider: z.string().optional().describe('Which AI to use (chatgpt, claude, gemini, perplexity, or any configured BYOK provider). Default: claude'),
+ provider: z.string().optional().describe('Which AI to use (chatgpt, claude, gemini, perplexity, kimi, or any configured BYOK provider). Default: claude'),
},
annotations: Object.freeze({ readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true }),
}, async ({ filePath, focus, provider: pn }) => {
diff --git a/tests/config/defaults.test.js b/tests/config/defaults.test.js
index 8218d5d..55b73a8 100644
--- a/tests/config/defaults.test.js
+++ b/tests/config/defaults.test.js
@@ -21,10 +21,10 @@ test('retry delays are internally consistent (base <= max)', () => {
assert.ok(DEFAULTS.MAX_RETRIES >= 0);
});
-test('PROVIDER_ORDER lists the four session providers', () => {
+test('PROVIDER_ORDER lists the session providers', () => {
assert.deepEqual(
[...DEFAULTS.PROVIDER_ORDER].sort(),
- ['chatgpt', 'claude', 'gemini', 'perplexity'],
+ ['chatgpt', 'claude', 'gemini', 'kimi', 'perplexity'],
);
});
diff --git a/tests/electron/providers/sender.test.js b/tests/electron/providers/sender.test.js
index b0cc81a..abc46ba 100644
--- a/tests/electron/providers/sender.test.js
+++ b/tests/electron/providers/sender.test.js
@@ -92,3 +92,14 @@ test('sendMessageToProvider: a failing send does not wedge the queue for the nex
const r = await sender.sendMessageToProvider('gemini', 'b');
assert.deepEqual(r, { response: 'recovered' });
});
+
+test('sendMessageToProvider: defaults think=true and forwards the think flag to sendViaAPI', async () => {
+ let captured;
+ sendImpl = async (...args) => { captured = args; return 'ok'; };
+ await sender.sendMessageToProvider('chatgpt', 'hi');
+ assert.equal(captured[6], true, 'think must default to true');
+
+ captured = null;
+ await sender.sendMessageToProvider('chatgpt', 'hi', null, null, null, false);
+ assert.equal(captured[6], false, 'explicit think=false must be forwarded');
+});
diff --git a/tests/fixtures/mcp-harness.js b/tests/fixtures/mcp-harness.js
index eb9731c..0f55b0a 100644
--- a/tests/fixtures/mcp-harness.js
+++ b/tests/fixtures/mcp-harness.js
@@ -5,7 +5,7 @@ import { z } from 'zod';
import { toolResponse, toolError } from '../../src/mcp/helpers.js';
export function makeHarness(overrides = {}) {
- const enabled = new Set(overrides.enabled || ['chatgpt', 'claude', 'gemini', 'perplexity']);
+ const enabled = new Set(overrides.enabled || ['chatgpt', 'claude', 'gemini', 'perplexity', 'kimi']);
const smartChatCalls = [];
const ipcCalls = [];
const tools = new Map();
diff --git a/tests/mcp/helpers.test.js b/tests/mcp/helpers.test.js
index 309c28f..eb86734 100644
--- a/tests/mcp/helpers.test.js
+++ b/tests/mcp/helpers.test.js
@@ -47,9 +47,9 @@ function write(name, obj) {
}
-test('getEnabledProviders: returns the 4 core providers by default when no config exists', () => {
+test('getEnabledProviders: returns the 5 core providers by default when no config exists', () => {
const set = getEnabledProviders();
- assert.deepEqual([...set].sort(), ['chatgpt', 'claude', 'gemini', 'perplexity']);
+ assert.deepEqual([...set].sort(), ['chatgpt', 'claude', 'gemini', 'kimi', 'perplexity']);
});
test('getEnabledProviders: session mode reads enabled-providers.json', () => {
diff --git a/tests/mcp/ipc-bridge.test.js b/tests/mcp/ipc-bridge.test.js
index f08c95b..77bb237 100644
--- a/tests/mcp/ipc-bridge.test.js
+++ b/tests/mcp/ipc-bridge.test.js
@@ -244,6 +244,21 @@ test('AIProvider.chat: cache key includes filePath (same text + different file
assert.equal(sendMessageCalls, 2, 'a different attached file must not reuse the cached answer');
});
+test('AIProvider.chat: forwards think=true by default and includes think in the cache key', async () => {
+ const sends = [];
+ const ipc = mockIpc(async (action, provider, data) => {
+ if (action === 'initProvider') return {};
+ if (action === 'sendMessage') { sends.push({ ...data }); return { response: 'r' }; }
+ return {};
+ });
+ const p = new AIProvider('claude', ipc, () => true);
+ await p.chat('q');
+ await p.chat('q', true, null, null, false);
+ assert.equal(sends.length, 2, 'toggling think must not reuse the cached answer');
+ assert.equal(sends[0].think, true, 'think must default to true');
+ assert.equal(sends[1].think, false, 'explicit think=false must be forwarded');
+});
+
test('AIProvider.chat: gateway {success:false,error:"rate limit"} throws a RateLimitError', async () => {
const ipc = mockIpc(async (action) => (action === 'initProvider' ? {} : { success: false, error: 'rate limit exceeded' }));
const p = new AIProvider('claude', ipc, () => true);
diff --git a/tests/mcp/tools-chat.test.js b/tests/mcp/tools-chat.test.js
index 7141598..6ce578a 100644
--- a/tests/mcp/tools-chat.test.js
+++ b/tests/mcp/tools-chat.test.js
@@ -6,11 +6,11 @@ import assert from 'node:assert';
import { register } from '../../src/mcp/tools-chat.js';
import { registerModule, textOf } from '../fixtures/mcp-harness.js';
-test('tools-chat: registers exactly the 8 documented chat tools with schemas', () => {
+test('tools-chat: registers exactly the 9 documented chat tools with schemas', () => {
const { tools } = registerModule(register);
assert.deepEqual(
[...tools.keys()].sort(),
- ['ask_all_ais', 'ask_chatgpt', 'ask_claude', 'ask_gemini', 'ask_model', 'new_conversation', 'smart_query'].sort()
+ ['ask_all_ais', 'ask_chatgpt', 'ask_claude', 'ask_gemini', 'ask_kimi', 'ask_model', 'new_conversation', 'smart_query'].sort()
.concat('ask_perplexity').sort(),
);
// Every tool exposes a non-empty description and an inputSchema object.
@@ -43,6 +43,24 @@ test('ask_claude: a smartChat failure is returned as an isError toolError', asyn
assert.match(textOf(res), /provider down/);
});
+test('ask_chatgpt: defaults to think mode (think=true) and honors an explicit think=false', async () => {
+ const h = registerModule(register);
+ await h.tools.get('ask_chatgpt').handler({ message: 'hello' });
+ assert.equal(h.smartChatCalls[0].provider, 'chatgpt');
+ assert.deepEqual(h.smartChatCalls[0].opts, { filePath: null, think: true });
+
+ await h.tools.get('ask_chatgpt').handler({ message: 'hello', think: false });
+ assert.equal(h.smartChatCalls[1].opts.think, false);
+});
+
+test('ask_model: forwards think default true and honors an explicit think=false', async () => {
+ const h = registerModule(register);
+ await h.tools.get('ask_model').handler({ provider: 'chatgpt', message: 'hi' });
+ assert.equal(h.smartChatCalls[0].opts.think, true);
+ await h.tools.get('ask_model').handler({ provider: 'chatgpt', message: 'hi', think: false });
+ assert.equal(h.smartChatCalls[1].opts.think, false);
+});
+
test('ask_model: normalizes provider case and forwards the model override as engine', async () => {
const h = registerModule(register);
await h.tools.get('ask_model').handler({ provider: 'Claude', message: 'hi', model: 'claude-x' });