@semalt-ai/code 1.8.3 → 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
@@ -6,6 +6,65 @@ const { URL } = require('url');
6
6
 
7
7
  const { buildToolsSchema, isUIActive } = require('./tools');
8
8
  const { TOOL_SPECS } = require('./tool_specs');
9
+ const writer = require('./ui/writer');
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
+ }
9
68
 
10
69
  function createApiClient({ getConfig, saveConfig, ui }) {
11
70
  const {
@@ -17,7 +76,6 @@ function createApiClient({ getConfig, saveConfig, ui }) {
17
76
  FG_RED,
18
77
  FG_TEAL,
19
78
  RST,
20
- StatusBar,
21
79
  StreamRenderer,
22
80
  } = ui;
23
81
 
@@ -358,6 +416,8 @@ function createApiClient({ getConfig, saveConfig, ui }) {
358
416
  const endpoint = apiUrl('/v1/chat/completions');
359
417
 
360
418
  async function doRequest(msgs) {
419
+ if (dbg.isFile()) debugDumpMessages(msgs);
420
+ validateToolCallInvariant(msgs);
361
421
  const reqPayload = { ...payload, messages: msgs };
362
422
  const reqBody = JSON.stringify(reqPayload);
363
423
  const res = await httpRequest(endpoint, {
@@ -472,10 +532,10 @@ function createApiClient({ getConfig, saveConfig, ui }) {
472
532
  const toolCallAcc = [];
473
533
  const renderer = new StreamRenderer({ firstLinePrefix: linePrefix, showThink });
474
534
  if (!silent) {
535
+ // audit: allowed — non-TUI streaming setup, must interleave with StreamRenderer sync writes.
475
536
  process.stdout.write('\n');
476
537
  renderer._linesWritten = 1;
477
538
  }
478
- let firstContentToken = true;
479
539
  let lineBuffer = '';
480
540
 
481
541
  function escapeXml(s) {
@@ -515,16 +575,13 @@ function createApiClient({ getConfig, saveConfig, ui }) {
515
575
  type: 'function',
516
576
  function: { name: t.name, arguments: t.arguments || '{}' },
517
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
+ );
518
583
  if (!nativeTools) appendToolCallsXml();
519
584
  if (!silent) renderer.flush();
520
- const elapsed = (Date.now() - startTime) / 1000;
521
- const tps = tokenCount / (elapsed || 1);
522
- if (StatusBar.current) {
523
- let latency = `${Math.round(tps)} tok/s · ${elapsed.toFixed(1)}s`;
524
- if (reasoningText) latency += ` · ${estimateTokens(reasoningText)} think`;
525
- StatusBar.current.liveUpdate({ tokens: `${tokenCount} tok`, latency });
526
- StatusBar.current.render();
527
- }
528
585
  // Fallback for endpoints that don't honor stream_options.include_usage:
529
586
  // estimate prompt/completion tokens locally so the status bar still updates.
530
587
  let usage = streamUsage;
@@ -571,6 +628,10 @@ function createApiClient({ getConfig, saveConfig, ui }) {
571
628
  res.setEncoding('utf8');
572
629
 
573
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
+ }
574
635
  lineBuffer += chunk;
575
636
  const lines = lineBuffer.split('\n');
576
637
  lineBuffer = lines.pop();
@@ -579,11 +640,14 @@ function createApiClient({ getConfig, saveConfig, ui }) {
579
640
  if (!line.startsWith('data: ')) continue;
580
641
  const data = line.slice(6).trim();
581
642
  if (data === '[DONE]') {
643
+ dbg.logExtended(`[SSE event] [DONE]`);
582
644
  finalize();
583
645
  res.destroy();
584
646
  return;
585
647
  }
586
648
 
649
+ dbg.logExtended(`[SSE event] ${data.slice(0, 500)}`);
650
+
587
651
  try {
588
652
  const obj = JSON.parse(data);
589
653
  if (obj.usage && (obj.usage.prompt_tokens !== undefined || obj.usage.completion_tokens !== undefined)) {
@@ -613,6 +677,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
613
677
  if (!inReasoning) {
614
678
  inReasoning = true;
615
679
  if (showThink && !uiActive) {
680
+ // audit: allowed — non-TUI thinking output, interleaves with StreamRenderer sync writes.
616
681
  process.stdout.write(`\n ${FG_DARK}${DIM}⟨thinking⟩${RST}`);
617
682
  renderer._linesWritten++;
618
683
  }
@@ -620,27 +685,36 @@ function createApiClient({ getConfig, saveConfig, ui }) {
620
685
  reasoningText += reasoning;
621
686
  tokenCount++;
622
687
  if (showThink && !uiActive) {
688
+ // audit: allowed — non-TUI thinking output, interleaves with StreamRenderer sync writes.
623
689
  process.stdout.write(`${FG_DARK}${DIM}${reasoning}${RST}`);
624
690
  }
625
691
  }
626
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.
627
700
  const toolCallsDelta = delta.tool_calls;
628
701
  if (Array.isArray(toolCallsDelta)) {
629
702
  for (const tc of toolCallsDelta) {
703
+ if (!tc || typeof tc !== 'object') continue;
630
704
  const idx = typeof tc.index === 'number' ? tc.index : toolCallAcc.length;
631
- const isNew = !toolCallAcc[idx];
632
- if (isNew) toolCallAcc[idx] = { id: '', name: '', arguments: '' };
633
- if (tc.id) toolCallAcc[idx].id = tc.id;
634
- if (tc.function?.name) toolCallAcc[idx].name += tc.function.name;
635
- if (tc.function?.arguments) toolCallAcc[idx].arguments += tc.function.arguments;
636
- // When the model streams purely via delta.tool_calls (no
637
- // delta.content), firstContentToken never flips, so the status
638
- // bar stays on "Thinking…" for the entire tool-call stream.
639
- // Surface each new tool slot the moment its name is known so
640
- // the user sees "Using tool: <name>" instead of a frozen UI.
641
- if (isNew && StatusBar.current && toolCallAcc[idx].name) {
642
- StatusBar.current.update('tool', `Using tool: ${toolCallAcc[idx].name}`);
705
+ if (!toolCallAcc[idx]) {
706
+ toolCallAcc[idx] = { id: '', name: '', arguments: '' };
643
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
+ );
644
718
  }
645
719
  }
646
720
 
@@ -649,30 +723,22 @@ function createApiClient({ getConfig, saveConfig, ui }) {
649
723
  if (inReasoning) {
650
724
  inReasoning = false;
651
725
  if (showThink && !silent) {
726
+ // audit: allowed — non-TUI thinking output, interleaves with StreamRenderer sync writes.
652
727
  process.stdout.write(`${FG_DARK}⟨/thinking⟩${RST}\n`);
653
728
  renderer._linesWritten++;
654
729
  }
655
730
  }
656
731
  if (onToken) {
657
- if (firstContentToken) {
658
- firstContentToken = false;
659
- if (StatusBar.current) StatusBar.current.update({ status: 'streaming' });
660
- }
661
732
  onToken(content);
662
733
  } else {
663
734
  renderer.feed(content);
664
735
  }
665
736
  fullText += content;
666
737
  tokenCount++;
667
- if (tokenCount % 20 === 0 && StatusBar.current) {
668
- const elapsedSec = (Date.now() - startTime) / 1000 || 0.001;
669
- StatusBar.current.liveUpdate({
670
- tokens: `${tokenCount} tok`,
671
- latency: `${Math.round(tokenCount / elapsedSec)} tok/s`,
672
- });
673
- }
674
738
  }
675
- } catch {}
739
+ } catch (err) {
740
+ dbg.logExtended(`[SSE parse-error] ${err.message} :: ${data.slice(0, 200)}`);
741
+ }
676
742
  }
677
743
  });
678
744
 
@@ -712,7 +778,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
712
778
  },
713
779
  }, body);
714
780
  } catch (error) {
715
- console.log(` ${FG_RED}✗ ${error.message}${RST}`);
781
+ messages.netError(error.message);
716
782
  return '';
717
783
  }
718
784
 
@@ -724,7 +790,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
724
790
  });
725
791
  res.on('end', () => {
726
792
  if (res.statusCode !== 200) {
727
- console.log(` ${FG_RED}✗ Error: HTTP ${res.statusCode} — ${data}${RST}`);
793
+ messages.netError(`HTTP ${res.statusCode} — ${data}`);
728
794
  resolve('');
729
795
  return;
730
796
  }
@@ -732,15 +798,15 @@ function createApiClient({ getConfig, saveConfig, ui }) {
732
798
  try {
733
799
  const parsed = JSON.parse(data);
734
800
  const content = parsed.choices[0].message.content;
735
- console.log(content);
801
+ writer.scrollback(content);
736
802
  resolve(content);
737
803
  } catch (error) {
738
- console.log(` ${FG_RED}✗ Parse error: ${error.message}${RST}`);
804
+ messages.netError(`Parse error: ${error.message}`);
739
805
  resolve('');
740
806
  }
741
807
  });
742
808
  res.on('error', (error) => {
743
- console.log(` ${FG_RED}✗ ${error.message}${RST}`);
809
+ messages.netError(error.message);
744
810
  resolve('');
745
811
  });
746
812
  });
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