@semalt-ai/code 1.8.4 → 1.8.5

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
@@ -8,6 +8,63 @@ const { buildToolsSchema, isUIActive } = require('./tools');
8
8
  const { TOOL_SPECS } = require('./tool_specs');
9
9
  const writer = require('./ui/writer');
10
10
  const messages = require('./ui/messages');
11
+ const dbg = require('./debug');
12
+
13
+ // Strict precondition for any payload that includes role:tool messages or
14
+ // assistant.tool_calls: every tool_call_id must reference a non-empty id from
15
+ // a prior assistant tool_calls entry. Catches the upstream "tool result's tool
16
+ // id() not found" 400 before it leaves the client and points at the exact
17
+ // violating message instead of a cryptic provider error.
18
+ function validateToolCallInvariant(msgs) {
19
+ const calledIds = new Set();
20
+ for (let idx = 0; idx < msgs.length; idx++) {
21
+ const m = msgs[idx];
22
+ if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
23
+ for (let j = 0; j < m.tool_calls.length; j++) {
24
+ const tc = m.tool_calls[j];
25
+ if (!tc || !tc.id) {
26
+ throw new Error(
27
+ `Invalid tool_calls invariant: messages[${idx}] role=assistant tool_calls[${j}] has empty id`
28
+ );
29
+ }
30
+ calledIds.add(tc.id);
31
+ }
32
+ }
33
+ }
34
+ for (let idx = 0; idx < msgs.length; idx++) {
35
+ const m = msgs[idx];
36
+ if (m.role !== 'tool') continue;
37
+ if (!m.tool_call_id) {
38
+ const preview = String(m.content || '').slice(0, 80).replace(/\s+/g, ' ');
39
+ throw new Error(
40
+ `Invalid tool_calls invariant: messages[${idx}] role=tool has empty tool_call_id (content_preview="${preview}")`
41
+ );
42
+ }
43
+ if (!calledIds.has(m.tool_call_id)) {
44
+ throw new Error(
45
+ `Invalid tool_calls invariant: messages[${idx}] role=tool tool_call_id=${m.tool_call_id} has no matching prior assistant tool_calls`
46
+ );
47
+ }
48
+ }
49
+ }
50
+
51
+ function debugDumpMessages(msgs) {
52
+ dbg.logExtended('[messages dump before API request]');
53
+ for (let i = 0; i < msgs.length; i++) {
54
+ const m = msgs[i];
55
+ const callIds = Array.isArray(m.tool_calls)
56
+ ? m.tool_calls.map((t) => (t && t.id) || '<EMPTY>').join(',')
57
+ : '';
58
+ const toolCallId = m.tool_call_id !== undefined
59
+ ? ` tool_call_id=${m.tool_call_id || '<EMPTY>'}`
60
+ : '';
61
+ const tcs = callIds ? ` tool_calls=[${callIds}]` : '';
62
+ const contentLen = (m.content !== undefined && m.content !== null)
63
+ ? ` content_chars=${(m.content + '').length}`
64
+ : '';
65
+ dbg.logExtended(` [${i}] role=${m.role}${toolCallId}${tcs}${contentLen}`);
66
+ }
67
+ }
11
68
 
