@semalt-ai/code 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +1117 -0
- package/package.json +18 -0
package/index.js
ADDED
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const readline = require('readline');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
const http = require('http');
|
|
10
|
+
const https = require('https');
|
|
11
|
+
const { URL } = require('url');
|
|
12
|
+
|
|
13
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const DEFAULT_CONFIG = {
|
|
16
|
+
api_base: 'http://127.0.0.1:8800',
|
|
17
|
+
api_key: 'any',
|
|
18
|
+
default_model: 'default',
|
|
19
|
+
temperature: 0.7,
|
|
20
|
+
max_tokens: 4096,
|
|
21
|
+
stream: true,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const CONFIG_PATH = path.join(os.homedir(), '.semalt-ai', 'config.json');
|
|
25
|
+
|
|
26
|
+
function loadConfig() {
|
|
27
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
28
|
+
try {
|
|
29
|
+
const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
30
|
+
return { ...DEFAULT_CONFIG, ...data };
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
return { ...DEFAULT_CONFIG };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function saveConfig(cfg) {
|
|
37
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
38
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let config = loadConfig();
|
|
42
|
+
|
|
43
|
+
// ── Terminal ──────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function getCols() {
|
|
46
|
+
return process.stdout.columns || 80;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const RST = '\x1b[0m';
|
|
50
|
+
const BOLD = '\x1b[1m';
|
|
51
|
+
const DIM = '\x1b[2m';
|
|
52
|
+
|
|
53
|
+
const FG_GRAY = '\x1b[38;5;245m';
|
|
54
|
+
const FG_DARK = '\x1b[38;5;240m';
|
|
55
|
+
const FG_BLUE = '\x1b[38;5;75m';
|
|
56
|
+
const FG_CYAN = '\x1b[38;5;116m';
|
|
57
|
+
const FG_GREEN = '\x1b[38;5;114m';
|
|
58
|
+
const FG_YELLOW = '\x1b[38;5;222m';
|
|
59
|
+
const FG_RED = '\x1b[38;5;203m';
|
|
60
|
+
const FG_TEAL = '\x1b[38;5;73m';
|
|
61
|
+
|
|
62
|
+
const BOX_H = '─';
|
|
63
|
+
const BOX_V = '│';
|
|
64
|
+
const BOX_TL = '╭';
|
|
65
|
+
const BOX_TR = '╮';
|
|
66
|
+
const BOX_BL = '╰';
|
|
67
|
+
const BOX_BR = '╯';
|
|
68
|
+
|
|
69
|
+
function hr(char = '─', color = FG_DARK) {
|
|
70
|
+
process.stdout.write(`${color}${char.repeat(getCols())}${RST}\n`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stripAnsi(str) {
|
|
74
|
+
return str.replace(/\x1b\[[^m]*m/g, '');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function boxLine(text, width) {
|
|
78
|
+
const w = width || (getCols() - 4);
|
|
79
|
+
const visible = stripAnsi(text).length;
|
|
80
|
+
const pad = Math.max(0, w - visible);
|
|
81
|
+
return ` ${FG_DARK}${BOX_V}${RST} ${text}${' '.repeat(pad)}${FG_DARK}${BOX_V}${RST}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Permission system ─────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
let AUTO_APPROVE_SHELL = false;
|
|
87
|
+
let AUTO_APPROVE_FILE = false;
|
|
88
|
+
|
|
89
|
+
function askPermission(actionType, description) {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
if (actionType === 'shell' && AUTO_APPROVE_SHELL) {
|
|
92
|
+
console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approved: ${description}${RST}`);
|
|
93
|
+
return resolve(true);
|
|
94
|
+
}
|
|
95
|
+
if (actionType === 'file' && AUTO_APPROVE_FILE) {
|
|
96
|
+
console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approved: ${description}${RST}`);
|
|
97
|
+
return resolve(true);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log();
|
|
101
|
+
console.log(` ${FG_YELLOW}${BOLD}⚠ Permission required${RST}`);
|
|
102
|
+
console.log(` ${FG_GRAY}${actionType}: ${description}${RST}`);
|
|
103
|
+
console.log();
|
|
104
|
+
console.log(` ${FG_CYAN}[y]${RST} Yes ${FG_CYAN}[a]${RST} Yes, always ${FG_CYAN}[n]${RST} No`);
|
|
105
|
+
console.log();
|
|
106
|
+
|
|
107
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
108
|
+
rl.question(` ${FG_YELLOW}?${RST} `, (answer) => {
|
|
109
|
+
rl.close();
|
|
110
|
+
const choice = (answer || '').trim().toLowerCase();
|
|
111
|
+
if (choice === 'y' || choice === 'yes') {
|
|
112
|
+
resolve(true);
|
|
113
|
+
} else if (choice === 'a' || choice === 'always') {
|
|
114
|
+
if (actionType === 'shell') AUTO_APPROVE_SHELL = true;
|
|
115
|
+
else AUTO_APPROVE_FILE = true;
|
|
116
|
+
console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approve enabled for ${actionType} operations${RST}`);
|
|
117
|
+
resolve(true);
|
|
118
|
+
} else {
|
|
119
|
+
console.log(` ${FG_RED}✗${RST} ${FG_DARK}Denied${RST}`);
|
|
120
|
+
resolve(false);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Agent: execute tools ──────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
async function agentExecShell(command) {
|
|
129
|
+
const approved = await askPermission('shell', command);
|
|
130
|
+
if (!approved) {
|
|
131
|
+
return { exit_code: -1, stdout: '', stderr: 'Permission denied by user' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log(` ${FG_DARK}$ ${command}${RST}`);
|
|
135
|
+
try {
|
|
136
|
+
const result = spawnSync(command, { shell: true, encoding: 'utf8', timeout: 60000 });
|
|
137
|
+
const stdout = result.stdout || '';
|
|
138
|
+
const stderr = result.stderr || '';
|
|
139
|
+
const combined = stdout + (stderr ? '\n' + stderr : '');
|
|
140
|
+
const lines = combined.trim().split('\n').filter(l => l !== '');
|
|
141
|
+
|
|
142
|
+
if (lines.length > 20) {
|
|
143
|
+
lines.slice(0, 15).forEach(l => console.log(` ${FG_GRAY}${l}${RST}`));
|
|
144
|
+
console.log(` ${FG_DARK}... (${lines.length - 15} more lines)${RST}`);
|
|
145
|
+
} else {
|
|
146
|
+
lines.forEach(l => console.log(` ${FG_GRAY}${l}${RST}`));
|
|
147
|
+
}
|
|
148
|
+
console.log();
|
|
149
|
+
|
|
150
|
+
return { exit_code: result.status ?? 0, stdout, stderr };
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.log(` ${FG_RED}✗ ${e.message}${RST}`);
|
|
153
|
+
return { exit_code: -1, stdout: '', stderr: e.message };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function agentExecFile(action, filePath, content = null) {
|
|
158
|
+
if (action === 'read') {
|
|
159
|
+
const approved = await askPermission('file', `Read ${filePath}`);
|
|
160
|
+
if (!approved) return { error: 'Permission denied' };
|
|
161
|
+
try {
|
|
162
|
+
const data = fs.readFileSync(filePath, 'utf8');
|
|
163
|
+
const lines = data.split('\n').length;
|
|
164
|
+
if (lines > 10) {
|
|
165
|
+
console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath} (${lines} lines, ${data.length} chars)${RST}`);
|
|
166
|
+
} else {
|
|
167
|
+
console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Read ${filePath}${RST}`);
|
|
168
|
+
}
|
|
169
|
+
return { content: data, path: filePath };
|
|
170
|
+
} catch (e) {
|
|
171
|
+
console.log(` ${FG_RED}✗ ${e.message}${RST}`);
|
|
172
|
+
return { error: e.message };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (action === 'write' || action === 'append') {
|
|
177
|
+
let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
|
|
178
|
+
if (content) desc += ` (${content.length} chars)`;
|
|
179
|
+
const approved = await askPermission('file', desc);
|
|
180
|
+
if (!approved) return { error: 'Permission denied' };
|
|
181
|
+
try {
|
|
182
|
+
const dir = path.dirname(filePath);
|
|
183
|
+
if (dir && dir !== '.') fs.mkdirSync(dir, { recursive: true });
|
|
184
|
+
if (action === 'write') {
|
|
185
|
+
fs.writeFileSync(filePath, content || '');
|
|
186
|
+
} else {
|
|
187
|
+
fs.appendFileSync(filePath, content || '');
|
|
188
|
+
}
|
|
189
|
+
const verb = action === 'write' ? 'Wrote' : 'Appended to';
|
|
190
|
+
console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}${verb} ${filePath}${RST}`);
|
|
191
|
+
return { status: 'ok', path: filePath, bytes: (content || '').length };
|
|
192
|
+
} catch (e) {
|
|
193
|
+
console.log(` ${FG_RED}✗ ${e.message}${RST}`);
|
|
194
|
+
return { error: e.message };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { error: `Unknown action: ${action}` };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Tool call extraction ──────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
function extractToolCalls(text) {
|
|
204
|
+
const calls = [];
|
|
205
|
+
|
|
206
|
+
// ```bash / ```shell / ```sh blocks
|
|
207
|
+
for (const m of text.matchAll(/```(?:shell|bash|sh)\n([\s\S]*?)```/g)) {
|
|
208
|
+
for (const line of m[1].trim().split('\n')) {
|
|
209
|
+
const cmd = line.trim();
|
|
210
|
+
if (cmd && !cmd.startsWith('#')) calls.push(['shell', cmd]);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// <shell> tags
|
|
215
|
+
for (const m of text.matchAll(/<shell>([\s\S]*?)<\/shell>/g))
|
|
216
|
+
calls.push(['shell', m[1].trim()]);
|
|
217
|
+
|
|
218
|
+
// <exec> tags
|
|
219
|
+
for (const m of text.matchAll(/<exec>([\s\S]*?)<\/exec>/g))
|
|
220
|
+
calls.push(['shell', m[1].trim()]);
|
|
221
|
+
|
|
222
|
+
// <read_file> tags
|
|
223
|
+
for (const m of text.matchAll(/<read_file>([\s\S]*?)<\/read_file>/g))
|
|
224
|
+
calls.push(['read', m[1].trim()]);
|
|
225
|
+
|
|
226
|
+
// <write_file> tags
|
|
227
|
+
for (const m of text.matchAll(/<write_file\s+path="([^"]+)">([\s\S]*?)<\/write_file>/g))
|
|
228
|
+
calls.push(['write', m[1], m[2]]);
|
|
229
|
+
|
|
230
|
+
return calls;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Stream Renderer ───────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
const FG_CODE_BG = '\x1b[48;5;236m';
|
|
236
|
+
const FG_CODE_BORDER = '\x1b[38;5;240m';
|
|
237
|
+
const FG_CODE_LANG = '\x1b[38;5;75m';
|
|
238
|
+
const FG_INLINE_CODE = '\x1b[38;5;215m';
|
|
239
|
+
const FG_HEADING = '\x1b[38;5;75m';
|
|
240
|
+
const FG_BULLET = '\x1b[38;5;114m';
|
|
241
|
+
const FG_BOLD_TEXT = '\x1b[38;5;255m';
|
|
242
|
+
const FG_DIFF_ADD = '\x1b[38;5;114m';
|
|
243
|
+
const FG_DIFF_DEL = '\x1b[38;5;203m';
|
|
244
|
+
const FG_DIFF_HDR = '\x1b[38;5;75m';
|
|
245
|
+
const FG_FILEPATH = '\x1b[38;5;222m';
|
|
246
|
+
const FG_TAG = '\x1b[38;5;176m';
|
|
247
|
+
|
|
248
|
+
const KEYWORDS = new Set([
|
|
249
|
+
'def', 'class', 'import', 'from', 'return', 'if', 'else', 'elif',
|
|
250
|
+
'for', 'while', 'try', 'except', 'finally', 'with', 'as', 'in',
|
|
251
|
+
'not', 'and', 'or', 'is', 'None', 'True', 'False', 'async', 'await',
|
|
252
|
+
'function', 'const', 'let', 'var', 'export', 'require', 'func',
|
|
253
|
+
'type', 'struct', 'interface', 'package', 'fn', 'pub', 'use',
|
|
254
|
+
'mod', 'impl', 'match', 'enum', 'self', 'print', 'len', 'range',
|
|
255
|
+
'yield', 'lambda', 'raise', 'pass', 'break', 'continue', 'del',
|
|
256
|
+
'global', 'nonlocal', 'assert',
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
class StreamRenderer {
|
|
260
|
+
constructor() {
|
|
261
|
+
this.buffer = '';
|
|
262
|
+
this.inCodeBlock = false;
|
|
263
|
+
this.codeLang = '';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
feed(chunk) {
|
|
267
|
+
this.buffer += chunk;
|
|
268
|
+
while (this.buffer.includes('\n')) {
|
|
269
|
+
const idx = this.buffer.indexOf('\n');
|
|
270
|
+
const line = this.buffer.slice(0, idx);
|
|
271
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
272
|
+
this._renderLine(line);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
flush() {
|
|
277
|
+
if (this.buffer) {
|
|
278
|
+
this._renderLine(this.buffer);
|
|
279
|
+
this.buffer = '';
|
|
280
|
+
}
|
|
281
|
+
if (this.inCodeBlock) {
|
|
282
|
+
process.stdout.write(` ${FG_CODE_BORDER}╰${'─'.repeat(50)}${RST}\n`);
|
|
283
|
+
this.inCodeBlock = false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
_renderLine(line) {
|
|
288
|
+
// Code block start
|
|
289
|
+
if (line.startsWith('```') && !this.inCodeBlock) {
|
|
290
|
+
this.inCodeBlock = true;
|
|
291
|
+
this.codeLang = line.slice(3).trim();
|
|
292
|
+
const label = this.codeLang ? ` ${this.codeLang} ` : '';
|
|
293
|
+
process.stdout.write(
|
|
294
|
+
` ${FG_CODE_BORDER}╭${'─'.repeat(20)}${RST}${FG_CODE_LANG}${label}${RST}` +
|
|
295
|
+
`${FG_CODE_BORDER}${'─'.repeat(Math.max(1, 30 - label.length))}${RST}\n`
|
|
296
|
+
);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Code block end
|
|
301
|
+
if (line.trim() === '```' && this.inCodeBlock) {
|
|
302
|
+
process.stdout.write(` ${FG_CODE_BORDER}╰${'─'.repeat(50)}${RST}\n`);
|
|
303
|
+
this.inCodeBlock = false;
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Inside code block
|
|
308
|
+
if (this.inCodeBlock) {
|
|
309
|
+
process.stdout.write(` ${FG_CODE_BORDER}│${RST} ${FG_CODE_BG}${this._colorizeCode(line)}${RST}\n`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Diff lines
|
|
314
|
+
if (/^\+(?!\+\+)/.test(line)) { process.stdout.write(` ${FG_DIFF_ADD}${line}${RST}\n`); return; }
|
|
315
|
+
if (/^-(?!--)/.test(line)) { process.stdout.write(` ${FG_DIFF_DEL}${line}${RST}\n`); return; }
|
|
316
|
+
if (line.startsWith('@@')) { process.stdout.write(` ${FG_DIFF_HDR}${line}${RST}\n`); return; }
|
|
317
|
+
|
|
318
|
+
// Tool tags
|
|
319
|
+
if (/^<(exec|shell|read_file|write_file)/.test(line)) { process.stdout.write(` ${FG_TAG}${line}${RST}\n`); return; }
|
|
320
|
+
if (/^<\/(exec|shell|read_file|write_file)/.test(line)){ process.stdout.write(` ${FG_TAG}${line}${RST}\n`); return; }
|
|
321
|
+
|
|
322
|
+
// Headings
|
|
323
|
+
const hm = line.match(/^(#{1,4})\s+(.*)/);
|
|
324
|
+
if (hm) {
|
|
325
|
+
const text = hm[2];
|
|
326
|
+
if (hm[1].length <= 2) {
|
|
327
|
+
process.stdout.write(` ${FG_HEADING}${BOLD}${hm[1]} ${text}${RST}\n`);
|
|
328
|
+
} else {
|
|
329
|
+
process.stdout.write(` ${FG_HEADING}${hm[1]} ${text}${RST}\n`);
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Bullet points
|
|
335
|
+
const bm = line.match(/^(\s*)([-*•])\s+(.*)/);
|
|
336
|
+
if (bm) {
|
|
337
|
+
process.stdout.write(` ${bm[1]}${FG_BULLET}•${RST} ${this._inlineFormat(bm[3])}\n`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Numbered list
|
|
342
|
+
const nm = line.match(/^(\s*)(\d+\.)\s+(.*)/);
|
|
343
|
+
if (nm) {
|
|
344
|
+
process.stdout.write(` ${nm[1]}${FG_CYAN}${nm[2]}${RST} ${this._inlineFormat(nm[3])}\n`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Regular text
|
|
349
|
+
process.stdout.write(` ${this._inlineFormat(line)}\n`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
_inlineFormat(text) {
|
|
353
|
+
text = text.replace(/\*\*(.+?)\*\*/g, `${FG_BOLD_TEXT}${BOLD}$1${RST}`);
|
|
354
|
+
text = text.replace(/`([^`]+)`/g, `${FG_INLINE_CODE}$1${RST}`);
|
|
355
|
+
text = text.replace(/(?<!\w)((?:\/[\w\-.]+)+(?:\.\w+)?)/g, `${FG_FILEPATH}$1${RST}`);
|
|
356
|
+
return text;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
_colorizeCode(line) {
|
|
360
|
+
const C_KW = '\x1b[38;5;176m';
|
|
361
|
+
const C_STR = '\x1b[38;5;114m';
|
|
362
|
+
const C_CMT = '\x1b[38;5;242m';
|
|
363
|
+
const C_NUM = '\x1b[38;5;215m';
|
|
364
|
+
const C_RST = `${RST}${FG_CODE_BG}`;
|
|
365
|
+
|
|
366
|
+
let result = '';
|
|
367
|
+
let i = 0;
|
|
368
|
+
|
|
369
|
+
while (i < line.length) {
|
|
370
|
+
// Comment
|
|
371
|
+
if (line[i] === '#' || (line[i] === '/' && line[i + 1] === '/')) {
|
|
372
|
+
result += `${C_CMT}${line.slice(i)}${C_RST}`;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// String
|
|
377
|
+
if (line[i] === '"' || line[i] === "'") {
|
|
378
|
+
const q = line[i];
|
|
379
|
+
let j = i + 1;
|
|
380
|
+
while (j < line.length && line[j] !== q) {
|
|
381
|
+
if (line[j] === '\\') j++;
|
|
382
|
+
j++;
|
|
383
|
+
}
|
|
384
|
+
j = Math.min(j + 1, line.length);
|
|
385
|
+
result += `${C_STR}${line.slice(i, j)}${C_RST}`;
|
|
386
|
+
i = j;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Identifier / keyword
|
|
391
|
+
if (/[a-zA-Z_]/.test(line[i])) {
|
|
392
|
+
let j = i;
|
|
393
|
+
while (j < line.length && /\w/.test(line[j])) j++;
|
|
394
|
+
const word = line.slice(i, j);
|
|
395
|
+
result += KEYWORDS.has(word) ? `${C_KW}${word}${C_RST}` : word;
|
|
396
|
+
i = j;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Number
|
|
401
|
+
if (/\d/.test(line[i])) {
|
|
402
|
+
let j = i;
|
|
403
|
+
while (j < line.length && /[\d.]/.test(line[j])) j++;
|
|
404
|
+
result += `${C_NUM}${line.slice(i, j)}${C_RST}`;
|
|
405
|
+
i = j;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
result += line[i++];
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── API Client ────────────────────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
function apiUrl(urlPath) {
|
|
419
|
+
return `${config.api_base.replace(/\/$/, '')}${urlPath}`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function estimateTokens(text) {
|
|
423
|
+
return Math.floor((text || '').length / 4);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function httpRequest(urlStr, options, body) {
|
|
427
|
+
return new Promise((resolve, reject) => {
|
|
428
|
+
const url = new URL(urlStr);
|
|
429
|
+
const lib = url.protocol === 'https:' ? https : http;
|
|
430
|
+
const reqOpts = {
|
|
431
|
+
hostname: url.hostname,
|
|
432
|
+
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
433
|
+
path: url.pathname + url.search,
|
|
434
|
+
method: options.method || 'GET',
|
|
435
|
+
headers: options.headers || {},
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const req = lib.request(reqOpts, (res) => {
|
|
439
|
+
resolve(res);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
req.on('error', reject);
|
|
443
|
+
|
|
444
|
+
if (options.timeout) {
|
|
445
|
+
req.setTimeout(options.timeout, () => {
|
|
446
|
+
req.destroy(new Error('Request timed out'));
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (body) req.write(body);
|
|
451
|
+
req.end();
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function chatStream(messages, { model, temperature, maxTokens } = {}) {
|
|
456
|
+
const payload = {
|
|
457
|
+
model: model || config.default_model,
|
|
458
|
+
messages,
|
|
459
|
+
temperature: temperature !== undefined ? temperature : config.temperature,
|
|
460
|
+
max_tokens: maxTokens || config.max_tokens,
|
|
461
|
+
stream: true,
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const body = JSON.stringify(payload);
|
|
465
|
+
let res;
|
|
466
|
+
|
|
467
|
+
try {
|
|
468
|
+
res = await httpRequest(apiUrl('/v1/chat/completions'), {
|
|
469
|
+
method: 'POST',
|
|
470
|
+
headers: {
|
|
471
|
+
'Content-Type': 'application/json',
|
|
472
|
+
'Authorization': `Bearer ${config.api_key}`,
|
|
473
|
+
'Content-Length': Buffer.byteLength(body),
|
|
474
|
+
},
|
|
475
|
+
}, body);
|
|
476
|
+
} catch (e) {
|
|
477
|
+
process.stdout.write(`\n ${FG_RED}✗ ${e.message}${RST}\n`);
|
|
478
|
+
return '';
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (res.statusCode !== 200) {
|
|
482
|
+
process.stdout.write(`\n ${FG_RED}✗ Error: HTTP ${res.statusCode}${RST}\n`);
|
|
483
|
+
res.resume();
|
|
484
|
+
return '';
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return new Promise((resolve) => {
|
|
488
|
+
const startTime = Date.now();
|
|
489
|
+
let fullText = '';
|
|
490
|
+
let reasoningText = '';
|
|
491
|
+
let tokenCount = 0;
|
|
492
|
+
let inReasoning = false;
|
|
493
|
+
const renderer = new StreamRenderer();
|
|
494
|
+
let lineBuffer = '';
|
|
495
|
+
|
|
496
|
+
res.setEncoding('utf8');
|
|
497
|
+
|
|
498
|
+
res.on('data', (chunk) => {
|
|
499
|
+
lineBuffer += chunk;
|
|
500
|
+
const lines = lineBuffer.split('\n');
|
|
501
|
+
lineBuffer = lines.pop();
|
|
502
|
+
|
|
503
|
+
for (const line of lines) {
|
|
504
|
+
if (!line.startsWith('data: ')) continue;
|
|
505
|
+
const data = line.slice(6).trim();
|
|
506
|
+
if (data === '[DONE]') continue;
|
|
507
|
+
try {
|
|
508
|
+
const obj = JSON.parse(data);
|
|
509
|
+
const delta = ((obj.choices || [])[0] || {}).delta || {};
|
|
510
|
+
|
|
511
|
+
const reasoning = delta.reasoning_content || '';
|
|
512
|
+
if (reasoning) {
|
|
513
|
+
if (!inReasoning) {
|
|
514
|
+
inReasoning = true;
|
|
515
|
+
process.stdout.write(`\n ${FG_DARK}${DIM}⟨thinking⟩${RST}`);
|
|
516
|
+
}
|
|
517
|
+
reasoningText += reasoning;
|
|
518
|
+
tokenCount++;
|
|
519
|
+
if (tokenCount % 20 === 0) process.stdout.write(`${FG_DARK}.${RST}`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const content = delta.content || '';
|
|
523
|
+
if (content) {
|
|
524
|
+
if (inReasoning) {
|
|
525
|
+
inReasoning = false;
|
|
526
|
+
process.stdout.write(`${FG_DARK}⟨/thinking⟩${RST}\n`);
|
|
527
|
+
}
|
|
528
|
+
renderer.feed(content);
|
|
529
|
+
fullText += content;
|
|
530
|
+
tokenCount++;
|
|
531
|
+
}
|
|
532
|
+
} catch {}
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
res.on('end', () => {
|
|
537
|
+
renderer.flush();
|
|
538
|
+
const elapsed = (Date.now() - startTime) / 1000;
|
|
539
|
+
const estTokens = estimateTokens(fullText + reasoningText);
|
|
540
|
+
const tps = tokenCount / (elapsed || 1);
|
|
541
|
+
const cols = getCols();
|
|
542
|
+
process.stdout.write(`\n ${FG_DARK}${'─'.repeat(Math.min(cols, 60) - 4)}${RST}\n`);
|
|
543
|
+
let costLine = `${FG_DARK}~${estTokens} tokens · ${elapsed.toFixed(1)}s · ${Math.round(tps)} tok/s${RST}`;
|
|
544
|
+
if (reasoningText) costLine += ` ${FG_DARK}· ${estimateTokens(reasoningText)} thinking${RST}`;
|
|
545
|
+
process.stdout.write(` ${costLine}\n`);
|
|
546
|
+
resolve(fullText);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
res.on('error', (e) => {
|
|
550
|
+
process.stdout.write(`\n ${FG_RED}✗ ${e.message}${RST}\n`);
|
|
551
|
+
resolve('');
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function chatSync(messages, { model } = {}) {
|
|
557
|
+
const payload = {
|
|
558
|
+
model: model || config.default_model,
|
|
559
|
+
messages,
|
|
560
|
+
temperature: config.temperature,
|
|
561
|
+
max_tokens: config.max_tokens,
|
|
562
|
+
stream: false,
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const body = JSON.stringify(payload);
|
|
566
|
+
let res;
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
res = await httpRequest(apiUrl('/v1/chat/completions'), {
|
|
570
|
+
method: 'POST',
|
|
571
|
+
headers: {
|
|
572
|
+
'Content-Type': 'application/json',
|
|
573
|
+
'Authorization': `Bearer ${config.api_key}`,
|
|
574
|
+
'Content-Length': Buffer.byteLength(body),
|
|
575
|
+
},
|
|
576
|
+
}, body);
|
|
577
|
+
} catch (e) {
|
|
578
|
+
console.log(` ${FG_RED}✗ ${e.message}${RST}`);
|
|
579
|
+
return '';
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return new Promise((resolve) => {
|
|
583
|
+
let data = '';
|
|
584
|
+
res.setEncoding('utf8');
|
|
585
|
+
res.on('data', chunk => data += chunk);
|
|
586
|
+
res.on('end', () => {
|
|
587
|
+
if (res.statusCode !== 200) {
|
|
588
|
+
console.log(` ${FG_RED}✗ Error: HTTP ${res.statusCode} — ${data}${RST}`);
|
|
589
|
+
return resolve('');
|
|
590
|
+
}
|
|
591
|
+
try {
|
|
592
|
+
const parsed = JSON.parse(data);
|
|
593
|
+
const content = parsed.choices[0].message.content;
|
|
594
|
+
console.log(content);
|
|
595
|
+
resolve(content);
|
|
596
|
+
} catch (e) {
|
|
597
|
+
console.log(` ${FG_RED}✗ Parse error: ${e.message}${RST}`);
|
|
598
|
+
resolve('');
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
res.on('error', (e) => {
|
|
602
|
+
console.log(` ${FG_RED}✗ ${e.message}${RST}`);
|
|
603
|
+
resolve('');
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ── Agent loop ────────────────────────────────────────────────────────────────
|
|
609
|
+
|
|
610
|
+
async function runAgentLoop(messages, model, maxIterations = 10) {
|
|
611
|
+
const cols = getCols();
|
|
612
|
+
|
|
613
|
+
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
614
|
+
console.log();
|
|
615
|
+
console.log(` ${FG_DARK}${'─'.repeat(Math.min(cols, 70) - 4)}${RST}`);
|
|
616
|
+
process.stdout.write(` ${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`);
|
|
617
|
+
if (iteration > 0) process.stdout.write(` ${FG_DARK}(step ${iteration + 1})${RST}`);
|
|
618
|
+
console.log();
|
|
619
|
+
console.log(` ${FG_DARK}${'─'.repeat(Math.min(cols, 70) - 4)}${RST}`);
|
|
620
|
+
console.log();
|
|
621
|
+
process.stdout.write(' ');
|
|
622
|
+
|
|
623
|
+
const reply = await chatStream(messages, { model });
|
|
624
|
+
if (!reply) break;
|
|
625
|
+
|
|
626
|
+
messages.push({ role: 'assistant', content: reply });
|
|
627
|
+
|
|
628
|
+
const toolCalls = extractToolCalls(reply);
|
|
629
|
+
if (toolCalls.length === 0) break;
|
|
630
|
+
|
|
631
|
+
console.log(`\n ${FG_TEAL}◆${RST} ${FG_GRAY}Found ${toolCalls.length} action(s) to execute${RST}`);
|
|
632
|
+
|
|
633
|
+
const results = [];
|
|
634
|
+
let aborted = false;
|
|
635
|
+
|
|
636
|
+
for (const call of toolCalls) {
|
|
637
|
+
if (call[0] === 'shell') {
|
|
638
|
+
const result = await agentExecShell(call[1]);
|
|
639
|
+
if (result.stderr === 'Permission denied by user') {
|
|
640
|
+
results.push(`Command \`${call[1]}\`: Permission denied by user.`);
|
|
641
|
+
aborted = true;
|
|
642
|
+
} else {
|
|
643
|
+
let out = result.stdout;
|
|
644
|
+
if (result.stderr) out += '\nSTDERR: ' + result.stderr;
|
|
645
|
+
results.push(`Command \`${call[1]}\`:\nExit code: ${result.exit_code}\n${out}`);
|
|
646
|
+
}
|
|
647
|
+
} else if (call[0] === 'read') {
|
|
648
|
+
const result = await agentExecFile('read', call[1]);
|
|
649
|
+
if (result.error) results.push(`Read ${call[1]}: Error — ${result.error}`);
|
|
650
|
+
else results.push(`File ${call[1]}:\n${result.content}`);
|
|
651
|
+
} else if (call[0] === 'write') {
|
|
652
|
+
const result = await agentExecFile('write', call[1], call[2]);
|
|
653
|
+
if (result.error) results.push(`Write ${call[1]}: Error — ${result.error}`);
|
|
654
|
+
else results.push(`Wrote ${result.bytes} bytes to ${call[1]}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const feedback = results.join('\n\n');
|
|
659
|
+
messages.push({
|
|
660
|
+
role: 'user',
|
|
661
|
+
content: `Tool execution results:\n\n${feedback}\n\nContinue with the task. If everything is done, summarize what was accomplished.`,
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
if (aborted) {
|
|
665
|
+
console.log(`\n ${FG_YELLOW}⚠${RST} ${FG_GRAY}Some actions were denied. Continuing with partial results.${RST}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return messages;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ── Banner / UI ───────────────────────────────────────────────────────────────
|
|
673
|
+
|
|
674
|
+
function printBanner() {
|
|
675
|
+
const w = Math.min(getCols() - 4, 60);
|
|
676
|
+
console.log();
|
|
677
|
+
console.log(` ${FG_DARK}${BOX_TL}${BOX_H.repeat(w + 2)}${BOX_TR}${RST}`);
|
|
678
|
+
console.log(boxLine('', w));
|
|
679
|
+
console.log(boxLine(`${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`, w));
|
|
680
|
+
console.log(boxLine(`${FG_GRAY}Self-hosted AI coding assistant${RST}`, w));
|
|
681
|
+
console.log(boxLine('', w));
|
|
682
|
+
console.log(` ${FG_DARK}${BOX_BL}${BOX_H.repeat(w + 2)}${BOX_BR}${RST}`);
|
|
683
|
+
console.log();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function printStatusBar(model, cwd) {
|
|
687
|
+
const left = `${FG_TEAL}${BOLD}◆${RST} ${FG_GRAY}${model}${RST}`;
|
|
688
|
+
const right = `${FG_DARK}${cwd}${RST}`;
|
|
689
|
+
console.log(` ${left} ${FG_DARK}│${RST} ${right}`);
|
|
690
|
+
hr();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function printHelpHints() {
|
|
694
|
+
const hints = [
|
|
695
|
+
[`${FG_BLUE}/help${RST}`, 'commands'],
|
|
696
|
+
[`${FG_BLUE}/model${RST}`, 'switch'],
|
|
697
|
+
[`${FG_BLUE}/file${RST}`, 'context'],
|
|
698
|
+
[`${FG_BLUE}/clear${RST}`, 'reset'],
|
|
699
|
+
];
|
|
700
|
+
process.stdout.write(` ${FG_DARK}Tips:${RST}`);
|
|
701
|
+
for (const [cmd, desc] of hints) {
|
|
702
|
+
process.stdout.write(` ${cmd} ${FG_DARK}${desc}${RST}`);
|
|
703
|
+
}
|
|
704
|
+
console.log();
|
|
705
|
+
console.log();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ── File context ──────────────────────────────────────────────────────────────
|
|
709
|
+
|
|
710
|
+
function readFileContext(filePaths) {
|
|
711
|
+
let context = '';
|
|
712
|
+
for (const fp of filePaths) {
|
|
713
|
+
if (!fs.existsSync(fp)) {
|
|
714
|
+
console.log(` ${FG_RED}✗${RST} ${FG_GRAY}Not found: ${fp}${RST}`);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
const stat = fs.statSync(fp);
|
|
718
|
+
if (stat.isFile()) {
|
|
719
|
+
try {
|
|
720
|
+
const content = fs.readFileSync(fp, 'utf8');
|
|
721
|
+
context += `\n--- File: ${fp} ---\n${content}\n`;
|
|
722
|
+
console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Loaded ${fp} (${content.length} chars)${RST}`);
|
|
723
|
+
} catch (e) {
|
|
724
|
+
console.log(` ${FG_RED}✗${RST} ${FG_GRAY}${fp}: ${e.message}${RST}`);
|
|
725
|
+
}
|
|
726
|
+
} else if (stat.isDirectory()) {
|
|
727
|
+
let count = 0;
|
|
728
|
+
function walkDir(dir) {
|
|
729
|
+
if (count >= 50) return;
|
|
730
|
+
let entries;
|
|
731
|
+
try { entries = fs.readdirSync(dir).sort(); } catch { return; }
|
|
732
|
+
for (const entry of entries) {
|
|
733
|
+
if (entry.startsWith('.')) continue;
|
|
734
|
+
const full = path.join(dir, entry);
|
|
735
|
+
let s;
|
|
736
|
+
try { s = fs.statSync(full); } catch { continue; }
|
|
737
|
+
if (s.isFile()) {
|
|
738
|
+
try {
|
|
739
|
+
const content = fs.readFileSync(full, 'utf8').slice(0, 10000);
|
|
740
|
+
context += `\n--- File: ${full} ---\n${content}\n`;
|
|
741
|
+
count++;
|
|
742
|
+
} catch {}
|
|
743
|
+
} else if (s.isDirectory()) {
|
|
744
|
+
walkDir(full);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
walkDir(fp);
|
|
749
|
+
console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Loaded ${count} files from ${fp}${RST}`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return context;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ── System prompt ─────────────────────────────────────────────────────────────
|
|
756
|
+
|
|
757
|
+
function getSystemPrompt() {
|
|
758
|
+
return `You are Semalt.AI, an expert AI coding assistant running in the user's terminal. You have the ability to execute shell commands and file operations.
|
|
759
|
+
|
|
760
|
+
IMPORTANT: You CAN execute commands on the user's system. When you need to run a command, use this exact format:
|
|
761
|
+
|
|
762
|
+
To run a shell command:
|
|
763
|
+
<exec>command here</exec>
|
|
764
|
+
|
|
765
|
+
To read a file:
|
|
766
|
+
<read_file>/path/to/file</read_file>
|
|
767
|
+
|
|
768
|
+
To write a file:
|
|
769
|
+
<write_file path="/path/to/file">file content here</write_file>
|
|
770
|
+
|
|
771
|
+
Rules:
|
|
772
|
+
- When the user asks you to do something on their system (create files, install packages, check status, etc.), USE the tools above — do NOT just print instructions.
|
|
773
|
+
- Each command will be shown to the user for approval before execution.
|
|
774
|
+
- After execution, you will receive the output and can continue working.
|
|
775
|
+
- You can chain multiple operations in one response.
|
|
776
|
+
- Be concise. Provide working solutions.
|
|
777
|
+
- Use markdown for code blocks in explanations.
|
|
778
|
+
- Current working directory: ${process.cwd()}`;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// ── Commands ──────────────────────────────────────────────────────────────────
|
|
782
|
+
|
|
783
|
+
async function cmdChat(opts) {
|
|
784
|
+
printBanner();
|
|
785
|
+
const cwd = process.cwd();
|
|
786
|
+
let currentModel = opts.model || config.default_model;
|
|
787
|
+
|
|
788
|
+
printStatusBar(currentModel, cwd);
|
|
789
|
+
printHelpHints();
|
|
790
|
+
|
|
791
|
+
let messages = [{ role: 'system', content: getSystemPrompt() }];
|
|
792
|
+
const cols = getCols();
|
|
793
|
+
|
|
794
|
+
const rl = readline.createInterface({
|
|
795
|
+
input: process.stdin,
|
|
796
|
+
output: process.stdout,
|
|
797
|
+
terminal: true,
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
rl.on('close', () => {
|
|
801
|
+
console.log(`\n ${FG_GRAY}Goodbye!${RST}\n`);
|
|
802
|
+
process.exit(0);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
async function prompt() {
|
|
806
|
+
rl.question(` ${FG_TEAL}${BOLD}>${RST} `, async (input) => {
|
|
807
|
+
const text = (input || '').trim();
|
|
808
|
+
|
|
809
|
+
if (!text) return prompt();
|
|
810
|
+
|
|
811
|
+
if (['exit', 'quit', '/exit', '/quit'].includes(text.toLowerCase())) {
|
|
812
|
+
console.log(`\n ${FG_GRAY}Goodbye!${RST}\n`);
|
|
813
|
+
rl.close();
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (text === '/help') {
|
|
818
|
+
console.log(`
|
|
819
|
+
${FG_BLUE}${BOLD}Commands:${RST}
|
|
820
|
+
${FG_CYAN}/file <path>${RST} ${FG_GRAY}Load file or dir into context${RST}
|
|
821
|
+
${FG_CYAN}/model <name>${RST} ${FG_GRAY}Switch model${RST}
|
|
822
|
+
${FG_CYAN}/clear${RST} ${FG_GRAY}Clear conversation${RST}
|
|
823
|
+
${FG_CYAN}/compact${RST} ${FG_GRAY}Show token usage${RST}
|
|
824
|
+
${FG_CYAN}/shell <cmd>${RST} ${FG_GRAY}Run shell command directly${RST}
|
|
825
|
+
${FG_CYAN}!<cmd>${RST} ${FG_GRAY}Run shell command directly${RST}
|
|
826
|
+
${FG_CYAN}/approve${RST} ${FG_GRAY}Toggle auto-approve for all actions${RST}
|
|
827
|
+
${FG_CYAN}/config${RST} ${FG_GRAY}Show config${RST}
|
|
828
|
+
${FG_CYAN}exit${RST} ${FG_GRAY}Quit${RST}
|
|
829
|
+
|
|
830
|
+
${FG_DARK}The AI can execute commands — you'll be asked to approve each one.${RST}
|
|
831
|
+
`);
|
|
832
|
+
return prompt();
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (text.startsWith('/file ')) {
|
|
836
|
+
const fp = text.slice(6).trim();
|
|
837
|
+
const ctx = readFileContext([fp]);
|
|
838
|
+
if (ctx) messages.push({ role: 'user', content: `Here is the file context:\n${ctx}` });
|
|
839
|
+
return prompt();
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (text.startsWith('/model ')) {
|
|
843
|
+
currentModel = text.slice(7).trim();
|
|
844
|
+
console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Model → ${currentModel}${RST}`);
|
|
845
|
+
printStatusBar(currentModel, cwd);
|
|
846
|
+
return prompt();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (text === '/clear') {
|
|
850
|
+
messages = [{ role: 'system', content: getSystemPrompt() }];
|
|
851
|
+
AUTO_APPROVE_SHELL = false;
|
|
852
|
+
AUTO_APPROVE_FILE = false;
|
|
853
|
+
console.log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Conversation and approvals cleared${RST}\n`);
|
|
854
|
+
return prompt();
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (text === '/compact' || text === '/cost') {
|
|
858
|
+
const total = messages.reduce((s, m) => s + estimateTokens(m.content), 0);
|
|
859
|
+
console.log(` ${FG_GRAY}${messages.length - 1} messages · ~${total} tokens${RST}\n`);
|
|
860
|
+
return prompt();
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (text === '/config') {
|
|
864
|
+
console.log(` ${FG_GRAY}${JSON.stringify(config, null, 2)}${RST}\n`);
|
|
865
|
+
return prompt();
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (text === '/approve') {
|
|
869
|
+
AUTO_APPROVE_SHELL = !AUTO_APPROVE_SHELL;
|
|
870
|
+
AUTO_APPROVE_FILE = !AUTO_APPROVE_FILE;
|
|
871
|
+
const state = AUTO_APPROVE_SHELL ? 'ON' : 'OFF';
|
|
872
|
+
const color = AUTO_APPROVE_SHELL ? FG_GREEN : FG_RED;
|
|
873
|
+
console.log(` ${color}●${RST} ${FG_GRAY}Auto-approve: ${state}${RST}\n`);
|
|
874
|
+
return prompt();
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (text.startsWith('/shell ') || text.startsWith('!')) {
|
|
878
|
+
const cmd = text.startsWith('/shell ') ? text.slice(7).trim() : text.slice(1).trim();
|
|
879
|
+
await agentExecShell(cmd);
|
|
880
|
+
return prompt();
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
messages.push({ role: 'user', content: text });
|
|
884
|
+
console.log(` ${FG_DARK}${'─'.repeat(Math.min(cols, 70) - 4)}${RST}`);
|
|
885
|
+
|
|
886
|
+
rl.pause();
|
|
887
|
+
messages = await runAgentLoop(messages, currentModel);
|
|
888
|
+
rl.resume();
|
|
889
|
+
|
|
890
|
+
console.log(` ${FG_DARK}${'━'.repeat(Math.min(cols, 70) - 4)}${RST}`);
|
|
891
|
+
console.log();
|
|
892
|
+
|
|
893
|
+
prompt();
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
prompt();
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function cmdCode(opts, promptArgs) {
|
|
901
|
+
if (!promptArgs.length) {
|
|
902
|
+
console.log(` ${FG_RED}Usage: semalt-code code <prompt>${RST}`);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const userPrompt = promptArgs.join(' ');
|
|
906
|
+
const context = opts.file ? readFileContext(opts.file) : '';
|
|
907
|
+
const fullPrompt = context ? `Context files:\n${context}\n\nTask: ${userPrompt}` : userPrompt;
|
|
908
|
+
|
|
909
|
+
let messages = [
|
|
910
|
+
{ role: 'system', content: getSystemPrompt() },
|
|
911
|
+
{ role: 'user', content: fullPrompt },
|
|
912
|
+
];
|
|
913
|
+
messages = await runAgentLoop(messages, opts.model || config.default_model);
|
|
914
|
+
console.log();
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function cmdEdit(opts, filePath, instructionArgs) {
|
|
918
|
+
if (!filePath) {
|
|
919
|
+
console.log(` ${FG_RED}Usage: semalt-code edit <file> <instruction>${RST}`);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (!fs.existsSync(filePath)) {
|
|
923
|
+
console.log(` ${FG_RED}✗ File not found: ${filePath}${RST}`);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
928
|
+
const instruction = instructionArgs.join(' ');
|
|
929
|
+
|
|
930
|
+
const messages = [
|
|
931
|
+
{ role: 'system', content: 'You are Semalt.AI. Output ONLY the modified file. No explanations, no fences.' },
|
|
932
|
+
{ role: 'user', content: `File: ${filePath}\n\n\`\`\`\n${content}\n\`\`\`\n\nInstruction: ${instruction}` },
|
|
933
|
+
];
|
|
934
|
+
|
|
935
|
+
console.log(` ${FG_GRAY}Editing ${filePath}...${RST}`);
|
|
936
|
+
let result = await chatSync(messages, { model: opts.model });
|
|
937
|
+
|
|
938
|
+
if (result && !opts.dryRun) {
|
|
939
|
+
if (result.startsWith('```')) {
|
|
940
|
+
const lines = result.split('\n');
|
|
941
|
+
result = lines.at(-1).trim() === '```'
|
|
942
|
+
? lines.slice(1, -1).join('\n')
|
|
943
|
+
: lines.slice(1).join('\n');
|
|
944
|
+
}
|
|
945
|
+
fs.writeFileSync(filePath, result);
|
|
946
|
+
console.log(` ${FG_GREEN}✓ Saved: ${filePath}${RST}`);
|
|
947
|
+
} else if (opts.dryRun) {
|
|
948
|
+
console.log(` ${FG_YELLOW}⚠ Dry run — not modified${RST}`);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async function cmdShell(opts, commandArgs) {
|
|
953
|
+
const command = commandArgs.join(' ');
|
|
954
|
+
if (!command) {
|
|
955
|
+
console.log(` ${FG_RED}Usage: semalt-code shell <command>${RST}`);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const result = await agentExecShell(command);
|
|
959
|
+
|
|
960
|
+
if (opts.analyze) {
|
|
961
|
+
const messages = [
|
|
962
|
+
{ role: 'system', content: 'You are Semalt.AI. Analyze the command output concisely.' },
|
|
963
|
+
{ role: 'user', content: `Command: ${command}\nExit: ${result.exit_code}\nStdout:\n${result.stdout}\nStderr:\n${result.stderr}` },
|
|
964
|
+
];
|
|
965
|
+
console.log();
|
|
966
|
+
console.log(` ${FG_TEAL}${BOLD}◆ Semalt.AI${RST}`);
|
|
967
|
+
console.log();
|
|
968
|
+
process.stdout.write(' ');
|
|
969
|
+
await chatStream(messages, { model: opts.model });
|
|
970
|
+
console.log();
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
async function cmdModels() {
|
|
975
|
+
const url = apiUrl('/v1/models');
|
|
976
|
+
let res;
|
|
977
|
+
try {
|
|
978
|
+
res = await httpRequest(url, {
|
|
979
|
+
method: 'GET',
|
|
980
|
+
headers: { 'Authorization': `Bearer ${config.api_key}` },
|
|
981
|
+
timeout: 10000,
|
|
982
|
+
}, null);
|
|
983
|
+
} catch (e) {
|
|
984
|
+
console.log(` ${FG_RED}✗ ${e.message}${RST}`);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
return new Promise((resolve) => {
|
|
989
|
+
let data = '';
|
|
990
|
+
res.setEncoding('utf8');
|
|
991
|
+
res.on('data', chunk => data += chunk);
|
|
992
|
+
res.on('end', () => {
|
|
993
|
+
try {
|
|
994
|
+
const parsed = JSON.parse(data);
|
|
995
|
+
console.log();
|
|
996
|
+
console.log(` ${FG_TEAL}${BOLD}◆ Available Models${RST}`);
|
|
997
|
+
console.log(` ${FG_DARK}${'─'.repeat(40)}${RST}`);
|
|
998
|
+
for (const m of (parsed.data || [])) console.log(` ${FG_GREEN}●${RST} ${m.id}`);
|
|
999
|
+
console.log();
|
|
1000
|
+
} catch (e) {
|
|
1001
|
+
console.log(` ${FG_RED}✗ ${e.message}${RST}`);
|
|
1002
|
+
}
|
|
1003
|
+
resolve();
|
|
1004
|
+
});
|
|
1005
|
+
res.on('error', (e) => {
|
|
1006
|
+
console.log(` ${FG_RED}✗ ${e.message}${RST}`);
|
|
1007
|
+
resolve();
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function cmdInit(opts) {
|
|
1013
|
+
const cfg = {
|
|
1014
|
+
api_base: opts.apiBase || 'http://127.0.0.1:8800',
|
|
1015
|
+
api_key: opts.apiKey || 'any',
|
|
1016
|
+
default_model: opts.defaultModel || 'default',
|
|
1017
|
+
temperature: 0.7,
|
|
1018
|
+
max_tokens: 4096,
|
|
1019
|
+
stream: true,
|
|
1020
|
+
};
|
|
1021
|
+
saveConfig(cfg);
|
|
1022
|
+
config = cfg;
|
|
1023
|
+
console.log(`\n ${FG_GREEN}✓${RST} Config saved to ${CONFIG_PATH}`);
|
|
1024
|
+
console.log(` ${FG_GRAY}${JSON.stringify(cfg, null, 2)}${RST}\n`);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// ── CLI arg parser ────────────────────────────────────────────────────────────
|
|
1028
|
+
|
|
1029
|
+
function parseArgs(argv) {
|
|
1030
|
+
const opts = {};
|
|
1031
|
+
const positional = [];
|
|
1032
|
+
let i = 0;
|
|
1033
|
+
while (i < argv.length) {
|
|
1034
|
+
switch (argv[i]) {
|
|
1035
|
+
case '-m': case '--model': opts.model = argv[++i]; break;
|
|
1036
|
+
case '-f': case '--file': (opts.file = opts.file || []).push(argv[++i]); break;
|
|
1037
|
+
case '-a': case '--analyze': opts.analyze = true; break;
|
|
1038
|
+
case '--dry-run': opts.dryRun = true; break;
|
|
1039
|
+
case '--api-base': opts.apiBase = argv[++i]; break;
|
|
1040
|
+
case '--api-key': opts.apiKey = argv[++i]; break;
|
|
1041
|
+
case '--default-model': opts.defaultModel = argv[++i]; break;
|
|
1042
|
+
default: positional.push(argv[i]);
|
|
1043
|
+
}
|
|
1044
|
+
i++;
|
|
1045
|
+
}
|
|
1046
|
+
return { opts, positional };
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
1050
|
+
|
|
1051
|
+
async function main() {
|
|
1052
|
+
const rawArgs = process.argv.slice(2);
|
|
1053
|
+
|
|
1054
|
+
if (rawArgs.length === 0) {
|
|
1055
|
+
await cmdChat({});
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const command = rawArgs[0];
|
|
1060
|
+
|
|
1061
|
+
if (command === '--help' || command === '-h') {
|
|
1062
|
+
console.log(`
|
|
1063
|
+
Semalt.AI — Self-hosted AI Coding Assistant
|
|
1064
|
+
|
|
1065
|
+
Usage: semalt-code [command] [options]
|
|
1066
|
+
|
|
1067
|
+
Commands:
|
|
1068
|
+
(none) Interactive chat mode (default)
|
|
1069
|
+
chat Interactive chat mode
|
|
1070
|
+
code <prompt> Generate code from a prompt
|
|
1071
|
+
edit <file> <instruction> Edit a file with AI
|
|
1072
|
+
shell <command> Run and optionally analyze a shell command
|
|
1073
|
+
models List available models
|
|
1074
|
+
init Initialize config
|
|
1075
|
+
|
|
1076
|
+
Options:
|
|
1077
|
+
-m, --model <name> Model name
|
|
1078
|
+
-f, --file <path> Load file into context (code command)
|
|
1079
|
+
-a, --analyze Analyze output with AI (shell command)
|
|
1080
|
+
--dry-run Don't save changes (edit command)
|
|
1081
|
+
--api-base <url> API base URL (init)
|
|
1082
|
+
--api-key <key> API key (init)
|
|
1083
|
+
--default-model <name> Default model (init)
|
|
1084
|
+
|
|
1085
|
+
Config: ${CONFIG_PATH}
|
|
1086
|
+
`);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (command === 'chat') {
|
|
1091
|
+
const { opts } = parseArgs(rawArgs.slice(1));
|
|
1092
|
+
await cmdChat(opts);
|
|
1093
|
+
} else if (command === 'code') {
|
|
1094
|
+
const { opts, positional } = parseArgs(rawArgs.slice(1));
|
|
1095
|
+
await cmdCode(opts, positional);
|
|
1096
|
+
} else if (command === 'edit') {
|
|
1097
|
+
const { opts, positional } = parseArgs(rawArgs.slice(1));
|
|
1098
|
+
await cmdEdit(opts, positional[0], positional.slice(1));
|
|
1099
|
+
} else if (command === 'shell') {
|
|
1100
|
+
const { opts, positional } = parseArgs(rawArgs.slice(1));
|
|
1101
|
+
await cmdShell(opts, positional);
|
|
1102
|
+
} else if (command === 'models') {
|
|
1103
|
+
await cmdModels();
|
|
1104
|
+
} else if (command === 'init') {
|
|
1105
|
+
const { opts } = parseArgs(rawArgs.slice(1));
|
|
1106
|
+
cmdInit(opts);
|
|
1107
|
+
} else {
|
|
1108
|
+
// Unknown command — treat all args as chat mode
|
|
1109
|
+
const { opts } = parseArgs(rawArgs);
|
|
1110
|
+
await cmdChat(opts);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
main().catch((e) => {
|
|
1115
|
+
process.stderr.write(`\n ${FG_RED}✗ Fatal: ${e.message}${RST}\n\n`);
|
|
1116
|
+
process.exit(1);
|
|
1117
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@semalt-ai/code",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Self-hosted AI Coding Assistant CLI",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"semalt-code": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node index.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["ai", "coding", "assistant", "cli", "semalt"],
|
|
13
|
+
"author": "Semalt.AI",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=16.0.0"
|
|
17
|
+
}
|
|
18
|
+
}
|