@semalt-ai/code 1.8.0 → 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/commands.js CHANGED
@@ -4,9 +4,10 @@ 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
+ const { AUDIT_LOG } = require('./audit');
10
11
 
11
12
  function formatTimeAgo(ts) {
12
13
  const diffMs = Date.now() - ts;
@@ -95,6 +96,7 @@ function createCommands({
95
96
  (m) => m.model === model || (m.api_base === config.api_base && m.model === config.default_model)
96
97
  );
97
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;
98
100
  return null;
99
101
  }
100
102
 
@@ -127,11 +129,21 @@ function createCommands({
127
129
 
128
130
  setUIActive(true);
129
131
 
132
+ const writer = require('./ui/writer');
130
133
  permissionManager.setUICallbacks({
131
134
  onAddMessage: (msg) => chatHistory.addMessage(msg),
132
135
  onRerenderMessage: (id) => chatHistory.rerenderById(id),
133
136
  onCollapseMessage: (id) => chatHistory.collapseById(id),
134
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
+ },
135
147
  onCaptureNavigation: (handler) => {
136
148
  inputField.captureNavigation(handler);
137
149
  return () => inputField.releaseNavigation();
@@ -150,6 +162,7 @@ function createCommands({
150
162
  let messages = [];
151
163
  let currentChatId = null;
152
164
  let savedUpTo = 0;
165
+ let debugMode = !!opts.debug;
153
166
 
154
167
  // Resolve system prompt override from --system-prompt file if provided
155
168
  let resolvedSystemPrompt = null;
@@ -185,14 +198,13 @@ function createCommands({
185
198
  }
186
199
  refreshInputSearchItems();
187
200
 
188
- // Banner — write at row 1, then compact the layout so the fixed panels sit
189
- // immediately below the banner with no blank gap. The layout grows as
190
- // 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.
191
205
  if (layout) {
192
- const BANNER_LINES = 8; // blank + top-border + empty + title + desc + empty + bottom-border + blank
193
206
  const w = Math.min(getCols() - 4, 60);
194
- process.stdout.write('\x1b[1;1H');
195
- process.stdout.write([
207
+ const banner = [
196
208
  ``,
197
209
  ` ${FG_DARK}╭${'─'.repeat(w + 1)}╮${RST}`,
198
210
  boxLine('', w),
@@ -201,19 +213,8 @@ function createCommands({
201
213
  boxLine('', w),
202
214
  ` ${FG_DARK}╰${'─'.repeat(w + 1)}╯${RST}`,
203
215
  ``,
204
- ].join('\n') + '\n');
205
-
206
- // Keep historyStart = 1 so the banner is inside the scroll region.
207
- // Growing mode uses _contentLines to position the first message below the
208
- // banner (at row BANNER_LINES + 1). When the terminal fills up the banner
209
- // scrolls naturally into the terminal scrollback — nothing disappears behind
210
- // a fixed header.
211
- layout._contentLines = BANNER_LINES;
212
- layout.rows = BANNER_LINES + 1 + layout.inputHeight + 3;
213
-
214
- // Erase the stale full-screen panels createUI drew before we compacted.
215
- process.stdout.write(`\x1b[${layout.rows + 1};1H\x1b[J`);
216
-
216
+ ].join('\n');
217
+ writer.scrollback(banner);
217
218
  redrawFixed();
218
219
  }
219
220
 
@@ -254,24 +255,29 @@ function createCommands({
254
255
  try { await dashboardSaveMessages(currentChatId, newMessages); savedUpTo = messages.length; } catch {}
255
256
  }
256
257
 
257
- const HISTORY_DISPLAY_TURNS = 3; // user+assistant pairs to show on load
258
-
259
258
  function displayLoadedMessages(loadedMessages) {
260
259
  chatHistory.clearMessages();
261
- const visible = loadedMessages.filter(
262
- (m) => (m.role === 'user' || m.role === 'assistant') &&
263
- (typeof m.content === 'string' ? m.content : '').trim()
264
- );
265
- const skip = Math.max(0, visible.length - HISTORY_DISPLAY_TURNS * 2);
266
- if (skip > 0) {
267
- chatHistory.addMessage({ role: 'system', content: `… ${skip} earlier messages not shown` });
268
- }
269
- for (const m of visible.slice(skip)) {
270
- chatHistory.addMessage({
271
- role: m.role,
272
- content: typeof m.content === 'string' ? m.content : '',
273
- ts: m.created_at ? new Date(m.created_at) : (m.ts ? new Date(m.ts) : new Date()),
274
- });
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 });
275
281
  }
276
282
  }
277
283
 
@@ -299,28 +305,6 @@ function createCommands({
299
305
  const PAGE_SIZE = 5;
300
306
  let listMsg = null;
301
307
 
302
- // In-place progress indicator for chunked HTTP fetches (http_get + http_get_next)
303
- let httpFetchMsg = null;
304
-
305
- function showHttpFetchProgress(url, part, total) {
306
- const maxUrl = Math.max(20, getCols() - 35);
307
- const shortUrl = url.length > maxUrl ? url.slice(0, maxUrl - 1) + '…' : url;
308
- const content = `Fetching URL · ${shortUrl} · Part ${part}/${total}`;
309
- if (!httpFetchMsg) {
310
- httpFetchMsg = { role: 'tool', tag: 'http_get', content, id: `http-fetch-${Date.now()}` };
311
- chatHistory.addMessage(httpFetchMsg);
312
- } else {
313
- httpFetchMsg.content = content;
314
- chatHistory.rerenderById(httpFetchMsg.id);
315
- }
316
- }
317
-
318
- function finalizeHttpFetch() {
319
- if (!httpFetchMsg) return;
320
- chatHistory.removeById(httpFetchMsg.id);
321
- httpFetchMsg = null;
322
- }
323
-
324
308
  function getNavSearchText(type, item) {
325
309
  if (type === 'history') {
326
310
  const date = new Date(item.created_at).toISOString().slice(0, 16);
@@ -576,6 +560,7 @@ function createCommands({
576
560
  ' /shell <cmd> Run shell command',
577
561
  ' !<cmd> Run shell command',
578
562
  ' /approve Toggle auto-approve',
563
+ ' /debug [off] Enable debug output + show last 5 audit entries',
579
564
  ' /config Show config',
580
565
  ' exit Quit',
581
566
  ].join('\n'),
@@ -737,7 +722,7 @@ function createCommands({
737
722
  }
738
723
 
739
724
  if (text === '/prompt') {
740
- const activePrompt = resolvedSystemPrompt !== null ? resolvedSystemPrompt : SYSTEM_PROMPT;
725
+ const activePrompt = resolvedSystemPrompt !== null ? resolvedSystemPrompt : getSystemPrompt();
741
726
  const src = resolvedSystemPrompt !== null ? `file: ${opts.systemPromptFile}` : 'built-in';
742
727
  const mode = getConfig().system_prompt_mode || 'system_role';
743
728
  chatHistory.addMessage({
@@ -753,6 +738,40 @@ function createCommands({
753
738
  return;
754
739
  }
755
740
 
741
+ if (text === '/debug' || text.startsWith('/debug ')) {
742
+ const arg = text === '/debug' ? '' : text.slice(7).trim().toLowerCase();
743
+ if (arg === 'off' || arg === 'false' || arg === '0') debugMode = false;
744
+ else debugMode = true;
745
+
746
+ let tail = '';
747
+ try {
748
+ const content = fs.readFileSync(AUDIT_LOG, 'utf8');
749
+ const lines = content.trim().split('\n').filter((l) => l.trim()).slice(-5);
750
+ if (lines.length) {
751
+ const formatted = lines.map((line) => {
752
+ try {
753
+ const entry = JSON.parse(line);
754
+ const mark = entry.approved ? '✓' : '✗';
755
+ return ` ${mark} ${entry.ts} ${entry.tag} ${entry.input} → ${entry.result}`;
756
+ } catch {
757
+ return ` ${line}`;
758
+ }
759
+ });
760
+ tail = '\nLast 5 audit entries:\n' + formatted.join('\n');
761
+ } else {
762
+ tail = '\nAudit log is empty.';
763
+ }
764
+ } catch {
765
+ tail = '\nNo audit log found.';
766
+ }
767
+
768
+ chatHistory.addMessage({
769
+ role: 'system',
770
+ content: `Debug output: ${debugMode ? 'ON' : 'OFF'} (raw messages, raw AI responses, raw HTTP errors → stderr)${tail}`,
771
+ });
772
+ return;
773
+ }
774
+
756
775
  if (text.startsWith('/shell ') || text.startsWith('!')) {
757
776
  const cmd = text.startsWith('/shell ') ? text.slice(7).trim() : text.slice(1).trim();
758
777
  inputField.setDisabled(true);
@@ -810,7 +829,12 @@ function createCommands({
810
829
  if (entry?.type === 'tool') {
811
830
  const actionLabel = entry.label || tag;
812
831
  const detail = attrs.path || attrs.url || attrs.key || attrs.src || '';
813
- statusBar.update('tool', `${actionLabel}${detail ? ': ' + detail : ''}`);
832
+ const isDownload = tag === 'download' || tag === 'http_get';
833
+ const barState = isDownload ? 'waiting_download' : 'tool';
834
+ const label = isDownload
835
+ ? `Waiting for download${detail ? ': ' + detail : ''}`
836
+ : `${actionLabel}${detail ? ': ' + detail : ''}`;
837
+ statusBar.update(barState, label);
814
838
  if (!opts.showThink) chatHistory.clearStreamingContent();
815
839
  }
816
840
  if (entry?.display === 'think_bubble') {
@@ -824,12 +848,16 @@ function createCommands({
824
848
  onToolStart: (tag, input, attrs) => {
825
849
  const actionLabel = TAG_REGISTRY[tag]?.label || tag;
826
850
  const short = input.length > 40 ? input.slice(0, 40) + '…' : input;
827
- statusBar.update('tool', `${actionLabel}: ${short}`);
851
+ const isDownload = tag === 'download' || tag === 'http_get';
852
+ if (isDownload) {
853
+ statusBar.update('waiting_download', `Waiting for download: ${short}`);
854
+ } else {
855
+ statusBar.update('tool', `${actionLabel}: ${short}`);
856
+ }
828
857
  },
829
858
  onToolEnd: (tag, result, durationMs) => {
830
859
  const isError = typeof result === 'string' && result.startsWith('Error');
831
860
  if (isError) {
832
- finalizeHttpFetch();
833
861
  chatHistory.addMessage({
834
862
  role: 'tool',
835
863
  tag,
@@ -837,24 +865,6 @@ function createCommands({
837
865
  output: typeof result === 'string' && result.trim() ? result : null,
838
866
  });
839
867
  statusBar.update('streaming', 'Streaming response');
840
- } else if (tag === 'http_get') {
841
- const chunkedMatch = typeof result === 'string' && result.match(/^HTTP GET (.+?) \(\d+\) \[Part 1\/(\d+)\]/);
842
- if (chunkedMatch) {
843
- showHttpFetchProgress(chunkedMatch[1], 1, parseInt(chunkedMatch[2], 10));
844
- } else {
845
- finalizeHttpFetch();
846
- statusBar.update('tool', `✓ ${TAG_REGISTRY[tag]?.label || tag} [${durationMs}ms]`);
847
- }
848
- } else if (tag === 'http_get_next') {
849
- const partMatch = typeof result === 'string' && result.match(/^HTTP content "(.+?)" \[Part (\d+)\/(\d+)\]/);
850
- if (partMatch) {
851
- const part = parseInt(partMatch[2], 10);
852
- const total = parseInt(partMatch[3], 10);
853
- showHttpFetchProgress(partMatch[1], part, total);
854
- if (part === total) finalizeHttpFetch();
855
- } else {
856
- finalizeHttpFetch();
857
- }
858
868
  } else {
859
869
  const actionLabel = TAG_REGISTRY[tag]?.label || tag;
860
870
  statusBar.update('tool', `✓ ${actionLabel} [${durationMs}ms]`);
@@ -890,6 +900,11 @@ function createCommands({
890
900
  onRetry: (attempt, max) => {
891
901
  statusBar.update('thinking', `Retrying (${attempt}/${max})...`);
892
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
+ },
893
908
  onError: (err) => {
894
909
  if (err && err.isWarning) {
895
910
  chatHistory.addMessage({ role: 'system', content: err.message || String(err) });
@@ -910,10 +925,19 @@ function createCommands({
910
925
  };
911
926
  inputField.on('abort', _onAbort);
912
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
+
913
937
  try {
914
938
  const agentResult = await runAgentLoop(messages, currentModel, undefined, resolvedTokenLimit, {
915
939
  showThink: opts.showThink || false,
916
- debug: opts.debug || false,
940
+ debug: debugMode,
917
941
  callbacks,
918
942
  systemPrompt: resolvedSystemPrompt,
919
943
  systemPromptMode: getConfig().system_prompt_mode || 'system_role',
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
  }
package/lib/constants.js CHANGED
@@ -17,23 +17,40 @@ const DEFAULT_CONFIG = {
17
17
  temperature: 0.7,
18
18
  request_timeout_ms: DEFAULT_API_TIMEOUT_MS,
19
19
  stream: true,
20
+ // native_tools (boolean, default true): when true, the
21
+ // client sends an OpenAI-format `tools` parameter and
22
+ // expects structured tool_calls in responses. Set to
23
+ // false only for models/endpoints that do not support
24
+ // native function calling (legacy finetunes, XML-only
25
+ // adapters). Per-profile flag on models[] entries.
20
26
  models: [],
21
27
  theme: 'dark',
22
28
  max_file_size_kb: 512,
23
29
  command_timeout_ms: 30000,
24
30
  max_output_lines: 50,
31
+ http_fetch_max_bytes: 262144,
25
32
  show_token_count: true,
26
33
  show_cost: false,
27
34
  system_prompt_mode: 'system_role',
35
+ repair_malformed_tool_xml: false,
28
36
  };
29
37
 
30
38
  const CONFIG_PATH = path.join(os.homedir(), '.semalt-ai', 'config.json');
31
39
 
40
+ // TAG_REGISTRY classifies every XML tag the stream parser may encounter.
41
+ // For 'tool'-type tags, the *parameter schema* lives in lib/tool_specs.js
42
+ // (TOOL_SPECS) — that file is the single source of truth for argument
43
+ // names, types, required flags, and descriptions used to build the
44
+ // native function-calling `tools` array and the system-prompt tag
45
+ // inventory. Adding or renaming a 'tool' entry here requires a matching
46
+ // change in TOOL_SPECS; the assertion at the bottom of this module
47
+ // enforces that parity at load time.
32
48
  const TAG_REGISTRY = {
33
49
  // Rendered visually in chat, never shown as raw text
34
50
  think: { type: 'visual', streaming: true, display: 'think_bubble' },
35
51
  reasoning: { type: 'visual', streaming: true, display: 'think_bubble' },
36
52
  reflection: { type: 'visual', streaming: true, display: 'think_bubble' },
53
+ plan: { type: 'visual', streaming: true, display: 'think_bubble' },
37
54
 
38
55
  // Executed as tool calls
39
56
  exec: { type: 'tool', streaming: false, label: 'Running command' },
@@ -58,13 +75,35 @@ const TAG_REGISTRY = {
58
75
  download: { type: 'tool', streaming: false, label: 'Downloading' },
59
76
  upload: { type: 'tool', streaming: false, label: 'Uploading' },
60
77
  http_get: { type: 'tool', streaming: false, label: 'Fetching URL' },
61
- http_get_next: { type: 'tool', streaming: false, label: 'Fetching next content chunk' },
62
78
  ask_user: { type: 'tool', streaming: false, label: 'Asking user' },
63
79
  store_memory: { type: 'tool', streaming: false, label: 'Storing memory' },
64
80
  recall_memory: { type: 'tool', streaming: false, label: 'Recalling memory' },
65
81
  list_memories: { type: 'tool', streaming: false, label: 'Listing memories' },
66
82
  system_info: { type: 'tool', streaming: false, label: 'Reading system info' },
67
83
 
84
+ // MiniMax-M2 native tool-call wrappers. `extractToolCalls` parses them into
85
+ // internal calls; classifying them here keeps raw XML out of the UI stream.
86
+ 'minimax:tool_call': { type: 'tool', streaming: false, label: 'Using tool' },
87
+ invoke: { type: 'strip' },
88
+ parameter: { type: 'strip' },
89
+
90
+ // Qwen / Hermes native tool-call wrappers. Qwen3-family models emit a
91
+ // JSON-shaped `<tool_call>{"name":"...","arguments":{...}}</tool_call>`
92
+ // block inline when the server's tool parser is not applied, and some
93
+ // finetunes also use the namespaced `<qwen:tool_call>` or the
94
+ // `<function_call>` spelling. `extractToolCalls` parses all three.
95
+ 'qwen:tool_call': { type: 'tool', streaming: false, label: 'Using tool' },
96
+ tool_call: { type: 'tool', streaming: false, label: 'Using tool' },
97
+ function_call: { type: 'tool', streaming: false, label: 'Using tool' },
98
+
99
+ // Qwen3-Coder / Qwen3.5 XML tool-call format: `<function=tool_name>…</function>`.
100
+ // The tool name is carried as an `=name` suffix on the opening tag rather
101
+ // than an attribute; `parameter` (already registered as `strip` above) covers
102
+ // the matching `<parameter=key>…</parameter>` child tags. StreamParser splits
103
+ // the tag name on `[\s=]`, so the registry lookup for `<function=read_file>`
104
+ // resolves here.
105
+ function: { type: 'tool', streaming: false, label: 'Using tool' },
106
+
68
107
  // Silently stripped — model wrapper artifacts
69
108
  answer: { type: 'strip' },
70
109
  response: { type: 'strip' },
@@ -74,8 +113,35 @@ const TAG_REGISTRY = {
74
113
  text: { type: 'strip' },
75
114
  result: { type: 'strip' },
76
115
  code: { type: 'strip' },
116
+
117
+ // Protocol wrapper: the model's declared final reply to the user. Tags are
118
+ // stripped from rendered output but the inner content IS the user-facing
119
+ // answer and must stream through onToken, not be buffered like tool blocks.
120
+ final_answer: { type: 'final', streaming: true, label: 'Final answer' },
77
121
  };
78
122
 
123
+ // Load-time parity check: every 'tool'-type tag in TAG_REGISTRY must have a
124
+ // matching entry in TOOL_SPECS, and TOOL_SPECS must not declare phantom
125
+ // tools that aren't registered. Requiring tool_specs.js here (rather than
126
+ // at the top of the file) keeps the module boundary one-directional —
127
+ // tool_specs.js does not depend on this file.
128
+ const { TOOL_SPECS } = require('./tool_specs');
129
+ (function assertToolSpecParity() {
130
+ const registryTools = Object.entries(TAG_REGISTRY)
131
+ .filter(([, v]) => v.type === 'tool')
132
+ .map(([k]) => k)
133
+ .sort();
134
+ const specTools = Object.keys(TOOL_SPECS).sort();
135
+ const missing = registryTools.filter((k) => !Object.prototype.hasOwnProperty.call(TOOL_SPECS, k));
136
+ const extra = specTools.filter((k) => !(k in TAG_REGISTRY) || TAG_REGISTRY[k].type !== 'tool');
137
+ if (missing.length || extra.length) {
138
+ const parts = [];
139
+ if (missing.length) parts.push(`missing in TOOL_SPECS: ${missing.join(', ')}`);
140
+ if (extra.length) parts.push(`extra in TOOL_SPECS: ${extra.join(', ')}`);
141
+ throw new Error(`TAG_REGISTRY ↔ TOOL_SPECS mismatch — ${parts.join('; ')}`);
142
+ }
143
+ })();
144
+
79
145
  module.exports = {
80
146
  CONFIG_PATH,
81
147
  DEFAULT_API_TIMEOUT_MS,
package/lib/metrics.js CHANGED
@@ -32,13 +32,22 @@ class Metrics {
32
32
  }
33
33
 
34
34
  tokenLimitStatus() {
35
- if (this.modelTokenLimit === null) return null;
36
35
  const used = this.contextTokens();
36
+ if (this.modelTokenLimit == null) {
37
+ // No known limit — still expose `used` once we have a turn's prompt_tokens
38
+ // so the UI can render "N tok · limit unknown" instead of hiding the line.
39
+ if (!this.turns.length || !used) return null;
40
+ return { used, limit: null, pct: null, bar: null };
41
+ }
37
42
  const pct = Math.round((used / this.modelTokenLimit) * 100);
38
43
  const bar = this._buildBar(pct, 10);
39
44
  return { used, limit: this.modelTokenLimit, pct, bar };
40
45
  }
41
46
 
47
+ setModelTokenLimit(limit) {
48
+ this.modelTokenLimit = Number.isInteger(limit) && limit > 0 ? limit : null;
49
+ }
50
+
42
51
  _buildBar(pct, width) {
43
52
  const filled = Math.min(Math.round((pct / 100) * width), width);
44
53
  const empty = Math.max(0, width - filled);
@@ -79,8 +88,12 @@ class Metrics {
79
88
 
80
89
  const status = this.tokenLimitStatus();
81
90
  if (status !== null) {
82
- lines.push(row(` Context used: ${this.contextTokens()}`));
83
- lines.push(row(` Token limit: ${status.used}/${status.limit} (${status.pct}%)`));
91
+ if (status.limit === null) {
92
+ lines.push(row(` Context used: ${status.used} (limit unknown)`));
93
+ } else {
94
+ lines.push(row(` Context used: ${this.contextTokens()}`));
95
+ lines.push(row(` Token limit: ${status.used}/${status.limit} (${status.pct}%)`));
96
+ }
84
97
  }
85
98
 
86
99
  lines.push(row(` Duration: ${durationStr}`));