@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/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
- // Set on the first context-overflow 400; used to proactively trim all subsequent calls.
60
- let _sessionInputLimit = null;
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) => resolve(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/3conservative for token-dense content (code, JSON, HTML).
263
+ // Uses chars/4aligned 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 = 3;
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: apply the session input limit discovered from a prior 400.
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
- if (_sessionInputLimit !== null) {
277
- if (Math.floor(JSON.stringify(messages).length / 3) > _sessionInputLimit) {
278
- trimmedMessages = trimToTokenBudget(messages, _sessionInputLimit);
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: model || config.default_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(apiUrl('/v1/chat/completions'), {
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 _sessionInputLimit is set so all subsequent calls are proactively trimmed.
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
- budget = contextWindow
349
- ? Math.floor(contextWindow / 2)
350
- : Math.floor(Math.floor(JSON.stringify(trimmedMessages).length / 3) * 0.5);
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 / 3) * 0.5);
441
+ budget = Math.floor(Math.floor(JSON.stringify(trimmedMessages).length / 4) * 0.5);
354
442
  }
355
- _sessionInputLimit = budget;
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
- appendToolCallsXml();
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
- resolve({ content: fullText, usage });
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 delta = ((obj.choices || [])[0] || {}).delta || {};
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 { SYSTEM_PROMPT } = require('./prompts');
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 — write at row 1, then compact the layout so the fixed panels sit
191
- // immediately below the banner with no blank gap. The layout grows as
192
- // messages are added (dynamic layout mode) until it reaches full-screen.
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
- process.stdout.write('\x1b[1;1H');
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') + '\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 visible = loadedMessages.filter(
264
- (m) => (m.role === 'user' || m.role === 'assistant') &&
265
- (typeof m.content === 'string' ? m.content : '').trim()
266
- );
267
- const skip = Math.max(0, visible.length - HISTORY_DISPLAY_TURNS * 2);
268
- if (skip > 0) {
269
- chatHistory.addMessage({ role: 'system', content: `… ${skip} earlier messages not shown` });
270
- }
271
- for (const m of visible.slice(skip)) {
272
- chatHistory.addMessage({
273
- role: m.role,
274
- content: typeof m.content === 'string' ? m.content : '',
275
- ts: m.created_at ? new Date(m.created_at) : (m.ts ? new Date(m.ts) : new Date()),
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 : SYSTEM_PROMPT;
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' || tag === 'http_get_next';
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' || tag === 'http_get_next';
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
- return normalizeConfig(data);
93
+ cfg = normalizeConfig(data);
68
94
  } catch {}
69
95
  }
70
- return normalizeConfig();
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 { SYSTEM_PROMPT } = require('./prompts');
98
- lines.push(` system_prompt: ${SYSTEM_PROMPT.slice(0, 80)}...`);
125
+ const { getSystemPrompt } = require('./prompts');
126
+ lines.push(` system_prompt: ${getSystemPrompt().slice(0, 80)}...`);
99
127
  }
100
128
  return lines.join('\n');
101
129
  }