@semalt-ai/code 1.8.1 → 1.8.3
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/.claude/settings.local.json +14 -1
- package/CLAUDE.md +2 -1
- package/index.js +15 -1
- package/lib/agent.js +582 -121
- package/lib/api.js +182 -25
- package/lib/commands.js +57 -80
- package/lib/config.js +32 -4
- package/lib/constants.js +51 -1
- package/lib/metrics.js +16 -3
- package/lib/permissions.js +66 -67
- package/lib/prompts.js +93 -86
- package/lib/tool_specs.js +499 -0
- package/lib/tools.js +405 -192
- package/lib/ui/ansi.js +13 -1
- package/lib/ui/chat-history.js +201 -61
- package/lib/ui/create-ui.js +116 -373
- package/lib/ui/diff.js +87 -75
- package/lib/ui/input-field.js +75 -57
- package/lib/ui/status-bar.js +53 -23
- package/lib/ui/terminal.js +58 -0
- package/lib/ui/theme.js +78 -0
- package/lib/ui/utils.js +63 -1
- package/lib/ui/writer.js +255 -0
- package/lib/ui.js +5 -0
- package/package.json +1 -1
package/lib/api.js
CHANGED
|
@@ -4,6 +4,9 @@ const http = require('http');
|
|
|
4
4
|
const https = require('https');
|
|
5
5
|
const { URL } = require('url');
|
|
6
6
|
|
|
7
|
+
const { buildToolsSchema, isUIActive } = require('./tools');
|
|
8
|
+
const { TOOL_SPECS } = require('./tool_specs');
|
|
9
|
+
|
|
7
10
|
function createApiClient({ getConfig, saveConfig, ui }) {
|
|
8
11
|
const {
|
|
9
12
|
BOLD,
|
|
@@ -55,9 +58,10 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
55
58
|
return Math.floor((text || '').length / 4);
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
// Discovered context limit for this process lifetime.
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
+
// Discovered context limit per model for this process lifetime.
|
|
62
|
+
// Keyed by resolved model name; set on the first context-overflow 400
|
|
63
|
+
// for that model and used to proactively trim subsequent calls.
|
|
64
|
+
const _sessionInputLimits = new Map();
|
|
61
65
|
|
|
62
66
|
function httpRequest(urlStr, options, body) {
|
|
63
67
|
return new Promise((resolve, reject) => {
|
|
@@ -71,7 +75,10 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
71
75
|
headers: options.headers || {},
|
|
72
76
|
};
|
|
73
77
|
|
|
74
|
-
const req = lib.request(reqOpts, (res) =>
|
|
78
|
+
const req = lib.request(reqOpts, (res) => {
|
|
79
|
+
if (options.onResponse) options.onResponse(res);
|
|
80
|
+
resolve(res);
|
|
81
|
+
});
|
|
75
82
|
req.on('error', reject);
|
|
76
83
|
|
|
77
84
|
if (options.timeout) {
|
|
@@ -80,6 +87,18 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
80
87
|
});
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
if (options.signal) {
|
|
91
|
+
if (options.signal.aborted) {
|
|
92
|
+
req.destroy(new Error('Aborted'));
|
|
93
|
+
return reject(new Error('Aborted'));
|
|
94
|
+
}
|
|
95
|
+
options.signal.addEventListener('abort', () => {
|
|
96
|
+
req.destroy(new Error('Aborted'));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (options.onRequest) options.onRequest(req);
|
|
101
|
+
|
|
83
102
|
if (body) req.write(body);
|
|
84
103
|
req.end();
|
|
85
104
|
});
|
|
@@ -224,17 +243,32 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
224
243
|
});
|
|
225
244
|
}
|
|
226
245
|
|
|
227
|
-
async function chatStream(messages, { model, temperature, maxTokens, linePrefix = '', showThink = false, onToken = null, silent = false } = {}) {
|
|
246
|
+
async function chatStream(messages, { model, temperature, maxTokens, linePrefix = '', showThink = false, onToken = null, silent = false, signal = null, onTrim = null, nativeTools = true } = {}) {
|
|
247
|
+
// nativeTools is plumbed through for downstream use (tools param + tool_calls parsing); no behavior change yet.
|
|
228
248
|
const config = getConfig();
|
|
249
|
+
const resolvedModel = model || config.default_model;
|
|
250
|
+
|
|
251
|
+
if (signal && signal.aborted) throw new Error('Aborted');
|
|
252
|
+
|
|
253
|
+
let trimNotified = false;
|
|
254
|
+
function notifyTrim(info) {
|
|
255
|
+
if (trimNotified) return;
|
|
256
|
+
trimNotified = true;
|
|
257
|
+
if (typeof onTrim === 'function') {
|
|
258
|
+
try { onTrim(info); } catch {}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
229
261
|
|
|
230
262
|
// Fit messages into tokenBudget tokens.
|
|
231
|
-
// Uses chars/
|
|
263
|
+
// Uses chars/4 — aligned with estimateTokens; a deliberate under-estimate
|
|
264
|
+
// for token-dense content (code, JSON, HTML) but consistent across the
|
|
265
|
+
// codebase.
|
|
232
266
|
//
|
|
233
267
|
// Always keeps: system prompt + first non-system message (original task).
|
|
234
268
|
// Drops intermediate messages oldest-first, then truncates the last tail
|
|
235
269
|
// message (typically a large tool result) if still over budget.
|
|
236
270
|
function trimToTokenBudget(msgs, tokenBudget) {
|
|
237
|
-
const CHARS_PER_TOKEN =
|
|
271
|
+
const CHARS_PER_TOKEN = 4;
|
|
238
272
|
const system = msgs.filter((m) => m.role === 'system');
|
|
239
273
|
const nonSystem = msgs.filter((m) => m.role !== 'system');
|
|
240
274
|
if (nonSystem.length === 0) return [...system];
|
|
@@ -271,28 +305,62 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
271
305
|
return tail.length > 0 ? [...system, pinned, ...tail] : [...system, pinned];
|
|
272
306
|
}
|
|
273
307
|
|
|
274
|
-
// Proactive trim:
|
|
308
|
+
// Proactive trim: prefer a limit learned from a prior 400 overflow; otherwise
|
|
309
|
+
// fall back to config.context_length (with a ~10% safety margin) as a hint.
|
|
310
|
+
// The fallback is not written to _sessionInputLimits so a real overflow
|
|
311
|
+
// always overrides the config hint.
|
|
275
312
|
let trimmedMessages = messages;
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
313
|
+
let sessionLimit = _sessionInputLimits.get(resolvedModel);
|
|
314
|
+
if (sessionLimit == null &&
|
|
315
|
+
Number.isInteger(config.context_length) && config.context_length > 0) {
|
|
316
|
+
sessionLimit = Math.floor(config.context_length * 0.9);
|
|
317
|
+
}
|
|
318
|
+
if (sessionLimit != null) {
|
|
319
|
+
if (Math.floor(JSON.stringify(messages).length / 4) > sessionLimit) {
|
|
320
|
+
trimmedMessages = trimToTokenBudget(messages, sessionLimit);
|
|
321
|
+
const dropped = messages.length - trimmedMessages.length;
|
|
322
|
+
const keptTokens = Math.floor(JSON.stringify(trimmedMessages).length / 4);
|
|
323
|
+
notifyTrim({ reason: 'proactive', dropped, keptTokens, limit: sessionLimit });
|
|
279
324
|
}
|
|
280
325
|
}
|
|
281
326
|
|
|
327
|
+
// MiniMax supports `reasoning_split: true` which moves thinking content
|
|
328
|
+
// into a separate reasoning_details field on the response (and
|
|
329
|
+
// delta.reasoning_content during streaming) instead of embedding
|
|
330
|
+
// <think>...</think> inside message.content. Only send this flag to
|
|
331
|
+
// MiniMax — other providers may reject unknown fields.
|
|
332
|
+
const isMiniMax =
|
|
333
|
+
/api\.minimax\.io/i.test(config.api_base || '') ||
|
|
334
|
+
/^minimax[-\/]/i.test(resolvedModel || '');
|
|
335
|
+
|
|
282
336
|
const payload = {
|
|
283
|
-
model:
|
|
337
|
+
model: resolvedModel,
|
|
284
338
|
messages: trimmedMessages,
|
|
285
339
|
temperature: temperature !== undefined ? temperature : config.temperature,
|
|
286
340
|
stream: true,
|
|
287
341
|
stream_options: { include_usage: true },
|
|
288
342
|
};
|
|
289
343
|
|
|
344
|
+
if (isMiniMax) payload.reasoning_split = true;
|
|
290
345
|
if (maxTokens !== undefined) payload.max_tokens = maxTokens;
|
|
291
346
|
|
|
347
|
+
// Native function-calling: advertise the tool schema and let the model
|
|
348
|
+
// emit structured tool_calls. Wrappers are XML envelopes, not callable
|
|
349
|
+
// tools — filter them out per the TOOL_SPECS contract.
|
|
350
|
+
if (nativeTools) {
|
|
351
|
+
const callable = Object.fromEntries(
|
|
352
|
+
Object.entries(TOOL_SPECS).filter(([, spec]) => !spec.wrapper)
|
|
353
|
+
);
|
|
354
|
+
payload.tools = buildToolsSchema(callable);
|
|
355
|
+
payload.tool_choice = 'auto';
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const endpoint = apiUrl('/v1/chat/completions');
|
|
359
|
+
|
|
292
360
|
async function doRequest(msgs) {
|
|
293
361
|
const reqPayload = { ...payload, messages: msgs };
|
|
294
362
|
const reqBody = JSON.stringify(reqPayload);
|
|
295
|
-
const res = await httpRequest(
|
|
363
|
+
const res = await httpRequest(endpoint, {
|
|
296
364
|
method: 'POST',
|
|
297
365
|
timeout: config.request_timeout_ms,
|
|
298
366
|
headers: {
|
|
@@ -300,6 +368,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
300
368
|
'Authorization': `Bearer ${config.api_key}`,
|
|
301
369
|
'Content-Length': Buffer.byteLength(reqBody),
|
|
302
370
|
},
|
|
371
|
+
signal,
|
|
303
372
|
}, reqBody);
|
|
304
373
|
|
|
305
374
|
if (res.statusCode !== 200) {
|
|
@@ -322,6 +391,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
322
391
|
err.detail = detail;
|
|
323
392
|
err.rawBody = errBody;
|
|
324
393
|
err.responseHeaders = res.headers;
|
|
394
|
+
err.endpoint = endpoint;
|
|
325
395
|
throw err;
|
|
326
396
|
}
|
|
327
397
|
return res;
|
|
@@ -330,7 +400,8 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
330
400
|
// On payload-too-large errors, trim and retry.
|
|
331
401
|
// 400 with context-overflow detail → parse exact context window, budget = window/2
|
|
332
402
|
// 413 Request Entity Too Large (Nginx/proxy) → no size hint, halve current estimate
|
|
333
|
-
// In both cases
|
|
403
|
+
// In both cases the per-model session input limit is set so all subsequent
|
|
404
|
+
// calls for this model are proactively trimmed.
|
|
334
405
|
let res;
|
|
335
406
|
try {
|
|
336
407
|
res = await doRequest(trimmedMessages);
|
|
@@ -345,15 +416,41 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
345
416
|
const limitMatch = err.detail.match(/context length is only (\d+)/i) ||
|
|
346
417
|
err.detail.match(/maximum.*?(\d+)\s*token/i);
|
|
347
418
|
const contextWindow = limitMatch ? parseInt(limitMatch[1], 10) : null;
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
419
|
+
if (contextWindow) {
|
|
420
|
+
budget = Math.floor(contextWindow * 0.9);
|
|
421
|
+
// Persist the learned context window so future turns/runs trim
|
|
422
|
+
// proactively without needing a second 400. Must not block the
|
|
423
|
+
// retry if the write fails.
|
|
424
|
+
try {
|
|
425
|
+
const currentConfig = getConfig();
|
|
426
|
+
const next = { ...currentConfig, context_length: contextWindow };
|
|
427
|
+
if (Array.isArray(currentConfig.models)) {
|
|
428
|
+
next.models = currentConfig.models.map((m) =>
|
|
429
|
+
m && m.api_base === currentConfig.api_base && m.model === resolvedModel
|
|
430
|
+
? { ...m, context_length: contextWindow }
|
|
431
|
+
: m
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
saveConfig(next);
|
|
435
|
+
} catch {}
|
|
436
|
+
} else {
|
|
437
|
+
budget = Math.floor(Math.floor(JSON.stringify(trimmedMessages).length / 4) * 0.5);
|
|
438
|
+
}
|
|
351
439
|
} else {
|
|
352
440
|
// 413: no token info available — halve the estimated size of the current payload.
|
|
353
|
-
budget = Math.floor(Math.floor(JSON.stringify(trimmedMessages).length /
|
|
441
|
+
budget = Math.floor(Math.floor(JSON.stringify(trimmedMessages).length / 4) * 0.5);
|
|
354
442
|
}
|
|
355
|
-
|
|
443
|
+
_sessionInputLimits.set(resolvedModel, budget);
|
|
444
|
+
const before = trimmedMessages;
|
|
356
445
|
trimmedMessages = trimToTokenBudget(trimmedMessages, budget);
|
|
446
|
+
const dropped = before.length - trimmedMessages.length;
|
|
447
|
+
const keptTokens = Math.floor(JSON.stringify(trimmedMessages).length / 4);
|
|
448
|
+
notifyTrim({
|
|
449
|
+
reason: is413 ? 'overflow-413' : 'overflow-400',
|
|
450
|
+
dropped,
|
|
451
|
+
keptTokens,
|
|
452
|
+
limit: budget,
|
|
453
|
+
});
|
|
357
454
|
res = await doRequest(trimmedMessages);
|
|
358
455
|
} else {
|
|
359
456
|
throw err;
|
|
@@ -364,9 +461,11 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
364
461
|
const startTime = Date.now();
|
|
365
462
|
let fullText = '';
|
|
366
463
|
let reasoningText = '';
|
|
464
|
+
let reasoningDetailsText = '';
|
|
367
465
|
let tokenCount = 0;
|
|
368
466
|
let inReasoning = false;
|
|
369
467
|
let streamUsage = null;
|
|
468
|
+
let streamFinishReason = null;
|
|
370
469
|
let resolved = false;
|
|
371
470
|
// delta.tool_calls accumulator (OpenAI function-calling streaming format).
|
|
372
471
|
// Keyed by `index` per the OpenAI spec.
|
|
@@ -407,7 +506,16 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
407
506
|
function finalize() {
|
|
408
507
|
if (resolved) return;
|
|
409
508
|
resolved = true;
|
|
410
|
-
|
|
509
|
+
// Native mode: surface tool calls as structured data; skip XML serialization.
|
|
510
|
+
// Legacy mode: serialize into <minimax:tool_call> XML so extractToolCalls picks them up.
|
|
511
|
+
const validToolCalls = toolCallAcc
|
|
512
|
+
.filter((t) => t && t.name)
|
|
513
|
+
.map((t, i) => ({
|
|
514
|
+
id: t.id || `call_${i}`,
|
|
515
|
+
type: 'function',
|
|
516
|
+
function: { name: t.name, arguments: t.arguments || '{}' },
|
|
517
|
+
}));
|
|
518
|
+
if (!nativeTools) appendToolCallsXml();
|
|
411
519
|
if (!silent) renderer.flush();
|
|
412
520
|
const elapsed = (Date.now() - startTime) / 1000;
|
|
413
521
|
const tps = tokenCount / (elapsed || 1);
|
|
@@ -426,7 +534,38 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
426
534
|
completion_tokens: estimateTokens(fullText) + estimateTokens(reasoningText),
|
|
427
535
|
};
|
|
428
536
|
}
|
|
429
|
-
|
|
537
|
+
const elapsedMs = Date.now() - startTime;
|
|
538
|
+
resolve({
|
|
539
|
+
content: fullText,
|
|
540
|
+
toolCalls: nativeTools ? validToolCalls : [],
|
|
541
|
+
usage,
|
|
542
|
+
usage_from_provider: !!streamUsage,
|
|
543
|
+
tool_calls_count: validToolCalls.length,
|
|
544
|
+
finish_reason: streamFinishReason,
|
|
545
|
+
finishReason: streamFinishReason,
|
|
546
|
+
elapsed_ms: elapsedMs,
|
|
547
|
+
reasoning: reasoningText,
|
|
548
|
+
reasoning_details: reasoningDetailsText,
|
|
549
|
+
endpoint,
|
|
550
|
+
request: {
|
|
551
|
+
model: payload.model,
|
|
552
|
+
temperature: payload.temperature,
|
|
553
|
+
max_tokens: payload.max_tokens,
|
|
554
|
+
stream: payload.stream,
|
|
555
|
+
stop: payload.stop,
|
|
556
|
+
native_tools: nativeTools,
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (signal) {
|
|
562
|
+
signal.addEventListener('abort', () => {
|
|
563
|
+
try { res?.destroy(); } catch {}
|
|
564
|
+
if (!resolved) {
|
|
565
|
+
resolved = true;
|
|
566
|
+
reject(new Error('Aborted'));
|
|
567
|
+
}
|
|
568
|
+
});
|
|
430
569
|
}
|
|
431
570
|
|
|
432
571
|
res.setEncoding('utf8');
|
|
@@ -450,20 +589,37 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
450
589
|
if (obj.usage && (obj.usage.prompt_tokens !== undefined || obj.usage.completion_tokens !== undefined)) {
|
|
451
590
|
streamUsage = obj.usage;
|
|
452
591
|
}
|
|
453
|
-
const
|
|
592
|
+
const choice = (obj.choices || [])[0] || {};
|
|
593
|
+
if (choice.finish_reason) streamFinishReason = choice.finish_reason;
|
|
594
|
+
const delta = choice.delta || {};
|
|
595
|
+
|
|
596
|
+
// MiniMax `reasoning_split: true` surfaces a structured
|
|
597
|
+
// reasoning_details field. It may arrive as a streaming delta
|
|
598
|
+
// (delta.reasoning_details) or as an authoritative final value
|
|
599
|
+
// on choice.message. Preserve it for debug output; not routed to
|
|
600
|
+
// the UI and not fed back into messages[] on subsequent turns.
|
|
601
|
+
const rdDelta = delta.reasoning_details;
|
|
602
|
+
if (rdDelta !== undefined && rdDelta !== null) {
|
|
603
|
+
reasoningDetailsText += typeof rdDelta === 'string' ? rdDelta : JSON.stringify(rdDelta);
|
|
604
|
+
}
|
|
605
|
+
const rdFinal = choice.message && choice.message.reasoning_details;
|
|
606
|
+
if (rdFinal !== undefined && rdFinal !== null) {
|
|
607
|
+
reasoningDetailsText = typeof rdFinal === 'string' ? rdFinal : JSON.stringify(rdFinal);
|
|
608
|
+
}
|
|
454
609
|
|
|
455
610
|
const reasoning = delta.reasoning_content || '';
|
|
456
611
|
if (reasoning) {
|
|
612
|
+
const uiActive = isUIActive();
|
|
457
613
|
if (!inReasoning) {
|
|
458
614
|
inReasoning = true;
|
|
459
|
-
if (showThink) {
|
|
615
|
+
if (showThink && !uiActive) {
|
|
460
616
|
process.stdout.write(`\n ${FG_DARK}${DIM}⟨thinking⟩${RST}`);
|
|
461
617
|
renderer._linesWritten++;
|
|
462
618
|
}
|
|
463
619
|
}
|
|
464
620
|
reasoningText += reasoning;
|
|
465
621
|
tokenCount++;
|
|
466
|
-
if (showThink) {
|
|
622
|
+
if (showThink && !uiActive) {
|
|
467
623
|
process.stdout.write(`${FG_DARK}${DIM}${reasoning}${RST}`);
|
|
468
624
|
}
|
|
469
625
|
}
|
|
@@ -473,7 +629,8 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
473
629
|
for (const tc of toolCallsDelta) {
|
|
474
630
|
const idx = typeof tc.index === 'number' ? tc.index : toolCallAcc.length;
|
|
475
631
|
const isNew = !toolCallAcc[idx];
|
|
476
|
-
if (isNew) toolCallAcc[idx] = { name: '', arguments: '' };
|
|
632
|
+
if (isNew) toolCallAcc[idx] = { id: '', name: '', arguments: '' };
|
|
633
|
+
if (tc.id) toolCallAcc[idx].id = tc.id;
|
|
477
634
|
if (tc.function?.name) toolCallAcc[idx].name += tc.function.name;
|
|
478
635
|
if (tc.function?.arguments) toolCallAcc[idx].arguments += tc.function.arguments;
|
|
479
636
|
// When the model streams purely via delta.tool_calls (no
|
package/lib/commands.js
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
|
|
5
5
|
const { CONFIG_PATH, DEFAULT_API_TIMEOUT_MS, TAG_REGISTRY } = require('./constants');
|
|
6
6
|
const { configShow } = require('./config');
|
|
7
|
-
const {
|
|
7
|
+
const { getSystemPrompt } = require('./prompts');
|
|
8
8
|
const { SessionStorage } = require('./storage');
|
|
9
9
|
const { getSkippedOps, setUIActive } = require('./tools');
|
|
10
10
|
const { AUDIT_LOG } = require('./audit');
|
|
@@ -96,6 +96,7 @@ function createCommands({
|
|
|
96
96
|
(m) => m.model === model || (m.api_base === config.api_base && m.model === config.default_model)
|
|
97
97
|
);
|
|
98
98
|
if (match && Number.isInteger(match.context_length) && match.context_length > 0) return match.context_length;
|
|
99
|
+
if (Number.isInteger(config.context_length) && config.context_length > 0) return config.context_length;
|
|
99
100
|
return null;
|
|
100
101
|
}
|
|
101
102
|
|
|
@@ -128,11 +129,21 @@ function createCommands({
|
|
|
128
129
|
|
|
129
130
|
setUIActive(true);
|
|
130
131
|
|
|
132
|
+
const writer = require('./ui/writer');
|
|
131
133
|
permissionManager.setUICallbacks({
|
|
132
134
|
onAddMessage: (msg) => chatHistory.addMessage(msg),
|
|
133
135
|
onRerenderMessage: (id) => chatHistory.rerenderById(id),
|
|
134
136
|
onCollapseMessage: (id) => chatHistory.collapseById(id),
|
|
135
137
|
onRemoveMessage: (id) => chatHistory.removeById(id),
|
|
138
|
+
// Modal-region API: setModal replaces the modal live band above the
|
|
139
|
+
// status region; clearModal drops it. Arrow-key redraws go through
|
|
140
|
+
// setModal only — no scrollback churn. When the picker resolves we
|
|
141
|
+
// clear the modal and push a single summary line to scrollback.
|
|
142
|
+
onShowModal: (lines) => writer.setModal(lines),
|
|
143
|
+
onCloseModal: (summary) => {
|
|
144
|
+
writer.clearModal();
|
|
145
|
+
if (summary) chatHistory.addMessage({ role: 'system', content: summary });
|
|
146
|
+
},
|
|
136
147
|
onCaptureNavigation: (handler) => {
|
|
137
148
|
inputField.captureNavigation(handler);
|
|
138
149
|
return () => inputField.releaseNavigation();
|
|
@@ -187,14 +198,13 @@ function createCommands({
|
|
|
187
198
|
}
|
|
188
199
|
refreshInputSearchItems();
|
|
189
200
|
|
|
190
|
-
// Banner —
|
|
191
|
-
//
|
|
192
|
-
//
|
|
201
|
+
// Banner — emit once as scrollback above the live region. In the
|
|
202
|
+
// bottom-anchored live-region TUI, scrollback flows into terminal
|
|
203
|
+
// scrollback naturally, so no absolute positioning or scroll-region
|
|
204
|
+
// trickery is needed here.
|
|
193
205
|
if (layout) {
|
|
194
|
-
const BANNER_LINES = 8; // blank + top-border + empty + title + desc + empty + bottom-border + blank
|
|
195
206
|
const w = Math.min(getCols() - 4, 60);
|
|
196
|
-
|
|
197
|
-
process.stdout.write([
|
|
207
|
+
const banner = [
|
|
198
208
|
``,
|
|
199
209
|
` ${FG_DARK}╭${'─'.repeat(w + 1)}╮${RST}`,
|
|
200
210
|
boxLine('', w),
|
|
@@ -203,19 +213,8 @@ function createCommands({
|
|
|
203
213
|
boxLine('', w),
|
|
204
214
|
` ${FG_DARK}╰${'─'.repeat(w + 1)}╯${RST}`,
|
|
205
215
|
``,
|
|
206
|
-
].join('\n')
|
|
207
|
-
|
|
208
|
-
// Keep historyStart = 1 so the banner is inside the scroll region.
|
|
209
|
-
// Growing mode uses _contentLines to position the first message below the
|
|
210
|
-
// banner (at row BANNER_LINES + 1). When the terminal fills up the banner
|
|
211
|
-
// scrolls naturally into the terminal scrollback — nothing disappears behind
|
|
212
|
-
// a fixed header.
|
|
213
|
-
layout._contentLines = BANNER_LINES;
|
|
214
|
-
layout.rows = BANNER_LINES + 1 + layout.inputHeight + 3;
|
|
215
|
-
|
|
216
|
-
// Erase the stale full-screen panels createUI drew before we compacted.
|
|
217
|
-
process.stdout.write(`\x1b[${layout.rows + 1};1H\x1b[J`);
|
|
218
|
-
|
|
216
|
+
].join('\n');
|
|
217
|
+
writer.scrollback(banner);
|
|
219
218
|
redrawFixed();
|
|
220
219
|
}
|
|
221
220
|
|
|
@@ -256,24 +255,29 @@ function createCommands({
|
|
|
256
255
|
try { await dashboardSaveMessages(currentChatId, newMessages); savedUpTo = messages.length; } catch {}
|
|
257
256
|
}
|
|
258
257
|
|
|
259
|
-
const HISTORY_DISPLAY_TURNS = 3; // user+assistant pairs to show on load
|
|
260
|
-
|
|
261
258
|
function displayLoadedMessages(loadedMessages) {
|
|
262
259
|
chatHistory.clearMessages();
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
260
|
+
for (const m of loadedMessages) {
|
|
261
|
+
if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') continue;
|
|
262
|
+
const raw = typeof m.content === 'string' ? m.content : '';
|
|
263
|
+
const ts = m.created_at ? new Date(m.created_at) : (m.ts ? new Date(m.ts) : new Date());
|
|
264
|
+
|
|
265
|
+
if (m.role === 'tool') {
|
|
266
|
+
chatHistory.addMessage({ role: 'tool', tag: 'tool', content: 'tool result', output: raw, ts });
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (m.role === 'user' && raw.startsWith('Tool execution results:')) {
|
|
271
|
+
const body = raw
|
|
272
|
+
.replace(/^Tool execution results[^\n]*\n+/, '')
|
|
273
|
+
.replace(/\n+Continue with the task\.[\s\S]*$/, '')
|
|
274
|
+
.trim();
|
|
275
|
+
chatHistory.addMessage({ role: 'tool', tag: 'tool', content: 'tool result', output: body || raw, ts });
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!raw.trim()) continue;
|
|
280
|
+
chatHistory.addMessage({ role: m.role, content: raw, ts });
|
|
277
281
|
}
|
|
278
282
|
}
|
|
279
283
|
|
|
@@ -301,28 +305,6 @@ function createCommands({
|
|
|
301
305
|
const PAGE_SIZE = 5;
|
|
302
306
|
let listMsg = null;
|
|
303
307
|
|
|
304
|
-
// In-place progress indicator for chunked HTTP fetches (http_get + http_get_next)
|
|
305
|
-
let httpFetchMsg = null;
|
|
306
|
-
|
|
307
|
-
function showHttpFetchProgress(url, part, total) {
|
|
308
|
-
const maxUrl = Math.max(20, getCols() - 35);
|
|
309
|
-
const shortUrl = url.length > maxUrl ? url.slice(0, maxUrl - 1) + '…' : url;
|
|
310
|
-
const content = `Fetching URL · ${shortUrl} · Part ${part}/${total}`;
|
|
311
|
-
if (!httpFetchMsg) {
|
|
312
|
-
httpFetchMsg = { role: 'tool', tag: 'http_get', content, id: `http-fetch-${Date.now()}` };
|
|
313
|
-
chatHistory.addMessage(httpFetchMsg);
|
|
314
|
-
} else {
|
|
315
|
-
httpFetchMsg.content = content;
|
|
316
|
-
chatHistory.rerenderById(httpFetchMsg.id);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function finalizeHttpFetch() {
|
|
321
|
-
if (!httpFetchMsg) return;
|
|
322
|
-
chatHistory.removeById(httpFetchMsg.id);
|
|
323
|
-
httpFetchMsg = null;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
308
|
function getNavSearchText(type, item) {
|
|
327
309
|
if (type === 'history') {
|
|
328
310
|
const date = new Date(item.created_at).toISOString().slice(0, 16);
|
|
@@ -740,7 +722,7 @@ function createCommands({
|
|
|
740
722
|
}
|
|
741
723
|
|
|
742
724
|
if (text === '/prompt') {
|
|
743
|
-
const activePrompt = resolvedSystemPrompt !== null ? resolvedSystemPrompt :
|
|
725
|
+
const activePrompt = resolvedSystemPrompt !== null ? resolvedSystemPrompt : getSystemPrompt();
|
|
744
726
|
const src = resolvedSystemPrompt !== null ? `file: ${opts.systemPromptFile}` : 'built-in';
|
|
745
727
|
const mode = getConfig().system_prompt_mode || 'system_role';
|
|
746
728
|
chatHistory.addMessage({
|
|
@@ -847,7 +829,7 @@ function createCommands({
|
|
|
847
829
|
if (entry?.type === 'tool') {
|
|
848
830
|
const actionLabel = entry.label || tag;
|
|
849
831
|
const detail = attrs.path || attrs.url || attrs.key || attrs.src || '';
|
|
850
|
-
const isDownload = tag === 'download' || tag === 'http_get'
|
|
832
|
+
const isDownload = tag === 'download' || tag === 'http_get';
|
|
851
833
|
const barState = isDownload ? 'waiting_download' : 'tool';
|
|
852
834
|
const label = isDownload
|
|
853
835
|
? `Waiting for download${detail ? ': ' + detail : ''}`
|
|
@@ -866,7 +848,7 @@ function createCommands({
|
|
|
866
848
|
onToolStart: (tag, input, attrs) => {
|
|
867
849
|
const actionLabel = TAG_REGISTRY[tag]?.label || tag;
|
|
868
850
|
const short = input.length > 40 ? input.slice(0, 40) + '…' : input;
|
|
869
|
-
const isDownload = tag === 'download' || tag === 'http_get'
|
|
851
|
+
const isDownload = tag === 'download' || tag === 'http_get';
|
|
870
852
|
if (isDownload) {
|
|
871
853
|
statusBar.update('waiting_download', `Waiting for download: ${short}`);
|
|
872
854
|
} else {
|
|
@@ -876,7 +858,6 @@ function createCommands({
|
|
|
876
858
|
onToolEnd: (tag, result, durationMs) => {
|
|
877
859
|
const isError = typeof result === 'string' && result.startsWith('Error');
|
|
878
860
|
if (isError) {
|
|
879
|
-
finalizeHttpFetch();
|
|
880
861
|
chatHistory.addMessage({
|
|
881
862
|
role: 'tool',
|
|
882
863
|
tag,
|
|
@@ -884,24 +865,6 @@ function createCommands({
|
|
|
884
865
|
output: typeof result === 'string' && result.trim() ? result : null,
|
|
885
866
|
});
|
|
886
867
|
statusBar.update('streaming', 'Streaming response');
|
|
887
|
-
} else if (tag === 'http_get') {
|
|
888
|
-
const chunkedMatch = typeof result === 'string' && result.match(/^HTTP GET (.+?) \(\d+\) \[Part 1\/(\d+)\]/);
|
|
889
|
-
if (chunkedMatch) {
|
|
890
|
-
showHttpFetchProgress(chunkedMatch[1], 1, parseInt(chunkedMatch[2], 10));
|
|
891
|
-
} else {
|
|
892
|
-
finalizeHttpFetch();
|
|
893
|
-
statusBar.update('tool', `✓ ${TAG_REGISTRY[tag]?.label || tag} [${durationMs}ms]`);
|
|
894
|
-
}
|
|
895
|
-
} else if (tag === 'http_get_next') {
|
|
896
|
-
const partMatch = typeof result === 'string' && result.match(/^HTTP content "(.+?)" \[Part (\d+)\/(\d+)\]/);
|
|
897
|
-
if (partMatch) {
|
|
898
|
-
const part = parseInt(partMatch[2], 10);
|
|
899
|
-
const total = parseInt(partMatch[3], 10);
|
|
900
|
-
showHttpFetchProgress(partMatch[1], part, total);
|
|
901
|
-
if (part === total) finalizeHttpFetch();
|
|
902
|
-
} else {
|
|
903
|
-
finalizeHttpFetch();
|
|
904
|
-
}
|
|
905
868
|
} else {
|
|
906
869
|
const actionLabel = TAG_REGISTRY[tag]?.label || tag;
|
|
907
870
|
statusBar.update('tool', `✓ ${actionLabel} [${durationMs}ms]`);
|
|
@@ -937,6 +900,11 @@ function createCommands({
|
|
|
937
900
|
onRetry: (attempt, max) => {
|
|
938
901
|
statusBar.update('thinking', `Retrying (${attempt}/${max})...`);
|
|
939
902
|
},
|
|
903
|
+
onDebug: (block) => {
|
|
904
|
+
// Render in-history as a tool-style bubble so ctrl+O expand works and
|
|
905
|
+
// the RAW RESPONSE text survives TUI redraws (stderr would be clobbered).
|
|
906
|
+
chatHistory.addMessage({ role: 'tool', tag: 'debug', content: 'DEBUG', output: block });
|
|
907
|
+
},
|
|
940
908
|
onError: (err) => {
|
|
941
909
|
if (err && err.isWarning) {
|
|
942
910
|
chatHistory.addMessage({ role: 'system', content: err.message || String(err) });
|
|
@@ -957,6 +925,15 @@ function createCommands({
|
|
|
957
925
|
};
|
|
958
926
|
inputField.on('abort', _onAbort);
|
|
959
927
|
|
|
928
|
+
// Refresh in case a prior turn's 400 overflow persisted a learned
|
|
929
|
+
// context_length to config after this chat started.
|
|
930
|
+
if (resolvedTokenLimit == null) {
|
|
931
|
+
const cfg = getConfig();
|
|
932
|
+
if (Number.isInteger(cfg.context_length) && cfg.context_length > 0) {
|
|
933
|
+
resolvedTokenLimit = cfg.context_length;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
960
937
|
try {
|
|
961
938
|
const agentResult = await runAgentLoop(messages, currentModel, undefined, resolvedTokenLimit, {
|
|
962
939
|
showThink: opts.showThink || false,
|
package/lib/config.js
CHANGED
|
@@ -2,9 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const { URL } = require('url');
|
|
5
6
|
|
|
6
7
|
const { CONFIG_PATH, DEFAULT_CONFIG } = require('./constants');
|
|
7
8
|
|
|
9
|
+
let _apiKeyAnyWarned = false;
|
|
10
|
+
const _LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
|
|
11
|
+
|
|
12
|
+
function _maybeWarnApiKeyAny(cfg) {
|
|
13
|
+
if (_apiKeyAnyWarned) return;
|
|
14
|
+
if (cfg.api_key !== 'any') return;
|
|
15
|
+
let host = '';
|
|
16
|
+
try {
|
|
17
|
+
host = new URL(cfg.api_base).hostname;
|
|
18
|
+
} catch {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (_LOCAL_HOSTS.has(host)) return;
|
|
22
|
+
_apiKeyAnyWarned = true;
|
|
23
|
+
process.stderr.write(
|
|
24
|
+
"⚠ api_key='any' against non-local endpoint — requests will likely fail " +
|
|
25
|
+
"with 401. Run 'semalt-code config set api_key <key>' to set a real key.\n"
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
8
29
|
function normalizeConfig(cfg = {}) {
|
|
9
30
|
const merged = { ...DEFAULT_CONFIG, ...cfg };
|
|
10
31
|
// Ensure every DEFAULT_CONFIG key is present without overwriting existing values
|
|
@@ -33,6 +54,7 @@ function normalizeConfig(cfg = {}) {
|
|
|
33
54
|
merged.dashboard_model_id = Number.isInteger(cfg.dashboard_model_id) && cfg.dashboard_model_id > 0
|
|
34
55
|
? cfg.dashboard_model_id
|
|
35
56
|
: null;
|
|
57
|
+
merged.repair_malformed_tool_xml = cfg.repair_malformed_tool_xml === true;
|
|
36
58
|
merged.models = Array.isArray(cfg.models)
|
|
37
59
|
? cfg.models
|
|
38
60
|
.filter((entry) => entry &&
|
|
@@ -53,6 +75,9 @@ function normalizeConfig(cfg = {}) {
|
|
|
53
75
|
if (Number.isInteger(entry.context_length) && entry.context_length > 0) {
|
|
54
76
|
normalized.context_length = entry.context_length;
|
|
55
77
|
}
|
|
78
|
+
// native_tools defaults to true; only explicit false/0/"false"/"0" opts out.
|
|
79
|
+
const nt = entry.native_tools;
|
|
80
|
+
normalized.native_tools = !(nt === false || nt === 0 || nt === '0' || nt === 'false');
|
|
56
81
|
return normalized;
|
|
57
82
|
})
|
|
58
83
|
: [];
|
|
@@ -61,13 +86,16 @@ function normalizeConfig(cfg = {}) {
|
|
|
61
86
|
|
|
62
87
|
function loadConfig() {
|
|
63
88
|
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
89
|
+
let cfg;
|
|
64
90
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
65
91
|
try {
|
|
66
92
|
const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
67
|
-
|
|
93
|
+
cfg = normalizeConfig(data);
|
|
68
94
|
} catch {}
|
|
69
95
|
}
|
|
70
|
-
|
|
96
|
+
if (!cfg) cfg = normalizeConfig();
|
|
97
|
+
_maybeWarnApiKeyAny(cfg);
|
|
98
|
+
return cfg;
|
|
71
99
|
}
|
|
72
100
|
|
|
73
101
|
function saveConfig(cfg) {
|
|
@@ -94,8 +122,8 @@ function configShow(systemPromptOverride = null) {
|
|
|
94
122
|
if (systemPromptOverride) {
|
|
95
123
|
lines.push(` system_prompt: [override from ${systemPromptOverride}]`);
|
|
96
124
|
} else {
|
|
97
|
-
const {
|
|
98
|
-
lines.push(` system_prompt: ${
|
|
125
|
+
const { getSystemPrompt } = require('./prompts');
|
|
126
|
+
lines.push(` system_prompt: ${getSystemPrompt().slice(0, 80)}...`);
|
|
99
127
|
}
|
|
100
128
|
return lines.join('\n');
|
|
101
129
|
}
|