@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/.claude/settings.local.json +3 -1
- package/CLAUDE.md +4 -1
- package/TECHNICAL_DEBT.md +66 -0
- package/index.js +9 -2
- package/lib/agent.js +234 -87
- package/lib/api.js +95 -6
- package/lib/args.js +22 -0
- package/lib/commands.js +168 -18
- package/lib/config.js +13 -0
- package/lib/debug.js +106 -0
- package/lib/proc.js +96 -0
- package/lib/prompts.js +4 -3
- package/lib/tool_specs.js +14 -7
- package/lib/tools.js +287 -113
- package/lib/ui/chat-history.js +19 -1
- package/lib/ui/format.js +79 -5
- package/lib/ui/terminal.js +10 -4
- package/lib/ui/writer.js +7 -9
- package/package.json +1 -1
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
if (tc.
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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'}
|
|
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
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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:
|
|
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
|
+
};
|