12
69
  function createApiClient({ getConfig, saveConfig, ui }) {
13
70
  const {
@@ -359,6 +416,8 @@ function createApiClient({ getConfig, saveConfig, ui }) {
359
416
  const endpoint = apiUrl('/v1/chat/completions');
360
417
 
361
418
  async function doRequest(msgs) {
419
+ if (dbg.isFile()) debugDumpMessages(msgs);
420
+ validateToolCallInvariant(msgs);
362
421
  const reqPayload = { ...payload, messages: msgs };
363
422
  const reqBody = JSON.stringify(reqPayload);
364
423
  const res = await httpRequest(endpoint, {
@@ -516,6 +575,11 @@ function createApiClient({ getConfig, saveConfig, ui }) {
516
575
  type: 'function',
517
576
  function: { name: t.name, arguments: t.arguments || '{}' },
518
577
  }));
578
+ dbg.logExtended(
579
+ `[tool_call finalize] acc_len=${toolCallAcc.length} ` +
580
+ `valid=${validToolCalls.length} nativeTools=${nativeTools} ` +
581
+ `acc=${JSON.stringify(toolCallAcc).slice(0, 400)}`
582
+ );
519
583
  if (!nativeTools) appendToolCallsXml();
520
584
  if (!silent) renderer.flush();
521
585
  // Fallback for endpoints that don't honor stream_options.include_usage:
@@ -564,6 +628,10 @@ function createApiClient({ getConfig, saveConfig, ui }) {
564
628
  res.setEncoding('utf8');
565
629
 
566
630
  res.on('data', (chunk) => {
631
+ if (dbg.isFile()) {
632
+ const raw = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
633
+ dbg.logExtended(`[SSE raw] ${raw.slice(0, 500).replace(/\n/g, '\\n')}`);
634
+ }
567
635
  lineBuffer += chunk;
568
636
  const lines = lineBuffer.split('\n');
569
637
  lineBuffer = lines.pop();
@@ -572,11 +640,14 @@ function createApiClient({ getConfig, saveConfig, ui }) {
572
640
  if (!line.startsWith('data: ')) continue;
573
641
  const data = line.slice(6).trim();
574
642
  if (data === '[DONE]') {
643
+ dbg.logExtended(`[SSE event] [DONE]`);
575
644
  finalize();
576
645
  res.destroy();
577
646
  return;
578
647
  }
579
648
 
649
+ dbg.logExtended(`[SSE event] ${data.slice(0, 500)}`);
650
+
580
651
  try {
581
652
  const obj = JSON.parse(data);
582
653
  if (obj.usage && (obj.usage.prompt_tokens !== undefined || obj.usage.completion_tokens !== undefined)) {
@@ -619,15 +690,31 @@ function createApiClient({ getConfig, saveConfig, ui }) {
619
690
  }
620
691
  }
621
692
 
693
+ // Standard OpenAI tool_call streaming: the announcement chunk
694
+ // carries id + type + function.name with arguments="", and one or
695
+ // more follow-up chunks stream arguments deltas (no id/name).
696
+ // Process every chunk that has delta.tool_calls and patch in
697
+ // whichever fields are present — never gate slot creation or
698
+ // field updates on arguments being non-empty, or the announcement
699
+ // (which carries the only id/name) gets dropped.
622
700
  const toolCallsDelta = delta.tool_calls;
623
701
  if (Array.isArray(toolCallsDelta)) {
624
702
  for (const tc of toolCallsDelta) {
703
+ if (!tc || typeof tc !== 'object') continue;
625
704
  const idx = typeof tc.index === 'number' ? tc.index : toolCallAcc.length;
626
- const isNew = !toolCallAcc[idx];
627
- if (isNew) toolCallAcc[idx] = { id: '', name: '', arguments: '' };
628
- if (tc.id) toolCallAcc[idx].id = tc.id;
629
- if (tc.function?.name) toolCallAcc[idx].name += tc.function.name;
630
- if (tc.function?.arguments) toolCallAcc[idx].arguments += tc.function.arguments;
705
+ if (!toolCallAcc[idx]) {
706
+ toolCallAcc[idx] = { id: '', name: '', arguments: '' };
707
+ }
708
+ const slot = toolCallAcc[idx];
709
+ if (tc.id) slot.id = tc.id;
710
+ const fnName = tc.function && tc.function.name;
711
+ if (typeof fnName === 'string' && fnName) slot.name = fnName;
712
+ const fnArgs = tc.function && tc.function.arguments;
713
+ if (typeof fnArgs === 'string') slot.arguments += fnArgs;
714
+ dbg.logExtended(
715
+ `[tool_call acc] idx=${idx} id=${slot.id || '<empty>'} ` +
716
+ `name=${slot.name || '<empty>'} args_len=${slot.arguments.length}`
717
+ );
631
718
  }
632
719
  }
633
720
 
@@ -649,7 +736,9 @@ function createApiClient({ getConfig, saveConfig, ui }) {
649
736
  fullText += content;
650
737
  tokenCount++;
651
738
  }
652
- } catch {}
739
+ } catch (err) {
740
+ dbg.logExtended(`[SSE parse-error] ${err.message} :: ${data.slice(0, 200)}`);
741
+ }
653
742
  }
654
743
  });
655
744
 
package/lib/args.js CHANGED
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const debug = require('./debug');
4
+
3
5
  function parseArgs(argv) {
4
6
  const opts = {};
5
7
  const positional = [];
@@ -62,6 +64,15 @@ function parseArgs(argv) {
62
64
  case '--debug':
63
65
  opts.debug = true;
64
66
  break;
67
+ case '--debug-file': {
68
+ const v = argv[++i];
69
+ if (!v || v.startsWith('-')) {
70
+ process.stderr.write(`Error: --debug-file requires a path argument.\n`);
71
+ process.exit(1);
72
+ }
73
+ opts.debugFile = v;
74
+ break;
75
+ }
65
76
  case '--system-prompt':
66
77
  opts.systemPromptFile = argv[++i];
67
78
  break;
@@ -71,6 +82,17 @@ function parseArgs(argv) {
71
82
  i++;
72
83
  }
73
84
 
85
+ if (opts.debug && opts.debugFile) {
86
+ process.stderr.write(
87
+ `Error: --debug and --debug-file are mutually exclusive.\n` +
88
+ ` Use --debug for inline debug output, or --debug-file <path>\n` +
89
+ ` for extended debug traces written to a file.\n`
90
+ );
91
+ process.exit(1);
92
+ }
93
+
94
+ debug.init({ debug: opts.debug, debugFile: opts.debugFile });
95
+
74
96
  return { opts, positional };
75
97
  }
76
98
 
package/lib/commands.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs');
4
4
 
5
5
  const { CONFIG_PATH, DEFAULT_API_TIMEOUT_MS, TAG_REGISTRY } = require('./constants');
6
- const { configShow } = require('./config');
6
+ const { configShow, isNativeToolsActive } = require('./config');
7
7
  const { getSystemPrompt } = require('./prompts');
8
8
  const { SessionStorage } = require('./storage');
9
9
  const { getSkippedOps, setUIActive } = require('./tools');
@@ -12,6 +12,63 @@ const { formatToolLine } = require('./ui/format');
12
12
  const writerModule = require('./ui/writer');
13
13
  const writer = writerModule;
14
14
  const msgs = require('./ui/messages');
15
+ const dbg = require('./debug');
16
+
17
+ // Drop assistant.tool_calls and role:tool messages whose ids don't pair up.
18
+ // A loaded chat may contain role:tool with empty/missing tool_call_id (legacy
19
+ // rows, dropped fields in transit) or assistant.tool_calls without a matching
20
+ // tool response (truncated turn). Either side without its partner produces a
21
+ // 400 from strict providers like MiniMax — the validator in api.js will throw
22
+ // — so we strip both sides of the orphan pair before sending.
23
+ function cleanOrphanedToolMessages(msgs) {
24
+ const calledIds = new Set();
25
+ const respondedIds = new Set();
26
+ for (const m of msgs) {
27
+ if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
28
+ for (const tc of m.tool_calls) {
29
+ if (tc && tc.id) calledIds.add(tc.id);
30
+ }
31
+ } else if (m.role === 'tool' && m.tool_call_id) {
32
+ respondedIds.add(m.tool_call_id);
33
+ }
34
+ }
35
+ const paired = new Set();
36
+ for (const id of calledIds) if (respondedIds.has(id)) paired.add(id);
37
+
38
+ let droppedTool = 0;
39
+ let droppedAssistantCalls = 0;
40
+ let droppedAssistantMsgs = 0;
41
+ const out = [];
42
+ for (const m of msgs) {
43
+ if (m.role === 'tool') {
44
+ if (!m.tool_call_id || !paired.has(m.tool_call_id)) { droppedTool++; continue; }
45
+ out.push(m);
46
+ } else if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
47
+ const kept = m.tool_calls.filter((tc) => tc && tc.id && paired.has(tc.id));
48
+ droppedAssistantCalls += m.tool_calls.length - kept.length;
49
+ const hasContent = typeof m.content === 'string' && m.content.trim().length > 0;
50
+ if (kept.length === 0 && !hasContent) { droppedAssistantMsgs++; continue; }
51
+ const next = { ...m };
52
+ if (kept.length > 0) next.tool_calls = kept;
53
+ else delete next.tool_calls;
54
+ out.push(next);
55
+ } else {
56
+ out.push(m);
57
+ }
58
+ }
59
+ return { messages: out, droppedTool, droppedAssistantCalls, droppedAssistantMsgs };
60
+ }
61
+
62
+ function reconstructLoadedMessage(m) {
63
+ const msg = { role: m.role, content: m.content };
64
+ if (m.tool_call_id !== undefined && m.tool_call_id !== null && m.tool_call_id !== '') {
65
+ msg.tool_call_id = m.tool_call_id;
66
+ }
67
+ if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
68
+ msg.tool_calls = m.tool_calls;
69
+ }
70
+ return msg;
71
+ }
15
72
 
16
73
  function formatTimeAgo(ts) {
17
74
  const diffMs = Date.now() - ts;
@@ -194,7 +251,11 @@ function createCommands({
194
251
  let messages = [];
195
252
  let currentChatId = null;
196
253
  let savedUpTo = 0;
197
- let debugMode = !!opts.debug;
254
+ // The agent loop's per-iteration `formatDebugBlock` runs whenever any
255
+ // debug mode is active. In simple mode the block is rendered as a TUI
256
+ // chat bubble (cb.onDebug → addMessage). In file mode emitDebug routes
257
+ // the block to the debug file instead, keeping the TUI clean.
258
+ let debugMode = dbg.isActive();
198
259
 
199
260
  // Resolve system prompt override from --system-prompt file if provided
200
261
  let resolvedSystemPrompt = null;
@@ -284,7 +345,15 @@ function createCommands({
284
345
  if (currentChatId === null) return;
285
346
  const newMessages = messages.slice(savedUpTo).filter((m) => m.role !== 'system');
286
347
  if (!newMessages.length) return;
287
- try { await dashboardSaveMessages(currentChatId, newMessages); savedUpTo = messages.length; } catch {}
348
+ try {
349
+ const resp = await dashboardSaveMessages(currentChatId, newMessages);
350
+ savedUpTo = messages.length;
351
+ if (resp && typeof resp.skipped_count === 'number' && resp.skipped_count > 0) {
352
+ msgs.sysWarn(`history save: ${resp.skipped_count} message(s) skipped by server`);
353
+ }
354
+ } catch (err) {
355
+ msgs.sysWarn(`history save failed: ${err && err.message ? err.message : String(err)}`);
356
+ }
288
357
  }
289
358
 
290
359
  function displayLoadedMessages(loadedMessages) {
@@ -295,7 +364,7 @@ function createCommands({
295
364
  const ts = m.created_at ? new Date(m.created_at) : (m.ts ? new Date(m.ts) : new Date());
296
365
 
297
366
  if (m.role === 'tool') {
298
- chatHistory.addMessage({ role: 'tool', tag: 'tool', content: 'tool result', output: raw, ts });
367
+ chatHistory.addMessage({ role: 'tool', tag: 'tool', content: raw, ts });
299
368
  continue;
300
369
  }
301
370
 
@@ -304,7 +373,7 @@ function createCommands({
304
373
  .replace(/^Tool execution results[^\n]*\n+/, '')
305
374
  .replace(/\n+Continue with the task\.[\s\S]*$/, '')
306
375
  .trim();
307
- chatHistory.addMessage({ role: 'tool', tag: 'tool', content: 'tool result', output: body || raw, ts });
376
+ chatHistory.addMessage({ role: 'tool', tag: 'tool', content: body || raw, ts });
308
377
  continue;
309
378
  }
310
379
 
@@ -313,6 +382,34 @@ function createCommands({
313
382
  }
314
383
  }
315
384
 
385
+ // After loading a saved chat (via --resume, /history, or /chats), the
386
+ // status bar has no API-reported prompt_tokens to display until the next
387
+ // turn completes — the indicator would sit at 0 until the user sends a
388
+ // follow-up. Seed it with a client-side estimate of the loaded messages
389
+ // using the same approxTokens estimator the live `addPendingTokens` path
390
+ // uses, then push it through `updateMetrics({contextTokens})` — the same
391
+ // setter agent.js wires up via cb.onMetricsUpdate. The next real turn
392
+ // overwrites this with the API's authoritative prompt_tokens.
393
+ function seedContextFromMessages() {
394
+ let total = 0;
395
+ for (const m of messages) {
396
+ if (typeof m.content === 'string') total += approxTokens(m.content);
397
+ }
398
+ statusBar.updateMetrics({ contextTokens: total });
399
+ }
400
+
401
+ function emitCleanupWarning(cleanup) {
402
+ if (cleanup.droppedTool === 0 && cleanup.droppedAssistantCalls === 0 && cleanup.droppedAssistantMsgs === 0) return;
403
+ const parts = [];
404
+ if (cleanup.droppedTool > 0) parts.push(`${cleanup.droppedTool} orphaned tool result(s)`);
405
+ if (cleanup.droppedAssistantCalls > 0) parts.push(`${cleanup.droppedAssistantCalls} dangling tool_call(s)`);
406
+ if (cleanup.droppedAssistantMsgs > 0) parts.push(`${cleanup.droppedAssistantMsgs} empty assistant message(s)`);
407
+ chatHistory.addMessage({
408
+ role: 'system',
409
+ content: `⚠ Loaded chat had ${parts.join(', ')}, cleaned up. The chat may be missing some context.`,
410
+ });
411
+ }
412
+
316
413
  // --resume: load previous chat
317
414
  if (opts.resume) {
318
415
  const resumeId = parseInt(opts.resume, 10);
@@ -320,12 +417,16 @@ function createCommands({
320
417
  try {
321
418
  const chatData = await dashboardGetChat(resumeId);
322
419
  const loaded = chatData && chatData.messages ? chatData.messages : [];
323
- for (const m of loaded) messages.push({ role: m.role, content: m.content });
420
+ for (const m of loaded) messages.push(reconstructLoadedMessage(m));
421
+ const cleanup = cleanOrphanedToolMessages(messages);
422
+ messages = cleanup.messages;
324
423
  currentChatId = resumeId;
325
424
  savedUpTo = messages.length;
326
425
  const title = chatData.chat && chatData.chat.title ? chatData.chat.title : `#${resumeId}`;
327
426
  displayLoadedMessages(loaded);
328
427
  chatHistory.addMessage({ role: 'system', content: `✓ Resumed: ${title} (${loaded.length} messages)` });
428
+ emitCleanupWarning(cleanup);
429
+ seedContextFromMessages();
329
430
  } catch (error) {
330
431
  chatHistory.addMessage({ role: 'system', content: `✗ Could not resume chat: ${error.message}`, isError: true });
331
432
  }
@@ -470,7 +571,9 @@ function createCommands({
470
571
  if (type === 'history') {
471
572
  const loaded = storage.load(activeItems[idx].id);
472
573
  if (loaded) {
473
- messages = (loaded.messages || []).filter((m) => m.role !== 'system');
574
+ const filtered = (loaded.messages || []).filter((m) => m.role !== 'system');
575
+ const cleanup = cleanOrphanedToolMessages(filtered);
576
+ messages = cleanup.messages;
474
577
  session = { id: loaded.id, created_at: loaded.created_at, model: loaded.model, messages, stats: loaded.stats || { total_tokens: 0, duration_sec: 0 } };
475
578
  currentChatId = null; savedUpTo = 0;
476
579
  if (loaded.model && loaded.model !== currentModel) {
@@ -481,16 +584,22 @@ function createCommands({
481
584
  }
482
585
  displayLoadedMessages(messages);
483
586
  chatHistory.addMessage({ role: 'system', content: `✓ Session loaded. Model → ${currentModel}` });
587
+ emitCleanupWarning(cleanup);
588
+ seedContextFromMessages();
484
589
  }
485
590
  } else if (type === 'chats') {
486
591
  const selectedChat = activeItems[idx];
487
592
  try {
488
593
  const chatData = await dashboardGetChat(selectedChat.id);
489
594
  const loaded = chatData && chatData.messages ? chatData.messages : [];
490
- messages = loaded.map((m) => ({ role: m.role, content: m.content }));
595
+ const reconstructed = loaded.map(reconstructLoadedMessage);
596
+ const cleanup = cleanOrphanedToolMessages(reconstructed);
597
+ messages = cleanup.messages;
491
598
  currentChatId = selectedChat.id; savedUpTo = messages.length;
492
599
  displayLoadedMessages(loaded);
493
600
  chatHistory.addMessage({ role: 'system', content: `✓ Resumed: ${selectedChat.title} (${loaded.length} messages)` });
601
+ emitCleanupWarning(cleanup);
602
+ seedContextFromMessages();
494
603
  } catch (err) {
495
604
  chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
496
605
  }
@@ -767,7 +876,8 @@ function createCommands({
767
876
  }
768
877
 
769
878
  if (text === '/prompt') {
770
- const activePrompt = resolvedSystemPrompt !== null ? resolvedSystemPrompt : getSystemPrompt();
879
+ const nativeTools = isNativeToolsActive(currentModel);
880
+ const activePrompt = resolvedSystemPrompt !== null ? resolvedSystemPrompt : getSystemPrompt(nativeTools);
771
881
  const src = resolvedSystemPrompt !== null ? `file: ${opts.systemPromptFile}` : 'built-in';
772
882
  const mode = getConfig().system_prompt_mode || 'system_role';
773
883
  chatHistory.addMessage({
@@ -810,9 +920,12 @@ function createCommands({
810
920
  tail = '\nNo audit log found.';
811
921
  }
812
922
 
923
+ const sink = dbg.isFile()
924
+ ? `file (${dbg.getMode()} mode)`
925
+ : 'inline chat history';
813
926
  chatHistory.addMessage({
814
927
  role: 'system',
815
- content: `Debug output: ${debugMode ? 'ON' : 'OFF'} (raw messages, raw AI responses, raw HTTP errors stderr)${tail}`,
928
+ content: `Debug output: ${debugMode ? 'ON' : 'OFF'} → ${sink}${tail}`,
816
929
  });
817
930
  return;
818
931
  }
@@ -895,6 +1008,21 @@ function createCommands({
895
1008
  chatHistory.addMessage({ role: 'think', content });
896
1009
  statusBar.update('streaming', 'Streaming response');
897
1010
  },
1011
+ onPermissionAsk: (tag, input) => {
1012
+ // Status-bar update fires while the permission picker is open so
1013
+ // the user can see what's pending in the side label, not just
1014
+ // inside the modal. Mirrors the labels onToolStart uses post-grant
1015
+ // — the next streaming/idle state will overwrite this when the
1016
+ // picker closes (whether granted or denied).
1017
+ const actionLabel = TAG_REGISTRY[tag]?.label || tag;
1018
+ const short = input && input.length > 40 ? input.slice(0, 40) + '…' : (input || '');
1019
+ const isDownload = tag === 'download' || tag === 'http_get';
1020
+ if (isDownload) {
1021
+ statusBar.update('waiting_download', `Waiting for download: ${short}`);
1022
+ } else {
1023
+ statusBar.update('tool', `${actionLabel}: ${short}`);
1024
+ }
1025
+ },
898
1026
  onToolStart: (tag, input, ctx) => {
899
1027
  const actionLabel = TAG_REGISTRY[tag]?.label || tag;
900
1028
  const short = input && input.length > 40 ? input.slice(0, 40) + '…' : (input || '');
@@ -908,18 +1036,39 @@ function createCommands({
908
1036
  // The render function is re-invoked by the writer on every
909
1037
  // redraw so the pending line's elapsed time stays current with
910
1038
  // the ticker cadence without an explicit refresh timer.
1039
+ //
1040
+ // ask_user is the only currently-blocking tool — it pauses the
1041
+ // agent until the user responds via the modal. A ticking
1042
+ // elapsed-time meter on a paused tool is misleading ("13s"
1043
+ // suggests work is happening), and the per-tick redraw
1044
+ // interacts badly with the open modal (see TECHNICAL_DEBT.md).
1045
+ // Render once with no duration meta and freeze. Replace this
1046
+ // name check with a category flag (e.g. blocking: true on the
1047
+ // tool spec) if more blocking tools appear.
911
1048
  if (ctx && ctx.id) {
912
- writerModule.startActivity(ctx.id, (elapsedMs) => formatToolLine({
913
- status: 'pending',
914
- tag,
915
- arg: input,
916
- attrs: ctx.attrs,
917
- durationMs: elapsedMs,
918
- }));
1049
+ if (tag === 'ask_user') {
1050
+ const staticLine = formatToolLine({
1051
+ status: 'pending',
1052
+ tag,
1053
+ arg: input,
1054
+ attrs: ctx.attrs,
1055
+ noDuration: true,
1056
+ });
1057
+ writerModule.startActivity(ctx.id, () => staticLine);
1058
+ } else {
1059
+ writerModule.startActivity(ctx.id, (elapsedMs) => formatToolLine({
1060
+ status: 'pending',
1061
+ tag,
1062
+ arg: input,
1063
+ attrs: ctx.attrs,
1064
+ durationMs: elapsedMs,
1065
+ }));
1066
+ }
919
1067
  }
920
1068
  },
921
1069
  onToolEnd: (tag, result, durationMs, ctx) => {
922
1070
  const hasError = !!(ctx && ctx.error);
1071
+ const isBlocking = tag === 'ask_user';
923
1072
  const finalLine = formatToolLine({
924
1073
  status: hasError ? 'failure' : 'success',
925
1074
  tag,
@@ -928,6 +1077,7 @@ function createCommands({
928
1077
  durationMs,
929
1078
  meta: ctx ? ctx.meta : null,
930
1079
  error: ctx ? ctx.error : null,
1080
+ noDuration: isBlocking,
931
1081
  });
932
1082
  if (ctx && ctx.id) {
933
1083
  writerModule.endActivity(ctx.id, finalLine);
@@ -1092,7 +1242,7 @@ function createCommands({
1092
1242
  let messages = [{ role: 'user', content: fullPrompt }];
1093
1243
  writer.scrollback(` ${FG_GRAY}◆ ${model}${RST}`);
1094
1244
  const codeResult = await runAgentLoop(messages, model, undefined, null, {
1095
- debug: opts.debug || false,
1245
+ debug: dbg.isActive(),
1096
1246
  systemPrompt: resolvedSystemPrompt,
1097
1247
  systemPromptMode: getConfig().system_prompt_mode || 'system_role',
1098
1248
  });
package/lib/config.js CHANGED
@@ -111,6 +111,18 @@ function configSet(key, value) {
111
111
  return cfg;
112
112
  }
113
113
 
114
+ // Resolves whether the active profile uses native function calling.
115
+ // Defaults to true if no profile match is found (matches normalizeConfig
116
+ // default and the agent.js lookup fallback).
117
+ function isNativeToolsActive(model) {
118
+ const cfg = loadConfig();
119
+ if (!Array.isArray(cfg.models)) return true;
120
+ const profile = cfg.models.find(
121
+ (p) => p && p.api_base === cfg.api_base && p.model === model
122
+ );
123
+ return !(profile && profile.native_tools === false);
124
+ }
125
+
114
126
  const REDACTED_KEYS = new Set(['api_key', 'auth_token']);
115
127
 
116
128
  function configShow(systemPromptOverride = null) {
@@ -132,6 +144,7 @@ function configShow(systemPromptOverride = null) {
132
144
  module.exports = {
133
145
  configSet,
134
146
  configShow,
147
+ isNativeToolsActive,
135
148
  loadConfig,
136
149
  normalizeConfig,
137
150
  saveConfig,
package/lib/debug.js ADDED
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ // Two mutually-exclusive debug modes, configured once at startup from the
4
+ // CLI flags (--debug or --debug-file <path>).
5
+ //
6
+ // off — no debug output anywhere.
7
+ // simple — visible inline. Basic per-iteration info routed through
8
+ // writer.scrollback so the TUI keeps working (no SSE dumps,
9
+ // no per-chunk noise).
10
+ // file — every debug call (basic AND extended) is written to a file.
11
+ // Nothing debug-related goes to stdout. The TUI stays clean.
12
+ //
13
+ // Two log functions with a clear semantic split:
14
+ //
15
+ // log(line) — "always-on" debug. Visible in simple mode (scrollback)
16
+ // and file mode (file). Silent in off mode.
17
+ // logExtended(line) — extended traces (raw SSE, request bodies, delta
18
+ // accumulators). Visible only in file mode.
19
+ //
20
+ // File-mode lines are formatted as `[ISO-timestamp] <line>\n` so they're
21
+ // greppable and tail-friendly.
22
+
23
+ const fs = require('fs');
24
+
25
+ let mode = 'off';
26
+ let fileStream = null;
27
+
28
+ function init({ debug, debugFile } = {}) {
29
+ if (debug && debugFile) {
30
+ // Belt-and-braces: cli.js (args parser) errors out before this is ever
31
+ // reached. Throw rather than silently coerce so any internal misuse is
32
+ // surfaced loudly.
33
+ throw new Error('debug and debugFile are mutually exclusive');
34
+ }
35
+ if (debugFile) {
36
+ mode = 'file';
37
+ fileStream = fs.createWriteStream(debugFile, { flags: 'a' });
38
+ const ts = new Date().toISOString();
39
+ try {
40
+ fileStream.write(`\n[${ts}] [session] semalt-code debug session start pid=${process.pid}\n`);
41
+ } catch {}
42
+ } else if (debug) {
43
+ mode = 'simple';
44
+ } else {
45
+ mode = 'off';
46
+ }
47
+ }
48
+
49
+ function isActive() { return mode !== 'off'; }
50
+ function isSimple() { return mode === 'simple'; }
51
+ function isFile() { return mode === 'file'; }
52
+ function getMode() { return mode; }
53
+
54
+ function _writeFile(line) {
55
+ if (!fileStream) return;
56
+ const ts = new Date().toISOString();
57
+ try { fileStream.write(`[${ts}] ${line}\n`); } catch {}
58
+ }
59
+
60
+ // "Always-on" debug — visible in simple mode (scrollback) and file mode (file).
61
+ // Silent in off mode. Multi-line input gets one timestamp per line in file mode
62
+ // so each line stays greppable.
63
+ function log(line) {
64
+ if (mode === 'off') return;
65
+ const s = String(line);
66
+ if (mode === 'simple') {
67
+ // Lazy-require to avoid a require cycle: writer pulls in this module
68
+ // for its own drift diagnostic.
69
+ const writer = require('./ui/writer');
70
+ writer.scrollback(s);
71
+ } else {
72
+ for (const l of s.split('\n')) _writeFile(l);
73
+ }
74
+ }
75
+
76
+ // Extended-only debug — visible in file mode only. Used for high-volume
77
+ // per-chunk traces (raw SSE, request body dumps, accumulator state) that
78
+ // would shred the TUI if printed inline.
79
+ function logExtended(line) {
80
+ if (mode !== 'file') return;
81
+ const s = String(line);
82
+ for (const l of s.split('\n')) _writeFile(l);
83
+ }
84
+
85
+ function close() {
86
+ if (fileStream) {
87
+ try {
88
+ const ts = new Date().toISOString();
89
+ fileStream.write(`[${ts}] [session] end pid=${process.pid}\n`);
90
+ fileStream.end();
91
+ } catch {}
92
+ fileStream = null;
93
+ }
94
+ mode = 'off';
95
+ }
96
+
97
+ module.exports = {
98
+ init,
99
+ isActive,
100
+ isSimple,
101
+ isFile,
102
+ getMode,
103
+ log,
104
+ logExtended,
105
+ close,
106
+ };