chainlesschain 0.38.1 → 0.40.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Task-based intelligent model selector for CLI
3
+ *
4
+ * Detects task type from user messages and recommends the best model
5
+ * for each LLM provider. Enables automatic model switching based on
6
+ * what the user is trying to accomplish.
7
+ */
8
+
9
+ /**
10
+ * Task types supported by the selector
11
+ */
12
+ export const TaskType = {
13
+ CHAT: "chat",
14
+ CODE: "code",
15
+ REASONING: "reasoning",
16
+ FAST: "fast",
17
+ TRANSLATE: "translate",
18
+ CREATIVE: "creative",
19
+ };
20
+
21
+ /**
22
+ * Task type → recommended model per provider
23
+ * Each provider maps to the best model for that task type.
24
+ */
25
+ const TASK_MODEL_MAP = {
26
+ [TaskType.CHAT]: {
27
+ volcengine: "doubao-seed-1-6-flash-250828",
28
+ openai: "gpt-4o-mini",
29
+ anthropic: "claude-sonnet-4-6",
30
+ deepseek: "deepseek-chat",
31
+ dashscope: "qwen-plus",
32
+ gemini: "gemini-2.0-flash",
33
+ mistral: "mistral-medium-latest",
34
+ ollama: "qwen2:7b",
35
+ },
36
+ [TaskType.CODE]: {
37
+ volcengine: "doubao-seed-code",
38
+ openai: "gpt-4o",
39
+ anthropic: "claude-sonnet-4-6",
40
+ deepseek: "deepseek-coder",
41
+ dashscope: "qwen-max",
42
+ gemini: "gemini-2.0-pro",
43
+ mistral: "mistral-large-latest",
44
+ ollama: "codellama:7b",
45
+ },
46
+ [TaskType.REASONING]: {
47
+ volcengine: "doubao-seed-1-6-251015",
48
+ openai: "o1",
49
+ anthropic: "claude-opus-4-6",
50
+ deepseek: "deepseek-reasoner",
51
+ dashscope: "qwen-max",
52
+ gemini: "gemini-2.0-pro",
53
+ mistral: "mistral-large-latest",
54
+ ollama: "qwen2:7b",
55
+ },
56
+ [TaskType.FAST]: {
57
+ volcengine: "doubao-seed-1-6-lite-251015",
58
+ openai: "gpt-4o-mini",
59
+ anthropic: "claude-haiku-4-5-20251001",
60
+ deepseek: "deepseek-chat",
61
+ dashscope: "qwen-turbo",
62
+ gemini: "gemini-2.0-flash",
63
+ mistral: "mistral-small-latest",
64
+ ollama: "qwen2:7b",
65
+ },
66
+ [TaskType.TRANSLATE]: {
67
+ volcengine: "doubao-seed-1-6-251015",
68
+ openai: "gpt-4o",
69
+ anthropic: "claude-sonnet-4-6",
70
+ deepseek: "deepseek-chat",
71
+ dashscope: "qwen-plus",
72
+ gemini: "gemini-2.0-flash",
73
+ mistral: "mistral-large-latest",
74
+ ollama: "qwen2:7b",
75
+ },
76
+ [TaskType.CREATIVE]: {
77
+ volcengine: "doubao-seed-1-6-251015",
78
+ openai: "gpt-4o",
79
+ anthropic: "claude-opus-4-6",
80
+ deepseek: "deepseek-chat",
81
+ dashscope: "qwen-max",
82
+ gemini: "gemini-2.0-pro",
83
+ mistral: "mistral-large-latest",
84
+ ollama: "qwen2:7b",
85
+ },
86
+ };
87
+
88
+ /**
89
+ * Task type display names (Chinese + English)
90
+ */
91
+ const TASK_NAMES = {
92
+ [TaskType.CHAT]: "日常对话",
93
+ [TaskType.CODE]: "代码任务",
94
+ [TaskType.REASONING]: "复杂推理",
95
+ [TaskType.FAST]: "快速响应",
96
+ [TaskType.TRANSLATE]: "翻译任务",
97
+ [TaskType.CREATIVE]: "创意写作",
98
+ };
99
+
100
+ /**
101
+ * Keyword patterns for detecting task type from user message.
102
+ * Each pattern is [regex, taskType, priority].
103
+ * Higher priority wins when multiple patterns match.
104
+ */
105
+ const TASK_PATTERNS = [
106
+ // Code patterns (priority 10) — English with word boundaries
107
+ [
108
+ /\b(code|coding|program|function|class|bug|debug|refactor|implement)\b/i,
109
+ TaskType.CODE,
110
+ 10,
111
+ ],
112
+ [
113
+ /\b(javascript|typescript|python|java|rust|go|c\+\+|sql|html|css|react|vue|node|npm|git|api|endpoint|database)\b/i,
114
+ TaskType.CODE,
115
+ 10,
116
+ ],
117
+ [/```[\s\S]*```/, TaskType.CODE, 10],
118
+ // Code patterns — Chinese (no \b, Chinese chars are not word-boundary compatible)
119
+ [
120
+ /(代码|编程|函数|调试|重构|实现|写[一个]*[代码函数方法])/,
121
+ TaskType.CODE,
122
+ 10,
123
+ ],
124
+
125
+ // Reasoning patterns (priority 8)
126
+ [
127
+ /\b(analyze|reason|explain why|prove|compare|evaluate)\b/i,
128
+ TaskType.REASONING,
129
+ 8,
130
+ ],
131
+ [/\b(step.by.step|think.*through)\b/i, TaskType.REASONING, 8],
132
+ [
133
+ /(分析|推理|解释为什么|证明|比较|评估|深度思考|逻辑|逐步|一步一步)/,
134
+ TaskType.REASONING,
135
+ 8,
136
+ ],
137
+
138
+ // Translation patterns (priority 9)
139
+ [/\b(translate|translation|translate.*to)\b/i, TaskType.TRANSLATE, 9],
140
+ [/(翻译|转换.*语言|英译中|中译英)/, TaskType.TRANSLATE, 9],
141
+
142
+ // Creative patterns (priority 7)
143
+ [
144
+ /\b(write|create|compose|story|poem|essay|blog|article)\b/i,
145
+ TaskType.CREATIVE,
146
+ 7,
147
+ ],
148
+ [/(写[一篇]*.*[故事诗歌文章博客]|创作|小说|剧本)/, TaskType.CREATIVE, 7],
149
+
150
+ // Fast patterns (priority 5)
151
+ [/\b(quick|brief|short)\b/i, TaskType.FAST, 5],
152
+ [/(简短|快速|简单回答|一句话)/, TaskType.FAST, 5],
153
+ ];
154
+
155
+ /**
156
+ * Detect the task type from a user message using keyword matching.
157
+ *
158
+ * @param {string} message - User's input message
159
+ * @returns {{ taskType: string, confidence: number, name: string }}
160
+ */
161
+ export function detectTaskType(message) {
162
+ if (!message || typeof message !== "string") {
163
+ return {
164
+ taskType: TaskType.CHAT,
165
+ confidence: 0,
166
+ name: TASK_NAMES[TaskType.CHAT],
167
+ };
168
+ }
169
+
170
+ let bestMatch = null;
171
+ let bestPriority = -1;
172
+ let matchCount = 0;
173
+
174
+ for (const [pattern, taskType, priority] of TASK_PATTERNS) {
175
+ if (pattern.test(message)) {
176
+ matchCount++;
177
+ if (priority > bestPriority) {
178
+ bestPriority = priority;
179
+ bestMatch = taskType;
180
+ }
181
+ }
182
+ }
183
+
184
+ if (!bestMatch) {
185
+ return {
186
+ taskType: TaskType.CHAT,
187
+ confidence: 0,
188
+ name: TASK_NAMES[TaskType.CHAT],
189
+ };
190
+ }
191
+
192
+ // Confidence based on match count and priority
193
+ const confidence = Math.min(1, matchCount * 0.3 + bestPriority * 0.07);
194
+
195
+ return {
196
+ taskType: bestMatch,
197
+ confidence,
198
+ name: TASK_NAMES[bestMatch],
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Select the best model for a given provider and task type.
204
+ *
205
+ * @param {string} provider - LLM provider name
206
+ * @param {string} taskType - Task type from TaskType enum
207
+ * @returns {string|null} Model ID, or null if no recommendation
208
+ */
209
+ export function selectModelForTask(provider, taskType) {
210
+ const taskMap = TASK_MODEL_MAP[taskType];
211
+ if (!taskMap) return null;
212
+ return taskMap[provider] || null;
213
+ }
214
+
215
+ /**
216
+ * Get a human-readable task name.
217
+ *
218
+ * @param {string} taskType - Task type
219
+ * @returns {string}
220
+ */
221
+ export function getTaskName(taskType) {
222
+ return TASK_NAMES[taskType] || taskType;
223
+ }
224
+
225
+ /**
226
+ * Get all supported task types.
227
+ *
228
+ * @returns {Object} TaskType enum
229
+ */
230
+ export function getTaskTypes() {
231
+ return { ...TaskType };
232
+ }
@@ -0,0 +1,474 @@
1
+ /**
2
+ * ChainlessChain WebSocket Server
3
+ *
4
+ * Exposes CLI commands over WebSocket for remote access by IDE plugins,
5
+ * web frontends, automation scripts, etc. Commands are executed by spawning
6
+ * child processes — all 60+ CLI commands are available immediately.
7
+ */
8
+
9
+ import { EventEmitter } from "node:events";
10
+ import { spawn } from "node:child_process";
11
+ import { fileURLToPath } from "node:url";
12
+ import { dirname, join } from "node:path";
13
+ import { WebSocketServer } from "ws";
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+
18
+ /** Absolute path to the CLI entry point */
19
+ const BIN_PATH = join(__dirname, "..", "..", "bin", "chainlesschain.js");
20
+
21
+ /** Commands that must not be executed via WebSocket */
22
+ const BLOCKED_COMMANDS = new Set(["serve", "chat", "agent", "setup"]);
23
+
24
+ /** Heartbeat interval (ms) */
25
+ const HEARTBEAT_INTERVAL = 30_000;
26
+
27
+ /**
28
+ * Tokenize a command string into an array of arguments.
29
+ * Handles double-quoted and single-quoted strings. Does NOT invoke a shell.
30
+ */
31
+ export function tokenizeCommand(input) {
32
+ const args = [];
33
+ let current = "";
34
+ let inDouble = false;
35
+ let inSingle = false;
36
+ let escape = false;
37
+
38
+ for (const ch of input) {
39
+ if (escape) {
40
+ current += ch;
41
+ escape = false;
42
+ continue;
43
+ }
44
+ if (ch === "\\" && inDouble) {
45
+ escape = true;
46
+ continue;
47
+ }
48
+ if (ch === '"' && !inSingle) {
49
+ inDouble = !inDouble;
50
+ continue;
51
+ }
52
+ if (ch === "'" && !inDouble) {
53
+ inSingle = !inSingle;
54
+ continue;
55
+ }
56
+ if ((ch === " " || ch === "\t") && !inDouble && !inSingle) {
57
+ if (current.length > 0) {
58
+ args.push(current);
59
+ current = "";
60
+ }
61
+ continue;
62
+ }
63
+ current += ch;
64
+ }
65
+ if (current.length > 0) {
66
+ args.push(current);
67
+ }
68
+ return args;
69
+ }
70
+
71
+ export class ChainlessChainWSServer extends EventEmitter {
72
+ /**
73
+ * @param {object} options
74
+ * @param {number} [options.port=18800]
75
+ * @param {string} [options.host="127.0.0.1"]
76
+ * @param {string} [options.token] - If set, clients must authenticate first
77
+ * @param {number} [options.maxConnections=10]
78
+ * @param {number} [options.timeout=30000] - Command execution timeout (ms)
79
+ */
80
+ constructor(options = {}) {
81
+ super();
82
+ this.port = options.port || 18800;
83
+ this.host = options.host || "127.0.0.1";
84
+ this.token = options.token || null;
85
+ this.maxConnections = options.maxConnections || 10;
86
+ this.timeout = options.timeout || 30000;
87
+
88
+ /** @type {WebSocketServer|null} */
89
+ this.wss = null;
90
+
91
+ /** Connected clients: clientId → { ws, authenticated, connectedAt } */
92
+ this.clients = new Map();
93
+
94
+ /** Running child processes: requestId → ChildProcess */
95
+ this.processes = new Map();
96
+
97
+ this._heartbeatTimer = null;
98
+ this._clientCounter = 0;
99
+ }
100
+
101
+ /** Start the WebSocket server */
102
+ start() {
103
+ return new Promise((resolve, reject) => {
104
+ this.wss = new WebSocketServer({
105
+ port: this.port,
106
+ host: this.host,
107
+ });
108
+
109
+ this.wss.on("listening", () => {
110
+ this._startHeartbeat();
111
+ this.emit("listening", { port: this.port, host: this.host });
112
+ resolve();
113
+ });
114
+
115
+ this.wss.on("error", (err) => {
116
+ this.emit("error", err);
117
+ reject(err);
118
+ });
119
+
120
+ this.wss.on("connection", (ws, req) => this._handleConnection(ws, req));
121
+ });
122
+ }
123
+
124
+ /** Stop the server and clean up */
125
+ async stop() {
126
+ if (this._heartbeatTimer) {
127
+ clearInterval(this._heartbeatTimer);
128
+ this._heartbeatTimer = null;
129
+ }
130
+
131
+ // Kill all running child processes
132
+ for (const [id, child] of this.processes) {
133
+ try {
134
+ child.kill("SIGTERM");
135
+ } catch (_err) {
136
+ // Process may have already exited
137
+ }
138
+ this.processes.delete(id);
139
+ }
140
+
141
+ // Close all client connections
142
+ for (const [, client] of this.clients) {
143
+ try {
144
+ client.ws.close(1001, "Server shutting down");
145
+ } catch (_err) {
146
+ // Connection may already be closed
147
+ }
148
+ }
149
+ this.clients.clear();
150
+
151
+ // Close the server
152
+ if (this.wss) {
153
+ await new Promise((resolve) => {
154
+ this.wss.close(() => resolve());
155
+ });
156
+ this.wss = null;
157
+ }
158
+
159
+ this.emit("stopped");
160
+ }
161
+
162
+ /** @private */
163
+ _handleConnection(ws, req) {
164
+ if (this.clients.size >= this.maxConnections) {
165
+ ws.close(1013, "Max connections reached");
166
+ return;
167
+ }
168
+
169
+ const clientId = `client-${++this._clientCounter}`;
170
+ const clientIp =
171
+ req.socket.remoteAddress || req.headers["x-forwarded-for"] || "unknown";
172
+
173
+ this.clients.set(clientId, {
174
+ ws,
175
+ authenticated: !this.token, // If no token required, auto-authenticated
176
+ connectedAt: Date.now(),
177
+ ip: clientIp,
178
+ alive: true,
179
+ });
180
+
181
+ this.emit("connection", { clientId, ip: clientIp });
182
+
183
+ ws.on("message", (data) => {
184
+ try {
185
+ const message = JSON.parse(data.toString("utf8"));
186
+ this._handleMessage(clientId, ws, message);
187
+ } catch (_err) {
188
+ this._send(ws, {
189
+ type: "error",
190
+ code: "INVALID_JSON",
191
+ message: "Failed to parse message as JSON",
192
+ });
193
+ }
194
+ });
195
+
196
+ ws.on("close", () => {
197
+ this.clients.delete(clientId);
198
+ this.emit("disconnection", { clientId });
199
+ });
200
+
201
+ ws.on("pong", () => {
202
+ const client = this.clients.get(clientId);
203
+ if (client) client.alive = true;
204
+ });
205
+ }
206
+
207
+ /** @private */
208
+ _handleMessage(clientId, ws, message) {
209
+ const { id, type } = message;
210
+
211
+ if (!id) {
212
+ this._send(ws, {
213
+ type: "error",
214
+ code: "MISSING_ID",
215
+ message: 'Message must include an "id" field',
216
+ });
217
+ return;
218
+ }
219
+
220
+ // Check authentication
221
+ const client = this.clients.get(clientId);
222
+ if (this.token && !client.authenticated && type !== "auth") {
223
+ this._send(ws, {
224
+ id,
225
+ type: "error",
226
+ code: "AUTH_REQUIRED",
227
+ message: "Authentication required. Send an auth message first.",
228
+ });
229
+ return;
230
+ }
231
+
232
+ switch (type) {
233
+ case "auth":
234
+ this._handleAuth(clientId, ws, message);
235
+ break;
236
+ case "ping":
237
+ this._send(ws, { id, type: "pong", serverTime: Date.now() });
238
+ break;
239
+ case "execute":
240
+ this._executeCommand(id, ws, message.command, false);
241
+ break;
242
+ case "stream":
243
+ this._executeCommand(id, ws, message.command, true);
244
+ break;
245
+ case "cancel":
246
+ this._cancelRequest(id, ws);
247
+ break;
248
+ default:
249
+ this._send(ws, {
250
+ id,
251
+ type: "error",
252
+ code: "UNKNOWN_TYPE",
253
+ message: `Unknown message type: ${type}`,
254
+ });
255
+ }
256
+ }
257
+
258
+ /** @private */
259
+ _handleAuth(clientId, ws, message) {
260
+ const { id, token } = message;
261
+ const success = token === this.token;
262
+ const client = this.clients.get(clientId);
263
+
264
+ if (success && client) {
265
+ client.authenticated = true;
266
+ }
267
+
268
+ this._send(ws, {
269
+ id,
270
+ type: "auth-result",
271
+ success,
272
+ ...(success ? {} : { message: "Invalid token" }),
273
+ });
274
+
275
+ if (!success) {
276
+ // Disconnect after failed auth
277
+ setTimeout(() => ws.close(4001, "Authentication failed"), 100);
278
+ }
279
+ }
280
+
281
+ /** @private */
282
+ _executeCommand(id, ws, command, stream) {
283
+ if (!command || typeof command !== "string") {
284
+ this._send(ws, {
285
+ id,
286
+ type: "error",
287
+ code: "INVALID_COMMAND",
288
+ message: "Command must be a non-empty string",
289
+ });
290
+ return;
291
+ }
292
+
293
+ const args = tokenizeCommand(command.trim());
294
+ if (args.length === 0) {
295
+ this._send(ws, {
296
+ id,
297
+ type: "error",
298
+ code: "INVALID_COMMAND",
299
+ message: "Empty command",
300
+ });
301
+ return;
302
+ }
303
+
304
+ // Block dangerous/interactive commands
305
+ const baseCmd = args[0];
306
+ if (BLOCKED_COMMANDS.has(baseCmd)) {
307
+ this._send(ws, {
308
+ id,
309
+ type: "error",
310
+ code: "COMMAND_BLOCKED",
311
+ message: `Command "${baseCmd}" cannot be executed via WebSocket (interactive or recursive)`,
312
+ });
313
+ return;
314
+ }
315
+
316
+ const child = spawn(process.execPath, [BIN_PATH, ...args], {
317
+ env: {
318
+ ...process.env,
319
+ FORCE_COLOR: "0",
320
+ NO_SPINNER: "1",
321
+ },
322
+ stdio: ["pipe", "pipe", "pipe"],
323
+ windowsHide: true,
324
+ });
325
+
326
+ this.processes.set(id, child);
327
+ this.emit("command:start", { id, command, stream });
328
+
329
+ // Timeout handling
330
+ const timer = setTimeout(() => {
331
+ if (this.processes.has(id)) {
332
+ try {
333
+ child.kill("SIGTERM");
334
+ } catch (_err) {
335
+ // Process may have already exited
336
+ }
337
+ this.processes.delete(id);
338
+ this._send(ws, {
339
+ id,
340
+ type: "error",
341
+ code: "COMMAND_TIMEOUT",
342
+ message: `Command timed out after ${this.timeout}ms`,
343
+ });
344
+ }
345
+ }, this.timeout);
346
+
347
+ if (stream) {
348
+ // Stream mode: send chunks as they arrive
349
+ child.stdout.on("data", (data) => {
350
+ this._send(ws, {
351
+ id,
352
+ type: "stream-data",
353
+ channel: "stdout",
354
+ data: data.toString("utf8"),
355
+ });
356
+ });
357
+
358
+ child.stderr.on("data", (data) => {
359
+ this._send(ws, {
360
+ id,
361
+ type: "stream-data",
362
+ channel: "stderr",
363
+ data: data.toString("utf8"),
364
+ });
365
+ });
366
+
367
+ child.on("close", (exitCode) => {
368
+ clearTimeout(timer);
369
+ this.processes.delete(id);
370
+ this._send(ws, {
371
+ id,
372
+ type: "stream-end",
373
+ exitCode: exitCode ?? 1,
374
+ });
375
+ this.emit("command:end", { id, exitCode });
376
+ });
377
+ } else {
378
+ // Buffered mode: collect all output then send result
379
+ const stdoutChunks = [];
380
+ const stderrChunks = [];
381
+
382
+ child.stdout.on("data", (data) => stdoutChunks.push(data));
383
+ child.stderr.on("data", (data) => stderrChunks.push(data));
384
+
385
+ child.on("close", (exitCode) => {
386
+ clearTimeout(timer);
387
+ this.processes.delete(id);
388
+
389
+ const stdout = Buffer.concat(stdoutChunks).toString("utf8");
390
+ const stderr = Buffer.concat(stderrChunks).toString("utf8");
391
+
392
+ this._send(ws, {
393
+ id,
394
+ type: "result",
395
+ success: exitCode === 0,
396
+ exitCode: exitCode ?? 1,
397
+ stdout,
398
+ stderr,
399
+ });
400
+ this.emit("command:end", { id, exitCode });
401
+ });
402
+ }
403
+
404
+ child.on("error", (err) => {
405
+ clearTimeout(timer);
406
+ this.processes.delete(id);
407
+ this._send(ws, {
408
+ id,
409
+ type: "error",
410
+ code: "SPAWN_ERROR",
411
+ message: err.message,
412
+ });
413
+ });
414
+ }
415
+
416
+ /** @private */
417
+ _cancelRequest(id, ws) {
418
+ const child = this.processes.get(id);
419
+ if (child) {
420
+ try {
421
+ child.kill("SIGTERM");
422
+ } catch (_err) {
423
+ // Process may have already exited
424
+ }
425
+ this.processes.delete(id);
426
+ this._send(ws, {
427
+ id,
428
+ type: "result",
429
+ success: false,
430
+ exitCode: -1,
431
+ stdout: "",
432
+ stderr: "Cancelled by client",
433
+ });
434
+ } else {
435
+ this._send(ws, {
436
+ id,
437
+ type: "error",
438
+ code: "NOT_FOUND",
439
+ message: `No running command with id "${id}"`,
440
+ });
441
+ }
442
+ }
443
+
444
+ /** @private — ping/pong heartbeat to detect dead connections */
445
+ _startHeartbeat() {
446
+ this._heartbeatTimer = setInterval(() => {
447
+ for (const [clientId, client] of this.clients) {
448
+ if (!client.alive) {
449
+ client.ws.terminate();
450
+ this.clients.delete(clientId);
451
+ this.emit("disconnection", { clientId, reason: "heartbeat timeout" });
452
+ continue;
453
+ }
454
+ client.alive = false;
455
+ try {
456
+ client.ws.ping();
457
+ } catch (_err) {
458
+ // Connection may be closing
459
+ }
460
+ }
461
+ }, HEARTBEAT_INTERVAL);
462
+ }
463
+
464
+ /** @private — safe JSON send */
465
+ _send(ws, data) {
466
+ if (ws.readyState === ws.OPEN) {
467
+ try {
468
+ ws.send(JSON.stringify(data));
469
+ } catch (_err) {
470
+ // Connection may have just closed
471
+ }
472
+ }
473
+ }
474
+ }