@semalt-ai/code 1.8.1 → 1.8.4

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/config.js CHANGED
@@ -2,9 +2,31 @@
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
+ // audit: allowed — pre-UI startup warning, fires once before TUI initialises.
24
+ process.stderr.write(
25
+ "⚠ api_key='any' against non-local endpoint — requests will likely fail " +
26
+ "with 401. Run 'semalt-code config set api_key <key>' to set a real key.\n"
27
+ );
28
+ }
29
+
8
30
  function normalizeConfig(cfg = {}) {
9
31
  const merged = { ...DEFAULT_CONFIG, ...cfg };
10
32
  // Ensure every DEFAULT_CONFIG key is present without overwriting existing values
@@ -33,6 +55,7 @@ function normalizeConfig(cfg = {}) {
33
55
  merged.dashboard_model_id = Number.isInteger(cfg.dashboard_model_id) && cfg.dashboard_model_id > 0
34
56
  ? cfg.dashboard_model_id
35
57
  : null;
58
+ merged.repair_malformed_tool_xml = cfg.repair_malformed_tool_xml === true;
36
59
  merged.models = Array.isArray(cfg.models)
37
60
  ? cfg.models
38
61
  .filter((entry) => entry &&
@@ -53,6 +76,9 @@ function normalizeConfig(cfg = {}) {
53
76
  if (Number.isInteger(entry.context_length) && entry.context_length > 0) {
54
77
  normalized.context_length = entry.context_length;
55
78
  }
79
+ // native_tools defaults to true; only explicit false/0/"false"/"0" opts out.
80
+ const nt = entry.native_tools;
81
+ normalized.native_tools = !(nt === false || nt === 0 || nt === '0' || nt === 'false');
56
82
  return normalized;
57
83
  })
58
84
  : [];
@@ -61,13 +87,16 @@ function normalizeConfig(cfg = {}) {
61
87
 
62
88
  function loadConfig() {
63
89
  fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
90
+ let cfg;
64
91
  if (fs.existsSync(CONFIG_PATH)) {
65
92
  try {
66
93
  const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
67
- return normalizeConfig(data);
94
+ cfg = normalizeConfig(data);
68
95
  } catch {}
69
96
  }
70
- return normalizeConfig();
97
+ if (!cfg) cfg = normalizeConfig();
98
+ _maybeWarnApiKeyAny(cfg);
99
+ return cfg;
71
100
  }
72
101
 
73
102
  function saveConfig(cfg) {
@@ -94,8 +123,8 @@ function configShow(systemPromptOverride = null) {
94
123
  if (systemPromptOverride) {
95
124
  lines.push(` system_prompt: [override from ${systemPromptOverride}]`);
96
125
  } else {
97
- const { SYSTEM_PROMPT } = require('./prompts');
98
- lines.push(` system_prompt: ${SYSTEM_PROMPT.slice(0, 80)}...`);
126
+ const { getSystemPrompt } = require('./prompts');
127
+ lines.push(` system_prompt: ${getSystemPrompt().slice(0, 80)}...`);
99
128
  }
100
129
  return lines.join('\n');
101
130
  }
package/lib/constants.js CHANGED
@@ -12,23 +12,39 @@ const DEFAULT_CONFIG = {
12
12
  api_key: 'any',
13
13
  dashboard_url: 'https://cli.semalt.ai',
14
14
  auth_token: '',
15
- default_model: 'default',
15
+ default_model: '',
16
16
  dashboard_model_id: null,
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' },
@@ -59,7 +75,6 @@ const TAG_REGISTRY = {
59
75
  download: { type: 'tool', streaming: false, label: 'Downloading' },
60
76
  upload: { type: 'tool', streaming: false, label: 'Uploading' },
61
77
  http_get: { type: 'tool', streaming: false, label: 'Fetching URL' },
62
- http_get_next: { type: 'tool', streaming: false, label: 'Fetching next content chunk' },
63
78
  ask_user: { type: 'tool', streaming: false, label: 'Asking user' },
64
79
  store_memory: { type: 'tool', streaming: false, label: 'Storing memory' },
65
80
  recall_memory: { type: 'tool', streaming: false, label: 'Recalling memory' },
@@ -81,6 +96,14 @@ const TAG_REGISTRY = {
81
96
  tool_call: { type: 'tool', streaming: false, label: 'Using tool' },
82
97
  function_call: { type: 'tool', streaming: false, label: 'Using tool' },
83
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
+
84
107
  // Silently stripped — model wrapper artifacts
85
108
  answer: { type: 'strip' },
86
109
  response: { type: 'strip' },
@@ -90,8 +113,35 @@ const TAG_REGISTRY = {
90
113
  text: { type: 'strip' },
91
114
  result: { type: 'strip' },
92
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' },
93
121
  };
94
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
+
95
145
  module.exports = {
96
146
  CONFIG_PATH,
97
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}`));
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const writer = require('./ui/writer');
4
+ const messages = require('./ui/messages');
5
+
3
6
  const TIER_FS = ['read_file', 'write_file', 'append_file', 'delete_file', 'list_dir', 'make_dir', 'move_file', 'copy_file', 'file_stat', 'search_files', 'store_memory', 'recall_memory'];
4
7
  const TIER_EXEC = ['exec'];
5
8
  const TIER_NET = ['http_get', 'download'];
@@ -8,7 +11,6 @@ const TIER_SYS = ['system_info', 'get_env', 'set_env'];
8
11
  const TIER_MAP = { fs: TIER_FS, exec: TIER_EXEC, net: TIER_NET, sys: TIER_SYS };
9
12
  const READONLY_BLOCKED = new Set(['write_file', 'append_file', 'delete_file', 'move_file', 'copy_file']);
10
13
 
11
- let _permissionCounter = 0;
12
14
  let _permissionQueueTail = Promise.resolve();
13
15
 
14
16
  function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {}) {
@@ -21,8 +23,8 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
21
23
  }
22
24
 
23
25
  const state = {
24
- autoApproveShell: false,
25
- autoApproveFile: false,
26
+ autoApproveAll: false,
27
+ sessionApprovedTags: new Set(),
26
28
  };
27
29
 
28
30
  let uiCallbacks = null;
@@ -40,127 +42,127 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
40
42
 
41
43
  const MAX_DESC_LINES = 12;
42
44
 
43
- function requestPermission(description, onAddMessage, onRerenderMessage, onCollapseMessage, onCaptureNavigation) {
45
+ // The picker renders into the writer's modal region — a live band above
46
+ // the status bar that redraws in place on every keystroke. Arrow-key
47
+ // navigation rebuilds the lines array and calls onShowModal again; nothing
48
+ // lands in scrollback until the user confirms. On resolve/cancel the
49
+ // modal is cleared and a single summary line is emitted to scrollback
50
+ // (for multi-line descriptions — e.g. a file-write diff — the full body
51
+ // is retained so the user can still see what was approved).
52
+ function requestPermission(description, onShowModal, onCloseModal, onCaptureNavigation) {
44
53
  // Serialize dialogs: each permission waits for the previous one to be answered
45
54
  const myTurn = _permissionQueueTail;
46
55
  let releaseQueue;
47
56
  _permissionQueueTail = new Promise((r) => { releaseQueue = r; });
48
57
 
49
58
  return myTurn.then(() => new Promise((resolve) => {
50
- const uniqueId = `perm_${++_permissionCounter}`;
51
59
  const options = ['Yes', 'Always', 'No'];
52
60
  let selectedIdx = 0;
53
61
  const descLines = description.split('\n');
54
62
  const truncatable = descLines.length > MAX_DESC_LINES;
55
63
 
56
- function buildContent() {
57
- let visibleDesc;
58
- if (truncatable) {
59
- visibleDesc = descLines.slice(0, MAX_DESC_LINES).join('\n') +
60
- `\n \x1b[2m… ${descLines.length - MAX_DESC_LINES} more lines\x1b[0m`;
61
- } else {
62
- visibleDesc = description;
64
+ function buildModalLines() {
65
+ const lines = [];
66
+ const visible = truncatable
67
+ ? descLines.slice(0, MAX_DESC_LINES).concat([` \x1b[2m… ${descLines.length - MAX_DESC_LINES} more lines\x1b[0m`])
68
+ : descLines;
69
+ // First description line gets the bullet glyph; continuation lines
70
+ // are indented to align under it. Matches the pre-modal rendering
71
+ // that went through chatHistory's system-message renderer.
72
+ const first = visible[0] || '';
73
+ lines.push(` \x1b[38;5;244m●\x1b[0m \x1b[38;5;244m${first}\x1b[0m`);
74
+ for (let i = 1; i < visible.length; i++) {
75
+ lines.push(` \x1b[38;5;244m${visible[i]}\x1b[0m`);
63
76
  }
64
- const parts = [visibleDesc, ''];
77
+ lines.push('');
65
78
  for (let i = 0; i < options.length; i++) {
66
- parts.push(i === selectedIdx
79
+ lines.push(i === selectedIdx
67
80
  ? `\x1b[1m\x1b[36m ► ${options[i]}\x1b[0m`
68
81
  : ` ${options[i]}`
69
82
  );
70
83
  }
71
- return parts.join('\n');
84
+ return lines;
72
85
  }
73
86
 
74
- const permMsg = { role: 'system', id: uniqueId, content: buildContent() };
75
- onAddMessage(permMsg);
87
+ onShowModal(buildModalLines());
88
+
89
+ function finish(result) {
90
+ const chosen = result === 'cancel' ? 'no' : options[selectedIdx].toLowerCase();
91
+ const glyph = (chosen === 'no') ? '✗' : '✓';
92
+ // The full `description` is preserved in the summary so multi-line
93
+ // bodies (e.g. file-write diffs) remain visible in scrollback after
94
+ // the modal closes. chatHistory's system-message renderer styles the
95
+ // first line by the leading glyph and indents continuations.
96
+ onCloseModal(`${glyph} ${description}`);
97
+ releaseQueue();
98
+ resolve(chosen);
99
+ }
76
100
 
77
101
  const releaseNav = onCaptureNavigation((action) => {
78
102
  if (action === 'next') {
79
103
  selectedIdx = (selectedIdx + 1) % options.length;
80
- permMsg.content = buildContent();
81
- onRerenderMessage(uniqueId);
104
+ onShowModal(buildModalLines());
82
105
  } else if (action === 'prev') {
83
106
  selectedIdx = (selectedIdx - 1 + options.length) % options.length;
84
- permMsg.content = buildContent();
85
- onRerenderMessage(uniqueId);
107
+ onShowModal(buildModalLines());
86
108
  } else if (action === 'select') {
87
- const chosen = options[selectedIdx];
88
109
  releaseNav();
89
- permMsg.content = chosen === 'No' ? `✗ ${description}` : `✓ ${description}`;
90
- onCollapseMessage(uniqueId);
91
- releaseQueue();
92
- resolve(chosen.toLowerCase());
110
+ finish('select');
93
111
  } else if (action === 'cancel') {
94
112
  releaseNav();
95
- permMsg.content = `✗ ${description}`;
96
- onCollapseMessage(uniqueId);
97
- releaseQueue();
98
- resolve('no');
113
+ finish('cancel');
99
114
  }
100
115
  });
101
116
  }));
102
117
  }
103
118
 
104
- async function askPermission(actionType, description, tag) {
105
- if (tag && autoApprovedTags.has(tag)) {
106
- if (uiCallbacks) {
107
- uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approved: ${description}` });
108
- } else {
109
- console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approved: ${description}${RST}`);
110
- }
111
- return true;
119
+ function _emitAutoApproved(description) {
120
+ if (uiCallbacks) {
121
+ uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approved: ${description}` });
122
+ } else {
123
+ messages.sysSuccess(`Auto-approved: ${description}`);
112
124
  }
125
+ }
113
126
 
114
- if (actionType === 'shell' && state.autoApproveShell) {
115
- if (uiCallbacks) {
116
- uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approved: ${description}` });
117
- } else {
118
- console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approved: ${description}${RST}`);
119
- }
127
+ async function askPermission(actionType, description, tag) {
128
+ if (state.autoApproveAll) {
129
+ _emitAutoApproved(description);
120
130
  return true;
121
131
  }
122
132
 
123
- if (actionType === 'file' && state.autoApproveFile) {
124
- if (uiCallbacks) {
125
- uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approved: ${description}` });
126
- } else {
127
- console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approved: ${description}${RST}`);
128
- }
133
+ if (tag && (autoApprovedTags.has(tag) || state.sessionApprovedTags.has(tag))) {
134
+ _emitAutoApproved(description);
129
135
  return true;
130
136
  }
131
137
 
132
138
  if (!process.stdout.isTTY || !process.stdin.isTTY) {
133
- process.stdout.write(` [non-TTY] Auto-approving: ${description}\n`);
139
+ writer.scrollback(` [non-TTY] Auto-approving: ${description}`);
134
140
  return true;
135
141
  }
136
142
 
137
143
  if (uiCallbacks) {
138
144
  const result = await requestPermission(
139
145
  `${actionType}: ${description}`,
140
- uiCallbacks.onAddMessage,
141
- uiCallbacks.onRerenderMessage,
142
- uiCallbacks.onCollapseMessage,
146
+ uiCallbacks.onShowModal,
147
+ uiCallbacks.onCloseModal,
143
148
  uiCallbacks.onCaptureNavigation,
144
149
  );
145
150
 
146
151
  if (result === 'no') return false;
147
152
 
148
- if (result === 'always') {
149
- if (actionType === 'shell') state.autoApproveShell = true;
150
- else state.autoApproveFile = true;
151
- uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approve enabled for ${actionType} operations` });
153
+ if (result === 'always' && tag) {
154
+ state.sessionApprovedTags.add(tag);
155
+ uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approve enabled for \`${tag}\` this session` });
152
156
  }
153
157
 
154
158
  return true;
155
159
  }
156
160
 
157
- // Fallback: legacy TTY interactive select (used outside of chat UI)
158
- const alwaysLabel = actionType === 'shell' ? 'Yes, always for shell' : 'Yes, always for files';
161
+ // Fallback: TTY interactive select (used outside of chat UI)
162
+ const alwaysLabel = tag ? `Yes, always for <${tag}>` : 'Yes, always';
159
163
  const choices = ['Yes', alwaysLabel, 'No'];
160
164
 
161
- console.log();
162
- console.log(` ${FG_YELLOW}${BOLD}⚠ Permission required${RST}`);
163
- console.log(` ${FG_GRAY}${actionType}: ${description}${RST}`);
165
+ writer.scrollback(`\n ${FG_YELLOW}${BOLD}⚠ Permission required${RST}\n ${FG_GRAY}${actionType}: ${description}${RST}`);
164
166
 
165
167
  const selectedIndex = await interactiveSelect(
166
168
  choices,
@@ -173,14 +175,13 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
173
175
  );
174
176
 
175
177
  if (selectedIndex === null || selectedIndex === 2) {
176
- console.log(` ${FG_RED}✗${RST} ${FG_DARK}Denied${RST}`);
178
+ writer.scrollback(` ${FG_RED}✗${RST} ${FG_DARK}Denied${RST}`);
177
179
  return false;
178
180
  }
179
181
 
180
- if (selectedIndex === 1) {
181
- if (actionType === 'shell') state.autoApproveShell = true;
182
- else state.autoApproveFile = true;
183
- console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approve enabled for ${actionType} operations${RST}`);
182
+ if (selectedIndex === 1 && tag) {
183
+ state.sessionApprovedTags.add(tag);
184
+ writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approve enabled for <${tag}> this session${RST}`);
184
185
  }
185
186
 
186
187
  return true;
@@ -194,14 +195,13 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
194
195
  }
195
196
 
196
197
  function clear() {
197
- state.autoApproveShell = false;
198
- state.autoApproveFile = false;
198
+ state.autoApproveAll = false;
199
+ state.sessionApprovedTags.clear();
199
200
  }
200
201
 
201
202
  function toggleAll() {
202
- state.autoApproveShell = !state.autoApproveShell;
203
- state.autoApproveFile = !state.autoApproveFile;
204
- return state.autoApproveShell;
203
+ state.autoApproveAll = !state.autoApproveAll;
204
+ return state.autoApproveAll;
205
205
  }
206
206
 
207
207
  return {