Files
NetMesh/infrastructure/ai/types.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

874 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// AI Provider types
import commandBlocklistTable from '../../lib/commandBlocklist.json';
import type { AgentActivity, AgentUsage } from '../../domain/agentActivity';
import type { ProviderContinuation } from './providerContinuation';
export type { AgentActivity, AgentUsage } from '../../domain/agentActivity';
export type AIProviderId =
| 'openai'
| 'anthropic'
| 'google'
| 'ollama'
| 'openrouter'
| 'qwen'
| 'deepseek'
| 'kimi'
| 'zhipu'
| 'doubao'
| 'mimo'
| 'custom';
/**
* Wire-protocol family for a provider. Three are supported because every
* Anthropic/OpenAI-compatible third party reduces to one of these.
* `providerId` stays as the routing/display identity; `style` decides
* which Vercel AI SDK client builds the request.
*/
export type ProviderStyle = 'openai' | 'anthropic' | 'google';
/**
* OpenAI-compatible request format. Chat Completions (`/v1/chat/completions`)
* is the default so existing providers and OpenAI-compat proxies keep working.
* Responses (`/v1/responses`) is opt-in for relays that cache better on that
* endpoint.
*/
export type OpenAIApiFormat = 'chat' | 'responses';
export interface ProviderAdvancedParams {
maxTokens?: number;
temperature?: number; // 02
topP?: number; // 01
frequencyPenalty?: number; // -22
presencePenalty?: number; // -22
}
export interface ProviderConfig {
id: string;
providerId: AIProviderId;
name: string;
/** Override the wire-protocol family; defaults from `providerId` via {@link resolveProviderStyle}. */
style?: ProviderStyle;
/**
* OpenAI-compatible endpoint family. Only used when style resolves to `openai`.
* Defaults to Chat Completions via {@link resolveOpenAIApi}.
*/
openaiApi?: OpenAIApiFormat;
/** Built-in icon key (slug under public/ai/providers/), independent of providerId. */
iconId?: string;
/** User-supplied icon as a data URL (compressed to 64x64 webp at write time). Wins over iconId. */
iconDataUrl?: string;
apiKey?: string; // encrypted via credentialBridge (enc:v1: prefix)
baseURL?: string; // custom endpoint URL
defaultModel?: string;
customHeaders?: Record<string, string>;
enabled: boolean;
skipTLSVerify?: boolean; // skip TLS certificate verification (for self-signed certs)
/** User override for the model context window, in tokens. Wins over discovered model metadata. */
contextWindow?: number;
/** Context windows discovered from provider model-list metadata, keyed by model id. */
modelContextWindows?: Record<string, number>;
advancedParams?: ProviderAdvancedParams;
}
/** Pick the protocol family for a provider config, falling back from providerId when style is unset. */
export function resolveProviderStyle(config: Pick<ProviderConfig, 'providerId' | 'style'>): ProviderStyle {
if (config.style) return config.style;
switch (config.providerId) {
case 'anthropic':
return 'anthropic';
case 'google':
return 'google';
default:
return 'openai';
}
}
/** Pick the OpenAI request format. Missing or unknown values stay on Chat Completions. */
export function resolveOpenAIApi(config: Pick<ProviderConfig, 'openaiApi'>): OpenAIApiFormat {
return config.openaiApi === 'responses' ? 'responses' : 'chat';
}
export interface ModelInfo {
id: string;
name: string;
providerId: AIProviderId;
contextWindow?: number;
supportsTools?: boolean;
supportsStreaming?: boolean;
}
// Chat types
export interface ChatMessageAttachment {
base64Data: string;
mediaType: string;
filename?: string;
filePath?: string; // original filesystem path, when available
terminalSelection?: boolean;
/** Set when the attachment was created from a Vault → Notes mention. */
vaultNoteId?: string;
/** Original note title, for display and prompt labels. */
vaultNoteTitle?: string;
previewText?: string;
lineCount?: number;
}
export interface UploadedFile {
id: string;
filename: string;
dataUrl: string;
base64Data: string;
mediaType: string;
filePath?: string;
terminalSelection?: boolean;
/** Set when the attachment was created from a Vault → Notes mention. */
vaultNoteId?: string;
/** Original note title, for display and prompt labels. */
vaultNoteTitle?: string;
previewText?: string;
lineCount?: number;
}
export interface AIDraft {
text: string;
agentId: string;
attachments: UploadedFile[];
selectedUserSkillSlugs: string[];
updatedAt: number;
}
export interface AISessionContextCompaction {
/** Summary sent in place of the older conversation on later Catty turns. */
summary: string;
/** Number of persisted messages covered by the summary. */
compactedMessageCount: number;
}
export type AIPanelView =
| { mode: 'draft' }
| { mode: 'session'; sessionId: string };
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';
content: string;
attachments?: ChatMessageAttachment[];
/** @deprecated Use attachments instead. Kept for backward compatibility with persisted sessions. */
images?: ChatMessageAttachment[];
thinking?: string;
thinkingDurationMs?: number;
providerContinuation?: ProviderContinuation;
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
agentActivities?: AgentActivity[];
usage?: AgentUsage;
timestamp: number;
model?: string;
providerId?: AIProviderId;
errorInfo?: {
type: 'network' | 'auth' | 'timeout' | 'provider' | 'agent' | 'unknown';
message: string;
retryable: boolean;
};
/** Transient status text shown with shimmer effect (e.g. "Waiting for response...") */
statusText?: string;
executionStatus?: 'pending' | 'approved' | 'rejected' | 'running' | 'completed' | 'failed' | 'cancelled';
pendingApproval?: {
approvalId: string;
toolCallId: string;
toolName: string;
toolArgs: Record<string, unknown>;
status: 'pending' | 'approved' | 'denied';
};
}
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
}
export interface ToolResult {
toolCallId: string;
/** Optional tool name carried by external SDK/MCP result streams. */
toolName?: string;
content: string;
isError?: boolean;
}
export interface ChatParams {
model: string;
messages: ChatMessage[];
tools?: ToolDefinition[];
temperature?: number;
maxTokens?: number;
systemPrompt?: string;
}
export interface ToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>; // JSON Schema
}
// Streaming events
export type ChatStreamEvent =
| { type: 'text'; content: string }
| { type: 'thinking'; content: string }
| { type: 'tool_call'; toolCall: ToolCall }
| { type: 'error'; error: string }
| { type: 'done'; usage?: { promptTokens: number; completionTokens: number } };
// AI Session types
export interface AISession {
id: string;
title: string;
agentId: string;
scope: AISessionScope;
messages: ChatMessage[];
contextCompaction?: AISessionContextCompaction;
externalSessionId?: string;
createdAt: number;
updatedAt: number;
}
export interface AISessionScope {
type: 'terminal' | 'workspace' | 'global';
targetId?: string; // sessionId or workspaceId
hostIds?: string[]; // resolved host IDs in scope
}
// Permission model
export type AIPermissionMode = 'observer' | 'confirm' | 'auto';
export type AIToolIntegrationMode = 'mcp' | 'skills';
export interface HostAIPermission {
hostId: string;
mode: AIPermissionMode;
allowedCommands?: string[]; // regex patterns
blockedCommands?: string[]; // regex patterns
allowFileWrite?: boolean;
maxConcurrentCommands?: number;
}
// Agent types
export interface AgentInfo {
id: string;
name: string;
type: 'builtin' | 'external';
icon?: string;
description?: string;
command?: string; // for external agents
args?: string[];
available: boolean;
}
// External agent config. Managed agents route through official SDK backends.
export type CodexRuntime = 'sdk' | 'app-server';
/** Grok Build turn transport: ACP (`grok agent stdio`) or headless streaming-json. */
export type GrokRuntime = 'acp' | 'streaming-json';
export type CursorAuthMode = 'api-key' | 'cli-login';
export interface ExternalAgentConfig {
id: string;
name: string;
command: string;
args?: string[];
env?: Record<string, string>;
apiKey?: string; // encrypted via credentialBridge (enc:v1: prefix)
icon?: string;
enabled: boolean;
available?: boolean;
/** SDK backend key for managed agents (claude|codex|copilot|cursor|codebuddy|opencode|grok). */
sdkBackend?: string;
/** Cursor only: mutually exclusive auth — API key vs local `cursor-agent` CLI login. */
cursorAuthMode?: CursorAuthMode;
/** Experimental Codex transport. Missing values keep the existing SDK behavior. */
codexRuntime?: CodexRuntime;
/**
* Grok Build transport. Missing values default to ACP (`grok agent stdio`).
* `streaming-json` is the original headless `grok -p --output-format streaming-json` path.
*/
grokRuntime?: GrokRuntime;
/** Internal: whether the managed command was set manually or auto-detected. */
commandSource?: "manual" | "auto";
/**
* Last probed CLI --version output for this agent binary (from settings
* resolve-cli or discovery). Used to gate version-specific model presets
* when the configured path is not on PATH discovery.
*/
cliVersion?: string;
/** @deprecated Legacy persisted field from the pre-SDK migration. Read only for compatibility. */
acpCommand?: string;
/** @deprecated Legacy persisted field from the pre-SDK migration. */
acpArgs?: string[];
/** Internal: disabled only because the managed CLI was unavailable. */
autoDisabledUntilAvailable?: boolean;
/** CodeBuddy-specific advanced options (SDK 0.3.230). */
codebuddyOptions?: CodebuddyAdvancedOptions;
}
/** Advanced options specific to the CodeBuddy backend (SDK 0.3.230+). */
export interface CodebuddyAdvancedOptions {
/** Effort level for model reasoning. */
effort?: 'low' | 'medium' | 'high' | 'xhigh';
/** Maximum conversation turns per request. */
maxTurns?: number;
/** Maximum budget in USD per request. */
maxBudgetUsd?: number;
/** Sandbox settings for tool execution. */
sandbox?: { enabled?: boolean; autoAllowBashIfSandboxed?: boolean };
/** Enable file checkpointing for rollback. */
enableFileCheckpointing?: boolean;
/** Fallback model when primary is unavailable. */
fallbackModel?: string;
}
// Discovered agent from system PATH
export interface DiscoveredAgent {
command: string;
name: string;
icon: string;
description: string;
args: string[];
path: string;
version: string;
available: boolean;
/** @deprecated Legacy discovery field from the pre-SDK migration. */
acpCommand?: string;
acpArgs?: string[];
/** SDK backend key (claude|codex|copilot|cursor|codebuddy|opencode|grok) — the routing value. */
sdkBackend?: 'claude' | 'codex' | 'copilot' | 'cursor' | 'codebuddy' | 'opencode' | 'grok';
/** Absolute resolved CLI path (preferred over `path`). */
binPath?: string;
installed?: boolean;
authenticated?: boolean;
authSource?: string | null;
}
// Web Search types
export type WebSearchProviderId = 'tavily' | 'exa' | 'bocha' | 'zhipu' | 'searxng';
export interface WebSearchConfig {
providerId: WebSearchProviderId;
apiKey?: string; // enc:v1: encrypted via credentialBridge
apiHost?: string; // custom API endpoint (required for SearXNG)
enabled: boolean;
maxResults?: number; // default 5
}
export const WEB_SEARCH_PROVIDER_PRESETS: Record<WebSearchProviderId, { name: string; defaultApiHost: string; requiresApiKey: boolean }> = {
tavily: { name: 'Tavily', defaultApiHost: 'https://api.tavily.com', requiresApiKey: true },
exa: { name: 'Exa', defaultApiHost: 'https://api.exa.ai', requiresApiKey: true },
bocha: { name: 'Bocha', defaultApiHost: 'https://api.bochaai.com', requiresApiKey: true },
zhipu: { name: 'Zhipu', defaultApiHost: 'https://open.bigmodel.cn/api/paas/v4', requiresApiKey: true },
searxng: { name: 'SearXNG', defaultApiHost: '', requiresApiKey: false },
};
/** Check if a WebSearchConfig is fully configured and ready to use. */
export function isWebSearchReady(config?: WebSearchConfig | null): boolean {
if (!config?.enabled) return false;
const preset = WEB_SEARCH_PROVIDER_PRESETS[config.providerId];
if (preset?.requiresApiKey && !config.apiKey) return false;
if (config.providerId === 'searxng' && !config.apiHost) return false;
// Validate apiHost is a well-formed URL if provided
if (config.apiHost) {
try { new URL(config.apiHost); } catch { return false; }
}
return true;
}
// AI Settings (stored in localStorage)
export interface AISettings {
providers: ProviderConfig[];
activeProviderId: string;
activeModelId: string;
globalPermissionMode: AIPermissionMode;
toolIntegrationMode: AIToolIntegrationMode;
externalAgents: ExternalAgentConfig[];
defaultAgentId: string;
commandBlocklist: string[]; // global command blocklist patterns
commandTimeout: number; // seconds, default 60
responseIdleTimeout: number; // seconds without response data, default 120
maxIterations: number; // doom loop prevention, default 20
webSearchConfig?: WebSearchConfig;
}
export const DEFAULT_COMMAND_BLOCKLIST = [
...commandBlocklistTable.common,
...commandBlocklistTable.posixNative,
...commandBlocklistTable.posix,
...commandBlocklistTable.powershell,
];
export const DEFAULT_COMMAND_TIMEOUT_SECONDS = 60;
export const MAX_COMMAND_TIMEOUT_SECONDS = 24 * 60 * 60;
export const DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS = 2 * 60;
export const MAX_RESPONSE_IDLE_TIMEOUT_SECONDS = 24 * 60 * 60;
export function normalizeCommandTimeoutSeconds(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_COMMAND_TIMEOUT_SECONDS;
return Math.min(MAX_COMMAND_TIMEOUT_SECONDS, Math.max(1, value));
}
export function normalizeResponseIdleTimeoutSeconds(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS;
return Math.min(MAX_RESPONSE_IDLE_TIMEOUT_SECONDS, Math.max(1, value));
}
export const DEFAULT_AI_SETTINGS: AISettings = {
providers: [],
activeProviderId: '',
activeModelId: '',
globalPermissionMode: 'confirm',
toolIntegrationMode: 'mcp',
externalAgents: [],
defaultAgentId: 'catty',
commandBlocklist: [...DEFAULT_COMMAND_BLOCKLIST],
commandTimeout: DEFAULT_COMMAND_TIMEOUT_SECONDS,
responseIdleTimeout: DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
maxIterations: 20,
};
export interface ProviderPreset {
name: string;
defaultBaseURL: string;
modelsEndpoint?: string;
defaultModels?: readonly string[];
}
// Provider presets for quick setup
export const PROVIDER_PRESETS: Record<AIProviderId, ProviderPreset> = {
openai: { name: 'OpenAI', defaultBaseURL: 'https://api.openai.com/v1', modelsEndpoint: '/models' },
anthropic: { name: 'Anthropic', defaultBaseURL: 'https://api.anthropic.com', modelsEndpoint: '/v1/models' },
google: { name: 'Google AI', defaultBaseURL: 'https://generativelanguage.googleapis.com/v1beta' },
ollama: { name: 'Ollama', defaultBaseURL: 'http://localhost:11434/v1', modelsEndpoint: '/models' },
openrouter: { name: 'OpenRouter', defaultBaseURL: 'https://openrouter.ai/api/v1', modelsEndpoint: '/models' },
qwen: {
name: 'Qwen',
defaultBaseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
modelsEndpoint: '/models',
defaultModels: [
'qwen3.7-plus',
'qwen3.7-max',
'qwen3.6-plus',
'qwen3.6-flash',
'qwen3.6-max-preview',
'qwen3.5-plus',
'qwen3-coder-plus',
'qwen3-coder-flash',
'qwen-plus',
'qwen-plus-latest',
],
},
deepseek: {
name: 'DeepSeek',
defaultBaseURL: 'https://api.deepseek.com/v1',
modelsEndpoint: '/models',
defaultModels: [
'deepseek-v4-flash',
'deepseek-v4-pro',
'deepseek-chat',
'deepseek-reasoner',
],
},
kimi: {
name: 'Kimi',
defaultBaseURL: 'https://api.moonshot.ai/v1',
modelsEndpoint: '/models',
defaultModels: [
'kimi-k2.6',
'kimi-k2.5',
'moonshot-v1-128k',
'moonshot-v1-32k',
'moonshot-v1-8k',
],
},
zhipu: {
name: 'Zhipu',
defaultBaseURL: 'https://open.bigmodel.cn/api/paas/v4',
modelsEndpoint: '/models',
defaultModels: [
'glm-5.1',
'glm-5',
'glm-5-turbo',
'glm-4.7',
'glm-4.7-flash',
'glm-4.6',
'glm-4.5',
'glm-4.5-air',
],
},
doubao: {
name: 'Doubao',
defaultBaseURL: 'https://ark.cn-beijing.volces.com/api/v3',
modelsEndpoint: '/models',
defaultModels: [
'doubao-seed-2-0-pro-260215',
'doubao-seed-2-0-lite-260215',
'doubao-seed-2-0-mini-260215',
'doubao-seed-2-0-code-preview-260215',
],
},
mimo: {
name: 'Xiaomi MiMo',
defaultBaseURL: 'https://api.xiaomimimo.com/v1',
modelsEndpoint: '/models',
defaultModels: [
'mimo-v2.5-pro',
'mimo-v2.5',
],
},
custom: { name: 'Custom', defaultBaseURL: '' },
};
// Agent model presets (hardcoded, same as 1code)
export interface AgentModelPreset {
id: string;
name: string;
description?: string;
/** Supported reasoning levels (renderer selection encoded as `id/thinking`). */
thinkingLevels?: string[];
/**
* Default effort used when auto-selecting a model with thinkingLevels.
* Must be one of `thinkingLevels` when set. Do not infer from array order —
* UI lists levels low→high but catalog defaults are usually mid-range.
*/
defaultThinkingLevel?: string;
/**
* When false, a stored bare model id stays unsuffixed so the driver can
* apply its own settings-level effort. Defaults to true.
*/
encodeDefaultThinking?: boolean;
/**
* Minimum agent CLI version that advertises this model (semver core).
* Netcatty is BYO-CLI: the packaged SDK does not replace the user's binary.
*/
minCliVersion?: string;
}
const CLAUDE_REASONING_LEVELS = ['low', 'medium', 'high', 'max'] as const;
export const CLAUDE_MODEL_PRESETS: AgentModelPreset[] = [
{
id: 'default',
name: 'Opus 4.6',
description: 'Recommended',
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
defaultThinkingLevel: 'medium',
},
{
id: 'sonnet',
name: 'Sonnet 4.6',
description: 'Everyday tasks',
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
defaultThinkingLevel: 'medium',
},
{
id: 'haiku',
name: 'Haiku 4.5',
description: 'Fastest',
thinkingLevels: [...CLAUDE_REASONING_LEVELS],
defaultThinkingLevel: 'low',
},
];
// Curated codex model list (codex-sdk has no enumeration API). IDs/efforts
// mirror openai/codex `models-manager/models.json` and peer open-source presets
// (pi openai-codex.models, lobehub agencyConfig, paperclip codex_local, suna
// codex-models, cherry-studio openai-codex). GPT-5.6 needs CLI >= 0.144.0.
// The codex driver splits "<id>/<effort>" into model + modelReasoningEffort.
const CODEX_REASONING_LEVELS = ['low', 'medium', 'high', 'xhigh'] as const;
// Sol/Terra advertise ultra; Luna stops at max (official catalog + lobehub).
const CODEX_REASONING_LEVELS_5_6 = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const;
const CODEX_REASONING_LEVELS_5_6_LUNA = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
/** Official catalog `minimal_client_version` for GPT-5.6 family. */
export const CODEX_GPT_5_6_MIN_CLI_VERSION = '0.144.0';
function codexPreset(
id: string,
name: string,
thinkingLevels: readonly string[],
defaultThinkingLevel: string,
options?: { description?: string; minCliVersion?: string },
): AgentModelPreset {
return {
id,
name,
description: options?.description,
thinkingLevels: [...thinkingLevels],
defaultThinkingLevel,
minCliVersion: options?.minCliVersion,
};
}
export const CODEX_MODEL_PRESETS: AgentModelPreset[] = [
// default_reasoning_level + minimal_client_version from openai/codex catalog
codexPreset('gpt-5.6-sol', 'GPT-5.6 Sol', CODEX_REASONING_LEVELS_5_6, 'low', {
description: 'Latest',
minCliVersion: CODEX_GPT_5_6_MIN_CLI_VERSION,
}),
codexPreset('gpt-5.6-terra', 'GPT-5.6 Terra', CODEX_REASONING_LEVELS_5_6, 'medium', {
description: 'Balanced',
minCliVersion: CODEX_GPT_5_6_MIN_CLI_VERSION,
}),
codexPreset('gpt-5.6-luna', 'GPT-5.6 Luna', CODEX_REASONING_LEVELS_5_6_LUNA, 'medium', {
description: 'Fast',
minCliVersion: CODEX_GPT_5_6_MIN_CLI_VERSION,
}),
codexPreset('gpt-5.5', 'GPT-5.5', CODEX_REASONING_LEVELS, 'medium'),
codexPreset('gpt-5.4', 'GPT-5.4', CODEX_REASONING_LEVELS, 'medium'),
codexPreset('gpt-5.4-mini', 'GPT-5.4 Mini', CODEX_REASONING_LEVELS, 'medium', {
description: 'Small & fast',
}),
// Still visibility:list in upstream catalog; keep selectable so stored
// gpt-5.2/* selections are not silently forced onto Sol.
codexPreset('gpt-5.2', 'GPT-5.2', CODEX_REASONING_LEVELS, 'medium'),
];
/** Extract `major.minor.patch` from CLI --version output (e.g. "codex-cli 0.144.1"). */
export function extractCliSemver(versionText: string | null | undefined): string | null {
if (!versionText) return null;
const match = String(versionText).match(/(\d+)\.(\d+)\.(\d+)/);
return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
}
function compareSemverCore(a: string, b: string): number {
const pa = a.split('.').map((part) => Number.parseInt(part, 10) || 0);
const pb = b.split('.').map((part) => Number.parseInt(part, 10) || 0);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const da = pa[i] ?? 0;
const db = pb[i] ?? 0;
if (da > db) return 1;
if (da < db) return -1;
}
return 0;
}
/**
* Drop presets whose minCliVersion exceeds the installed CLI.
* When the CLI version is unknown (pre-discovery / probe failed), also drop
* version-gated presets so old installs cannot auto-default to Sol before
* discovery finishes. Older ungated presets keep the picker non-empty.
*/
export function filterAgentModelPresetsForCliVersion(
presets: AgentModelPreset[],
cliVersionText: string | null | undefined,
): AgentModelPreset[] {
const cliSemver = extractCliSemver(cliVersionText);
return presets.filter((preset) => {
if (!preset.minCliVersion) return true;
if (!cliSemver) return false;
return compareSemverCore(cliSemver, preset.minCliVersion) >= 0;
});
}
/** Resolve the model id (with optional /effort) for auto-selection. */
export function resolveAgentModelSelection(preset: AgentModelPreset): string {
const levels = preset.thinkingLevels;
if (!levels?.length) return preset.id;
if (preset.encodeDefaultThinking === false) return preset.id;
const preferred = preset.defaultThinkingLevel;
if (preferred && levels.includes(preferred)) {
return `${preset.id}/${preferred}`;
}
// Conservative fallback: first listed effort (usually the cheapest/fastest).
return `${preset.id}/${levels[0]}`;
}
function looksLikeFilesystemPath(command: string): boolean {
return /[\\/]/.test(command) || /^[A-Za-z]:/.test(command);
}
function normalizeCliPathKey(pathText: string): string {
// Case-fold on Windows-style paths; keep POSIX case-sensitive otherwise.
const trimmed = pathText.trim();
if (/^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.includes('\\')) {
return trimmed.replace(/\\/g, '/').toLowerCase();
}
return trimmed;
}
/** Match a configured agent to a discovery row to read its probed CLI version. */
export function resolveDiscoveredAgentCliVersion(
agent: Pick<ExternalAgentConfig, 'command' | 'sdkBackend'> | null | undefined,
discovered: Array<Pick<DiscoveredAgent, 'command' | 'path' | 'binPath' | 'sdkBackend' | 'version'>>,
): string | undefined {
if (!agent || discovered.length === 0) return undefined;
const command = (agent.command || '').trim();
if (!command) return undefined;
// Absolute/relative path configs must match the exact discovered binary —
// basename-only matching would gate against PATH codex while the driver
// launches a different manual binary.
if (looksLikeFilesystemPath(command)) {
const wanted = normalizeCliPathKey(command);
const pathMatch = discovered.find((entry) => {
const candidates = [entry.path, entry.binPath].filter(Boolean) as string[];
return candidates.some((candidate) => normalizeCliPathKey(candidate) === wanted);
});
return pathMatch?.version || undefined;
}
const basename = command.split(/[\\/]/).pop()?.toLowerCase() ?? command.toLowerCase();
const match = discovered.find((entry) => {
if (agent.sdkBackend && entry.sdkBackend && agent.sdkBackend !== entry.sdkBackend) {
return false;
}
return entry.command === basename || entry.command === command;
});
return match?.version || undefined;
}
/**
* Prefer the version stored on the agent config (settings resolve-cli / enable),
* then fall back to a matching discovery probe.
*/
export function resolveAgentCliVersion(
agent: Pick<ExternalAgentConfig, 'command' | 'sdkBackend' | 'cliVersion'> | null | undefined,
discovered: Array<Pick<DiscoveredAgent, 'command' | 'path' | 'binPath' | 'sdkBackend' | 'version'>>,
): string | undefined {
const stored = agent?.cliVersion?.trim();
if (stored) return stored;
return resolveDiscoveredAgentCliVersion(agent, discovered);
}
const CURSOR_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
export const CURSOR_MODEL_PRESETS: AgentModelPreset[] = [
{ id: 'auto', name: 'Auto', description: 'Recommended for CLI login / subscription quota' },
{ id: 'composer-2.5', name: 'Composer 2.5', description: 'Recommended for API key' },
{
id: 'gpt-5',
name: 'GPT-5',
thinkingLevels: [...CURSOR_REASONING_LEVELS],
defaultThinkingLevel: 'medium',
},
{
id: 'gpt-5.5',
name: 'GPT-5.5',
thinkingLevels: [...CURSOR_REASONING_LEVELS],
defaultThinkingLevel: 'medium',
},
{
id: 'gpt-5.2',
name: 'GPT-5.2',
thinkingLevels: [...CURSOR_REASONING_LEVELS],
defaultThinkingLevel: 'medium',
},
{
id: 'gpt-5.1',
name: 'GPT-5.1',
thinkingLevels: [...CURSOR_REASONING_LEVELS],
defaultThinkingLevel: 'medium',
},
{
id: 'claude-opus-4.6',
name: 'Claude Opus 4.6',
thinkingLevels: [...CURSOR_REASONING_LEVELS],
defaultThinkingLevel: 'medium',
},
{
id: 'claude-sonnet-4.6',
name: 'Claude Sonnet 4.6',
thinkingLevels: [...CURSOR_REASONING_LEVELS],
defaultThinkingLevel: 'medium',
},
];
// CodeBuddy's SDK model enumeration can be empty depending on CLI/account
// state; keep a CLI-supported fallback list so users can still pass --model.
const CODEBUDDY_REASONING_LEVELS = ['low', 'medium', 'high', 'xhigh'] as const;
function codebuddyPreset(id: string, name: string): AgentModelPreset {
return {
id,
name,
thinkingLevels: [...CODEBUDDY_REASONING_LEVELS],
defaultThinkingLevel: 'medium',
encodeDefaultThinking: false,
};
}
export const CODEBUDDY_MODEL_PRESETS: AgentModelPreset[] = [
codebuddyPreset('deepseek-v4-pro', 'DeepSeek V4 Pro'),
codebuddyPreset('deepseek-v4-flash', 'DeepSeek V4 Flash'),
codebuddyPreset('deepseek-v3-2-volc', 'DeepSeek V3.2'),
codebuddyPreset('glm-5.1', 'GLM 5.1'),
codebuddyPreset('glm-5.0', 'GLM 5.0'),
codebuddyPreset('glm-5.0-turbo', 'GLM 5.0 Turbo'),
codebuddyPreset('glm-5v-turbo', 'GLM 5V Turbo'),
codebuddyPreset('glm-4.7', 'GLM 4.7'),
codebuddyPreset('minimax-m3-pay', 'MiniMax M3'),
codebuddyPreset('minimax-m2.7', 'MiniMax M2.7'),
codebuddyPreset('kimi-k2.6', 'Kimi K2.6'),
codebuddyPreset('hy3-preview', 'Hy3 Preview'),
];
export const OPENCODE_MODEL_PRESETS: AgentModelPreset[] = [
{ id: 'openai/gpt-5.1', name: 'OpenAI GPT-5.1' },
{ id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
{ id: 'deepseek/deepseek-chat', name: 'DeepSeek Chat' },
{ id: 'openrouter/openai/gpt-5.1', name: 'OpenRouter GPT-5.1' },
{ id: 'ollama/llama3.3', name: 'Ollama Llama 3.3' },
];
// Curated Grok Build models when `grok models` is unavailable. IDs mirror the
// public Grok Build / xAI coding agent lineup; live discovery still overrides.
export const GROK_MODEL_PRESETS: AgentModelPreset[] = [
{
id: 'grok-4.5',
name: 'Grok 4.5',
description: 'Default',
thinkingLevels: ['high', 'medium', 'low'],
defaultThinkingLevel: 'high',
},
{
id: 'grok-4.6',
name: 'Grok 4.6',
thinkingLevels: ['xhigh', 'high', 'medium', 'low'],
defaultThinkingLevel: 'high',
},
];
export function getAgentModelPresets(
agentCommand?: string,
sdkBackend?: string,
): AgentModelPreset[] {
// Prefer sdkBackend: CLI login stores the real `cursor-agent` binary path,
// which would otherwise yield an empty preset list without this hint.
const backend = String(sdkBackend || '').trim().toLowerCase();
if (backend === 'claude') return CLAUDE_MODEL_PRESETS;
if (backend === 'codex') return CODEX_MODEL_PRESETS;
if (backend === 'cursor') return CURSOR_MODEL_PRESETS;
if (backend === 'codebuddy') return CODEBUDDY_MODEL_PRESETS;
if (backend === 'opencode') return OPENCODE_MODEL_PRESETS;
if (backend === 'grok') return GROK_MODEL_PRESETS;
if (!agentCommand) return [];
// Split on both POSIX (/) and Windows (\) separators so command paths like
// "C:\\Users\\foo\\codex.cmd" resolve to the right basename. Splitting only
// on "/" leaves the full path intact on Windows, which never matches the
// preset prefixes below and yields an empty list (presets silently lost).
const basename = agentCommand.split(/[\\/]/).pop()?.toLowerCase() ?? '';
if (basename.startsWith('claude')) return CLAUDE_MODEL_PRESETS;
if (basename.startsWith('codex')) return CODEX_MODEL_PRESETS;
// Cursor CLI login resolves to `cursor-agent` on PATH.
if (
basename.startsWith('cursor')
|| basename.startsWith('cursor-agent')
) {
return CURSOR_MODEL_PRESETS;
}
if (basename.startsWith('codebuddy')) return CODEBUDDY_MODEL_PRESETS;
if (basename.startsWith('opencode')) return OPENCODE_MODEL_PRESETS;
if (basename.startsWith('grok')) return GROK_MODEL_PRESETS;
return [];
}
export function formatThinkingLabel(level: string): string {
if (level === 'xhigh') return 'Extra High';
return level.charAt(0).toUpperCase() + level.slice(1);
}