@semalt-ai/code 1.8.0 → 1.8.1
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/lib/agent.js +71 -2
- package/lib/api.js +61 -1
- package/lib/commands.js +50 -3
- package/lib/constants.js +16 -0
- package/lib/prompts.js +18 -11
- package/lib/tools.js +254 -141
- package/lib/ui/ansi.js +4 -3
- package/lib/ui/chat-history.js +1 -1
- package/lib/ui/input-field.js +1 -1
- package/lib/ui/status-bar.js +3 -2
- package/package.json +1 -1
package/lib/agent.js
CHANGED
|
@@ -395,6 +395,19 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
|
|
|
395
395
|
break;
|
|
396
396
|
} catch (err) {
|
|
397
397
|
lastApiErr = err;
|
|
398
|
+
if (debug) {
|
|
399
|
+
const header = `\n───── raw http error (iteration ${iteration + 1}, attempt ${attempt}/${MAX_RETRIES}) ─────\n`;
|
|
400
|
+
const footer = `\n───── end raw http error ─────\n`;
|
|
401
|
+
const status = err.statusCode ? `HTTP ${err.statusCode}` : 'network error';
|
|
402
|
+
const headerLines = err.responseHeaders
|
|
403
|
+
? Object.entries(err.responseHeaders).map(([k, v]) => `${k}: ${v}`).join('\n')
|
|
404
|
+
: '';
|
|
405
|
+
const body = err.rawBody !== undefined ? err.rawBody : (err.stack || err.message || String(err));
|
|
406
|
+
const parts = [status];
|
|
407
|
+
if (headerLines) parts.push(headerLines);
|
|
408
|
+
parts.push(body || '(empty body)');
|
|
409
|
+
process.stderr.write(header + parts.join('\n\n') + footer);
|
|
410
|
+
}
|
|
398
411
|
}
|
|
399
412
|
}
|
|
400
413
|
|
|
@@ -433,18 +446,74 @@ function createAgentRunner({ chatStream, extractToolCalls, agentExecShell, agent
|
|
|
433
446
|
}
|
|
434
447
|
}
|
|
435
448
|
|
|
436
|
-
if (!reply)
|
|
449
|
+
if (!reply) {
|
|
450
|
+
// Empty reply from the model — stream resolved with no content and no
|
|
451
|
+
// tool_calls. Most common causes: server-side disconnect mid-stream,
|
|
452
|
+
// context-window overflow that slipped past the 400/413 handler, or a
|
|
453
|
+
// model that returns only a stop token. Surface it so the user isn't
|
|
454
|
+
// left staring at an idle prompt.
|
|
455
|
+
if (cb.onError) {
|
|
456
|
+
const hint = iteration > 0 ? ' (after tool execution)' : '';
|
|
457
|
+
cb.onError({ message: `Agent returned an empty response${hint}. The connection to the model may have dropped — try again or /compact if context is large.`, isWarning: true });
|
|
458
|
+
}
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
437
461
|
|
|
438
462
|
const toolCalls = extractToolCalls(reply);
|
|
439
463
|
const cleanedReply = cleanAssistantContent(reply);
|
|
440
464
|
|
|
465
|
+
// Detect mid-tag truncation: an opening tool tag in the raw reply with
|
|
466
|
+
// no matching close. This happens when the model streams a large
|
|
467
|
+
// `<write_file>…` body and hits max_tokens or a server-side cutoff
|
|
468
|
+
// before the closing tag arrives. cleanAssistantContent strips the
|
|
469
|
+
// unclosed tag + its trailing content, so cleanedReply looks
|
|
470
|
+
// legitimate (just the planning preamble) and extractToolCalls finds
|
|
471
|
+
// zero calls — the loop would break silently and the user sees the
|
|
472
|
+
// planning text followed by nothing. Surface it so the user can retry,
|
|
473
|
+
// shorten the request, or bump max_tokens.
|
|
474
|
+
let truncatedTag = null;
|
|
475
|
+
for (const [tag, entry] of Object.entries(TAG_REGISTRY)) {
|
|
476
|
+
if (entry.type !== 'tool') continue;
|
|
477
|
+
let opens = 0;
|
|
478
|
+
for (const m of reply.matchAll(new RegExp(`<${tag}([^>]*)>`, 'gi'))) {
|
|
479
|
+
// Skip self-closing (`<tag .../>`) — they don't need a matching close.
|
|
480
|
+
if (!m[1].trimEnd().endsWith('/')) opens++;
|
|
481
|
+
}
|
|
482
|
+
if (opens === 0) continue;
|
|
483
|
+
const closes = (reply.match(new RegExp(`<\\/${tag}>`, 'gi')) || []).length;
|
|
484
|
+
if (opens > closes) { truncatedTag = tag; break; }
|
|
485
|
+
}
|
|
486
|
+
if (truncatedTag && cb.onError) {
|
|
487
|
+
cb.onError({ message: `Response truncated mid-<${truncatedTag}> tag — likely hit max_tokens or a server-side cutoff. Try again, shorten the request, or raise the model's max_tokens.`, isWarning: true });
|
|
488
|
+
}
|
|
489
|
+
|
|
441
490
|
messages.push({ role: 'assistant', content: cleanedReply });
|
|
442
491
|
// When showThink is off and the turn has tool calls, suppress the text bubble —
|
|
443
492
|
// pre-tool reasoning is noise, tool result bubbles already convey what happened.
|
|
444
493
|
const displayReply = (!showThink && toolCalls.length > 0) ? '' : cleanedReply;
|
|
445
494
|
if (cb.onAssistantMessage) cb.onAssistantMessage(displayReply);
|
|
446
495
|
|
|
447
|
-
|
|
496
|
+
// If nothing meaningful came back (no text to show, no tools to run) but
|
|
497
|
+
// the reply string wasn't strictly empty, it's usually model wrapper
|
|
498
|
+
// noise or a stripped-only response. Still a dead-end for the user.
|
|
499
|
+
if (toolCalls.length === 0 && !cleanedReply.trim()) {
|
|
500
|
+
if (cb.onError) {
|
|
501
|
+
cb.onError({ message: 'Agent reply had no visible content and no actions — stopping.', isWarning: true });
|
|
502
|
+
}
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (toolCalls.length === 0) {
|
|
507
|
+
// Model narrated next steps but didn't emit a tool tag. Happens when the
|
|
508
|
+
// model ends a plan with "Let me do that for you." and stops. If we just
|
|
509
|
+
// break, the user sees a dangling promise and thinks the connection dropped.
|
|
510
|
+
if (iteration > 0 && /\b(let me|i['’]?ll|i will|i'?m going to|next[, ]|now[, ]? ?(i|we)|going to (create|write|build|add|make|run|do|set up|install))\b/i.test(cleanedReply)) {
|
|
511
|
+
if (cb.onError) {
|
|
512
|
+
cb.onError({ message: 'Agent described next steps but did not emit a tool call. Reply "continue" (or similar) to push it forward, or restart if it keeps stalling.', isWarning: true });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
448
517
|
if (isAborted()) break;
|
|
449
518
|
|
|
450
519
|
if (!cb.onToolStart) {
|
package/lib/api.js
CHANGED
|
@@ -284,6 +284,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
284
284
|
messages: trimmedMessages,
|
|
285
285
|
temperature: temperature !== undefined ? temperature : config.temperature,
|
|
286
286
|
stream: true,
|
|
287
|
+
stream_options: { include_usage: true },
|
|
287
288
|
};
|
|
288
289
|
|
|
289
290
|
if (maxTokens !== undefined) payload.max_tokens = maxTokens;
|
|
@@ -319,6 +320,8 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
319
320
|
err.statusCode = res.statusCode;
|
|
320
321
|
err.parsedErr = parsedErr;
|
|
321
322
|
err.detail = detail;
|
|
323
|
+
err.rawBody = errBody;
|
|
324
|
+
err.responseHeaders = res.headers;
|
|
322
325
|
throw err;
|
|
323
326
|
}
|
|
324
327
|
return res;
|
|
@@ -365,6 +368,9 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
365
368
|
let inReasoning = false;
|
|
366
369
|
let streamUsage = null;
|
|
367
370
|
let resolved = false;
|
|
371
|
+
// delta.tool_calls accumulator (OpenAI function-calling streaming format).
|
|
372
|
+
// Keyed by `index` per the OpenAI spec.
|
|
373
|
+
const toolCallAcc = [];
|
|
368
374
|
const renderer = new StreamRenderer({ firstLinePrefix: linePrefix, showThink });
|
|
369
375
|
if (!silent) {
|
|
370
376
|
process.stdout.write('\n');
|
|
@@ -373,9 +379,35 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
373
379
|
let firstContentToken = true;
|
|
374
380
|
let lineBuffer = '';
|
|
375
381
|
|
|
382
|
+
function escapeXml(s) {
|
|
383
|
+
return String(s)
|
|
384
|
+
.replace(/&/g, '&')
|
|
385
|
+
.replace(/</g, '<')
|
|
386
|
+
.replace(/>/g, '>');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Convert any accumulated tool_calls into a MiniMax XML block and
|
|
390
|
+
// append it to fullText so extractToolCalls() picks them up. Runs once
|
|
391
|
+
// at stream end.
|
|
392
|
+
function appendToolCallsXml() {
|
|
393
|
+
const valid = toolCallAcc.filter((t) => t && t.name);
|
|
394
|
+
if (valid.length === 0) return;
|
|
395
|
+
const invokes = valid.map((tc) => {
|
|
396
|
+
let args = {};
|
|
397
|
+
try { args = tc.arguments ? JSON.parse(tc.arguments) : {}; } catch {}
|
|
398
|
+
const params = Object.entries(args).map(([k, v]) => {
|
|
399
|
+
const val = typeof v === 'string' ? v : JSON.stringify(v);
|
|
400
|
+
return `<parameter name="${escapeXml(k)}">${val}</parameter>`;
|
|
401
|
+
}).join('\n');
|
|
402
|
+
return `<invoke name="${escapeXml(tc.name)}">\n${params}\n</invoke>`;
|
|
403
|
+
}).join('\n');
|
|
404
|
+
fullText += `\n<minimax:tool_call>\n${invokes}\n</minimax:tool_call>`;
|
|
405
|
+
}
|
|
406
|
+
|
|
376
407
|
function finalize() {
|
|
377
408
|
if (resolved) return;
|
|
378
409
|
resolved = true;
|
|
410
|
+
appendToolCallsXml();
|
|
379
411
|
if (!silent) renderer.flush();
|
|
380
412
|
const elapsed = (Date.now() - startTime) / 1000;
|
|
381
413
|
const tps = tokenCount / (elapsed || 1);
|
|
@@ -385,7 +417,16 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
385
417
|
StatusBar.current.liveUpdate({ tokens: `${tokenCount} tok`, latency });
|
|
386
418
|
StatusBar.current.render();
|
|
387
419
|
}
|
|
388
|
-
|
|
420
|
+
// Fallback for endpoints that don't honor stream_options.include_usage:
|
|
421
|
+
// estimate prompt/completion tokens locally so the status bar still updates.
|
|
422
|
+
let usage = streamUsage;
|
|
423
|
+
if (!usage) {
|
|
424
|
+
usage = {
|
|
425
|
+
prompt_tokens: estimateTokens(JSON.stringify(trimmedMessages)),
|
|
426
|
+
completion_tokens: estimateTokens(fullText) + estimateTokens(reasoningText),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
resolve({ content: fullText, usage });
|
|
389
430
|
}
|
|
390
431
|
|
|
391
432
|
res.setEncoding('utf8');
|
|
@@ -427,6 +468,25 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
427
468
|
}
|
|
428
469
|
}
|
|
429
470
|
|
|
471
|
+
const toolCallsDelta = delta.tool_calls;
|
|
472
|
+
if (Array.isArray(toolCallsDelta)) {
|
|
473
|
+
for (const tc of toolCallsDelta) {
|
|
474
|
+
const idx = typeof tc.index === 'number' ? tc.index : toolCallAcc.length;
|
|
475
|
+
const isNew = !toolCallAcc[idx];
|
|
476
|
+
if (isNew) toolCallAcc[idx] = { name: '', arguments: '' };
|
|
477
|
+
if (tc.function?.name) toolCallAcc[idx].name += tc.function.name;
|
|
478
|
+
if (tc.function?.arguments) toolCallAcc[idx].arguments += tc.function.arguments;
|
|
479
|
+
// When the model streams purely via delta.tool_calls (no
|
|
480
|
+
// delta.content), firstContentToken never flips, so the status
|
|
481
|
+
// bar stays on "Thinking…" for the entire tool-call stream.
|
|
482
|
+
// Surface each new tool slot the moment its name is known so
|
|
483
|
+
// the user sees "Using tool: <name>" instead of a frozen UI.
|
|
484
|
+
if (isNew && StatusBar.current && toolCallAcc[idx].name) {
|
|
485
|
+
StatusBar.current.update('tool', `Using tool: ${toolCallAcc[idx].name}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
430
490
|
const content = delta.content || '';
|
|
431
491
|
if (content) {
|
|
432
492
|
if (inReasoning) {
|
package/lib/commands.js
CHANGED
|
@@ -7,6 +7,7 @@ const { configShow } = require('./config');
|
|
|
7
7
|
const { SYSTEM_PROMPT } = require('./prompts');
|
|
8
8
|
const { SessionStorage } = require('./storage');
|
|
9
9
|
const { getSkippedOps, setUIActive } = require('./tools');
|
|
10
|
+
const { AUDIT_LOG } = require('./audit');
|
|
10
11
|
|
|
11
12
|
function formatTimeAgo(ts) {
|
|
12
13
|
const diffMs = Date.now() - ts;
|
|
@@ -150,6 +151,7 @@ function createCommands({
|
|
|
150
151
|
let messages = [];
|
|
151
152
|
let currentChatId = null;
|
|
152
153
|
let savedUpTo = 0;
|
|
154
|
+
let debugMode = !!opts.debug;
|
|
153
155
|
|
|
154
156
|
// Resolve system prompt override from --system-prompt file if provided
|
|
155
157
|
let resolvedSystemPrompt = null;
|
|
@@ -576,6 +578,7 @@ function createCommands({
|
|
|
576
578
|
' /shell <cmd> Run shell command',
|
|
577
579
|
' !<cmd> Run shell command',
|
|
578
580
|
' /approve Toggle auto-approve',
|
|
581
|
+
' /debug [off] Enable debug output + show last 5 audit entries',
|
|
579
582
|
' /config Show config',
|
|
580
583
|
' exit Quit',
|
|
581
584
|
].join('\n'),
|
|
@@ -753,6 +756,40 @@ function createCommands({
|
|
|
753
756
|
return;
|
|
754
757
|
}
|
|
755
758
|
|
|
759
|
+
if (text === '/debug' || text.startsWith('/debug ')) {
|
|
760
|
+
const arg = text === '/debug' ? '' : text.slice(7).trim().toLowerCase();
|
|
761
|
+
if (arg === 'off' || arg === 'false' || arg === '0') debugMode = false;
|
|
762
|
+
else debugMode = true;
|
|
763
|
+
|
|
764
|
+
let tail = '';
|
|
765
|
+
try {
|
|
766
|
+
const content = fs.readFileSync(AUDIT_LOG, 'utf8');
|
|
767
|
+
const lines = content.trim().split('\n').filter((l) => l.trim()).slice(-5);
|
|
768
|
+
if (lines.length) {
|
|
769
|
+
const formatted = lines.map((line) => {
|
|
770
|
+
try {
|
|
771
|
+
const entry = JSON.parse(line);
|
|
772
|
+
const mark = entry.approved ? '✓' : '✗';
|
|
773
|
+
return ` ${mark} ${entry.ts} ${entry.tag} ${entry.input} → ${entry.result}`;
|
|
774
|
+
} catch {
|
|
775
|
+
return ` ${line}`;
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
tail = '\nLast 5 audit entries:\n' + formatted.join('\n');
|
|
779
|
+
} else {
|
|
780
|
+
tail = '\nAudit log is empty.';
|
|
781
|
+
}
|
|
782
|
+
} catch {
|
|
783
|
+
tail = '\nNo audit log found.';
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
chatHistory.addMessage({
|
|
787
|
+
role: 'system',
|
|
788
|
+
content: `Debug output: ${debugMode ? 'ON' : 'OFF'} (raw messages, raw AI responses, raw HTTP errors → stderr)${tail}`,
|
|
789
|
+
});
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
756
793
|
if (text.startsWith('/shell ') || text.startsWith('!')) {
|
|
757
794
|
const cmd = text.startsWith('/shell ') ? text.slice(7).trim() : text.slice(1).trim();
|
|
758
795
|
inputField.setDisabled(true);
|
|
@@ -810,7 +847,12 @@ function createCommands({
|
|
|
810
847
|
if (entry?.type === 'tool') {
|
|
811
848
|
const actionLabel = entry.label || tag;
|
|
812
849
|
const detail = attrs.path || attrs.url || attrs.key || attrs.src || '';
|
|
813
|
-
|
|
850
|
+
const isDownload = tag === 'download' || tag === 'http_get' || tag === 'http_get_next';
|
|
851
|
+
const barState = isDownload ? 'waiting_download' : 'tool';
|
|
852
|
+
const label = isDownload
|
|
853
|
+
? `Waiting for download${detail ? ': ' + detail : ''}`
|
|
854
|
+
: `${actionLabel}${detail ? ': ' + detail : ''}`;
|
|
855
|
+
statusBar.update(barState, label);
|
|
814
856
|
if (!opts.showThink) chatHistory.clearStreamingContent();
|
|
815
857
|
}
|
|
816
858
|
if (entry?.display === 'think_bubble') {
|
|
@@ -824,7 +866,12 @@ function createCommands({
|
|
|
824
866
|
onToolStart: (tag, input, attrs) => {
|
|
825
867
|
const actionLabel = TAG_REGISTRY[tag]?.label || tag;
|
|
826
868
|
const short = input.length > 40 ? input.slice(0, 40) + '…' : input;
|
|
827
|
-
|
|
869
|
+
const isDownload = tag === 'download' || tag === 'http_get' || tag === 'http_get_next';
|
|
870
|
+
if (isDownload) {
|
|
871
|
+
statusBar.update('waiting_download', `Waiting for download: ${short}`);
|
|
872
|
+
} else {
|
|
873
|
+
statusBar.update('tool', `${actionLabel}: ${short}`);
|
|
874
|
+
}
|
|
828
875
|
},
|
|
829
876
|
onToolEnd: (tag, result, durationMs) => {
|
|
830
877
|
const isError = typeof result === 'string' && result.startsWith('Error');
|
|
@@ -913,7 +960,7 @@ function createCommands({
|
|
|
913
960
|
try {
|
|
914
961
|
const agentResult = await runAgentLoop(messages, currentModel, undefined, resolvedTokenLimit, {
|
|
915
962
|
showThink: opts.showThink || false,
|
|
916
|
-
debug:
|
|
963
|
+
debug: debugMode,
|
|
917
964
|
callbacks,
|
|
918
965
|
systemPrompt: resolvedSystemPrompt,
|
|
919
966
|
systemPromptMode: getConfig().system_prompt_mode || 'system_role',
|
package/lib/constants.js
CHANGED
|
@@ -34,6 +34,7 @@ const TAG_REGISTRY = {
|
|
|
34
34
|
think: { type: 'visual', streaming: true, display: 'think_bubble' },
|
|
35
35
|
reasoning: { type: 'visual', streaming: true, display: 'think_bubble' },
|
|
36
36
|
reflection: { type: 'visual', streaming: true, display: 'think_bubble' },
|
|
37
|
+
plan: { type: 'visual', streaming: true, display: 'think_bubble' },
|
|
37
38
|
|
|
38
39
|
// Executed as tool calls
|
|
39
40
|
exec: { type: 'tool', streaming: false, label: 'Running command' },
|
|
@@ -65,6 +66,21 @@ const TAG_REGISTRY = {
|
|
|
65
66
|
list_memories: { type: 'tool', streaming: false, label: 'Listing memories' },
|
|
66
67
|
system_info: { type: 'tool', streaming: false, label: 'Reading system info' },
|
|
67
68
|
|
|
69
|
+
// MiniMax-M2 native tool-call wrappers. `extractToolCalls` parses them into
|
|
70
|
+
// internal calls; classifying them here keeps raw XML out of the UI stream.
|
|
71
|
+
'minimax:tool_call': { type: 'tool', streaming: false, label: 'Using tool' },
|
|
72
|
+
invoke: { type: 'strip' },
|
|
73
|
+
parameter: { type: 'strip' },
|
|
74
|
+
|
|
75
|
+
// Qwen / Hermes native tool-call wrappers. Qwen3-family models emit a
|
|
76
|
+
// JSON-shaped `<tool_call>{"name":"...","arguments":{...}}</tool_call>`
|
|
77
|
+
// block inline when the server's tool parser is not applied, and some
|
|
78
|
+
// finetunes also use the namespaced `<qwen:tool_call>` or the
|
|
79
|
+
// `<function_call>` spelling. `extractToolCalls` parses all three.
|
|
80
|
+
'qwen:tool_call': { type: 'tool', streaming: false, label: 'Using tool' },
|
|
81
|
+
tool_call: { type: 'tool', streaming: false, label: 'Using tool' },
|
|
82
|
+
function_call: { type: 'tool', streaming: false, label: 'Using tool' },
|
|
83
|
+
|
|
68
84
|
// Silently stripped — model wrapper artifacts
|
|
69
85
|
answer: { type: 'strip' },
|
|
70
86
|
response: { type: 'strip' },
|
package/lib/prompts.js
CHANGED
|
@@ -85,20 +85,27 @@ To read an environment variable:
|
|
|
85
85
|
To set an environment variable for the current session:
|
|
86
86
|
<set_env name="VARIABLE_NAME" value="value"/>
|
|
87
87
|
|
|
88
|
-
To
|
|
89
|
-
<
|
|
88
|
+
To plan your next action before executing a tool (hidden from user by default):
|
|
89
|
+
<plan>your reasoning and planned next step here</plan>
|
|
90
|
+
|
|
91
|
+
## Reasoning vs planning — IMPORTANT:
|
|
92
|
+
|
|
93
|
+
- Your internal chain-of-thought reasoning uses your native \`<think>...</think>\` block. Use it normally for deliberation. Do NOT treat \`<think>\` as a user-facing tool and do NOT try to emit \`<think>\` as an action — it is reserved for your own reasoning and is handled by the runtime.
|
|
94
|
+
- When you need to explicitly record a short plan that the agent framework can see (for logging or hand-off between steps), use \`<plan>...</plan>\` instead. \`<plan>\` is a tool tag; \`<think>\` is not.
|
|
95
|
+
- Never emit \`<think>\` as one of the "one tool tag per response" actions. The valid action tags are only the ones listed above (exec, read_file, write_file, plan, etc.).
|
|
90
96
|
|
|
91
97
|
## STRICT RULES — follow exactly:
|
|
92
98
|
|
|
93
|
-
1. **ONE tool tag per response, no exceptions.** After emitting a tag, stop. Wait for the result. Then emit the next tag in your next response. Never put two or more action tags in the same response.
|
|
94
|
-
2.
|
|
95
|
-
3.
|
|
96
|
-
4.
|
|
97
|
-
5.
|
|
98
|
-
6.
|
|
99
|
-
7.
|
|
100
|
-
8. If
|
|
101
|
-
9.
|
|
99
|
+
1. **ONE tool tag per response, no exceptions.** After emitting a tag, stop. Wait for the result. Then emit the next tag in your next response. Never put two or more action tags in the same response. (Your native \`<think>\` reasoning block does NOT count as a tool tag and may precede the action tag normally.)
|
|
100
|
+
2. **Always end your response with exactly one action tag** whenever the task is not yet complete. Do NOT end a response with only prose like "Let me create the files…" — that prose must be immediately followed by the actual tool tag in the same response. Preamble without a tool tag is a bug.
|
|
101
|
+
3. Only use the exact tag names listed above. Unknown tag names will not be executed.
|
|
102
|
+
4. When the user asks you to do something on their system, USE the tools — do NOT just print instructions.
|
|
103
|
+
5. Each action will be shown to the user for approval before execution.
|
|
104
|
+
6. After execution you will receive the result and can proceed with the next action in a new response.
|
|
105
|
+
7. Be concise. Provide working solutions. Use markdown for code blocks in explanations.
|
|
106
|
+
8. **Permission denied fallback:** If any tool action (write_file, delete_file, make_dir, etc.) is denied or returns a permission error, immediately retry the same operation using \`<exec>\` with an equivalent shell command (e.g. \`tee\`, \`rm\`, \`mkdir -p\`, \`cp\`, \`mv\`). Never give up after a single denial — the shell is always the fallback.
|
|
107
|
+
9. If a shell command itself also fails with a permission error, immediately retry it prefixed with \`sudo\` (e.g. \`sudo tee /path\`, \`sudo mkdir -p /path\`). Do not ask — just try. Only stop and explain if sudo itself fails.
|
|
108
|
+
10. Current working directory: ${process.cwd()}`;
|
|
102
109
|
|
|
103
110
|
module.exports = {
|
|
104
111
|
SYSTEM_PROMPT,
|
package/lib/tools.js
CHANGED
|
@@ -125,8 +125,12 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
125
125
|
// For append the final state is existing + new content
|
|
126
126
|
const finalContent = action === 'write' ? (content || '') : (existing + (content || ''));
|
|
127
127
|
|
|
128
|
+
// In CLI mode, print the diff inline. In TUI mode, direct stdout writes
|
|
129
|
+
// collide with the live chat-history/status-bar redraw, so we route the
|
|
130
|
+
// diff into the permission description instead (where it renders inside
|
|
131
|
+
// the permission bubble and is safely truncated by MAX_DESC_LINES).
|
|
128
132
|
const diffOutput = renderDiff(existing, finalContent, filePath);
|
|
129
|
-
process.stdout.write(diffOutput + '\n');
|
|
133
|
+
if (!_uiActive) process.stdout.write(diffOutput + '\n');
|
|
130
134
|
|
|
131
135
|
// Dry-run: record the skipped op and return without writing
|
|
132
136
|
if (_dryRun) {
|
|
@@ -139,6 +143,7 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
139
143
|
// Permission check — routes through TUI dialog in chat mode, interactiveSelect in legacy CLI mode
|
|
140
144
|
let desc = `${action === 'write' ? 'Write' : 'Append to'} ${filePath}`;
|
|
141
145
|
if (content) desc += ` (${content.length} chars)`;
|
|
146
|
+
if (_uiActive) desc = `${desc}\n${diffOutput}`;
|
|
142
147
|
const approved = await permissionManager.askPermission('file', desc, tag);
|
|
143
148
|
if (!approved) {
|
|
144
149
|
logToolCall(tag, { path: filePath, content }, false, 'denied');
|
|
@@ -270,27 +275,21 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
270
275
|
try {
|
|
271
276
|
const dstDir = path.dirname(dst);
|
|
272
277
|
if (dstDir && dstDir !== '.') fs.mkdirSync(dstDir, { recursive: true });
|
|
273
|
-
const cfg = getConfig ? getConfig() : {};
|
|
274
|
-
const timeout = cfg.command_timeout_ms || 30000;
|
|
275
|
-
const mvResult = spawnSync('mv', [src, dst], { encoding: 'utf8', timeout });
|
|
276
|
-
if (mvResult.error && mvResult.error.code === 'ENOENT') throw new Error('mv not available');
|
|
277
|
-
if (mvResult.error) throw mvResult.error;
|
|
278
|
-
if (mvResult.status !== 0) throw new Error((mvResult.stderr || 'mv failed').trim());
|
|
279
|
-
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Moved ${src} → ${dst}${RST}`);
|
|
280
|
-
logToolCall('move_file', { src, dst }, true, 'ok');
|
|
281
|
-
return { status: 'ok', src, dst };
|
|
282
|
-
} catch (mvErr) {
|
|
283
|
-
// Fallback: JS rename (works only within same filesystem)
|
|
284
278
|
try {
|
|
285
279
|
fs.renameSync(src, dst);
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
logToolCall('move_file', { src, dst }, true, 'error');
|
|
292
|
-
return { error: error.message };
|
|
280
|
+
} catch (renameErr) {
|
|
281
|
+
if (renameErr.code !== 'EXDEV') throw renameErr;
|
|
282
|
+
// Cross-device rename not supported — copy then remove
|
|
283
|
+
fs.cpSync(src, dst, { recursive: true });
|
|
284
|
+
fs.rmSync(src, { recursive: true, force: true });
|
|
293
285
|
}
|
|
286
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Moved ${src} → ${dst}${RST}`);
|
|
287
|
+
logToolCall('move_file', { src, dst }, true, 'ok');
|
|
288
|
+
return { status: 'ok', src, dst };
|
|
289
|
+
} catch (error) {
|
|
290
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
291
|
+
logToolCall('move_file', { src, dst }, true, 'error');
|
|
292
|
+
return { error: error.message };
|
|
294
293
|
}
|
|
295
294
|
}
|
|
296
295
|
|
|
@@ -360,36 +359,18 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
360
359
|
return { error: 'Permission denied' };
|
|
361
360
|
}
|
|
362
361
|
try {
|
|
363
|
-
const
|
|
364
|
-
const
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
// grep exit 1 = no matches (not an error), 2 = real error
|
|
369
|
-
if (gr.status === 2) throw new Error((gr.stderr || '').trim() || 'grep error');
|
|
370
|
-
const matches = (gr.stdout || '').split('\n').filter(Boolean).map(line => {
|
|
371
|
-
const colon = line.indexOf(':');
|
|
372
|
-
return colon === -1 ? null : { line: parseInt(line.slice(0, colon), 10), content: line.slice(colon + 1) };
|
|
373
|
-
}).filter(Boolean);
|
|
362
|
+
const data = fs.readFileSync(filePath, 'utf8');
|
|
363
|
+
const regex = new RegExp(pattern);
|
|
364
|
+
const matches = data.split('\n')
|
|
365
|
+
.map((content, idx) => regex.test(content) ? { line: idx + 1, content } : null)
|
|
366
|
+
.filter(Boolean);
|
|
374
367
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${matches.length} match(es) in ${filePath}${RST}`);
|
|
375
368
|
logToolCall('search_in_file', { path: filePath, pattern }, true, 'ok');
|
|
376
369
|
return { matches, path: filePath };
|
|
377
|
-
} catch (
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
const regex = new RegExp(pattern);
|
|
382
|
-
const matches = data.split('\n')
|
|
383
|
-
.map((content, idx) => regex.test(content) ? { line: idx + 1, content } : null)
|
|
384
|
-
.filter(Boolean);
|
|
385
|
-
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${matches.length} match(es) in ${filePath}${RST}`);
|
|
386
|
-
logToolCall('search_in_file', { path: filePath, pattern }, true, 'ok');
|
|
387
|
-
return { matches, path: filePath };
|
|
388
|
-
} catch (error) {
|
|
389
|
-
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
390
|
-
logToolCall('search_in_file', { path: filePath, pattern }, true, 'error');
|
|
391
|
-
return { error: error.message };
|
|
392
|
-
}
|
|
370
|
+
} catch (error) {
|
|
371
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
372
|
+
logToolCall('search_in_file', { path: filePath, pattern }, true, 'error');
|
|
373
|
+
return { error: error.message };
|
|
393
374
|
}
|
|
394
375
|
}
|
|
395
376
|
|
|
@@ -430,52 +411,32 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
430
411
|
return { error: 'Permission denied' };
|
|
431
412
|
}
|
|
432
413
|
try {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
const
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
414
|
+
let regStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
415
|
+
regStr = regStr.replace(/\*\*/g, '\x00');
|
|
416
|
+
regStr = regStr.replace(/\*/g, '[^/]*');
|
|
417
|
+
regStr = regStr.replace(/\x00\//g, '(?:.*/)?');
|
|
418
|
+
regStr = regStr.replace(/\x00/g, '.*');
|
|
419
|
+
const regex = new RegExp(`^${regStr}$`);
|
|
420
|
+
const matchName = !pattern.includes('/');
|
|
421
|
+
const files = [];
|
|
422
|
+
function walk(dir, rel) {
|
|
423
|
+
let entries;
|
|
424
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
425
|
+
for (const entry of entries) {
|
|
426
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
427
|
+
if (regex.test(matchName ? entry.name : relPath)) files.push(relPath);
|
|
428
|
+
if (entry.isDirectory()) walk(path.join(dir, entry.name), relPath);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
walk(searchDir, '');
|
|
432
|
+
files.sort();
|
|
447
433
|
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${files.length} file(s) matching "${pattern}"${RST}`);
|
|
448
434
|
logToolCall('search_files', { pattern, dir: searchDir }, true, 'ok');
|
|
449
435
|
return { files, pattern, dir: searchDir };
|
|
450
|
-
} catch (
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
regStr = regStr.replace(/\*\*/g, '\x00');
|
|
455
|
-
regStr = regStr.replace(/\*/g, '[^/]*');
|
|
456
|
-
regStr = regStr.replace(/\x00\//g, '(?:.*/)?');
|
|
457
|
-
regStr = regStr.replace(/\x00/g, '.*');
|
|
458
|
-
const regex = new RegExp(`^${regStr}$`);
|
|
459
|
-
const matchName = !pattern.includes('/');
|
|
460
|
-
const files = [];
|
|
461
|
-
function walk(dir, rel) {
|
|
462
|
-
let entries;
|
|
463
|
-
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
464
|
-
for (const entry of entries) {
|
|
465
|
-
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
466
|
-
if (regex.test(matchName ? entry.name : relPath)) files.push(relPath);
|
|
467
|
-
if (entry.isDirectory()) walk(path.join(dir, entry.name), relPath);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
walk(searchDir, '');
|
|
471
|
-
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Found ${files.length} file(s) matching "${pattern}"${RST}`);
|
|
472
|
-
logToolCall('search_files', { pattern, dir: searchDir }, true, 'ok');
|
|
473
|
-
return { files, pattern, dir: searchDir };
|
|
474
|
-
} catch (error) {
|
|
475
|
-
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
476
|
-
logToolCall('search_files', { pattern, dir: searchDir }, true, 'error');
|
|
477
|
-
return { error: error.message };
|
|
478
|
-
}
|
|
436
|
+
} catch (error) {
|
|
437
|
+
_log(` ${FG_RED}✗ ${error.message}${RST}`);
|
|
438
|
+
logToolCall('search_files', { pattern, dir: searchDir }, true, 'error');
|
|
439
|
+
return { error: error.message };
|
|
479
440
|
}
|
|
480
441
|
}
|
|
481
442
|
|
|
@@ -543,37 +504,50 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
543
504
|
logToolCall('download', { url }, false, 'denied');
|
|
544
505
|
return { error: 'Permission denied' };
|
|
545
506
|
}
|
|
546
|
-
const dlResult = spawnSync('curl', ['-sLo', outPath, url], { encoding: 'utf8', timeout: 120000 });
|
|
547
|
-
if (!dlResult.error || dlResult.error.code !== 'ENOENT') {
|
|
548
|
-
if (dlResult.error || dlResult.status !== 0) {
|
|
549
|
-
try { fs.unlinkSync(outPath); } catch {}
|
|
550
|
-
const msg = dlResult.error ? dlResult.error.message : (dlResult.stderr || 'curl failed').trim();
|
|
551
|
-
_log(` ${FG_RED}✗ ${msg}${RST}`);
|
|
552
|
-
logToolCall('download', { url }, true, 'error');
|
|
553
|
-
return { error: msg };
|
|
554
|
-
}
|
|
555
|
-
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Downloaded to ${outPath}${RST}`);
|
|
556
|
-
logToolCall('download', { url }, true, 'ok');
|
|
557
|
-
return { status: 'ok', path: outPath };
|
|
558
|
-
}
|
|
559
|
-
// Fallback: Node.js http/https
|
|
560
507
|
return new Promise((resolve) => {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
508
|
+
function doDownload(target, redirectsLeft) {
|
|
509
|
+
const proto = target.startsWith('https') ? https : http;
|
|
510
|
+
const req = proto.get(target, (res) => {
|
|
511
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode) && redirectsLeft > 0 && res.headers.location) {
|
|
512
|
+
res.resume();
|
|
513
|
+
return doDownload(res.headers.location, redirectsLeft - 1);
|
|
514
|
+
}
|
|
515
|
+
if (res.statusCode >= 400) {
|
|
516
|
+
res.resume();
|
|
517
|
+
const msg = `HTTP ${res.statusCode}`;
|
|
518
|
+
_log(` ${FG_RED}✗ ${msg}${RST}`);
|
|
519
|
+
logToolCall('download', { url }, true, 'error');
|
|
520
|
+
return resolve({ error: msg });
|
|
521
|
+
}
|
|
522
|
+
const file = fs.createWriteStream(outPath);
|
|
523
|
+
res.pipe(file);
|
|
524
|
+
file.on('finish', () => {
|
|
525
|
+
file.close();
|
|
526
|
+
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}Downloaded to ${outPath}${RST}`);
|
|
527
|
+
logToolCall('download', { url }, true, 'ok');
|
|
528
|
+
resolve({ status: 'ok', path: outPath });
|
|
529
|
+
});
|
|
530
|
+
file.on('error', (err) => {
|
|
531
|
+
fs.unlink(outPath, () => {});
|
|
532
|
+
_log(` ${FG_RED}✗ ${err.message}${RST}`);
|
|
533
|
+
logToolCall('download', { url }, true, 'error');
|
|
534
|
+
resolve({ error: err.message });
|
|
535
|
+
});
|
|
570
536
|
});
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
537
|
+
req.on('error', (err) => {
|
|
538
|
+
fs.unlink(outPath, () => {});
|
|
539
|
+
_log(` ${FG_RED}✗ ${err.message}${RST}`);
|
|
540
|
+
logToolCall('download', { url }, true, 'error');
|
|
541
|
+
resolve({ error: err.message });
|
|
542
|
+
});
|
|
543
|
+
req.setTimeout(120000, () => {
|
|
544
|
+
req.destroy();
|
|
545
|
+
fs.unlink(outPath, () => {});
|
|
546
|
+
logToolCall('download', { url }, true, 'error');
|
|
547
|
+
resolve({ error: 'Request timeout' });
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
doDownload(url, 5);
|
|
577
551
|
});
|
|
578
552
|
}
|
|
579
553
|
|
|
@@ -662,32 +636,13 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
662
636
|
return { error: 'Permission denied' };
|
|
663
637
|
}
|
|
664
638
|
const httpCfg = getConfig ? getConfig() : {};
|
|
665
|
-
const
|
|
666
|
-
// Try curl first: -sL follows redirects; -w appends status code on its own line
|
|
667
|
-
const curlResult = spawnSync(
|
|
668
|
-
'curl', ['-sL', '--max-time', String(curlTimeout), '-w', '\n%{http_code}', url],
|
|
669
|
-
{ encoding: 'utf8', timeout: (curlTimeout + 5) * 1000 }
|
|
670
|
-
);
|
|
671
|
-
if (!curlResult.error || curlResult.error.code !== 'ENOENT') {
|
|
672
|
-
if (curlResult.error) {
|
|
673
|
-
_log(` ${FG_RED}✗ ${curlResult.error.message}${RST}`);
|
|
674
|
-
logToolCall('http_get', { url }, true, 'error');
|
|
675
|
-
return { error: curlResult.error.message };
|
|
676
|
-
}
|
|
677
|
-
const stdout = curlResult.stdout || '';
|
|
678
|
-
const lastNl = stdout.lastIndexOf('\n');
|
|
679
|
-
const body = lastNl >= 0 ? stdout.slice(0, lastNl) : stdout;
|
|
680
|
-
const statusCode = parseInt((lastNl >= 0 ? stdout.slice(lastNl + 1) : '').trim(), 10) || 0;
|
|
681
|
-
_log(` ${FG_GREEN}✓${RST} ${FG_GRAY}HTTP GET ${url} (${statusCode}, ${body.length} chars)${RST}`);
|
|
682
|
-
logToolCall('http_get', { url }, true, statusCode < 400 ? 'ok' : 'error');
|
|
683
|
-
return buildHttpResult(url, statusCode, body, rawHtml);
|
|
684
|
-
}
|
|
685
|
-
// Fallback: Node.js http/https
|
|
639
|
+
const reqTimeoutMs = Math.max(15000, httpCfg.request_timeout_ms || 15000);
|
|
686
640
|
return new Promise((resolve) => {
|
|
687
641
|
function doGet(target, redirectsLeft) {
|
|
688
642
|
const proto = target.startsWith('https') ? https : http;
|
|
689
643
|
const req = proto.get(target, (res) => {
|
|
690
644
|
if ([301, 302, 303, 307, 308].includes(res.statusCode) && redirectsLeft > 0 && res.headers.location) {
|
|
645
|
+
res.resume();
|
|
691
646
|
return doGet(res.headers.location, redirectsLeft - 1);
|
|
692
647
|
}
|
|
693
648
|
let data = '';
|
|
@@ -704,9 +659,13 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
704
659
|
logToolCall('http_get', { url: target }, true, 'error');
|
|
705
660
|
resolve({ error: err.message });
|
|
706
661
|
});
|
|
707
|
-
req.setTimeout(
|
|
662
|
+
req.setTimeout(reqTimeoutMs, () => {
|
|
663
|
+
req.destroy();
|
|
664
|
+
logToolCall('http_get', { url: target }, true, 'error');
|
|
665
|
+
resolve({ error: 'Request timeout' });
|
|
666
|
+
});
|
|
708
667
|
}
|
|
709
|
-
doGet(url,
|
|
668
|
+
doGet(url, 5);
|
|
710
669
|
});
|
|
711
670
|
}
|
|
712
671
|
|
|
@@ -841,9 +800,162 @@ function createToolExecutor(permissionManager, ui, getConfig) {
|
|
|
841
800
|
};
|
|
842
801
|
}
|
|
843
802
|
|
|
803
|
+
// Map a MiniMax-style {name, params} invocation to the internal
|
|
804
|
+
// [action, arg1, arg2, …] call tuple consumed by the agent loop.
|
|
805
|
+
function mapInvokeToCall(toolName, params) {
|
|
806
|
+
const name = (toolName || '').toLowerCase();
|
|
807
|
+
const p = params || {};
|
|
808
|
+
switch (name) {
|
|
809
|
+
case 'write_file':
|
|
810
|
+
case 'create_file':
|
|
811
|
+
return p.path ? ['write', p.path, p.content != null ? p.content : ''] : null;
|
|
812
|
+
case 'read_file':
|
|
813
|
+
return p.path ? ['read', p.path] : null;
|
|
814
|
+
case 'append_file':
|
|
815
|
+
return p.path ? ['append', p.path, p.content != null ? p.content : ''] : null;
|
|
816
|
+
case 'delete_file':
|
|
817
|
+
return p.path ? ['delete_file', p.path] : null;
|
|
818
|
+
case 'list_dir':
|
|
819
|
+
return ['list_dir', p.path || p.dir || '.'];
|
|
820
|
+
case 'make_dir':
|
|
821
|
+
return p.path ? ['make_dir', p.path] : null;
|
|
822
|
+
case 'remove_dir':
|
|
823
|
+
return p.path ? ['remove_dir', p.path] : null;
|
|
824
|
+
case 'move_file':
|
|
825
|
+
return p.src && p.dst ? ['move_file', p.src, p.dst] : null;
|
|
826
|
+
case 'copy_file':
|
|
827
|
+
return p.src && p.dst ? ['copy_file', p.src, p.dst] : null;
|
|
828
|
+
case 'file_stat':
|
|
829
|
+
return p.path ? ['file_stat', p.path] : null;
|
|
830
|
+
case 'search_files':
|
|
831
|
+
return ['search_files', p.pattern || p.glob || '*', p.dir || '.'];
|
|
832
|
+
case 'search_in_file':
|
|
833
|
+
return p.path && p.pattern ? ['search_in_file', p.path, p.pattern] : null;
|
|
834
|
+
case 'replace_in_file':
|
|
835
|
+
return p.path && p.search !== undefined
|
|
836
|
+
? ['replace_in_file', p.path, p.search, p.replace != null ? p.replace : '', p.flags || '']
|
|
837
|
+
: null;
|
|
838
|
+
case 'edit_file':
|
|
839
|
+
return p.path && p.line !== undefined
|
|
840
|
+
? ['edit_file', p.path, parseInt(p.line, 10), p.content != null ? p.content : '']
|
|
841
|
+
: null;
|
|
842
|
+
case 'get_env':
|
|
843
|
+
return p.name ? ['get_env', p.name] : null;
|
|
844
|
+
case 'set_env':
|
|
845
|
+
return p.name ? ['set_env', p.name, p.value != null ? p.value : ''] : null;
|
|
846
|
+
case 'download':
|
|
847
|
+
return p.url ? ['download', p.url] : null;
|
|
848
|
+
case 'upload':
|
|
849
|
+
return p.path ? ['upload', p.path, p.content != null ? p.content : ''] : null;
|
|
850
|
+
case 'http_get':
|
|
851
|
+
return p.url ? ['http_get', p.url, p.raw || ''] : null;
|
|
852
|
+
case 'http_get_next':
|
|
853
|
+
return p.key ? ['http_get_next', p.key] : null;
|
|
854
|
+
case 'ask_user':
|
|
855
|
+
return p.question ? ['ask_user', p.question] : null;
|
|
856
|
+
case 'store_memory':
|
|
857
|
+
return p.key ? ['store_memory', p.key, p.value != null ? p.value : ''] : null;
|
|
858
|
+
case 'recall_memory':
|
|
859
|
+
return p.key ? ['recall_memory', p.key] : null;
|
|
860
|
+
case 'list_memories':
|
|
861
|
+
return ['list_memories'];
|
|
862
|
+
case 'system_info':
|
|
863
|
+
return ['system_info'];
|
|
864
|
+
case 'exec':
|
|
865
|
+
case 'shell':
|
|
866
|
+
case 'run':
|
|
867
|
+
case 'run_command':
|
|
868
|
+
case 'bash':
|
|
869
|
+
return p.command ? ['shell', p.command] : null;
|
|
870
|
+
default:
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
844
875
|
function extractToolCalls(text) {
|
|
845
876
|
const calls = [];
|
|
846
877
|
|
|
878
|
+
// MiniMax-M2 / Qwen3 native tool-call wrappers. Emitted inline when the
|
|
879
|
+
// inference server's tool parser is disabled, or round-tripped back into
|
|
880
|
+
// text by chatStream when delta.tool_calls is streamed.
|
|
881
|
+
//
|
|
882
|
+
// <minimax:tool_call> <qwen:tool_call>
|
|
883
|
+
// <invoke name="write_file"> <invoke name="write_file">
|
|
884
|
+
// <parameter name="path">… <parameter name="path">…
|
|
885
|
+
// </invoke> </invoke>
|
|
886
|
+
// </minimax:tool_call> </qwen:tool_call>
|
|
887
|
+
const INVOKE_RE = /<invoke\s+name="([^"]+)"\s*>([\s\S]*?)<\/invoke>/g;
|
|
888
|
+
const PARAM_RE = /<parameter\s+name="([^"]+)"\s*>([\s\S]*?)<\/parameter>/g;
|
|
889
|
+
const WRAPPER_BLOCK_RE = /<(?:minimax:tool_call|qwen:tool_call)>([\s\S]*?)<\/(?:minimax:tool_call|qwen:tool_call)>/g;
|
|
890
|
+
for (const blockMatch of text.matchAll(WRAPPER_BLOCK_RE)) {
|
|
891
|
+
const block = blockMatch[1];
|
|
892
|
+
for (const invokeMatch of block.matchAll(INVOKE_RE)) {
|
|
893
|
+
const params = {};
|
|
894
|
+
for (const pMatch of invokeMatch[2].matchAll(PARAM_RE)) {
|
|
895
|
+
let val = pMatch[2];
|
|
896
|
+
if (val.startsWith('\n')) val = val.slice(1);
|
|
897
|
+
if (val.endsWith('\n')) val = val.slice(0, -1);
|
|
898
|
+
params[pMatch[1]] = val;
|
|
899
|
+
}
|
|
900
|
+
const call = mapInvokeToCall(invokeMatch[1], params);
|
|
901
|
+
if (call) calls.push(call);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Qwen3 / Hermes-style JSON tool-call format. Qwen3-30B-A3B, Qwen3.5-4B,
|
|
906
|
+
// and most Qwen-derived finetunes (Qwen3.6-Opus4.7 etc.) emit:
|
|
907
|
+
//
|
|
908
|
+
// <tool_call>
|
|
909
|
+
// {"name": "write_file", "arguments": {"path": "a.css", "content": "…"}}
|
|
910
|
+
// </tool_call>
|
|
911
|
+
//
|
|
912
|
+
// Some variants use <function_call> or the key `parameters` instead of
|
|
913
|
+
// `arguments`. The block may also wrap <invoke> when the finetune follows
|
|
914
|
+
// the MiniMax instruction template — handle both.
|
|
915
|
+
const JSON_BLOCK_RE = /<(tool_call|function_call)>([\s\S]*?)<\/\1>/g;
|
|
916
|
+
for (const blockMatch of text.matchAll(JSON_BLOCK_RE)) {
|
|
917
|
+
const inner = blockMatch[2].trim();
|
|
918
|
+
if (!inner) continue;
|
|
919
|
+
|
|
920
|
+
if (/<invoke\s/i.test(inner)) {
|
|
921
|
+
for (const invokeMatch of inner.matchAll(INVOKE_RE)) {
|
|
922
|
+
const params = {};
|
|
923
|
+
for (const pMatch of invokeMatch[2].matchAll(PARAM_RE)) {
|
|
924
|
+
let val = pMatch[2];
|
|
925
|
+
if (val.startsWith('\n')) val = val.slice(1);
|
|
926
|
+
if (val.endsWith('\n')) val = val.slice(0, -1);
|
|
927
|
+
params[pMatch[1]] = val;
|
|
928
|
+
}
|
|
929
|
+
const call = mapInvokeToCall(invokeMatch[1], params);
|
|
930
|
+
if (call) calls.push(call);
|
|
931
|
+
}
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
let parsed = null;
|
|
936
|
+
try { parsed = JSON.parse(inner); } catch {}
|
|
937
|
+
if (!parsed) {
|
|
938
|
+
const firstBrace = inner.indexOf('{');
|
|
939
|
+
const lastBrace = inner.lastIndexOf('}');
|
|
940
|
+
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
|
941
|
+
try { parsed = JSON.parse(inner.slice(firstBrace, lastBrace + 1)); } catch {}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
if (!parsed) continue;
|
|
945
|
+
|
|
946
|
+
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
|
947
|
+
for (const entry of entries) {
|
|
948
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
949
|
+
const name = entry.name || entry.tool || entry.function || entry.tool_name;
|
|
950
|
+
const params = entry.arguments || entry.parameters || entry.params || entry.args || {};
|
|
951
|
+
const resolved = typeof params === 'string'
|
|
952
|
+
? (() => { try { return JSON.parse(params); } catch { return {}; } })()
|
|
953
|
+
: params;
|
|
954
|
+
const call = mapInvokeToCall(name, resolved);
|
|
955
|
+
if (call) calls.push(call);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
847
959
|
for (const match of text.matchAll(/```(?:shell|bash|sh)\n([\s\S]*?)```/g)) {
|
|
848
960
|
for (const line of match[1].trim().split('\n')) {
|
|
849
961
|
const cmd = line.trim();
|
|
@@ -977,5 +1089,6 @@ module.exports = {
|
|
|
977
1089
|
createToolExecutor,
|
|
978
1090
|
extractToolCalls,
|
|
979
1091
|
getSkippedOps,
|
|
1092
|
+
mapInvokeToCall,
|
|
980
1093
|
setUIActive,
|
|
981
1094
|
};
|
package/lib/ui/ansi.js
CHANGED
|
@@ -50,9 +50,10 @@ const KEYWORDS = new Set([
|
|
|
50
50
|
]);
|
|
51
51
|
|
|
52
52
|
const SPINNER_DEFS = {
|
|
53
|
-
thinking:
|
|
54
|
-
streaming:
|
|
55
|
-
tool:
|
|
53
|
+
thinking: { frames: ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'], color: '\x1b[36m' },
|
|
54
|
+
streaming: { frames: ['▁','▂','▃','▄','▅','▆','▇','█','▇','▆','▅','▄','▃','▂'], color: '\x1b[32m' },
|
|
55
|
+
tool: { frames: ['⣾','⣽','⣻','⢿','⡿','⣟','⣯','⣷'], color: '\x1b[33m' },
|
|
56
|
+
waiting_download: { frames: ['⬇ ','⬇⠂','⬇⠆','⬇⠇','⬇⠧','⬇⠷','⬇⠿','⬇⠾','⬇⠼','⬇⠸','⬇⠰','⬇⠠'], color: '\x1b[38;5;75m' },
|
|
56
57
|
};
|
|
57
58
|
|
|
58
59
|
module.exports = {
|
package/lib/ui/chat-history.js
CHANGED
|
@@ -138,7 +138,7 @@ class ChatHistory {
|
|
|
138
138
|
} else if (msg.role === 'permission') {
|
|
139
139
|
process.stdout.write(`\n${FG_YELLOW} ⚠ Permission required: ${content}${RST}\n`);
|
|
140
140
|
} else {
|
|
141
|
-
const isErr = msg.isError
|
|
141
|
+
const isErr = !!msg.isError && !msg.isWarning;
|
|
142
142
|
const color = isErr ? FG_RED : FG_YELLOW;
|
|
143
143
|
const prefix = isErr ? '✕' : '⚠';
|
|
144
144
|
const lines = content.split('\n');
|
package/lib/ui/input-field.js
CHANGED
|
@@ -7,7 +7,7 @@ const { RST, DIM, FG_CYAN, FG_GREEN, FG_YELLOW } = require('./ansi');
|
|
|
7
7
|
|
|
8
8
|
const SLASH_CMDS = [
|
|
9
9
|
'/help','/file','/new','/model','/models','/shell','/compact',
|
|
10
|
-
'/clear','/approve','/config','/history','/login','/whoami','/logout','/chats',
|
|
10
|
+
'/clear','/approve','/debug','/config','/history','/login','/whoami','/logout','/chats',
|
|
11
11
|
];
|
|
12
12
|
|
|
13
13
|
// ─── Key sequence parser ──────────────────────────────────────────────────────
|
package/lib/ui/status-bar.js
CHANGED
|
@@ -42,7 +42,7 @@ class FullStatusBar {
|
|
|
42
42
|
this._streamStart = null; this._streamTokens = 0; this._speed = 0;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
const animStates = ['thinking', 'streaming', 'tool'];
|
|
45
|
+
const animStates = ['thinking', 'streaming', 'tool', 'waiting_download'];
|
|
46
46
|
if (animStates.includes(state) && !this._animTimer) {
|
|
47
47
|
this._animTimer = setInterval(() => { this._animIdx++; this._renderBar(); }, 100);
|
|
48
48
|
} else if (!animStates.includes(state) && this._animTimer) {
|
|
@@ -102,7 +102,8 @@ class FullStatusBar {
|
|
|
102
102
|
const timePart = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
|
|
103
103
|
const rightParts = [timePart];
|
|
104
104
|
if (this._model) rightParts.push(this._model);
|
|
105
|
-
|
|
105
|
+
const liveTokens = this._totalTokens + this._streamTokens;
|
|
106
|
+
rightParts.push(`${liveTokens.toLocaleString()} tok`);
|
|
106
107
|
if (state === 'streaming' && this._speed > 0) rightParts.push(`${this._speed} t/s`);
|
|
107
108
|
|
|
108
109
|
const rightVisible = rightParts.join(' · ');
|