dave-code 1.0.0
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/LICENSE +15 -0
- package/README.md +53 -0
- package/assets/dave-octopus.ico +0 -0
- package/assets/dave-octopus.png +0 -0
- package/bin/aiClient.js +243 -0
- package/bin/cliMenu.js +496 -0
- package/bin/configManager.js +220 -0
- package/bin/index.js +994 -0
- package/bin/logoRenderer.js +71 -0
- package/package.json +29 -0
package/bin/index.js
ADDED
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ── DESIGN PHILOSOPHY / 视觉设计与交互规范 ──
|
|
4
|
+
* 为了向用户提供极致、顺滑的交互体验,Dave Code 规定:
|
|
5
|
+
* 1. 所有耗时的 API 请求、大文件处理和本地工具调用,都必须具有动画化(Animated)或动态(Dynamic)的终端 UI。
|
|
6
|
+
* 2. 严禁使用静态、刷屏式的连续 Log 输出。对于多步骤操作(如分片消化),应使用单行原地更新(In-place update)的进度条和加载动画。
|
|
7
|
+
* 3. 使用富文本终端色彩(如精心调配的 HSL/ANSI 颜色)和微型字符动画(如 Spinner)来提供实时的视觉反馈。
|
|
8
|
+
* 后续维护与功能续写时,必须严格遵守此设计理念。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { exec } from 'child_process';
|
|
14
|
+
import { getLogoLines } from './logoRenderer.js';
|
|
15
|
+
import { hasApiKey, getAIResponse, sessionTokenUsage, resetTokenUsage } from './aiClient.js';
|
|
16
|
+
import {
|
|
17
|
+
CONFIG_FILE,
|
|
18
|
+
getProfiles,
|
|
19
|
+
getActiveProfile,
|
|
20
|
+
setActiveProfile,
|
|
21
|
+
updateProfile,
|
|
22
|
+
openConfigFileInEditor,
|
|
23
|
+
getActiveEffort,
|
|
24
|
+
setActiveEffort,
|
|
25
|
+
EFFORT_PRESETS
|
|
26
|
+
} from './configManager.js';
|
|
27
|
+
import {
|
|
28
|
+
selectMenu,
|
|
29
|
+
secureInputPrompt,
|
|
30
|
+
chatInputPrompt,
|
|
31
|
+
waitForEnter,
|
|
32
|
+
startSpinner,
|
|
33
|
+
selectEffortMenu,
|
|
34
|
+
confirmPrompt
|
|
35
|
+
} from './cliMenu.js';
|
|
36
|
+
|
|
37
|
+
// Localization Dictionary
|
|
38
|
+
const locales = {
|
|
39
|
+
cn: {
|
|
40
|
+
welcome: '欢迎回来!',
|
|
41
|
+
modelAgentSuffix: '智能体',
|
|
42
|
+
tipsTitle: '\x1b[1;38;2;250;100;30m新手指引\x1b[0m',
|
|
43
|
+
tipsLines: [
|
|
44
|
+
'运行 \x1b[36m/help\x1b[0m 列出所有可用的 CLI 命令',
|
|
45
|
+
'提示:您已在项目工作目录中启动了 dave。',
|
|
46
|
+
'为了获得最佳体验,请在 git 仓库中运行。',
|
|
47
|
+
''
|
|
48
|
+
],
|
|
49
|
+
newsTitle: '\x1b[1;38;2;250;100;30m更新日志\x1b[0m',
|
|
50
|
+
newsLines: [
|
|
51
|
+
'简化了配置文件结构为扁平的 JSON 数组!',
|
|
52
|
+
'输入 /config 将直接用默认文本编辑器打开配置文件 (~/.dave-code-config.json)',
|
|
53
|
+
'数组的第一个模型配置对象将自动作为当前活动大模型服务',
|
|
54
|
+
'通过键盘方向键输入 /model 即可快速调整模型列表顺序进行切换',
|
|
55
|
+
''
|
|
56
|
+
],
|
|
57
|
+
docTitle: '\x1b[1;38;2;250;100;30m文档与支持\x1b[0m',
|
|
58
|
+
docLines: [
|
|
59
|
+
'访问项目文件夹以阅读相关文档。',
|
|
60
|
+
'按 Ctrl+C 或输入 \x1b[36m/exit\x1b[0m 关闭代理。',
|
|
61
|
+
'输入 \x1b[36m/lang\x1b[0m 切换中英文界面。'
|
|
62
|
+
],
|
|
63
|
+
thinking: '正在思考...',
|
|
64
|
+
helpTitle: '可用命令:',
|
|
65
|
+
helpLines: [
|
|
66
|
+
' /help - 显示此帮助信息',
|
|
67
|
+
' /clear - 清屏并重置主面板',
|
|
68
|
+
' /reset - 重置对话历史',
|
|
69
|
+
' /stats - 显示当前会话的 Token 使用统计',
|
|
70
|
+
' /lang - 切换中英文界面',
|
|
71
|
+
' /config - 编辑本地配置文件 (~/.dave-code-config.json)',
|
|
72
|
+
' /model - 快捷切换当前激活的服务配置',
|
|
73
|
+
' /api - 快捷修改当前配置的 API 密钥',
|
|
74
|
+
' /effort - 调整 AI 思考与阅读的努力程度 (low/medium/high/xhigh/max/ultracode)',
|
|
75
|
+
' /open - 打开文件或文件夹并在其中工作',
|
|
76
|
+
' /exit - 退出 Dave Code 代理'
|
|
77
|
+
],
|
|
78
|
+
goodbye: '再见!',
|
|
79
|
+
switchMsg: '已切换为中文界面!',
|
|
80
|
+
resetMsg: '对话历史已成功重置!',
|
|
81
|
+
noKeyError: `\x1b[31m错误:未检测到任何有效的服务配置!\x1b[0m
|
|
82
|
+
请运行 \x1b[36m/config\x1b[0m 打开配置文件,并在 JSON 数组中添加您的服务配置。`,
|
|
83
|
+
profileSelectTitle: '切换服务模型 (Switch Model)',
|
|
84
|
+
profileSelectDesc: '选择一个您配置的模型。系统会将所选模型移动到配置数组首位以进行激活。',
|
|
85
|
+
enterKeyPrompt: '请输入新的 API 密钥 (输入内容会被隐藏): ',
|
|
86
|
+
keySuccess: 'API 密钥已更新成功:',
|
|
87
|
+
noActiveError: '错误:没有已保存的服务配置。请先运行 /config 并在配置文件中添加模型。',
|
|
88
|
+
openingConfigMsg: `\n\x1b[32m正在您的默认文本编辑器中打开配置文件...\x1b[0m
|
|
89
|
+
文件路径: \x1b[36m${CONFIG_FILE}\x1b[0m
|
|
90
|
+
请在打开的编辑器中修改、保存并关闭文件。
|
|
91
|
+
保存并关闭文件后,请在下方按 \x1b[1;32m[回车 (Enter)]\x1b[0m 键重载配置。`,
|
|
92
|
+
reloadingConfigMsg: '\n\x1b[32m正在重载配置...\x1b[0m\n',
|
|
93
|
+
openDirSuccess: '已切换工作目录至: ',
|
|
94
|
+
openFileSuccess: '已在编辑器中打开文件,并将其内容加载至 AI 上下文。',
|
|
95
|
+
openPathError: '路径不存在或无法访问:',
|
|
96
|
+
openUsagePrompt: '使用方法:/open <文件或文件夹路径>'
|
|
97
|
+
},
|
|
98
|
+
en: {
|
|
99
|
+
welcome: 'Welcome back!',
|
|
100
|
+
modelAgentSuffix: 'Agent',
|
|
101
|
+
tipsTitle: '\x1b[1;38;2;250;100;30mTips for getting started\x1b[0m',
|
|
102
|
+
tipsLines: [
|
|
103
|
+
'Run \x1b[36m/help\x1b[0m to list available CLI commands',
|
|
104
|
+
'Note: You have launched dave in your project directory.',
|
|
105
|
+
'For the best experience, run it in a git repository.',
|
|
106
|
+
''
|
|
107
|
+
],
|
|
108
|
+
newsTitle: '\x1b[1;38;2;250;100;30mWhat\'s new\x1b[0m',
|
|
109
|
+
newsLines: [
|
|
110
|
+
'Simplified configuration format to a flat JSON array!',
|
|
111
|
+
'Type /config to immediately edit the JSON config file (~/.dave-code-config.json)',
|
|
112
|
+
'The first model configuration object in the array serves as the active model',
|
|
113
|
+
'Run /model to dynamically reorder and switch between configured models',
|
|
114
|
+
''
|
|
115
|
+
],
|
|
116
|
+
docTitle: '\x1b[1;38;2;250;100;30mDocumentation & Support\x1b[0m',
|
|
117
|
+
docLines: [
|
|
118
|
+
'Visit our project folder to read documentation.',
|
|
119
|
+
'Press Ctrl+C or type \x1b[36m/exit\x1b[0m to close the agent.',
|
|
120
|
+
'Type \x1b[36m/lang\x1b[0m to switch language.'
|
|
121
|
+
],
|
|
122
|
+
thinking: 'Thinking...',
|
|
123
|
+
helpTitle: 'Available commands:',
|
|
124
|
+
helpLines: [
|
|
125
|
+
' /help - Show this help message',
|
|
126
|
+
' /clear - Clear screen and refresh home panel',
|
|
127
|
+
' /reset - Reset conversation history',
|
|
128
|
+
' /stats - Show session token usage statistics',
|
|
129
|
+
' /lang - Switch language between Chinese and English',
|
|
130
|
+
' /config - Open and edit config file (~/.dave-code-config.json)',
|
|
131
|
+
' /model - Interactively switch active service profile',
|
|
132
|
+
' /api - Interactively set API key for active profile',
|
|
133
|
+
' /effort - Adjust AI thinking/reading effort level (low/medium/high/xhigh/max/ultracode)',
|
|
134
|
+
' /open - Open a file or directory to work in',
|
|
135
|
+
' /exit - Exit the Dave Code agent'
|
|
136
|
+
],
|
|
137
|
+
goodbye: 'Goodbye!',
|
|
138
|
+
switchMsg: 'Switched to English interface!',
|
|
139
|
+
resetMsg: 'Conversation history reset successfully!',
|
|
140
|
+
noKeyError: `\x1b[31mError: No valid configuration profile found!\x1b[0m
|
|
141
|
+
Please run \x1b[36m/config\x1b[0m to open the config file and add your service settings inside the JSON array.`,
|
|
142
|
+
profileSelectTitle: 'Switch Model',
|
|
143
|
+
profileSelectDesc: 'Select a configured model. The chosen model will be moved to the top of the array.',
|
|
144
|
+
enterKeyPrompt: 'Enter new API key (input characters will be hidden): ',
|
|
145
|
+
keySuccess: 'API key updated successfully: ',
|
|
146
|
+
noActiveError: 'Error: No active profile. Please run /config to open the file and add services.',
|
|
147
|
+
openingConfigMsg: `\n\x1b[32mOpening configuration file in your default text editor...\x1b[0m
|
|
148
|
+
File Path: \x1b[36m${CONFIG_FILE}\x1b[0m
|
|
149
|
+
Please edit and save the file in your editor.
|
|
150
|
+
Once done, press \x1b[1;32m[Enter]\x1b[0m below in this terminal to reload configuration.`,
|
|
151
|
+
reloadingConfigMsg: '\n\x1b[32mReloading configuration...\x1b[0m\n',
|
|
152
|
+
openDirSuccess: 'Switched working directory to: ',
|
|
153
|
+
openFileSuccess: 'Opened file in editor and loaded its content into AI context.',
|
|
154
|
+
openPathError: 'Path does not exist or is not accessible: ',
|
|
155
|
+
openUsagePrompt: 'Usage: /open <file or folder path>'
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
let currentLang = 'cn';
|
|
160
|
+
let messages = [];
|
|
161
|
+
let activeOpenFile = null;
|
|
162
|
+
|
|
163
|
+
function getStringWidth(str) {
|
|
164
|
+
const cleanStr = str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
165
|
+
let width = 0;
|
|
166
|
+
for (let i = 0; i < cleanStr.length; i++) {
|
|
167
|
+
const code = cleanStr.charCodeAt(i);
|
|
168
|
+
if (
|
|
169
|
+
(code >= 0x4e00 && code <= 0x9fff) ||
|
|
170
|
+
(code >= 0x3400 && code <= 0x4dbf) ||
|
|
171
|
+
(code >= 0x3000 && code <= 0x303f) ||
|
|
172
|
+
(code >= 0xff00 && code <= 0xffef)
|
|
173
|
+
) {
|
|
174
|
+
width += 2;
|
|
175
|
+
} else {
|
|
176
|
+
width += 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return width;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function centerLine(str, width) {
|
|
183
|
+
const strWidth = getStringWidth(str);
|
|
184
|
+
if (strWidth >= width) return str;
|
|
185
|
+
const leftPad = Math.floor((width - strWidth) / 2);
|
|
186
|
+
const rightPad = width - strWidth - leftPad;
|
|
187
|
+
return ' '.repeat(leftPad) + str + ' '.repeat(rightPad);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function padLine(str, width) {
|
|
191
|
+
const strWidth = getStringWidth(str);
|
|
192
|
+
if (strWidth >= width) return str;
|
|
193
|
+
return str + ' '.repeat(width - strWidth);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function maskKey(key) {
|
|
197
|
+
if (!key) return '(None/Not Configured)';
|
|
198
|
+
if (key.length <= 8) return '********';
|
|
199
|
+
return key.slice(0, 6) + '...' + key.slice(-4);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function clearConsole() {
|
|
203
|
+
process.stdout.write('\x1b[2J\x1b[0f');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function runConcurrentLimit(items, limit, fn) {
|
|
207
|
+
const results = [];
|
|
208
|
+
const executing = [];
|
|
209
|
+
for (const item of items) {
|
|
210
|
+
const p = Promise.resolve().then(() => fn(item));
|
|
211
|
+
results.push(p);
|
|
212
|
+
if (limit <= items.length) {
|
|
213
|
+
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
|
|
214
|
+
executing.push(e);
|
|
215
|
+
if (executing.length >= limit) {
|
|
216
|
+
await Promise.race(executing);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return Promise.all(results);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function runFileDigestionWorkflow(filePath) {
|
|
224
|
+
const resolvedPath = path.resolve(filePath);
|
|
225
|
+
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
226
|
+
const lines = content.split('\n');
|
|
227
|
+
const totalLines = lines.length;
|
|
228
|
+
|
|
229
|
+
const chunkSize = 2000;
|
|
230
|
+
const chunks = [];
|
|
231
|
+
for (let i = 0; i < totalLines; i += chunkSize) {
|
|
232
|
+
chunks.push(lines.slice(i, i + chunkSize).join('\n'));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const totalChunks = chunks.length;
|
|
236
|
+
let completedCount = 0;
|
|
237
|
+
let failedCount = 0;
|
|
238
|
+
|
|
239
|
+
const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
240
|
+
let spinnerIdx = 0;
|
|
241
|
+
|
|
242
|
+
function drawProgressBar() {
|
|
243
|
+
const percentage = totalChunks > 0 ? Math.round((completedCount / totalChunks) * 100) : 100;
|
|
244
|
+
const barLength = 25;
|
|
245
|
+
const filledLength = totalChunks > 0 ? Math.round((completedCount / totalChunks) * barLength) : barLength;
|
|
246
|
+
const emptyLength = barLength - filledLength;
|
|
247
|
+
|
|
248
|
+
const filledBar = '█'.repeat(filledLength);
|
|
249
|
+
const emptyBar = '░'.repeat(emptyLength);
|
|
250
|
+
|
|
251
|
+
const barColor = completedCount === totalChunks ? '\x1b[32m' : '\x1b[38;2;140;90;240m';
|
|
252
|
+
const spinnerFrame = spinnerFrames[spinnerIdx];
|
|
253
|
+
|
|
254
|
+
let suffix = '';
|
|
255
|
+
if (failedCount > 0) {
|
|
256
|
+
suffix = ` \x1b[31m(${failedCount} failed)\x1b[0m`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
process.stdout.write(
|
|
260
|
+
`\r\x1b[K\x1b[35m[Ultracode Workflow]\x1b[0m Digesting ${path.basename(filePath)}: ` +
|
|
261
|
+
`${barColor}[${filledBar}${emptyBar}]\x1b[0m ${percentage}% ` +
|
|
262
|
+
`(${completedCount}/${totalChunks} chunks) ${completedCount === totalChunks ? '✔' : '\x1b[36m' + spinnerFrame + '\x1b[0m'}${suffix}`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
process.stdout.write('\x1b[?25l');
|
|
267
|
+
const spinnerInterval = setInterval(() => {
|
|
268
|
+
spinnerIdx = (spinnerIdx + 1) % spinnerFrames.length;
|
|
269
|
+
drawProgressBar();
|
|
270
|
+
}, 80);
|
|
271
|
+
|
|
272
|
+
const tasks = chunks.map((chunkText, idx) => ({ chunkText, idx }));
|
|
273
|
+
|
|
274
|
+
const results = await runConcurrentLimit(tasks, 5, async (task) => {
|
|
275
|
+
const start = task.idx * chunkSize + 1;
|
|
276
|
+
const end = Math.min((task.idx + 1) * chunkSize, totalLines);
|
|
277
|
+
|
|
278
|
+
const promptMsg = [
|
|
279
|
+
{
|
|
280
|
+
role: 'user',
|
|
281
|
+
content: `Analyze the following segment (Lines ${start}-${end}) of the file "${path.basename(filePath)}" and write a detailed, dense summary of its contents (key functions/classes/logic if code, or major events/plot/characters if text). Keep the summary highly informative and structured:\n\n${task.chunkText}`
|
|
282
|
+
}
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const summary = await getAIResponse(promptMsg);
|
|
287
|
+
completedCount++;
|
|
288
|
+
drawProgressBar();
|
|
289
|
+
return { idx: task.idx, start, end, summary };
|
|
290
|
+
} catch (err) {
|
|
291
|
+
completedCount++;
|
|
292
|
+
failedCount++;
|
|
293
|
+
drawProgressBar();
|
|
294
|
+
return { idx: task.idx, start, end, summary: `[Error reading this segment: ${err.message}]` };
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
clearInterval(spinnerInterval);
|
|
299
|
+
drawProgressBar(); // final draw to show 100% complete
|
|
300
|
+
process.stdout.write('\n\x1b[?25h');
|
|
301
|
+
|
|
302
|
+
results.sort((a, b) => a.idx - b.idx);
|
|
303
|
+
|
|
304
|
+
let report = `=== ULTRACODE DIGESTION REPORT FOR "${path.basename(filePath)}" ===\n`;
|
|
305
|
+
report += `Total Lines: ${totalLines}\n`;
|
|
306
|
+
report += `Resolved Path: ${resolvedPath}\n\n`;
|
|
307
|
+
|
|
308
|
+
results.forEach(res => {
|
|
309
|
+
report += `--- Segment ${res.idx + 1} (Lines ${res.start}-${res.end}) ---\n`;
|
|
310
|
+
report += `${res.summary}\n\n`;
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
report += `=========================================================\n`;
|
|
314
|
+
return report;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function drawHeader() {
|
|
318
|
+
const logoLines = getLogoLines();
|
|
319
|
+
const t = locales[currentLang];
|
|
320
|
+
|
|
321
|
+
const rightLines = [
|
|
322
|
+
t.tipsTitle,
|
|
323
|
+
...t.tipsLines,
|
|
324
|
+
t.newsTitle,
|
|
325
|
+
...t.newsLines,
|
|
326
|
+
t.docTitle,
|
|
327
|
+
...t.docLines,
|
|
328
|
+
'',
|
|
329
|
+
'',
|
|
330
|
+
'',
|
|
331
|
+
''
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
const leftColWidth = 45;
|
|
335
|
+
const rightColWidth = 47;
|
|
336
|
+
const boxWidth = 95;
|
|
337
|
+
|
|
338
|
+
const boxColor = '\x1b[38;2;250;100;30m';
|
|
339
|
+
const resetColor = '\x1b[0m';
|
|
340
|
+
|
|
341
|
+
const topBorder = boxColor + '┌── ' + resetColor + '\x1b[1mDave Code v0.1.0\x1b[0m' + boxColor + ' ' + '─'.repeat(boxWidth - 21) + '┐' + resetColor;
|
|
342
|
+
const bottomBorder = boxColor + '└' + '─'.repeat(boxWidth) + '┘' + resetColor;
|
|
343
|
+
|
|
344
|
+
console.log(topBorder);
|
|
345
|
+
|
|
346
|
+
for (let i = 0; i < 20; i++) {
|
|
347
|
+
let leftContent = '';
|
|
348
|
+
if (i === 0) {
|
|
349
|
+
leftContent = centerLine(`\x1b[1m${t.welcome}\x1b[0m`, leftColWidth);
|
|
350
|
+
} else if (i >= 1 && i <= 16) {
|
|
351
|
+
leftContent = padLine(logoLines[i - 1] || '', leftColWidth);
|
|
352
|
+
} else if (i === 17) {
|
|
353
|
+
leftContent = ' '.repeat(leftColWidth);
|
|
354
|
+
} else if (i === 18) {
|
|
355
|
+
// Dynamic profile name & model display
|
|
356
|
+
const active = getActiveProfile();
|
|
357
|
+
if (active) {
|
|
358
|
+
leftContent = centerLine(`\x1b[90m${active.model} · ${t.modelAgentSuffix}\x1b[0m`, leftColWidth);
|
|
359
|
+
} else {
|
|
360
|
+
leftContent = centerLine(`\x1b[90m(No Models / 未配置)\x1b[0m`, leftColWidth);
|
|
361
|
+
}
|
|
362
|
+
} else if (i === 19) {
|
|
363
|
+
const cwd = process.cwd();
|
|
364
|
+
const truncatedCwd = cwd.length > leftColWidth - 2 ? '...' + cwd.slice(-(leftColWidth - 5)) : cwd;
|
|
365
|
+
leftContent = centerLine(`\x1b[90m${truncatedCwd}\x1b[0m`, leftColWidth);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const rightContent = padLine(rightLines[i] || '', rightColWidth);
|
|
369
|
+
|
|
370
|
+
console.log(
|
|
371
|
+
boxColor + '│ ' + resetColor +
|
|
372
|
+
leftContent +
|
|
373
|
+
boxColor + ' │ ' + resetColor +
|
|
374
|
+
rightContent +
|
|
375
|
+
boxColor + ' │' + resetColor
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
console.log(bottomBorder);
|
|
380
|
+
console.log('');
|
|
381
|
+
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
class AgentActionTracker {
|
|
385
|
+
constructor() {
|
|
386
|
+
this.actionsCount = 0;
|
|
387
|
+
this.actionTypes = {};
|
|
388
|
+
this.lastActionText = '';
|
|
389
|
+
this.spinnerIdx = 0;
|
|
390
|
+
this.spinnerInterval = null;
|
|
391
|
+
this.statusColor = '\x1b[35m';
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
start(actionText) {
|
|
395
|
+
this.actionsCount++;
|
|
396
|
+
this.lastActionText = actionText;
|
|
397
|
+
|
|
398
|
+
// Parse action type
|
|
399
|
+
let type = 'Action';
|
|
400
|
+
if (actionText.includes('Reading file')) type = 'Read';
|
|
401
|
+
else if (actionText.includes('Writing file')) type = 'Write';
|
|
402
|
+
else if (actionText.includes('Listing directory')) type = 'List';
|
|
403
|
+
else if (actionText.includes('Searching files')) type = 'Search';
|
|
404
|
+
this.actionTypes[type] = (this.actionTypes[type] || 0) + 1;
|
|
405
|
+
|
|
406
|
+
if (!this.spinnerInterval) {
|
|
407
|
+
process.stdout.write('\x1b[?25l'); // Hide cursor
|
|
408
|
+
const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
409
|
+
this.spinnerInterval = setInterval(() => {
|
|
410
|
+
this.spinnerIdx = (this.spinnerIdx + 1) % spinnerFrames.length;
|
|
411
|
+
this.draw();
|
|
412
|
+
}, 80);
|
|
413
|
+
}
|
|
414
|
+
this.draw();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
draw() {
|
|
418
|
+
const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
419
|
+
const spinnerFrame = spinnerFrames[this.spinnerIdx];
|
|
420
|
+
|
|
421
|
+
const cleanActionText = this.lastActionText.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
422
|
+
|
|
423
|
+
let displayAction = cleanActionText;
|
|
424
|
+
if (displayAction.length > 50) {
|
|
425
|
+
displayAction = displayAction.slice(0, 47) + '...';
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
process.stdout.write(
|
|
429
|
+
`\r\x1b[K\x1b[35m⚙ [Agent Actions]\x1b[0m ${this.actionsCount} actions | Last: ${displayAction} \x1b[36m${spinnerFrame}\x1b[0m`
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
stop() {
|
|
434
|
+
if (this.spinnerInterval) {
|
|
435
|
+
clearInterval(this.spinnerInterval);
|
|
436
|
+
this.spinnerInterval = null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
finish() {
|
|
441
|
+
this.stop();
|
|
442
|
+
if (this.actionsCount > 0) {
|
|
443
|
+
const typesStr = Object.entries(this.actionTypes)
|
|
444
|
+
.map(([type, count]) => `${count} ${type.toLowerCase()}${count > 1 ? 's' : ''}`)
|
|
445
|
+
.join(', ');
|
|
446
|
+
const summaryText = `Completed ${this.actionsCount} actions (${typesStr})`;
|
|
447
|
+
process.stdout.write(`\r\x1b[K\x1b[32m✔ [Agent Actions]\x1b[0m ${summaryText}\n`);
|
|
448
|
+
}
|
|
449
|
+
process.stdout.write('\x1b[?25h'); // Show cursor
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function promptUser() {
|
|
454
|
+
const effort = getActiveEffort();
|
|
455
|
+
const effortText = `● ${effort} · /effort`;
|
|
456
|
+
const N = effort.length;
|
|
457
|
+
const padding = ' '.repeat(Math.max(1, 80 - N));
|
|
458
|
+
const colorCode = effort === 'ultracode' ? '\x1b[1;38;2;140;90;240m' : '\x1b[90m';
|
|
459
|
+
console.log(padding + colorCode + effortText + '\x1b[0m');
|
|
460
|
+
const activeFileBase = activeOpenFile ? path.basename(activeOpenFile) : '';
|
|
461
|
+
const placeholder = activeOpenFile
|
|
462
|
+
? (currentLang === 'cn' ? `正在针对 [${activeFileBase}] 工作...` : `Working on [${activeFileBase}]...`)
|
|
463
|
+
: (currentLang === 'cn' ? 'Try "how do I log an error?" / 输入提示词或命令...' : 'Try "how do I log an error?" / Enter prompt or command...');
|
|
464
|
+
const input = await chatInputPrompt(placeholder, currentLang);
|
|
465
|
+
handleInput(input);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── Interactive Config Handlers ──
|
|
469
|
+
|
|
470
|
+
async function triggerProfileSwitch(t) {
|
|
471
|
+
const profiles = getProfiles();
|
|
472
|
+
if (profiles.length === 0) {
|
|
473
|
+
console.log(`\n\x1b[31m${t.noActiveError}\x1b[0m\n`);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const menuOptions = profiles.map(p => ({
|
|
478
|
+
name: p.model,
|
|
479
|
+
desc: `Base: ${p.apiBase || '(Default)'}`
|
|
480
|
+
}));
|
|
481
|
+
|
|
482
|
+
const selectedIdx = await selectMenu(t.profileSelectTitle, t.profileSelectDesc, menuOptions, 0);
|
|
483
|
+
if (selectedIdx === -1) return;
|
|
484
|
+
|
|
485
|
+
const chosenProfile = profiles[selectedIdx];
|
|
486
|
+
setActiveProfile(chosenProfile.model);
|
|
487
|
+
|
|
488
|
+
clearConsole();
|
|
489
|
+
drawHeader();
|
|
490
|
+
console.log(`\n\x1b[32mActive model set to: ${chosenProfile.model}\x1b[0m\n`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── Main REPL Input Handler ──
|
|
494
|
+
|
|
495
|
+
async function handleInput(input) {
|
|
496
|
+
const trimmed = input.trim();
|
|
497
|
+
const t = locales[currentLang];
|
|
498
|
+
|
|
499
|
+
// Reset last request's token usage on every new input
|
|
500
|
+
sessionTokenUsage.lastInputTokens = 0;
|
|
501
|
+
sessionTokenUsage.lastOutputTokens = 0;
|
|
502
|
+
sessionTokenUsage.lastTotalTokens = 0;
|
|
503
|
+
|
|
504
|
+
if (trimmed === '') {
|
|
505
|
+
promptUser();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (trimmed === '/exit' || trimmed === 'exit') {
|
|
510
|
+
console.log(t.goodbye);
|
|
511
|
+
process.exit(0);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (trimmed === '/clear') {
|
|
515
|
+
clearConsole();
|
|
516
|
+
drawHeader();
|
|
517
|
+
promptUser();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (trimmed === '/reset') {
|
|
522
|
+
messages = [];
|
|
523
|
+
resetTokenUsage();
|
|
524
|
+
console.log(`\x1b[32m${t.resetMsg}\x1b[0m\n`);
|
|
525
|
+
promptUser();
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (trimmed === '/stats' || trimmed === '/tokens') {
|
|
530
|
+
console.log(`\n\x1b[1;36mToken Usage Statistics / Token 使用统计:\x1b[0m`);
|
|
531
|
+
console.log(` \x1b[90mLast Request (单次请求):\x1b[0m`);
|
|
532
|
+
console.log(` Input (输入): ${sessionTokenUsage.lastInputTokens.toLocaleString()}`);
|
|
533
|
+
console.log(` Output (输出): ${sessionTokenUsage.lastOutputTokens.toLocaleString()}`);
|
|
534
|
+
console.log(` Total (总计): ${sessionTokenUsage.lastTotalTokens.toLocaleString()}`);
|
|
535
|
+
console.log(` \x1b[90mSession Cumulative (累计会话):\x1b[0m`);
|
|
536
|
+
console.log(` Input (输入): ${sessionTokenUsage.inputTokens.toLocaleString()}`);
|
|
537
|
+
console.log(` Output (输出): ${sessionTokenUsage.outputTokens.toLocaleString()}`);
|
|
538
|
+
console.log(` Total (总计): ${sessionTokenUsage.totalTokens.toLocaleString()}\n`);
|
|
539
|
+
promptUser();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (trimmed === '/lang' || trimmed === '/language') {
|
|
544
|
+
currentLang = currentLang === 'cn' ? 'en' : 'cn';
|
|
545
|
+
const nextT = locales[currentLang];
|
|
546
|
+
clearConsole();
|
|
547
|
+
drawHeader();
|
|
548
|
+
console.log(`\x1b[32m${nextT.switchMsg}\x1b[0m\n`);
|
|
549
|
+
promptUser();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (trimmed === '/help') {
|
|
554
|
+
console.log(`\n\x1b[1;36m${t.helpTitle}\x1b[0m`);
|
|
555
|
+
t.helpLines.forEach(line => console.log(line));
|
|
556
|
+
console.log('');
|
|
557
|
+
promptUser();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Handle /model (direct profile switcher)
|
|
562
|
+
if (trimmed === '/model') {
|
|
563
|
+
await triggerProfileSwitch(t);
|
|
564
|
+
promptUser();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Handle /effort (adjust effort level slider)
|
|
569
|
+
if (trimmed === '/effort') {
|
|
570
|
+
const currentEffort = getActiveEffort();
|
|
571
|
+
const selected = await selectEffortMenu(currentEffort, currentLang);
|
|
572
|
+
if (selected) {
|
|
573
|
+
setActiveEffort(selected);
|
|
574
|
+
clearConsole();
|
|
575
|
+
drawHeader();
|
|
576
|
+
console.log(`\n\x1b[32mThinking effort set to: ${selected}\x1b[0m\n`);
|
|
577
|
+
} else {
|
|
578
|
+
clearConsole();
|
|
579
|
+
drawHeader();
|
|
580
|
+
}
|
|
581
|
+
promptUser();
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Handle /api (direct edit API key for active profile)
|
|
586
|
+
if (trimmed === '/api') {
|
|
587
|
+
const active = getActiveProfile();
|
|
588
|
+
if (!active) {
|
|
589
|
+
console.log(`\n\x1b[31m${t.noActiveError}\x1b[0m\n`);
|
|
590
|
+
} else {
|
|
591
|
+
const key = await secureInputPrompt(`${active.model} - ${t.enterKeyPrompt}`);
|
|
592
|
+
if (key) {
|
|
593
|
+
updateProfile(active.model, { apiKey: key });
|
|
594
|
+
console.log(`\n${t.keySuccess}\x1b[32m${maskKey(key)}\x1b[0m\n`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
promptUser();
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Handle /config (Dashboard - Opens config file in default editor, pauses and waits for Enter)
|
|
602
|
+
if (trimmed === '/config') {
|
|
603
|
+
openConfigFileInEditor();
|
|
604
|
+
|
|
605
|
+
// Pause prompt and wait for Enter key
|
|
606
|
+
await waitForEnter(t.openingConfigMsg);
|
|
607
|
+
|
|
608
|
+
// Reload config and refresh
|
|
609
|
+
console.log(t.reloadingConfigMsg);
|
|
610
|
+
clearConsole();
|
|
611
|
+
drawHeader();
|
|
612
|
+
promptUser();
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Handle /open <path>
|
|
617
|
+
if (trimmed.startsWith('/open')) {
|
|
618
|
+
const args = trimmed.slice(5).trim();
|
|
619
|
+
if (!args) {
|
|
620
|
+
console.log(`\n\x1b[31m${t.openUsagePrompt}\x1b[0m\n`);
|
|
621
|
+
promptUser();
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const resolvedPath = path.resolve(args);
|
|
626
|
+
let stats;
|
|
627
|
+
try {
|
|
628
|
+
stats = fs.existsSync(resolvedPath) ? fs.statSync(resolvedPath) : null;
|
|
629
|
+
} catch (e) {
|
|
630
|
+
// Path not accessible
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (stats && stats.isDirectory()) {
|
|
634
|
+
try {
|
|
635
|
+
process.chdir(resolvedPath);
|
|
636
|
+
activeOpenFile = null; // Switch to directory mode clears active file focus
|
|
637
|
+
clearConsole();
|
|
638
|
+
drawHeader();
|
|
639
|
+
console.log(`\n\x1b[32m${t.openDirSuccess}${resolvedPath}\x1b[0m\n`);
|
|
640
|
+
} catch (err) {
|
|
641
|
+
console.log(`\n\x1b[31m${t.openPathError}${resolvedPath} (${err.message})\x1b[0m\n`);
|
|
642
|
+
}
|
|
643
|
+
promptUser();
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// It is a file or a path to be created as a file
|
|
648
|
+
try {
|
|
649
|
+
// Create empty file if it doesn't exist
|
|
650
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
651
|
+
fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
|
|
652
|
+
fs.writeFileSync(resolvedPath, '', 'utf8');
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
activeOpenFile = resolvedPath; // Track currently open file
|
|
656
|
+
|
|
657
|
+
// Read content
|
|
658
|
+
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
659
|
+
const lines = content.split('\n');
|
|
660
|
+
const activeEffort = getActiveEffort();
|
|
661
|
+
const preset = EFFORT_PRESETS[activeEffort] || EFFORT_PRESETS.high;
|
|
662
|
+
|
|
663
|
+
let contextContent = '';
|
|
664
|
+
if (activeEffort === 'ultracode') {
|
|
665
|
+
if (lines.length > 1000) {
|
|
666
|
+
const totalChunks = Math.ceil(lines.length / 2000);
|
|
667
|
+
const confirmMsg = `\n\x1b[35m[Ultracode]\x1b[0m File "${path.basename(resolvedPath)}" has ${lines.length} lines. Digesting it will take ${totalChunks} API calls. Proceed? (y/n): `;
|
|
668
|
+
const proceed = await confirmPrompt(confirmMsg);
|
|
669
|
+
|
|
670
|
+
if (proceed) {
|
|
671
|
+
const report = await runFileDigestionWorkflow(resolvedPath);
|
|
672
|
+
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
673
|
+
contextContent = `[System Context: User opened file "${resolvedPath}" (${lines.length} lines). The file has been processed using the chunked digestion workflow. Below is the full digestion report. If you need to view raw code or text of specific lines, use the <<READ_FILE: ${relPath}:start-end>> tool (e.g., <<READ_FILE: ${relPath}:1200-1350>>).]\n\n${report}`;
|
|
674
|
+
} else {
|
|
675
|
+
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
676
|
+
contextContent = `[System Context: User opened file "${resolvedPath}" (${lines.length} lines). Since the file is large, digestion was skipped by user. Use the <<READ_FILE: filePath:start-end>> tool (e.g. <<READ_FILE: ${relPath}:1-500>>) to read other segments of the file if needed.]`;
|
|
677
|
+
console.log(`\n\x1b[33m[Warning] Skipped digestion. AI will read file sections on demand.\x1b[0m`);
|
|
678
|
+
}
|
|
679
|
+
} else if (lines.length > preset.maxReadLines) {
|
|
680
|
+
const truncatedContent = lines.slice(0, 100).join('\n');
|
|
681
|
+
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
682
|
+
contextContent = `[System Context: User opened file "${resolvedPath}" (${lines.length} lines). Since the file is large, only the first 100 lines are shown below. Use the <<READ_FILE: filePath:start-end>> tool (e.g. <<READ_FILE: ${relPath}:101-300>>) to read other segments of the file if needed.]\n\n${truncatedContent}`;
|
|
683
|
+
console.log(`\n\x1b[33m[Warning] File is large (${lines.length} lines). Truncated first 100 lines for AI context.\x1b[0m`);
|
|
684
|
+
} else {
|
|
685
|
+
contextContent = `[System Context: User opened file "${resolvedPath}" for editing and working. Current file contents:\n\n${content}]`;
|
|
686
|
+
}
|
|
687
|
+
} else {
|
|
688
|
+
// Non-ultracode modes: Smart Incremental Reading
|
|
689
|
+
if (lines.length > preset.maxReadLines) {
|
|
690
|
+
const truncatedContent = lines.slice(0, 100).join('\n');
|
|
691
|
+
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
692
|
+
contextContent = `[System Context: User opened file "${resolvedPath}" (${lines.length} lines). Since the file is large, only the first 100 lines are shown below. Use the <<READ_FILE: filePath:start-end>> tool (e.g. <<READ_FILE: ${relPath}:101-300>>) to read other segments of the file if needed.]\n\n${truncatedContent}`;
|
|
693
|
+
} else {
|
|
694
|
+
contextContent = `[System Context: User opened file "${resolvedPath}" for editing and working. Current file contents:\n\n${content}]`;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Load file into messages history as a System context message
|
|
699
|
+
messages.push({
|
|
700
|
+
role: 'user',
|
|
701
|
+
content: contextContent
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
// Open in default editor
|
|
705
|
+
const command = process.platform === 'win32'
|
|
706
|
+
? `start "" "${resolvedPath}"`
|
|
707
|
+
: (process.platform === 'darwin' ? `open "${resolvedPath}"` : `xdg-open "${resolvedPath}"`);
|
|
708
|
+
|
|
709
|
+
exec(command, (err) => {
|
|
710
|
+
if (err && process.platform === 'win32') {
|
|
711
|
+
exec(`notepad "${resolvedPath}"`);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
console.log(`\n\x1b[32m${t.openFileSuccess}\n\x1b[90mPath: ${resolvedPath}\x1b[0m\n`);
|
|
716
|
+
} catch (err) {
|
|
717
|
+
console.log(`\n\x1b[31m${t.openPathError}${resolvedPath} (${err.message})\x1b[0m\n`);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
promptUser();
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Check if active profile and API key are configured
|
|
725
|
+
if (!hasApiKey()) {
|
|
726
|
+
console.log(`\n${t.noKeyError}\n`);
|
|
727
|
+
promptUser();
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Push user message to history
|
|
732
|
+
messages.push({ role: 'user', content: trimmed });
|
|
733
|
+
|
|
734
|
+
const activeEffort = getActiveEffort();
|
|
735
|
+
const preset = EFFORT_PRESETS[activeEffort] || EFFORT_PRESETS.high;
|
|
736
|
+
const maxLoops = preset.maxLoops;
|
|
737
|
+
|
|
738
|
+
const actionTracker = new AgentActionTracker();
|
|
739
|
+
let loopCount = 0;
|
|
740
|
+
let activeReply = '';
|
|
741
|
+
|
|
742
|
+
while (loopCount < maxLoops) {
|
|
743
|
+
// Simulate thinking with animated spinner
|
|
744
|
+
const prefixText = currentLang === 'cn' ? '正在思考' : 'Thinking';
|
|
745
|
+
const stopSpinner = startSpinner(prefixText);
|
|
746
|
+
|
|
747
|
+
try {
|
|
748
|
+
activeReply = await getAIResponse(messages, activeOpenFile, preset.maxReadLines);
|
|
749
|
+
stopSpinner();
|
|
750
|
+
} catch (error) {
|
|
751
|
+
stopSpinner();
|
|
752
|
+
console.log(`\n\x1b[31mAPI Error:\x1b[0m ${error.message}\n`);
|
|
753
|
+
if (loopCount === 0) messages.pop();
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Check if the reply contains any tool tags
|
|
758
|
+
const match = activeReply.match(/<<([A-Z_]+):\s*([\s\S]*?)>>/);
|
|
759
|
+
if (!match) {
|
|
760
|
+
// Final response from Dave
|
|
761
|
+
let finalReply = activeReply;
|
|
762
|
+
const thinkMatch = finalReply.match(/<think>([\s\S]*?)<\/think>/);
|
|
763
|
+
if (thinkMatch) {
|
|
764
|
+
const thinkContent = thinkMatch[1].trim();
|
|
765
|
+
if (thinkContent) {
|
|
766
|
+
console.log(`\n\x1b[90m💭 Dave (Thinking):\n${thinkContent}\x1b[0m`);
|
|
767
|
+
}
|
|
768
|
+
finalReply = finalReply.replace(/<think>[\s\S]*?<\/think>/, '').trim();
|
|
769
|
+
}
|
|
770
|
+
if (finalReply === '') {
|
|
771
|
+
finalReply = '(Finished reasoning / 已思考完毕但未返回额外文本)';
|
|
772
|
+
}
|
|
773
|
+
console.log(`\n\x1b[1;38;2;250;100;30mDave\x1b[0m: ${finalReply}\n`);
|
|
774
|
+
messages.push({ role: 'assistant', content: activeReply });
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Extract thought text if present
|
|
779
|
+
let thoughtText = activeReply.slice(0, match.index).trim();
|
|
780
|
+
if (thoughtText) {
|
|
781
|
+
const thinkMatch = thoughtText.match(/<think>([\s\S]*?)<\/think>/);
|
|
782
|
+
if (thinkMatch) {
|
|
783
|
+
const thinkContent = thinkMatch[1].trim();
|
|
784
|
+
if (thinkContent) {
|
|
785
|
+
console.log(`\n\x1b[90m💭 Dave (Thinking):\n${thinkContent}\x1b[0m`);
|
|
786
|
+
}
|
|
787
|
+
thoughtText = thoughtText.replace(/<think>[\s\S]*?<\/think>/, '').trim();
|
|
788
|
+
}
|
|
789
|
+
if (thoughtText) {
|
|
790
|
+
console.log(`\x1b[90m💭 Dave: ${thoughtText}\x1b[0m`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Save Dave's intermediate tool call message to history
|
|
795
|
+
messages.push({ role: 'assistant', content: activeReply });
|
|
796
|
+
|
|
797
|
+
const toolName = match[1];
|
|
798
|
+
const toolArg = match[2];
|
|
799
|
+
|
|
800
|
+
let toolResult = '';
|
|
801
|
+
const statusColor = '\x1b[35m'; // purple for agent action
|
|
802
|
+
const resetColor = '\x1b[0m';
|
|
803
|
+
|
|
804
|
+
try {
|
|
805
|
+
if (toolName === 'LIST_DIR') {
|
|
806
|
+
const resolvedPath = path.resolve(toolArg.trim() || '.');
|
|
807
|
+
actionTracker.start(`Listing directory: ${path.basename(resolvedPath) || '.'}`);
|
|
808
|
+
|
|
809
|
+
const files = fs.readdirSync(resolvedPath);
|
|
810
|
+
toolResult = files.map(f => {
|
|
811
|
+
const stat = fs.statSync(path.join(resolvedPath, f));
|
|
812
|
+
return `${f} (${stat.isDirectory() ? 'Dir' : 'File, ' + stat.size + 'B'})`;
|
|
813
|
+
}).join('\n') || '(Empty directory)';
|
|
814
|
+
}
|
|
815
|
+
else if (toolName === 'READ_FILE') {
|
|
816
|
+
let filePath = toolArg.trim();
|
|
817
|
+
let startLine = 1;
|
|
818
|
+
let endLine = null;
|
|
819
|
+
|
|
820
|
+
// Parse range like: path/to/file:20-80
|
|
821
|
+
const rangeMatch = filePath.match(/:(\d+)-(\d+)$/);
|
|
822
|
+
if (rangeMatch) {
|
|
823
|
+
filePath = filePath.slice(0, -rangeMatch[0].length);
|
|
824
|
+
startLine = parseInt(rangeMatch[1], 10);
|
|
825
|
+
endLine = parseInt(rangeMatch[2], 10);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const resolvedPath = path.resolve(filePath);
|
|
829
|
+
actionTracker.start(`Reading file: ${path.basename(filePath)}${rangeMatch ? ` (Lines ${startLine}-${endLine})` : ''}`);
|
|
830
|
+
|
|
831
|
+
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
832
|
+
const lines = content.split('\n');
|
|
833
|
+
const activeEffort = getActiveEffort();
|
|
834
|
+
const preset = EFFORT_PRESETS[activeEffort] || EFFORT_PRESETS.high;
|
|
835
|
+
|
|
836
|
+
if (rangeMatch) {
|
|
837
|
+
const sliced = lines.slice(startLine - 1, endLine);
|
|
838
|
+
toolResult = sliced.join('\n');
|
|
839
|
+
} else {
|
|
840
|
+
if (activeEffort === 'ultracode') {
|
|
841
|
+
if (lines.length > 1000) {
|
|
842
|
+
actionTracker.stop(); // Stop the dynamic status line before prompt
|
|
843
|
+
process.stdout.write('\n'); // Carriage return to next line for clean confirmation
|
|
844
|
+
const totalChunks = Math.ceil(lines.length / 2000);
|
|
845
|
+
const confirmMsg = `\x1b[35m[Ultracode]\x1b[0m AI requested to read "${path.basename(resolvedPath)}" (${lines.length} lines). Digesting it will take ${totalChunks} API calls. Proceed? (y/n): `;
|
|
846
|
+
const proceed = await confirmPrompt(confirmMsg);
|
|
847
|
+
|
|
848
|
+
if (proceed) {
|
|
849
|
+
const report = await runFileDigestionWorkflow(resolvedPath);
|
|
850
|
+
toolResult = `[File is large (${lines.length} lines). Triggered Ultracode Digestion Workflow. Below is the report. Use READ_FILE with ranges for specific parts if needed.]\n\n${report}`;
|
|
851
|
+
} else {
|
|
852
|
+
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
853
|
+
toolResult = `[Warning: File is large (${lines.length} lines) and digestion was skipped by user. You MUST read specific parts using range constraints like <<READ_FILE: ${relPath}:start-end>> (e.g. 1-500) to find the information.]`;
|
|
854
|
+
console.log(`\n\x1b[33m[Warning] Skipped digestion. AI will read file sections on demand.\x1b[0m`);
|
|
855
|
+
}
|
|
856
|
+
} else if (lines.length > preset.maxReadLines) {
|
|
857
|
+
const truncated = lines.slice(0, preset.maxReadLines).join('\n');
|
|
858
|
+
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
859
|
+
toolResult = `[Warning: File is large (${lines.length} lines). Truncated first ${preset.maxReadLines} lines. Use range constraints like <<READ_FILE: ${relPath}:start-end>> to read other sections.]\n\n${truncated}`;
|
|
860
|
+
console.log(`\n\x1b[33m[Warning] File is large (${lines.length} lines). Truncated to first ${preset.maxReadLines} lines.\x1b[0m`);
|
|
861
|
+
} else {
|
|
862
|
+
toolResult = content;
|
|
863
|
+
}
|
|
864
|
+
} else {
|
|
865
|
+
// Non-ultracode modes: Smart Incremental Reading (精读模式)
|
|
866
|
+
if (lines.length > preset.maxReadLines) {
|
|
867
|
+
const truncated = lines.slice(0, preset.maxReadLines).join('\n');
|
|
868
|
+
const relPath = path.relative(process.cwd(), resolvedPath);
|
|
869
|
+
toolResult = `[System Context: File is large (${lines.length} lines). Truncated first ${preset.maxReadLines} lines for this read. To read other sections, you MUST use range constraints like <<READ_FILE: ${relPath}:start-end>> (e.g. 200-800) to find the information.]\n\n${truncated}`;
|
|
870
|
+
} else {
|
|
871
|
+
toolResult = content;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
else if (toolName === 'WRITE_FILE') {
|
|
877
|
+
const firstNewline = toolArg.indexOf('\n');
|
|
878
|
+
const filePathPart = firstNewline === -1 ? toolArg : toolArg.slice(0, firstNewline);
|
|
879
|
+
const fileContentPart = firstNewline === -1 ? '' : toolArg.slice(firstNewline + 1);
|
|
880
|
+
|
|
881
|
+
const resolvedPath = path.resolve(filePathPart.trim());
|
|
882
|
+
actionTracker.start(`Writing file: ${path.basename(resolvedPath)}`);
|
|
883
|
+
|
|
884
|
+
fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
|
|
885
|
+
fs.writeFileSync(resolvedPath, fileContentPart, 'utf8');
|
|
886
|
+
toolResult = `Successfully wrote to file: ${resolvedPath}`;
|
|
887
|
+
}
|
|
888
|
+
else if (toolName === 'SEARCH_GREP') {
|
|
889
|
+
const query = toolArg.trim();
|
|
890
|
+
actionTracker.start(`Searching files for: "${query}"`);
|
|
891
|
+
|
|
892
|
+
const results = [];
|
|
893
|
+
let totalMatches = 0;
|
|
894
|
+
const maxTotalMatches = 50;
|
|
895
|
+
|
|
896
|
+
function searchGrep(dir, q, depth = 0) {
|
|
897
|
+
if (depth > 5 || totalMatches >= maxTotalMatches) return;
|
|
898
|
+
const files = fs.readdirSync(dir);
|
|
899
|
+
for (const f of files) {
|
|
900
|
+
if (totalMatches >= maxTotalMatches) break;
|
|
901
|
+
if (f === 'node_modules' || f === '.git' || f === '.gemini' || f === 'package-lock.json') continue;
|
|
902
|
+
const fullPath = path.join(dir, f);
|
|
903
|
+
try {
|
|
904
|
+
const stat = fs.statSync(fullPath);
|
|
905
|
+
if (stat.isDirectory()) {
|
|
906
|
+
searchGrep(fullPath, q, depth + 1);
|
|
907
|
+
} else if (stat.isFile()) {
|
|
908
|
+
const ext = path.extname(f).toLowerCase();
|
|
909
|
+
const textExts = ['.js', '.json', '.txt', '.md', '.html', '.css', '.yml', '.yaml', '.sh', '.bat', '.cmd', '.py', '.cpp', '.h', '.c', '.go', '.rs', '.java', '.ts'];
|
|
910
|
+
if (textExts.includes(ext) || stat.size < 100000) {
|
|
911
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
912
|
+
if (content.includes(q)) {
|
|
913
|
+
const lines = content.split('\n');
|
|
914
|
+
const fileMatches = [];
|
|
915
|
+
for (let i = 0; i < lines.length; i++) {
|
|
916
|
+
if (lines[i].includes(q)) {
|
|
917
|
+
fileMatches.push({ lineNum: i + 1, text: lines[i].trim() });
|
|
918
|
+
totalMatches++;
|
|
919
|
+
if (totalMatches >= maxTotalMatches) break;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (fileMatches.length > 0) {
|
|
923
|
+
results.push({
|
|
924
|
+
file: path.relative(process.cwd(), fullPath),
|
|
925
|
+
matches: fileMatches
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
} catch (e) {
|
|
932
|
+
// Ignore unreadable files
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
searchGrep(process.cwd(), query);
|
|
938
|
+
|
|
939
|
+
if (results.length > 0) {
|
|
940
|
+
let outputText = 'Found matches:\n';
|
|
941
|
+
results.forEach(res => {
|
|
942
|
+
outputText += `- ${res.file}:\n`;
|
|
943
|
+
res.matches.forEach(m => {
|
|
944
|
+
const displayInfo = m.text.length > 120 ? m.text.slice(0, 117) + '...' : m.text;
|
|
945
|
+
outputText += ` Line ${m.lineNum}: ${displayInfo}\n`;
|
|
946
|
+
});
|
|
947
|
+
});
|
|
948
|
+
toolResult = outputText;
|
|
949
|
+
} else {
|
|
950
|
+
toolResult = `No matches found for "${query}"`;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
else {
|
|
954
|
+
toolResult = `Unknown tool: ${toolName}`;
|
|
955
|
+
}
|
|
956
|
+
} catch (err) {
|
|
957
|
+
toolResult = `Error executing tool ${toolName}: ${err.message}`;
|
|
958
|
+
console.log(`\n\x1b[31m[Agent Error] ${err.message}\x1b[0m\n`);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Append tool response to history as a user message
|
|
962
|
+
messages.push({
|
|
963
|
+
role: 'user',
|
|
964
|
+
content: `[Tool Response for ${toolName}:\n${toolResult}]`
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
actionTracker.stop(); // Stop the action tracker spinner before we loop back to thinking
|
|
968
|
+
|
|
969
|
+
loopCount++;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
actionTracker.finish();
|
|
973
|
+
if (loopCount >= maxLoops) {
|
|
974
|
+
console.log(`\n\x1b[33m[Warning] Loop limit reached (${maxLoops} steps). Dave stopped to prevent high API token usage. Try raising the effort level using /effort or narrowing down the task.\x1b[0m\n`);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (sessionTokenUsage.lastTotalTokens > 0) {
|
|
978
|
+
console.log(
|
|
979
|
+
`\x1b[90mTokens: ${sessionTokenUsage.lastInputTokens.toLocaleString()} input, ` +
|
|
980
|
+
`${sessionTokenUsage.lastOutputTokens.toLocaleString()} output ` +
|
|
981
|
+
`(${sessionTokenUsage.lastTotalTokens.toLocaleString()} total) | ` +
|
|
982
|
+
`Session Total: ${sessionTokenUsage.inputTokens.toLocaleString()} input, ` +
|
|
983
|
+
`${sessionTokenUsage.outputTokens.toLocaleString()} output ` +
|
|
984
|
+
`(${sessionTokenUsage.totalTokens.toLocaleString()} total)\x1b[0m\n`
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
promptUser();
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Start the UI
|
|
992
|
+
clearConsole();
|
|
993
|
+
drawHeader();
|
|
994
|
+
promptUser();
|