@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/.claude/settings.local.json +14 -1
- package/CLAUDE.md +2 -1
- package/index.js +15 -1
- package/lib/agent.js +607 -77
- package/lib/api.js +240 -23
- package/lib/commands.js +105 -81
- package/lib/config.js +32 -4
- package/lib/constants.js +67 -1
- package/lib/metrics.js +16 -3
- package/lib/permissions.js +66 -67
- package/lib/prompts.js +97 -83
- package/lib/tool_specs.js +499 -0
- package/lib/tools.js +645 -319
- package/lib/ui/ansi.js +17 -4
- package/lib/ui/chat-history.js +201 -61
- package/lib/ui/create-ui.js +116 -373
- package/lib/ui/diff.js +87 -75
- package/lib/ui/input-field.js +76 -58
- package/lib/ui/status-bar.js +56 -25
- package/lib/ui/terminal.js +58 -0
- package/lib/ui/theme.js +78 -0
- package/lib/ui/utils.js +63 -1
- package/lib/ui/writer.js +255 -0
- package/lib/ui.js +5 -0
- package/package.json +1 -1
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 {
|
|
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 —
|
|
189
|
-
//
|
|
190
|
-
//
|
|
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
|
-
|
|
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')
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
93
|
+
cfg = normalizeConfig(data);
|
|
68
94
|
} catch {}
|
|
69
95
|
}
|
|
70
|
-
|
|
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 {
|
|
98
|
-
lines.push(` system_prompt: ${
|
|
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
|
-
|
|
83
|
-
|
|
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}`));
|