@indiccoder/mentis-cli 1.1.4 → 1.1.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.
Files changed (64) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/.mentis/session.json +15 -0
  3. package/.mentis/sessions/1769189035730.json +23 -0
  4. package/.mentis/sessions/1769189569160.json +23 -0
  5. package/.mentis/sessions/1769767538672.json +23 -0
  6. package/.mentis/sessions/1769767785155.json +23 -0
  7. package/.mentis/sessions/1769768745802.json +23 -0
  8. package/.mentis/sessions/1769769600884.json +31 -0
  9. package/.mentis/sessions/1769770030160.json +31 -0
  10. package/.mentis/sessions/1769770606004.json +78 -0
  11. package/.mentis/sessions/1769771084515.json +141 -0
  12. package/.mentis/sessions/1769881926630.json +57 -0
  13. package/README.md +17 -0
  14. package/dist/checkpoint/CheckpointManager.js +92 -0
  15. package/dist/debug_google.js +61 -0
  16. package/dist/debug_lite.js +49 -0
  17. package/dist/debug_lite_headers.js +57 -0
  18. package/dist/debug_search.js +16 -0
  19. package/dist/index.js +10 -0
  20. package/dist/mcp/JsonRpcClient.js +16 -0
  21. package/dist/mcp/McpConfig.js +132 -0
  22. package/dist/mcp/McpManager.js +189 -0
  23. package/dist/repl/PersistentShell.js +20 -1
  24. package/dist/repl/ReplManager.js +410 -138
  25. package/dist/tools/AskQuestionTool.js +172 -0
  26. package/dist/tools/EditFileTool.js +141 -0
  27. package/dist/tools/FileTools.js +7 -1
  28. package/dist/tools/PlanModeTool.js +53 -0
  29. package/dist/tools/WebSearchTool.js +190 -27
  30. package/dist/ui/DiffViewer.js +110 -0
  31. package/dist/ui/InputBox.js +16 -2
  32. package/dist/ui/MultiFileSelector.js +123 -0
  33. package/dist/ui/PlanModeUI.js +105 -0
  34. package/dist/ui/ToolExecutor.js +154 -0
  35. package/dist/ui/UIManager.js +12 -2
  36. package/docs/MCP_INTEGRATION.md +290 -0
  37. package/google_dump.html +18 -0
  38. package/lite_dump.html +176 -0
  39. package/lite_headers_dump.html +176 -0
  40. package/package.json +16 -5
  41. package/scripts/test_exa_mcp.ts +90 -0
  42. package/src/checkpoint/CheckpointManager.ts +102 -0
  43. package/src/debug_google.ts +30 -0
  44. package/src/debug_lite.ts +18 -0
  45. package/src/debug_lite_headers.ts +25 -0
  46. package/src/debug_search.ts +18 -0
  47. package/src/index.ts +12 -0
  48. package/src/mcp/JsonRpcClient.ts +19 -0
  49. package/src/mcp/McpConfig.ts +153 -0
  50. package/src/mcp/McpManager.ts +224 -0
  51. package/src/repl/PersistentShell.ts +24 -1
  52. package/src/repl/ReplManager.ts +1521 -1204
  53. package/src/tools/AskQuestionTool.ts +197 -0
  54. package/src/tools/EditFileTool.ts +172 -0
  55. package/src/tools/FileTools.ts +3 -0
  56. package/src/tools/PlanModeTool.ts +50 -0
  57. package/src/tools/WebSearchTool.ts +235 -63
  58. package/src/ui/DiffViewer.ts +117 -0
  59. package/src/ui/InputBox.ts +17 -2
  60. package/src/ui/MultiFileSelector.ts +135 -0
  61. package/src/ui/PlanModeUI.ts +121 -0
  62. package/src/ui/ToolExecutor.ts +182 -0
  63. package/src/ui/UIManager.ts +15 -2
  64. package/console.log(tick) +0 -0
@@ -1,1204 +1,1521 @@
1
- import inquirer from 'inquirer';
2
- import chalk from 'chalk';
3
- import ora from 'ora';
4
- import { ConfigManager } from '../config/ConfigManager';
5
- import { ModelClient, ChatMessage } from '../llm/ModelInterface';
6
- import { OpenAIClient } from '../llm/OpenAIClient';
7
-
8
- import { ContextManager } from '../context/ContextManager';
9
- import { UIManager } from '../ui/UIManager';
10
- import { InputBox } from '../ui/InputBox';
11
- import { WriteFileTool, ReadFileTool, ListDirTool } from '../tools/FileTools';
12
- import { SearchFileTool } from '../tools/SearchTools';
13
- import { PersistentShellTool } from '../tools/PersistentShellTool';
14
- import { PersistentShell } from './PersistentShell';
15
- import { WebSearchTool } from '../tools/WebSearchTool';
16
- import { GitStatusTool, GitDiffTool, GitCommitTool, GitPushTool, GitPullTool } from '../tools/GitTools';
17
- import { Tool } from '../tools/Tool';
18
- import { McpClient } from '../mcp/McpClient';
19
-
20
- import { CheckpointManager } from '../checkpoint/CheckpointManager';
21
- import { SkillsManager } from '../skills/SkillsManager';
22
- import { LoadSkillTool, ListSkillsTool, ReadSkillFileTool } from '../skills/LoadSkillTool';
23
- import { ContextVisualizer } from '../utils/ContextVisualizer';
24
- import { ProjectInitializer } from '../utils/ProjectInitializer';
25
- import { ConversationCompacter } from '../utils/ConversationCompacter';
26
- import { CommandManager } from '../commands/CommandManager';
27
- import { SlashCommandTool, ListCommandsTool } from '../commands/SlashCommandTool';
28
- import * as readline from 'readline';
29
- import * as fs from 'fs';
30
- import * as path from 'path';
31
- import * as os from 'os';
32
- import { marked } from 'marked';
33
- import TerminalRenderer from 'marked-terminal';
34
-
35
- const HISTORY_FILE = path.join(os.homedir(), '.mentis_history');
36
-
37
- export interface CliOptions {
38
- resume: boolean;
39
- yolo: boolean;
40
- headless: boolean;
41
- headlessPrompt?: string;
42
- }
43
-
44
- export class ReplManager {
45
- private configManager: ConfigManager;
46
- private modelClient!: ModelClient;
47
- private contextManager: ContextManager;
48
- private checkpointManager: CheckpointManager;
49
- private skillsManager: SkillsManager;
50
- private contextVisualizer: ContextVisualizer;
51
- private conversationCompacter: ConversationCompacter;
52
- private commandManager: CommandManager;
53
- private history: ChatMessage[] = [];
54
- private mode: 'PLAN' | 'BUILD' = 'BUILD';
55
- private tools: Tool[] = [];
56
- private mcpClients: McpClient[] = [];
57
- private shell: PersistentShell;
58
- private currentModelName: string = 'Unknown';
59
- private activeSkill: string | null = null; // Track currently active skill for allowed-tools
60
- private options: CliOptions;
61
-
62
- constructor(options: CliOptions = { resume: false, yolo: false, headless: false }) {
63
- this.options = options;
64
- this.configManager = new ConfigManager();
65
- this.contextManager = new ContextManager();
66
- this.checkpointManager = new CheckpointManager();
67
- this.skillsManager = new SkillsManager();
68
- this.contextVisualizer = new ContextVisualizer();
69
- this.conversationCompacter = new ConversationCompacter();
70
- this.commandManager = new CommandManager();
71
- this.shell = new PersistentShell();
72
-
73
- // Create tools array without skill tools first
74
- this.tools = [
75
- new WriteFileTool(),
76
- new ReadFileTool(),
77
- new ListDirTool(),
78
- new SearchFileTool(), // grep
79
- new WebSearchTool(),
80
- new GitStatusTool(),
81
- new GitDiffTool(),
82
- new GitCommitTool(),
83
- new GitPushTool(),
84
- new GitPullTool(),
85
- new PersistentShellTool(this.shell) // /run
86
- ];
87
-
88
- // Configure Markdown Renderer
89
- marked.setOptions({
90
- // @ts-ignore
91
- renderer: new TerminalRenderer()
92
- });
93
- // Default to Ollama if not specified, assuming compatible endpoint
94
- this.initializeClient();
95
-
96
- // Initialize skills system after client is ready
97
- this.initializeSkills();
98
- }
99
-
100
- /**
101
- * Initialize the skills and custom commands system
102
- */
103
- private async initializeSkills() {
104
- // Initialize skills
105
- this.skillsManager.ensureDirectoriesExist();
106
- await this.skillsManager.discoverSkills();
107
-
108
- // Initialize custom commands
109
- this.commandManager.ensureDirectoriesExist();
110
- await this.commandManager.discoverCommands();
111
-
112
- // Add skill tools to the tools list
113
- // Pass callback to LoadSkillTool to track active skill
114
- this.tools.push(
115
- new LoadSkillTool(this.skillsManager, (skill) => {
116
- this.activeSkill = skill ? skill.name : null;
117
- }),
118
- new ListSkillsTool(this.skillsManager),
119
- new ReadSkillFileTool(this.skillsManager),
120
- new SlashCommandTool(this.commandManager),
121
- new ListCommandsTool(this.commandManager)
122
- );
123
- }
124
-
125
- /**
126
- * Check if a tool is allowed by the currently active skill
127
- * Returns true if tool is allowed, false if it requires confirmation
128
- */
129
- private isToolAllowedBySkill(toolName: string): boolean {
130
- if (!this.activeSkill) {
131
- // No active skill, all tools require confirmation as per normal flow
132
- return false;
133
- }
134
-
135
- const skill = this.skillsManager.getSkill(this.activeSkill);
136
- if (!skill || !skill.allowedTools || skill.allowedTools.length === 0) {
137
- // No skill or no allowed-tools restriction
138
- return false;
139
- }
140
-
141
- // Map tool names to allowed tool names
142
- const toolMapping: Record<string, string> = {
143
- 'write_file': 'Write',
144
- 'read_file': 'Read',
145
- 'edit_file': 'Edit',
146
- 'search_files': 'Grep',
147
- 'list_dir': 'ListDir',
148
- 'search_file': 'SearchFile',
149
- 'run_shell': 'RunShell',
150
- 'web_search': 'WebSearch',
151
- 'git_status': 'GitStatus',
152
- 'git_diff': 'GitDiff',
153
- 'git_commit': 'GitCommit',
154
- 'git_push': 'GitPush',
155
- 'git_pull': 'GitPull',
156
- 'load_skill': 'Read',
157
- 'list_skills': 'Read',
158
- 'read_skill_file': 'Read',
159
- 'slash_command': 'Read',
160
- 'list_commands': 'Read'
161
- };
162
-
163
- const mappedToolName = toolMapping[toolName] || toolName;
164
- return skill.allowedTools.includes(mappedToolName);
165
- }
166
-
167
- private initializeClient() {
168
- const config = this.configManager.getConfig();
169
- const provider = config.defaultProvider || 'ollama';
170
-
171
- let baseUrl: string | undefined;
172
- let apiKey: string;
173
- let model: string;
174
-
175
- if (provider === 'gemini') {
176
- baseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai/';
177
- apiKey = config.gemini?.apiKey || '';
178
- model = config.gemini?.model || 'gemini-2.5-flash';
179
- } else if (provider === 'openai') {
180
- baseUrl = config.openai?.baseUrl || 'https://api.openai.com/v1';
181
- apiKey = config.openai?.apiKey || '';
182
- model = config.openai?.model || 'gpt-4o';
183
- } else if (provider === 'glm') {
184
- // Use the "Coding Plan" endpoint which supports glm-4.6 and this specific key type
185
- baseUrl = config.glm?.baseUrl || 'https://api.z.ai/api/coding/paas/v4/';
186
- apiKey = config.glm?.apiKey || '';
187
- model = config.glm?.model || 'glm-4.6';
188
- } else { // Default to Ollama
189
- baseUrl = config.ollama?.baseUrl || 'http://localhost:11434/v1';
190
- apiKey = 'ollama'; // Ollama typically doesn't use an API key in the same way
191
- model = config.ollama?.model || 'llama3:latest';
192
- }
193
-
194
- this.currentModelName = model;
195
- this.modelClient = new OpenAIClient(baseUrl, apiKey, model);
196
- // console.log(chalk.dim(`Initialized ${provider} client with model ${model}`));
197
- }
198
-
199
- public async start() {
200
- // Headless mode: non-interactive, process prompt and exit
201
- if (this.options.headless && this.options.headlessPrompt) {
202
- await this.handleChat(this.options.headlessPrompt);
203
- process.exit(0);
204
- return;
205
- }
206
-
207
- UIManager.renderDashboard({
208
- model: this.currentModelName,
209
- mode: this.mode,
210
- cwd: process.cwd()
211
- });
212
-
213
- // Auto-resume if --resume flag is set
214
- if (this.options.resume) {
215
- const cp = this.checkpointManager.load('latest');
216
- if (cp) {
217
- this.history = cp.history;
218
- console.log(chalk.green(`\n✓ Resumed session from ${new Date(cp.timestamp).toLocaleString()}`));
219
- console.log(chalk.dim(` Messages: ${this.history.length}\n`));
220
- } else {
221
- console.log(chalk.yellow('\n⚠ No previous session found to resume.\n'));
222
- }
223
- }
224
-
225
- // Load History
226
- let commandHistory: string[] = [];
227
- if (fs.existsSync(HISTORY_FILE)) {
228
- try {
229
- commandHistory = fs.readFileSync(HISTORY_FILE, 'utf-8').split('\n').filter(Boolean).reverse();
230
- } catch (e) { }
231
- }
232
-
233
- // Initialize InputBox with history
234
- const inputBox = new InputBox(commandHistory);
235
-
236
- while (true) {
237
- // Calculate context usage for display
238
- const usage = this.contextVisualizer.calculateUsage(this.history);
239
-
240
- // Display enhanced input frame
241
- inputBox.displayFrame({
242
- messageCount: this.history.length,
243
- contextPercent: usage.percentage
244
- });
245
-
246
- // Get styled input
247
- const answer = await inputBox.prompt({
248
- showHint: this.history.length === 0,
249
- hint: 'Type your message or /help for commands'
250
- });
251
-
252
- const input = answer.trim();
253
-
254
- if (input) {
255
- // Update history via InputBox
256
- inputBox.addToHistory(input);
257
-
258
- // Append to file
259
- try {
260
- fs.appendFileSync(HISTORY_FILE, input + '\n');
261
- } catch (e) { }
262
- }
263
-
264
- if (!input) continue;
265
-
266
- if (input.startsWith('/')) {
267
- await this.handleCommand(input);
268
- continue;
269
- }
270
-
271
- await this.handleChat(input);
272
- }
273
- }
274
-
275
- private async handleCommand(input: string) {
276
- const [command, ...args] = input.split(' ');
277
- switch (command) {
278
- case '/help':
279
- console.log(chalk.yellow('Available commands:'));
280
- console.log(' /help - Show this help message');
281
- console.log(' /clear - Clear chat history');
282
- console.log(' /exit - Exit the application');
283
- console.log(' /update - Check for and install updates');
284
- console.log(' /config - Configure settings');
285
- console.log(' /add <file> - Add file to context');
286
- console.log(' /drop <file> - Remove file from context');
287
- console.log(' /plan - Switch to PLAN mode');
288
- console.log(' /build - Switch to BUILD mode');
289
- console.log(' /model - Interactively select Provider & Model');
290
- console.log(' /use <provider> [model] - Quick switch (legacy)');
291
- console.log(' /mcp <cmd> - Manage MCP servers');
292
- console.log(' /skills <list|show|create|validate> - Manage Agent Skills');
293
- console.log(' /commands <list|create|validate> - Manage Custom Commands');
294
- console.log(' /resume - Resume last session');
295
- console.log(' /checkpoint <save|load|list> [name] - Manage checkpoints');
296
- console.log(' /search <query> - Search codebase');
297
- console.log(' /run <cmd> - Run shell command');
298
- console.log(' /commit [msg] - Git commit all changes');
299
- console.log(' /init - Initialize project with .mentis.md');
300
- break;
301
- case '/plan':
302
- this.mode = 'PLAN';
303
- console.log(chalk.blue('Switched to PLAN mode.'));
304
- break;
305
- case '/build':
306
- this.mode = 'BUILD';
307
- console.log(chalk.yellow('Switched to BUILD mode.'));
308
- break;
309
- case '/build':
310
- this.mode = 'BUILD';
311
- console.log(chalk.yellow('Switched to BUILD mode.'));
312
- break;
313
- case '/model':
314
- await this.handleModelCommand(args);
315
- break;
316
- case '/connect':
317
- console.log(chalk.dim('Tip: Use /model for an interactive menu.'));
318
- await this.handleConnectCommand(args);
319
- break;
320
- case '/use':
321
- await this.handleUseCommand(args);
322
- break;
323
- case '/mcp':
324
- await this.handleMcpCommand(args);
325
- break;
326
- case '/resume':
327
- await this.handleResumeCommand();
328
- break;
329
- case '/checkpoint':
330
- await this.handleCheckpointCommand(args);
331
- break;
332
- case '/clear':
333
- this.history = [];
334
- this.contextManager.clear();
335
- UIManager.displayLogo(); // Redraw logo on clear
336
- console.log(chalk.yellow('Chat history and context cleared.'));
337
- break;
338
- case '/add':
339
- if (args.length === 0) {
340
- console.log(chalk.red('Usage: /add <file_path>'));
341
- } else {
342
- const result = await this.contextManager.addFile(args[0]);
343
- console.log(chalk.yellow(result));
344
- }
345
- break;
346
- case '/drop':
347
- if (args.length === 0) {
348
- console.log(chalk.red('Usage: /drop <file_path>'));
349
- } else {
350
- const result = await this.contextManager.removeFile(args[0]);
351
- console.log(chalk.yellow(result));
352
- }
353
- break;
354
- case '/config':
355
- await this.handleConfigCommand();
356
- break;
357
- case '/exit':
358
- // Auto-save on exit
359
- this.checkpointManager.save('latest', this.history, this.contextManager.getFiles());
360
- this.shell.kill(); // Kill the shell process
361
- console.log(chalk.green('Session saved. Goodbye!'));
362
- process.exit(0);
363
- break;
364
- case '/update':
365
- const UpdateManager = require('../utils/UpdateManager').UpdateManager;
366
- const updater = new UpdateManager();
367
- await updater.checkAndPerformUpdate(true);
368
- break;
369
- case '/clear':
370
- this.history = [];
371
- console.log(chalk.green('\n✓ Context cleared\n'));
372
- break;
373
- case '/init':
374
- await this.handleInitCommand();
375
- break;
376
- case '/skills':
377
- await this.handleSkillsCommand(args);
378
- break;
379
- case '/commands':
380
- await this.handleCommandsCommand(args);
381
- break;
382
- default:
383
- console.log(chalk.red(`Unknown command: ${command}`));
384
- }
385
- }
386
-
387
- private async handleChat(input: string) {
388
- const context = this.contextManager.getContextString();
389
- const skillsContext = this.skillsManager.getSkillsContext();
390
- const commandsContext = this.commandManager.getCommandsContext();
391
- let fullInput = input;
392
-
393
- let modeInstruction = '';
394
- if (this.mode === 'PLAN') {
395
- modeInstruction = '\n[SYSTEM: You are in PLAN mode. Focus on high-level architecture, requirements analysis, and creating a sturdy plan. Do not write full code implementation yet, just scaffolds or pseudocode if needed.]';
396
- } else {
397
- modeInstruction = '\n[SYSTEM: You are in BUILD mode. Focus on implementing working code that solves the user request efficiently.]';
398
- }
399
-
400
- fullInput = `${input}${modeInstruction}`;
401
-
402
- // Add skills context if available
403
- if (skillsContext) {
404
- fullInput = `${skillsContext}\n\n${fullInput}`;
405
- }
406
-
407
- // Add commands context if available
408
- if (commandsContext) {
409
- fullInput = `${commandsContext}\n\n${fullInput}`;
410
- }
411
-
412
- if (context) {
413
- fullInput = `${context}\n\nUser Question: ${fullInput}`;
414
- }
415
-
416
- this.history.push({ role: 'user', content: fullInput });
417
-
418
- let spinner = ora('Thinking... (Press Esc to cancel)').start();
419
- const controller = new AbortController();
420
-
421
- // Setup cancellation listener
422
- const keyListener = (str: string, key: any) => {
423
- if (key.name === 'escape') {
424
- controller.abort();
425
- }
426
- };
427
-
428
- if (process.stdin.isTTY) {
429
- readline.emitKeypressEvents(process.stdin);
430
- process.stdin.setRawMode(true);
431
- process.stdin.on('keypress', keyListener);
432
- }
433
-
434
- try {
435
- // First call
436
- let response = await this.modelClient.chat(this.history, this.tools.map(t => ({
437
- type: 'function',
438
- function: {
439
- name: t.name,
440
- description: t.description,
441
- parameters: t.parameters
442
- }
443
- })), controller.signal);
444
-
445
- // Loop for tool calls
446
- while (response.tool_calls && response.tool_calls.length > 0) {
447
- if (controller.signal.aborted) throw new Error('Request cancelled by user');
448
-
449
- spinner.stop();
450
-
451
- // Add the assistant's request to use tool to history
452
- this.history.push({
453
- role: 'assistant',
454
- content: response.content,
455
- tool_calls: response.tool_calls
456
- });
457
-
458
- // Execute tools
459
- for (const toolCall of response.tool_calls) {
460
- if (controller.signal.aborted) break;
461
-
462
- const toolName = toolCall.function.name;
463
- const toolArgsStr = toolCall.function.arguments;
464
- const toolArgs = JSON.parse(toolArgsStr);
465
-
466
- // Truncate long arguments
467
- let displayArgs = toolArgsStr;
468
- if (displayArgs.length > 100) {
469
- displayArgs = displayArgs.substring(0, 100) + '...';
470
- }
471
- console.log(chalk.dim(` [Action] ${toolName}(${displayArgs})`));
472
-
473
- // Safety check for write_file
474
- // Skip confirmation if tool is allowed by active skill
475
- if (toolName === 'write_file' && !this.isToolAllowedBySkill('Write')) {
476
- // Pause cancellation listener during user interaction
477
- if (process.stdin.isTTY) {
478
- process.stdin.removeListener('keypress', keyListener);
479
- process.stdin.setRawMode(false);
480
- process.stdin.pause(); // Explicitly pause before inquirer
481
- }
482
-
483
- spinner.stop(); // Stop spinner to allow input
484
-
485
- const { confirm } = await inquirer.prompt([
486
- {
487
- type: 'confirm',
488
- name: 'confirm',
489
- message: `Allow writing to ${chalk.yellow(toolArgs.filePath)}?`,
490
- default: true
491
- }
492
- ]);
493
-
494
- // Resume cancellation listener
495
- if (process.stdin.isTTY) {
496
- process.stdin.setRawMode(true);
497
- process.stdin.resume(); // Explicitly resume
498
- process.stdin.on('keypress', keyListener);
499
- }
500
-
501
- if (!confirm) {
502
- this.history.push({
503
- role: 'tool',
504
- tool_call_id: toolCall.id,
505
- name: toolName,
506
- content: 'Error: User rejected write operation.'
507
- });
508
- console.log(chalk.red(' Action cancelled by user.'));
509
- // Do not restart spinner here. Let the outer loop logic or next step handle it.
510
- // If we continue, we go to next tool or finish loop.
511
- // If finished, lines following loop will start spinner.
512
- continue;
513
- }
514
- spinner = ora('Executing...').start();
515
- }
516
-
517
- const tool = this.tools.find(t => t.name === toolName);
518
- let result = '';
519
-
520
- if (tool) {
521
- try {
522
- // Tools typically run synchronously or promise-based.
523
- // Verify if we want Tools to be cancellable?
524
- // For now, if aborted during tool, we let tool finish but stop loop.
525
- result = await tool.execute(toolArgs);
526
- } catch (e: any) {
527
- result = `Error: ${e.message}`;
528
- }
529
- } else {
530
- result = `Error: Tool ${toolName} not found.`;
531
- }
532
-
533
- if (spinner.isSpinning) {
534
- spinner.stop();
535
- }
536
-
537
- this.history.push({
538
- role: 'tool',
539
- tool_call_id: toolCall.id,
540
- name: toolName,
541
- content: result
542
- });
543
- }
544
-
545
- if (controller.signal.aborted) throw new Error('Request cancelled by user');
546
-
547
- spinner = ora('Thinking (processing tools)...').start();
548
-
549
- // Get next response
550
- response = await this.modelClient.chat(this.history, this.tools.map(t => ({
551
- type: 'function',
552
- function: {
553
- name: t.name,
554
- description: t.description,
555
- parameters: t.parameters
556
- }
557
- })), controller.signal);
558
- }
559
-
560
- spinner.stop();
561
-
562
- console.log('');
563
- if (response.content) {
564
- console.log(chalk.bold.blue('Mentis:'));
565
- console.log(marked(response.content));
566
-
567
- if (response.usage) {
568
- const { input_tokens, output_tokens } = response.usage;
569
- const totalCost = this.estimateCost(input_tokens, output_tokens);
570
- console.log(chalk.dim(`\n(Tokens: ${input_tokens} in / ${output_tokens} out | Est. Cost: $${totalCost.toFixed(5)})`));
571
- }
572
-
573
- // Display context bar
574
- const contextBar = this.contextVisualizer.getContextBar(this.history);
575
- console.log(chalk.dim(`\n${contextBar}`));
576
-
577
- console.log('');
578
- this.history.push({ role: 'assistant', content: response.content });
579
-
580
- // Auto-compact prompt when context is at 80%
581
- const usage = this.contextVisualizer.calculateUsage(this.history);
582
- if (usage.percentage >= 80) {
583
- this.history = await this.conversationCompacter.promptIfCompactNeeded(
584
- usage.percentage,
585
- this.history,
586
- this.modelClient,
587
- this.options.yolo
588
- );
589
- }
590
- }
591
- } catch (error: any) {
592
- spinner.stop();
593
- if (error.message === 'Request cancelled by user') {
594
- console.log(chalk.yellow('\nRequest cancelled by user.'));
595
- } else {
596
- spinner.fail('Error getting response from model.');
597
- console.error(error.message);
598
- }
599
- } finally {
600
- if (process.stdin.isTTY) {
601
- process.stdin.removeListener('keypress', keyListener);
602
- process.stdin.setRawMode(false);
603
- process.stdin.pause(); // Reset flow
604
- }
605
- }
606
- }
607
-
608
- private async handleConfigCommand() {
609
- const config = this.configManager.getConfig();
610
- const { action } = await inquirer.prompt([
611
- {
612
- type: 'list',
613
- name: 'action',
614
- message: 'Configuration',
615
- prefix: '',
616
- choices: [
617
- 'Show Current Configuration',
618
- 'Set Active Provider',
619
- 'Set API Key (for active provider)',
620
- 'Set Base URL (for active provider)',
621
- 'Back'
622
- ]
623
- }
624
- ]);
625
-
626
- if (action === 'Back') return;
627
-
628
- if (action === 'Show Current Configuration') {
629
- console.log(JSON.stringify(config, null, 2));
630
- return;
631
- }
632
-
633
- if (action === 'Set Active Provider') {
634
- const { provider } = await inquirer.prompt([{
635
- type: 'list',
636
- name: 'provider',
637
- message: 'Select Provider:',
638
- choices: ['Gemini', 'Ollama', 'OpenAI', 'GLM']
639
- }]);
640
- const key = provider.toLowerCase();
641
- this.configManager.updateConfig({ defaultProvider: key });
642
- console.log(chalk.green(`Active provider set to: ${provider}`));
643
- this.initializeClient();
644
- return;
645
- }
646
-
647
- const currentProvider = config.defaultProvider;
648
-
649
- if (action === 'Set API Key (for active provider)') {
650
- if (currentProvider === 'ollama') {
651
- console.log(chalk.yellow('Ollama typically does not require an API key.'));
652
- }
653
- const { value } = await inquirer.prompt([{
654
- type: 'password',
655
- name: 'value',
656
- message: `Enter API Key for ${currentProvider}:`,
657
- mask: '*'
658
- }]);
659
-
660
- const updates: any = {};
661
- updates[currentProvider] = { ...((config as any)[currentProvider] || {}), apiKey: value };
662
- this.configManager.updateConfig(updates);
663
- console.log(chalk.green(`API Key updated for ${currentProvider}.`));
664
- this.initializeClient();
665
- }
666
-
667
- if (action === 'Set Base URL (for active provider)') {
668
- const defaultUrl = (config as any)[currentProvider]?.baseUrl || '';
669
- const { value } = await inquirer.prompt([{
670
- type: 'input',
671
- name: 'value',
672
- message: `Enter Base URL for ${currentProvider}:`,
673
- default: defaultUrl
674
- }]);
675
-
676
- const updates: any = {};
677
- updates[currentProvider] = { ...((config as any)[currentProvider] || {}), baseUrl: value };
678
- this.configManager.updateConfig(updates);
679
- console.log(chalk.green(`Base URL updated for ${currentProvider}.`));
680
- this.initializeClient();
681
- }
682
- }
683
-
684
- private async handleModelCommand(args: string[]) {
685
- const config = this.configManager.getConfig();
686
- const currentProvider = config.defaultProvider || 'ollama';
687
-
688
- // Direct argument: /model gpt-4o (updates active provider's model)
689
- if (args.length > 0) {
690
- const modelName = args[0];
691
- const updates: any = {};
692
- updates[currentProvider] = { ...((config as any)[currentProvider] || {}), model: modelName };
693
- this.configManager.updateConfig(updates);
694
- this.initializeClient(); // Re-init with new model
695
- console.log(chalk.green(`\nModel set to ${chalk.bold(modelName)} for ${currentProvider}!`));
696
- return;
697
- }
698
-
699
- // Interactive Mode: Streamlined Provider -> Model Flow
700
- console.log(chalk.cyan('Configure Model & Provider'));
701
-
702
- const { provider } = await inquirer.prompt([
703
- {
704
- type: 'list',
705
- name: 'provider',
706
- message: 'Select Provider:',
707
- choices: ['Gemini', 'Ollama', 'OpenAI', 'GLM'],
708
- default: currentProvider.charAt(0).toUpperCase() + currentProvider.slice(1) // Capitalize for default selection
709
- }
710
- ]);
711
-
712
- const selectedProvider = provider.toLowerCase();
713
-
714
- let models: string[] = [];
715
- if (selectedProvider === 'gemini') {
716
- models = ['gemini-2.5-flash', 'gemini-1.5-pro', 'gemini-1.0-pro', 'Other...'];
717
- } else if (selectedProvider === 'ollama') {
718
- models = ['llama3:latest', 'deepseek-r1:latest', 'mistral:latest', 'qwen2.5-coder', 'Other...'];
719
- } else if (selectedProvider === 'openai') {
720
- models = ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'Other...'];
721
- } else if (selectedProvider === 'glm') {
722
- models = ['glm-4.6', 'glm-4-plus', 'glm-4', 'glm-4-air', 'glm-4-flash', 'Other...'];
723
- } else {
724
- models = ['Other...'];
725
- }
726
-
727
- let { model } = await inquirer.prompt([
728
- {
729
- type: 'list',
730
- name: 'model',
731
- message: `Select Model for ${provider}:`,
732
- choices: models,
733
- // Try to find current model in list to set default
734
- default: (config as any)[selectedProvider]?.model
735
- }
736
- ]);
737
-
738
- if (model === 'Other...') {
739
- const { customModel } = await inquirer.prompt([{
740
- type: 'input',
741
- name: 'customModel',
742
- message: 'Enter model name:'
743
- }]);
744
- model = customModel;
745
- }
746
-
747
- // Check for missing API Key (except for Ollama)
748
- let newApiKey = undefined;
749
- const currentKey = (config as any)[selectedProvider]?.apiKey;
750
-
751
- if (selectedProvider !== 'ollama' && !currentKey) {
752
- console.log(chalk.yellow(`\n⚠️ No API Key found for ${provider}.`));
753
- const { apiKey } = await inquirer.prompt([{
754
- type: 'password',
755
- name: 'apiKey',
756
- message: `Enter API Key for ${provider} (or leave empty to skip):`,
757
- mask: '*'
758
- }]);
759
- if (apiKey && apiKey.trim()) {
760
- newApiKey = apiKey.trim();
761
- }
762
- }
763
-
764
- const updates: any = {};
765
- updates.defaultProvider = selectedProvider;
766
- updates[selectedProvider] = {
767
- ...((config as any)[selectedProvider] || {}),
768
- model: model
769
- };
770
-
771
- if (newApiKey) {
772
- updates[selectedProvider].apiKey = newApiKey;
773
- }
774
-
775
- this.configManager.updateConfig(updates);
776
- this.initializeClient();
777
- console.log(chalk.green(`\nSwitched to ${chalk.bold(provider)} (${model})!`));
778
- }
779
-
780
- private async handleConnectCommand(args: string[]) {
781
- if (args.length < 1) {
782
- console.log(chalk.red('Usage: /connect <provider> [key_or_url]'));
783
- return;
784
- }
785
-
786
- const provider = args[0].toLowerCase();
787
- const value = args[1]; // Optional for ollama (defaults), required for others usually
788
-
789
- const config = this.configManager.getConfig();
790
-
791
- if (provider === 'gemini') {
792
- if (!value) {
793
- console.log(chalk.red('Error: API Key required for Gemini. usage: /connect gemini <api_key>'));
794
- return;
795
- }
796
- this.configManager.updateConfig({
797
- gemini: { ...config.gemini, apiKey: value },
798
- defaultProvider: 'gemini'
799
- });
800
- console.log(chalk.green(`Connected to Gemini with key: ${value.substring(0, 8)}...`));
801
- } else if (provider === 'ollama') {
802
- const url = value || 'http://localhost:11434/v1';
803
- this.configManager.updateConfig({
804
- ollama: { ...config.ollama, baseUrl: url },
805
- defaultProvider: 'ollama'
806
- });
807
- console.log(chalk.green(`Connected to Ollama at ${url}`));
808
- } else if (provider === 'openai') { // Support OpenAI since client supports it
809
- if (!value) {
810
- console.log(chalk.red('Error: API Key required for OpenAI. usage: /connect openai <api_key>'));
811
- return;
812
- }
813
- this.configManager.updateConfig({
814
- openai: { ...config.openai, apiKey: value },
815
- defaultProvider: 'openai' // We might need to handle 'openai' in initializeClient if we add it officially
816
- });
817
- console.log(chalk.green(`Connected to OpenAI.`));
818
- } else if (provider === 'glm') {
819
- if (!value) {
820
- console.log(chalk.red('Error: API Key required for GLM. usage: /connect glm <api_key>'));
821
- return;
822
- }
823
- this.configManager.updateConfig({
824
- glm: { ...config.glm, apiKey: value },
825
- defaultProvider: 'glm'
826
- });
827
- console.log(chalk.green(`Connected to GLM (ZhipuAI).`));
828
- } else {
829
- console.log(chalk.red(`Unknown provider: ${provider}. Use 'gemini', 'ollama', 'openai', or 'glm'.`));
830
- return;
831
- }
832
-
833
- this.initializeClient();
834
- }
835
-
836
- private async handleUseCommand(args: string[]) {
837
- if (args.length < 1) {
838
- console.log(chalk.red('Usage: /use <provider> [model_name]'));
839
- return;
840
- }
841
-
842
- const provider = args[0].toLowerCase();
843
- const model = args[1]; // Optional
844
-
845
- const config = this.configManager.getConfig();
846
-
847
- if (provider === 'gemini') {
848
- const updates: any = { defaultProvider: 'gemini' };
849
- if (model) {
850
- updates.gemini = { ...config.gemini, model: model };
851
- }
852
- this.configManager.updateConfig(updates);
853
- } else if (provider === 'ollama') {
854
- const updates: any = { defaultProvider: 'ollama' };
855
- if (model) {
856
- updates.ollama = { ...config.ollama, model: model };
857
- }
858
- this.configManager.updateConfig(updates);
859
- } else if (provider === 'glm') {
860
- const updates: any = { defaultProvider: 'glm' };
861
- if (model) {
862
- updates.glm = { ...config.glm, model: model };
863
- }
864
- this.configManager.updateConfig(updates);
865
-
866
- // Auto switch if connecting to a new provider
867
- if ((provider as string) === 'gemini') {
868
- updates.defaultProvider = 'gemini';
869
- this.configManager.updateConfig(updates);
870
- } else if ((provider as string) === 'ollama') {
871
- updates.defaultProvider = 'ollama';
872
- this.configManager.updateConfig(updates);
873
- }
874
- } else {
875
- console.log(chalk.red(`Unknown provider: ${provider}`));
876
- return;
877
- }
878
-
879
- this.initializeClient();
880
- console.log(chalk.green(`Switched to ${provider} ${model ? `using model ${model}` : ''}`));
881
- }
882
-
883
- private async handleMcpCommand(args: string[]) {
884
- if (args.length < 1) {
885
- console.log(chalk.red('Usage: /mcp <connect|list|disconnect> [args]'));
886
- return;
887
- }
888
-
889
- const action = args[0];
890
-
891
- if (action === 'connect') {
892
- const commandParts = args.slice(1);
893
- if (commandParts.length === 0) {
894
- console.log(chalk.red('Usage: /mcp connect <command> [args...]'));
895
- return;
896
- }
897
-
898
- // Example: /mcp connect npx -y @modelcontextprotocol/server-memory
899
- // On Windows, npx might be npx.cmd
900
- const cmd = process.platform === 'win32' && commandParts[0] === 'npx' ? 'npx.cmd' : commandParts[0];
901
- const cmdArgs = commandParts.slice(1);
902
-
903
- const spinner = ora(`Connecting to MCP server: ${cmd} ${cmdArgs.join(' ')}...`).start();
904
-
905
- try {
906
- const client = new McpClient(cmd, cmdArgs);
907
- await client.initialize();
908
- const mcpTools = await client.listTools();
909
-
910
- this.mcpClients.push(client);
911
- this.tools.push(...mcpTools);
912
-
913
- spinner.succeed(chalk.green(`Connected to ${client.serverName}!`));
914
- console.log(chalk.green(`Added ${mcpTools.length} tools:`));
915
- mcpTools.forEach(t => console.log(chalk.dim(` - ${t.name}: ${t.description.substring(0, 50)}...`)));
916
-
917
- } catch (e: any) {
918
- spinner.fail(chalk.red(`Failed to connect: ${e.message}`));
919
- }
920
-
921
- } else if (action === 'list') {
922
- if (this.mcpClients.length === 0) {
923
- console.log('No active MCP connections.');
924
- } else {
925
- console.log(chalk.cyan('Active MCP Connections:'));
926
- this.mcpClients.forEach((client, idx) => {
927
- console.log(`${idx + 1}. ${client.serverName}`);
928
- });
929
- }
930
- } else if (action === 'disconnect') {
931
- // Basic disconnect all for now or by index if we wanted
932
- console.log(chalk.yellow('Disconnecting all MCP clients...'));
933
- this.mcpClients.forEach(c => c.disconnect());
934
- this.mcpClients = [];
935
- // Re-init core tools
936
- this.tools = [
937
- new WriteFileTool(),
938
- new ReadFileTool(),
939
- new ListDirTool(),
940
- new SearchFileTool(),
941
- new PersistentShellTool(this.shell),
942
- new WebSearchTool(),
943
- new GitStatusTool(),
944
- new GitDiffTool(),
945
- new GitCommitTool(),
946
- new GitPushTool(),
947
- new GitPullTool()
948
- ];
949
- } else {
950
- console.log(chalk.red(`Unknown MCP action: ${action}`));
951
- }
952
- }
953
-
954
- private async handleResumeCommand() {
955
- if (!this.checkpointManager.exists('latest')) {
956
- console.log(chalk.yellow('No previous session found to resume.'));
957
- return;
958
- }
959
- await this.loadCheckpoint('latest');
960
- }
961
-
962
- private async handleCheckpointCommand(args: string[]) {
963
- if (args.length < 1) {
964
- console.log(chalk.red('Usage: /checkpoint <save|load|list> [name]'));
965
- return;
966
- }
967
- const action = args[0];
968
- const name = args[1] || 'default';
969
-
970
- if (action === 'save') {
971
- this.checkpointManager.save(name, this.history, this.contextManager.getFiles());
972
- console.log(chalk.green(`Checkpoint '${name}' saved.`));
973
- } else if (action === 'load') {
974
- await this.loadCheckpoint(name);
975
- } else if (action === 'list') {
976
- const points = this.checkpointManager.list();
977
- console.log(chalk.cyan('Available Checkpoints:'));
978
- points.forEach(p => console.log(` - ${p}`));
979
- } else {
980
- console.log(chalk.red(`Unknown action: ${action}`));
981
- }
982
- }
983
-
984
- private async loadCheckpoint(name: string) {
985
- const cp = this.checkpointManager.load(name);
986
- if (!cp) {
987
- console.log(chalk.red(`Checkpoint '${name}' not found.`));
988
- return;
989
- }
990
-
991
- this.history = cp.history;
992
- this.contextManager.clear();
993
-
994
- // Restore context files
995
- if (cp.files && cp.files.length > 0) {
996
- console.log(chalk.dim('Restoring context files...'));
997
- for (const file of cp.files) {
998
- await this.contextManager.addFile(file);
999
- }
1000
- }
1001
- console.log(chalk.green(`Resumed session '${name}' (${new Date(cp.timestamp).toLocaleString()})`));
1002
- // Re-display last assistant message if any
1003
- const lastMsg = this.history[this.history.length - 1];
1004
- if (lastMsg && lastMsg.role === 'assistant' && lastMsg.content) {
1005
- console.log(chalk.blue('\nLast message:'));
1006
- console.log(lastMsg.content);
1007
- }
1008
- }
1009
-
1010
- private async handleSkillsCommand(args: string[]) {
1011
- const { SkillCreator, validateSkills } = await import('../skills/SkillCreator');
1012
-
1013
- if (args.length < 1) {
1014
- // Show skills list by default
1015
- await this.handleSkillsCommand(['list']);
1016
- return;
1017
- }
1018
-
1019
- const action = args[0];
1020
-
1021
- switch (action) {
1022
- case 'list':
1023
- await this.handleSkillsList();
1024
- break;
1025
- case 'show':
1026
- if (args.length < 2) {
1027
- console.log(chalk.red('Usage: /skills show <name>'));
1028
- return;
1029
- }
1030
- await this.handleSkillsShow(args[1]);
1031
- break;
1032
- case 'create':
1033
- const creator = new SkillCreator(this.skillsManager);
1034
- await creator.run(args[1]);
1035
- // Re-discover skills after creation
1036
- await this.skillsManager.discoverSkills();
1037
- break;
1038
- case 'validate':
1039
- await validateSkills(this.skillsManager);
1040
- break;
1041
- default:
1042
- console.log(chalk.red(`Unknown skills action: ${action}`));
1043
- console.log(chalk.yellow('Available actions: list, show, create, validate'));
1044
- }
1045
- }
1046
-
1047
- private async handleSkillsList(): Promise<void> {
1048
- const skills = this.skillsManager.getAllSkills();
1049
-
1050
- if (skills.length === 0) {
1051
- console.log(chalk.yellow('No skills available.'));
1052
- console.log(chalk.dim('Create skills with: /skills create'));
1053
- console.log(chalk.dim('Add skills to: ~/.mentis/skills/ or .mentis/skills/'));
1054
- return;
1055
- }
1056
-
1057
- console.log(chalk.cyan(`\nAvailable Skills (${skills.length}):\n`));
1058
-
1059
- for (const skill of skills) {
1060
- const statusIcon = skill.isValid ? '✓' : '✗';
1061
- const typeLabel = skill.type === 'personal' ? 'Personal' : 'Project';
1062
-
1063
- console.log(`${statusIcon} ${chalk.bold(skill.name)} (${typeLabel})`);
1064
- console.log(` ${skill.description}`);
1065
-
1066
- if (skill.allowedTools && skill.allowedTools.length > 0) {
1067
- console.log(chalk.dim(` Allowed tools: ${skill.allowedTools.join(', ')}`));
1068
- }
1069
-
1070
- if (!skill.isValid && skill.errors) {
1071
- console.log(chalk.red(` Errors: ${skill.errors.join(', ')}`));
1072
- }
1073
-
1074
- console.log('');
1075
- }
1076
- }
1077
-
1078
- private async handleSkillsShow(name: string): Promise<void> {
1079
- const skill = await this.skillsManager.loadFullSkill(name);
1080
-
1081
- if (!skill) {
1082
- console.log(chalk.red(`Skill "${name}" not found.`));
1083
- return;
1084
- }
1085
-
1086
- console.log(chalk.cyan(`\n# ${skill.name}\n`));
1087
- console.log(chalk.dim(`Type: ${skill.type}`));
1088
- console.log(chalk.dim(`Path: ${skill.path}`));
1089
-
1090
- if (skill.allowedTools && skill.allowedTools.length > 0) {
1091
- console.log(chalk.dim(`Allowed tools: ${skill.allowedTools.join(', ')}`));
1092
- }
1093
-
1094
- console.log('');
1095
- console.log(skill.content || 'No content available');
1096
-
1097
- // List supporting files
1098
- const files = this.skillsManager.listSkillFiles(name);
1099
- if (files.length > 0) {
1100
- console.log(chalk.dim(`\nSupporting files: ${files.join(', ')}`));
1101
- }
1102
- }
1103
-
1104
- private async handleInitCommand(): Promise<void> {
1105
- const initializer = new ProjectInitializer();
1106
- await initializer.run();
1107
- }
1108
-
1109
- private async handleCommandsCommand(args: string[]) {
1110
- if (args.length < 1) {
1111
- // Show commands list by default
1112
- await this.handleCommandsCommand(['list']);
1113
- return;
1114
- }
1115
-
1116
- const action = args[0];
1117
-
1118
- switch (action) {
1119
- case 'list':
1120
- await this.handleCommandsList();
1121
- break;
1122
- case 'create':
1123
- await this.handleCommandsCreate(args[1]);
1124
- break;
1125
- case 'validate':
1126
- await this.handleCommandsValidate();
1127
- break;
1128
- default:
1129
- console.log(chalk.red(`Unknown commands action: ${action}`));
1130
- console.log(chalk.yellow('Available actions: list, create, validate'));
1131
- }
1132
- }
1133
-
1134
- private async handleCommandsList(): Promise<void> {
1135
- const commands = this.commandManager.getAllCommands();
1136
-
1137
- if (commands.length === 0) {
1138
- console.log(chalk.yellow('No custom commands available.'));
1139
- console.log(chalk.dim('Create commands with: /commands create'));
1140
- console.log(chalk.dim('Add commands to: ~/.mentis/commands/ or .mentis/commands/'));
1141
- return;
1142
- }
1143
-
1144
- console.log(chalk.cyan(`\nCustom Commands (${commands.length}):\n`));
1145
-
1146
- // Group by namespace
1147
- const grouped = new Map<string, any[]>();
1148
- for (const cmd of commands) {
1149
- const ns = cmd.description.match(/\(([^)]+)\)/)?.[1] || cmd.type;
1150
- if (!grouped.has(ns)) {
1151
- grouped.set(ns, []);
1152
- }
1153
- grouped.get(ns)!.push(cmd);
1154
- }
1155
-
1156
- for (const [namespace, cmds] of grouped) {
1157
- console.log(chalk.bold(`\n${namespace}`));
1158
- for (const cmd of cmds) {
1159
- const params = cmd.frontmatter['argument-hint'] ? ` ${cmd.frontmatter['argument-hint']}` : '';
1160
- console.log(` /${cmd.name}${params}`);
1161
- console.log(` ${cmd.description.replace(/\s*\([^)]+\)/, '')}`);
1162
-
1163
- if (cmd.frontmatter['allowed-tools'] && cmd.frontmatter['allowed-tools'].length > 0) {
1164
- console.log(chalk.dim(` Allowed tools: ${cmd.frontmatter['allowed-tools'].join(', ')}`));
1165
- }
1166
- }
1167
- }
1168
- console.log('');
1169
- }
1170
-
1171
- private async handleCommandsCreate(name?: string): Promise<void> {
1172
- const { CommandCreator } = await import('../commands/CommandCreator');
1173
- const creator = new CommandCreator(this.commandManager);
1174
- await creator.run(name);
1175
- // Re-discover commands after creation
1176
- await this.commandManager.discoverCommands();
1177
- }
1178
-
1179
- private async handleCommandsValidate(): Promise<void> {
1180
- const { validateCommands } = await import('../commands/CommandCreator');
1181
- await validateCommands(this.commandManager);
1182
- }
1183
-
1184
- private estimateCost(input: number, output: number): number {
1185
- const config = this.configManager.getConfig();
1186
- const provider = config.defaultProvider;
1187
-
1188
- let rateIn = 0;
1189
- let rateOut = 0;
1190
-
1191
- if (provider === 'openai') {
1192
- rateIn = 5.00 / 1000000;
1193
- rateOut = 15.00 / 1000000;
1194
- } else if (provider === 'gemini') {
1195
- rateIn = 0.35 / 1000000;
1196
- rateOut = 0.70 / 1000000;
1197
- } else if (provider === 'glm') {
1198
- rateIn = 14.00 / 1000000; // Approximate for GLM-4
1199
- rateOut = 14.00 / 1000000;
1200
- }
1201
-
1202
- return (input * rateIn) + (output * rateOut);
1203
- }
1204
- }
1
+ import inquirer from 'inquirer';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { ConfigManager } from '../config/ConfigManager';
5
+ import { ModelClient, ChatMessage } from '../llm/ModelInterface';
6
+ import { OpenAIClient } from '../llm/OpenAIClient';
7
+
8
+ import { ContextManager } from '../context/ContextManager';
9
+ import { UIManager } from '../ui/UIManager';
10
+ import { InputBox } from '../ui/InputBox';
11
+ import { DiffViewer } from '../ui/DiffViewer';
12
+ import { MultiFileSelector } from '../ui/MultiFileSelector';
13
+ import { ToolExecutor } from '../ui/ToolExecutor';
14
+ import { PlanModeUI } from '../ui/PlanModeUI';
15
+ import { WriteFileTool, ReadFileTool, ListDirTool, EditFileTool, AskQuestionTool, PlanModeTool } from '../tools/FileTools';
16
+ import { SearchFileTool } from '../tools/SearchTools';
17
+ import { PersistentShellTool } from '../tools/PersistentShellTool';
18
+ import { PersistentShell } from './PersistentShell';
19
+ import { WebSearchTool } from '../tools/WebSearchTool';
20
+ import { GitStatusTool, GitDiffTool, GitCommitTool, GitPushTool, GitPullTool } from '../tools/GitTools';
21
+ import { Tool } from '../tools/Tool';
22
+ import { McpClient } from '../mcp/McpClient';
23
+ import { McpManager } from '../mcp/McpManager';
24
+
25
+ import { CheckpointManager } from '../checkpoint/CheckpointManager';
26
+ import { SkillsManager } from '../skills/SkillsManager';
27
+ import { LoadSkillTool, ListSkillsTool, ReadSkillFileTool } from '../skills/LoadSkillTool';
28
+ import { ContextVisualizer } from '../utils/ContextVisualizer';
29
+ import { ProjectInitializer } from '../utils/ProjectInitializer';
30
+ import { ConversationCompacter } from '../utils/ConversationCompacter';
31
+ import { CommandManager } from '../commands/CommandManager';
32
+ import { SlashCommandTool, ListCommandsTool } from '../commands/SlashCommandTool';
33
+ import * as readline from 'readline';
34
+ import * as fs from 'fs';
35
+ import * as path from 'path';
36
+ import * as os from 'os';
37
+ import { marked } from 'marked';
38
+ import TerminalRenderer from 'marked-terminal';
39
+
40
+ const HISTORY_FILE = path.join(os.homedir(), '.mentis_history');
41
+
42
+ export interface CliOptions {
43
+ resume: boolean;
44
+ yolo: boolean;
45
+ headless: boolean;
46
+ headlessPrompt?: string;
47
+ }
48
+
49
+ export class ReplManager {
50
+ private configManager: ConfigManager;
51
+ private modelClient!: ModelClient;
52
+ private contextManager: ContextManager;
53
+ private checkpointManager: CheckpointManager;
54
+ private skillsManager: SkillsManager;
55
+ private contextVisualizer: ContextVisualizer;
56
+ private conversationCompacter: ConversationCompacter;
57
+ private commandManager: CommandManager;
58
+ private history: ChatMessage[] = [];
59
+ private mode: 'PLAN' | 'BUILD' = 'BUILD';
60
+ private tools: Tool[] = [];
61
+ private mcpClients: McpClient[] = [];
62
+ private mcpManager: McpManager;
63
+ private shell: PersistentShell;
64
+ private currentModelName: string = 'Unknown';
65
+ private activeSkill: string | null = null; // Track currently active skill for allowed-tools
66
+ private options: CliOptions;
67
+
68
+ constructor(options: CliOptions = { resume: false, yolo: false, headless: false }) {
69
+ this.options = options;
70
+ this.configManager = new ConfigManager();
71
+ this.contextManager = new ContextManager();
72
+ this.checkpointManager = new CheckpointManager();
73
+ this.skillsManager = new SkillsManager();
74
+ this.contextVisualizer = new ContextVisualizer();
75
+ this.conversationCompacter = new ConversationCompacter();
76
+ this.commandManager = new CommandManager();
77
+ this.mcpManager = new McpManager();
78
+ this.shell = new PersistentShell();
79
+
80
+ // Create tools array without skill tools first
81
+ this.tools = [
82
+ new PlanModeTool(), // AI can suggest plan mode for complex tasks
83
+ new AskQuestionTool(), // For plan mode questions
84
+ new WriteFileTool(),
85
+ new EditFileTool(),
86
+ new ReadFileTool(),
87
+ new ListDirTool(),
88
+ new SearchFileTool(), // grep
89
+ new WebSearchTool(),
90
+ new GitStatusTool(),
91
+ new GitDiffTool(),
92
+ new GitCommitTool(),
93
+ new GitPushTool(),
94
+ new GitPullTool(),
95
+ new PersistentShellTool(this.shell) // /run
96
+ ];
97
+
98
+ // Configure Markdown Renderer
99
+ marked.setOptions({
100
+ // @ts-ignore
101
+ renderer: new TerminalRenderer()
102
+ });
103
+ // Default to Ollama if not specified, assuming compatible endpoint
104
+ this.initializeClient();
105
+
106
+ // Initialize skills system after client is ready
107
+ this.initializeSkills();
108
+ }
109
+
110
+ /**
111
+ * Initialize the skills and custom commands system
112
+ */
113
+ private async initializeSkills() {
114
+ // Initialize skills
115
+ this.skillsManager.ensureDirectoriesExist();
116
+ await this.skillsManager.discoverSkills();
117
+
118
+ // Initialize custom commands
119
+ this.commandManager.ensureDirectoriesExist();
120
+ await this.commandManager.discoverCommands();
121
+
122
+ // Add skill tools to the tools list
123
+ // Pass callback to LoadSkillTool to track active skill
124
+ this.tools.push(
125
+ new LoadSkillTool(this.skillsManager, (skill) => {
126
+ this.activeSkill = skill ? skill.name : null;
127
+ }),
128
+ new ListSkillsTool(this.skillsManager),
129
+ new ReadSkillFileTool(this.skillsManager),
130
+ new SlashCommandTool(this.commandManager),
131
+ new ListCommandsTool(this.commandManager)
132
+ );
133
+
134
+ // Auto-connect to MCP servers
135
+ await this.mcpManager.autoConnect();
136
+ this.refreshToolsFromMcp();
137
+ }
138
+
139
+ /**
140
+ * Refresh tools list from MCP connections
141
+ */
142
+ private refreshToolsFromMcp() {
143
+ // Remove existing MCP tools (keep core tools)
144
+ this.tools = this.tools.filter(tool =>
145
+ !tool.name.startsWith('mcp_') &&
146
+ !['load_skill', 'list_skills', 'read_skill_file', 'slash_command', 'list_commands'].includes(tool.name)
147
+ );
148
+
149
+ // Add MCP tools
150
+ const mcpTools = this.mcpManager.getAllTools();
151
+ this.tools.push(...mcpTools);
152
+
153
+ // Re-add skill tools
154
+ this.tools.push(
155
+ new LoadSkillTool(this.skillsManager, (skill) => {
156
+ this.activeSkill = skill ? skill.name : null;
157
+ }),
158
+ new ListSkillsTool(this.skillsManager),
159
+ new ReadSkillFileTool(this.skillsManager),
160
+ new SlashCommandTool(this.commandManager),
161
+ new ListCommandsTool(this.commandManager)
162
+ );
163
+ }
164
+
165
+ /**
166
+ * Check if a tool is allowed by the currently active skill
167
+ * Returns true if tool is allowed, false if it requires confirmation
168
+ */
169
+ private isToolAllowedBySkill(toolName: string): boolean {
170
+ if (!this.activeSkill) {
171
+ // No active skill, all tools require confirmation as per normal flow
172
+ return false;
173
+ }
174
+
175
+ const skill = this.skillsManager.getSkill(this.activeSkill);
176
+ if (!skill || !skill.allowedTools || skill.allowedTools.length === 0) {
177
+ // No skill or no allowed-tools restriction
178
+ return false;
179
+ }
180
+
181
+ // Map tool names to allowed tool names
182
+ const toolMapping: Record<string, string> = {
183
+ 'write_file': 'Write',
184
+ 'read_file': 'Read',
185
+ 'edit_file': 'Edit',
186
+ 'search_files': 'Grep',
187
+ 'list_dir': 'ListDir',
188
+ 'search_file': 'SearchFile',
189
+ 'run_shell': 'RunShell',
190
+ 'search_web': 'WebSearch',
191
+ 'git_status': 'GitStatus',
192
+ 'git_diff': 'GitDiff',
193
+ 'git_commit': 'GitCommit',
194
+ 'git_push': 'GitPush',
195
+ 'git_pull': 'GitPull',
196
+ 'load_skill': 'Read',
197
+ 'list_skills': 'Read',
198
+ 'read_skill_file': 'Read',
199
+ 'slash_command': 'Read',
200
+ 'list_commands': 'Read'
201
+ };
202
+
203
+ const mappedToolName = toolMapping[toolName] || toolName;
204
+ return skill.allowedTools.includes(mappedToolName);
205
+ }
206
+
207
+ private initializeClient() {
208
+ const config = this.configManager.getConfig();
209
+ const provider = config.defaultProvider || 'ollama';
210
+
211
+ let baseUrl: string | undefined;
212
+ let apiKey: string;
213
+ let model: string;
214
+
215
+ if (provider === 'gemini') {
216
+ baseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai/';
217
+ apiKey = config.gemini?.apiKey || '';
218
+ model = config.gemini?.model || 'gemini-2.5-flash';
219
+ } else if (provider === 'openai') {
220
+ baseUrl = config.openai?.baseUrl || 'https://api.openai.com/v1';
221
+ apiKey = config.openai?.apiKey || '';
222
+ model = config.openai?.model || 'gpt-4o';
223
+ } else if (provider === 'glm') {
224
+ // Use the "Coding Plan" endpoint which supports glm-4.6 and this specific key type
225
+ baseUrl = config.glm?.baseUrl || 'https://api.z.ai/api/coding/paas/v4/';
226
+ apiKey = config.glm?.apiKey || '';
227
+ model = config.glm?.model || 'glm-4.6';
228
+ } else { // Default to Ollama
229
+ baseUrl = config.ollama?.baseUrl || 'http://localhost:11434/v1';
230
+ apiKey = 'ollama'; // Ollama typically doesn't use an API key in the same way
231
+ model = config.ollama?.model || 'llama3:latest';
232
+ }
233
+
234
+ this.currentModelName = model;
235
+ this.modelClient = new OpenAIClient(baseUrl, apiKey, model);
236
+ // console.log(chalk.dim(`Initialized ${provider} client with model ${model}`));
237
+ }
238
+
239
+ public async start() {
240
+ // Headless mode: non-interactive, process prompt and exit
241
+ if (this.options.headless && this.options.headlessPrompt) {
242
+ await this.handleChat(this.options.headlessPrompt);
243
+ process.exit(0);
244
+ return;
245
+ }
246
+
247
+ UIManager.renderDashboard({
248
+ model: this.currentModelName,
249
+ mode: this.mode,
250
+ cwd: process.cwd()
251
+ });
252
+
253
+ // Auto-resume if --resume flag is set
254
+ if (this.options.resume) {
255
+ // Prefer local (per-directory) session, fall back to global
256
+ const cwd = process.cwd();
257
+ let cp = this.checkpointManager.loadLocalSession(cwd);
258
+ let source = 'local';
259
+ if (!cp) {
260
+ cp = this.checkpointManager.load('latest');
261
+ source = 'global';
262
+ }
263
+ if (cp) {
264
+ this.history = cp.history;
265
+ console.log(chalk.green(`\n✓ Resumed ${source} session from ${new Date(cp.timestamp).toLocaleString()}`));
266
+ console.log(chalk.dim(` Messages: ${this.history.length}\n`));
267
+ } else {
268
+ console.log(chalk.yellow('\n⚠ No previous session found to resume.\n'));
269
+ }
270
+ }
271
+
272
+ // Load History
273
+ let commandHistory: string[] = [];
274
+ if (fs.existsSync(HISTORY_FILE)) {
275
+ try {
276
+ commandHistory = fs.readFileSync(HISTORY_FILE, 'utf-8').split('\n').filter(Boolean).reverse();
277
+ } catch (e) { }
278
+ }
279
+
280
+ // Initialize InputBox with history
281
+ const inputBox = new InputBox(commandHistory);
282
+
283
+ while (true) {
284
+ // Calculate context usage for display
285
+ const usage = this.contextVisualizer.calculateUsage(this.history);
286
+
287
+ // Display enhanced input frame
288
+ inputBox.displayFrame({
289
+ messageCount: this.history.length,
290
+ contextPercent: usage.percentage
291
+ });
292
+
293
+ // Get styled input
294
+ const answer = await inputBox.prompt({
295
+ showHint: this.history.length === 0,
296
+ hint: 'Type your message or /help for commands'
297
+ });
298
+
299
+ const input = answer.trim();
300
+
301
+ if (input) {
302
+ // Update history via InputBox
303
+ inputBox.addToHistory(input);
304
+
305
+ // Append to file
306
+ try {
307
+ fs.appendFileSync(HISTORY_FILE, input + '\n');
308
+ } catch (e) { }
309
+ }
310
+
311
+ if (!input) continue;
312
+
313
+ if (input.startsWith('/')) {
314
+ await this.handleCommand(input);
315
+ continue;
316
+ }
317
+
318
+ // Wrap handleChat in try-catch to prevent REPL from entering inconsistent state
319
+ try {
320
+ await this.handleChat(input);
321
+ } catch (error: any) {
322
+ console.error(chalk.red(`Error: ${error.message}`));
323
+ console.error(chalk.dim('The REPL has recovered. You can continue using the CLI.'));
324
+ }
325
+ }
326
+ }
327
+
328
+ private async handleCommand(input: string) {
329
+ const [command, ...args] = input.split(' ');
330
+ switch (command) {
331
+ case '/help':
332
+ console.log(chalk.yellow('Available commands:'));
333
+ console.log(' /help - Show this help message');
334
+ console.log(' /clear - Clear chat history');
335
+ console.log(' /exit - Exit the application');
336
+ console.log(' /update - Check for and install updates');
337
+ console.log(' /config - Configure settings');
338
+ console.log(' /add <file> - Add file to context');
339
+ console.log(' /drop <file> - Remove file from context');
340
+ console.log(' /plan - Switch to PLAN mode');
341
+ console.log(' /build - Switch to BUILD mode');
342
+ console.log(' /model - Interactively select Provider & Model');
343
+ console.log(' /use <provider> [model] - Quick switch (legacy)');
344
+ console.log(' /mcp <cmd> - Manage MCP servers');
345
+ console.log(' /skills <list|show|create|validate> - Manage Agent Skills');
346
+ console.log(' /commands <list|create|validate> - Manage Custom Commands');
347
+ console.log(' /resume - Resume last session');
348
+ console.log(' /search <query> - Search codebase');
349
+ console.log(' /run <cmd> - Run shell command');
350
+ console.log(' /commit [msg] - Git commit all changes');
351
+ console.log(' /init - Initialize project with .mentis.md');
352
+ break;
353
+ case '/plan':
354
+ this.mode = 'PLAN';
355
+ UIManager.logBullet('Entered plan mode', 'magenta');
356
+ PlanModeUI.showPlanHeader();
357
+ PlanModeUI.showQAHistory();
358
+ break;
359
+ case '/build':
360
+ this.mode = 'BUILD';
361
+ UIManager.logBullet('Entered build mode', 'green');
362
+ PlanModeUI.showPlanSummary();
363
+ UIManager.logSystem('Mentis is building the solution...');
364
+ break;
365
+ case '/model':
366
+ await this.handleModelCommand(args);
367
+ break;
368
+ case '/connect':
369
+ console.log(chalk.dim('Tip: Use /model for an interactive menu.'));
370
+ await this.handleConnectCommand(args);
371
+ break;
372
+ case '/use':
373
+ await this.handleUseCommand(args);
374
+ break;
375
+ case '/mcp':
376
+ await this.handleMcpCommand(args);
377
+ break;
378
+ case '/resume':
379
+ await this.handleResumeCommand();
380
+ break;
381
+ case '/clear':
382
+ this.history = [];
383
+ this.contextManager.clear();
384
+ UIManager.displayLogo(); // Redraw logo on clear
385
+ console.log(chalk.yellow('Chat history and context cleared.'));
386
+ break;
387
+ case '/add':
388
+ if (args.length === 0) {
389
+ console.log(chalk.red('Usage: /add <file_path>'));
390
+ } else {
391
+ const result = await this.contextManager.addFile(args[0]);
392
+ console.log(chalk.yellow(result));
393
+ }
394
+ break;
395
+ case '/drop':
396
+ if (args.length === 0) {
397
+ console.log(chalk.red('Usage: /drop <file_path>'));
398
+ } else {
399
+ const result = await this.contextManager.removeFile(args[0]);
400
+ console.log(chalk.yellow(result));
401
+ }
402
+ break;
403
+ case '/config':
404
+ await this.handleConfigCommand();
405
+ break;
406
+ case '/exit':
407
+ // Auto-save on exit (both local and global)
408
+ const cwd = process.cwd();
409
+ this.checkpointManager.saveLocalSession(cwd, this.history, this.contextManager.getFiles());
410
+ this.checkpointManager.save('latest', this.history, this.contextManager.getFiles());
411
+ this.shell.kill(); // Kill the shell process
412
+ this.mcpManager.disconnectAll(); // Disconnect all MCP connections
413
+ console.log(chalk.green('Session saved to .mentis/sessions/. Goodbye!'));
414
+ process.exit(0);
415
+ break;
416
+ case '/update':
417
+ const UpdateManager = require('../utils/UpdateManager').UpdateManager;
418
+ const updater = new UpdateManager();
419
+ await updater.checkAndPerformUpdate(true);
420
+ break;
421
+ case '/clear':
422
+ this.history = [];
423
+ console.log(chalk.green('\n✓ Context cleared\n'));
424
+ break;
425
+ case '/init':
426
+ await this.handleInitCommand();
427
+ break;
428
+ case '/skills':
429
+ await this.handleSkillsCommand(args);
430
+ break;
431
+ case '/commands':
432
+ await this.handleCommandsCommand(args);
433
+ break;
434
+ default:
435
+ console.log(chalk.red(`Unknown command: ${command}`));
436
+ }
437
+ }
438
+
439
+ private getLoadingMessage(): string {
440
+ const messages = [
441
+ "Reticulating splines...",
442
+ "Consulting the silicon oracle...",
443
+ "Compiling neural pathways...",
444
+ "Optimizing flux capacitors...",
445
+ "Analyzing project structure...",
446
+ "Deciphering your intent...",
447
+ "Brewing digital coffee...",
448
+ "Checking for infinite loops...",
449
+ "Connecting to the matrix...",
450
+ "Calculating the answer (42?)...",
451
+ "Refactoring the universe...",
452
+ "Downloading more RAM...",
453
+ "Searching for bugs...",
454
+ "Asking the rubber duck...",
455
+ "Hyperspacing..."
456
+ ];
457
+ return messages[Math.floor(Math.random() * messages.length)];
458
+ }
459
+
460
+ private async handleChat(input: string) {
461
+ const context = this.contextManager.getContextString();
462
+ const skillsContext = this.skillsManager.getSkillsContext();
463
+ const commandsContext = this.commandManager.getCommandsContext();
464
+ let fullInput = input;
465
+
466
+ let modeInstruction = '';
467
+ if (this.mode === 'PLAN') {
468
+ modeInstruction = '\n[SYSTEM: You are in PLAN mode. Focus on high-level architecture, requirements analysis, and creating a sturdy plan. Do not write full code implementation yet, just scaffolds or pseudocode if needed.]';
469
+ } else {
470
+ modeInstruction = '\n[SYSTEM: You are in BUILD mode. Focus on implementing working code that solves the user request efficiently.]';
471
+ }
472
+
473
+ fullInput = `${input}${modeInstruction}`;
474
+
475
+ // Add skills context if available
476
+ if (skillsContext) {
477
+ fullInput = `${skillsContext}\n\n${fullInput}`;
478
+ }
479
+
480
+ // Add commands context if available
481
+ if (commandsContext) {
482
+ fullInput = `${commandsContext}\n\n${fullInput}`;
483
+ }
484
+
485
+ if (context) {
486
+ fullInput = `${context}\n\nUser Question: ${fullInput}`;
487
+ }
488
+
489
+ this.history.push({ role: 'user', content: fullInput });
490
+
491
+ const msg = this.getLoadingMessage();
492
+ let spinner = ora({ text: ` ${msg}`, color: 'cyan' }).start();
493
+ const controller = new AbortController();
494
+
495
+ // Setup cancellation listener
496
+ const keyListener = (str: string, key: any) => {
497
+ if (key.name === 'escape') {
498
+ controller.abort();
499
+ }
500
+ };
501
+
502
+ if (process.stdin.isTTY) {
503
+ readline.emitKeypressEvents(process.stdin);
504
+ process.stdin.setRawMode(true);
505
+ process.stdin.on('keypress', keyListener);
506
+ }
507
+
508
+ try {
509
+ // First call
510
+ let response = await this.modelClient.chat(this.history, this.tools.map(t => ({
511
+ type: 'function',
512
+ function: {
513
+ name: t.name,
514
+ description: t.description,
515
+ parameters: t.parameters
516
+ }
517
+ })), controller.signal);
518
+
519
+ // Loop for tool calls
520
+ while (response.tool_calls && response.tool_calls.length > 0) {
521
+ if (controller.signal.aborted) throw new Error('Request cancelled by user');
522
+
523
+ spinner.stop();
524
+
525
+ // Add the assistant's request to use tool to history
526
+ this.history.push({
527
+ role: 'assistant',
528
+ content: response.content,
529
+ tool_calls: response.tool_calls
530
+ });
531
+
532
+ // Execute tools
533
+ for (const toolCall of response.tool_calls) {
534
+ if (controller.signal.aborted) break;
535
+
536
+ const toolName = toolCall.function.name;
537
+ const toolArgsStr = toolCall.function.arguments;
538
+ const toolArgs = JSON.parse(toolArgsStr);
539
+
540
+ // Show tool execution with visual feedback
541
+ ToolExecutor.showInline(toolName, toolArgs);
542
+
543
+ // Safety checks for write/edit operations
544
+ // Skip confirmation if tool is allowed by active skill
545
+ let approved = true;
546
+ const needsApproval = (toolName === 'write_file' || toolName === 'edit_file' || toolName === 'read_file')
547
+ && !this.isToolAllowedBySkill(toolName === 'read_file' ? 'Read' : 'Write');
548
+
549
+ if (needsApproval) {
550
+ // Pause cancellation listener during user interaction
551
+ if (process.stdin.isTTY) {
552
+ process.stdin.removeListener('keypress', keyListener);
553
+ process.stdin.setRawMode(false);
554
+ process.stdin.pause();
555
+ }
556
+
557
+ // Handle write_file with diff preview
558
+ if (toolName === 'write_file') {
559
+ approved = await this.handleWriteApproval(toolArgs.filePath, toolArgs.content);
560
+ }
561
+
562
+ // Handle edit_file with diff preview
563
+ if (toolName === 'edit_file') {
564
+ approved = await this.handleEditApproval(toolArgs);
565
+ }
566
+
567
+ // Handle read_file with multi-file selector
568
+ if (toolName === 'read_file') {
569
+ approved = await this.handleReadApproval(toolArgs.filePath);
570
+ }
571
+
572
+ // Resume cancellation listener
573
+ if (process.stdin.isTTY) {
574
+ process.stdin.setRawMode(true);
575
+ process.stdin.resume();
576
+ process.stdin.on('keypress', keyListener);
577
+ }
578
+ }
579
+
580
+ if (!approved) {
581
+ this.history.push({
582
+ role: 'tool',
583
+ tool_call_id: toolCall.id,
584
+ name: toolName,
585
+ content: 'Error: User rejected operation.'
586
+ });
587
+ console.log(chalk.red(' Action cancelled by user.'));
588
+ continue;
589
+ }
590
+
591
+ const tool = this.tools.find(t => t.name === toolName);
592
+ let result = '';
593
+
594
+ if (tool) {
595
+ try {
596
+ result = await tool.execute(toolArgs);
597
+
598
+ // Record Q&A for ask_question tool in plan mode
599
+ if (toolName === 'ask_question' && this.mode === 'PLAN') {
600
+ PlanModeUI.recordQA(toolArgs.question, result);
601
+ }
602
+
603
+ // Handle plan mode switch approval
604
+ if (toolName === 'enter_plan_mode' && result.includes('User approved')) {
605
+ this.mode = 'PLAN';
606
+ UIManager.logBullet('Auto-switched to plan mode', 'magenta');
607
+ }
608
+ } catch (e: any) {
609
+ result = `Error: ${e.message}`;
610
+ }
611
+ } else {
612
+ result = `Error: Tool ${toolName} not found.`;
613
+ }
614
+
615
+ this.history.push({
616
+ role: 'tool',
617
+ tool_call_id: toolCall.id,
618
+ name: toolName,
619
+ content: result
620
+ });
621
+ }
622
+
623
+ if (controller.signal.aborted) throw new Error('Request cancelled by user');
624
+
625
+ const msg = this.getLoadingMessage();
626
+ spinner = ora({ text: ` ${msg}`, color: 'cyan' }).start();
627
+
628
+ // Get next response
629
+ response = await this.modelClient.chat(this.history, this.tools.map(t => ({
630
+ type: 'function',
631
+ function: {
632
+ name: t.name,
633
+ description: t.description,
634
+ parameters: t.parameters
635
+ }
636
+ })), controller.signal);
637
+ }
638
+
639
+ spinner.stop();
640
+
641
+ console.log('');
642
+ if (response.content) {
643
+ UIManager.logBullet('Mentis:', 'magenta');
644
+ console.log(marked(response.content));
645
+
646
+ if (response.usage) {
647
+ const { input_tokens, output_tokens } = response.usage;
648
+ const totalCost = this.estimateCost(input_tokens, output_tokens);
649
+ console.log(chalk.dim(`\n(Tokens: ${input_tokens} in / ${output_tokens} out | Est. Cost: $${totalCost.toFixed(5)})`));
650
+ }
651
+
652
+ // Display context bar
653
+ const contextBar = this.contextVisualizer.getContextBar(this.history);
654
+ console.log(chalk.dim(`\n${contextBar}`));
655
+
656
+ console.log('');
657
+ this.history.push({ role: 'assistant', content: response.content });
658
+
659
+ // Auto-compact prompt when context is at 80%
660
+ const usage = this.contextVisualizer.calculateUsage(this.history);
661
+ if (usage.percentage >= 80) {
662
+ this.history = await this.conversationCompacter.promptIfCompactNeeded(
663
+ usage.percentage,
664
+ this.history,
665
+ this.modelClient,
666
+ this.options.yolo
667
+ );
668
+ }
669
+ }
670
+ } catch (error: any) {
671
+ spinner.stop();
672
+ if (error.message === 'Request cancelled by user') {
673
+ console.log(chalk.yellow('\nRequest cancelled by user.'));
674
+ } else {
675
+ spinner.fail('Error getting response from model.');
676
+ console.error(error.message);
677
+ }
678
+ } finally {
679
+ if (process.stdin.isTTY) {
680
+ process.stdin.removeListener('keypress', keyListener);
681
+ process.stdin.setRawMode(false);
682
+ process.stdin.pause(); // Reset flow
683
+ }
684
+ }
685
+ }
686
+
687
+ private async handleConfigCommand() {
688
+ const config = this.configManager.getConfig();
689
+ const { action } = await inquirer.prompt([
690
+ {
691
+ type: 'list',
692
+ name: 'action',
693
+ message: 'Configuration',
694
+ prefix: '',
695
+ choices: [
696
+ 'Show Current Configuration',
697
+ 'Set Active Provider',
698
+ 'Set API Key (for active provider)',
699
+ 'Set Base URL (for active provider)',
700
+ 'Back'
701
+ ]
702
+ }
703
+ ]);
704
+
705
+ if (action === 'Back') return;
706
+
707
+ if (action === 'Show Current Configuration') {
708
+ console.log(JSON.stringify(config, null, 2));
709
+ return;
710
+ }
711
+
712
+ if (action === 'Set Active Provider') {
713
+ const { provider } = await inquirer.prompt([{
714
+ type: 'list',
715
+ name: 'provider',
716
+ message: 'Select Provider:',
717
+ choices: ['Gemini', 'Ollama', 'OpenAI', 'GLM']
718
+ }]);
719
+ const key = provider.toLowerCase();
720
+ this.configManager.updateConfig({ defaultProvider: key });
721
+ console.log(chalk.green(`Active provider set to: ${provider}`));
722
+ this.initializeClient();
723
+ return;
724
+ }
725
+
726
+ const currentProvider = config.defaultProvider;
727
+
728
+ if (action === 'Set API Key (for active provider)') {
729
+ if (currentProvider === 'ollama') {
730
+ console.log(chalk.yellow('Ollama typically does not require an API key.'));
731
+ }
732
+ const { value } = await inquirer.prompt([{
733
+ type: 'password',
734
+ name: 'value',
735
+ message: `Enter API Key for ${currentProvider}:`,
736
+ mask: '*'
737
+ }]);
738
+
739
+ const updates: any = {};
740
+ updates[currentProvider] = { ...((config as any)[currentProvider] || {}), apiKey: value };
741
+ this.configManager.updateConfig(updates);
742
+ console.log(chalk.green(`API Key updated for ${currentProvider}.`));
743
+ this.initializeClient();
744
+ }
745
+
746
+ if (action === 'Set Base URL (for active provider)') {
747
+ const defaultUrl = (config as any)[currentProvider]?.baseUrl || '';
748
+ const { value } = await inquirer.prompt([{
749
+ type: 'input',
750
+ name: 'value',
751
+ message: `Enter Base URL for ${currentProvider}:`,
752
+ default: defaultUrl
753
+ }]);
754
+
755
+ const updates: any = {};
756
+ updates[currentProvider] = { ...((config as any)[currentProvider] || {}), baseUrl: value };
757
+ this.configManager.updateConfig(updates);
758
+ console.log(chalk.green(`Base URL updated for ${currentProvider}.`));
759
+ this.initializeClient();
760
+ }
761
+ }
762
+
763
+ private async handleModelCommand(args: string[]) {
764
+ const config = this.configManager.getConfig();
765
+ const currentProvider = config.defaultProvider || 'ollama';
766
+
767
+ // Direct argument: /model gpt-4o (updates active provider's model)
768
+ if (args.length > 0) {
769
+ const modelName = args[0];
770
+ const updates: any = {};
771
+ updates[currentProvider] = { ...((config as any)[currentProvider] || {}), model: modelName };
772
+ this.configManager.updateConfig(updates);
773
+ this.initializeClient(); // Re-init with new model
774
+ console.log(chalk.green(`\nModel set to ${chalk.bold(modelName)} for ${currentProvider}!`));
775
+ return;
776
+ }
777
+
778
+ // Interactive Mode: Streamlined Provider -> Model Flow
779
+ console.log(chalk.cyan('Configure Model & Provider'));
780
+
781
+ const { provider } = await inquirer.prompt([
782
+ {
783
+ type: 'list',
784
+ name: 'provider',
785
+ message: 'Select Provider:',
786
+ choices: ['Gemini', 'Ollama', 'OpenAI', 'GLM'],
787
+ default: currentProvider.charAt(0).toUpperCase() + currentProvider.slice(1) // Capitalize for default selection
788
+ }
789
+ ]);
790
+
791
+ const selectedProvider = provider.toLowerCase();
792
+
793
+ let models: string[] = [];
794
+ if (selectedProvider === 'gemini') {
795
+ models = ['gemini-2.5-flash', 'gemini-1.5-pro', 'gemini-1.0-pro', 'Other...'];
796
+ } else if (selectedProvider === 'ollama') {
797
+ models = ['llama3:latest', 'deepseek-r1:latest', 'mistral:latest', 'qwen2.5-coder', 'Other...'];
798
+ } else if (selectedProvider === 'openai') {
799
+ models = ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'Other...'];
800
+ } else if (selectedProvider === 'glm') {
801
+ models = ['glm-4.6', 'glm-4-plus', 'glm-4', 'glm-4-air', 'glm-4-flash', 'Other...'];
802
+ } else {
803
+ models = ['Other...'];
804
+ }
805
+
806
+ let { model } = await inquirer.prompt([
807
+ {
808
+ type: 'list',
809
+ name: 'model',
810
+ message: `Select Model for ${provider}:`,
811
+ choices: models,
812
+ // Try to find current model in list to set default
813
+ default: (config as any)[selectedProvider]?.model
814
+ }
815
+ ]);
816
+
817
+ if (model === 'Other...') {
818
+ const { customModel } = await inquirer.prompt([{
819
+ type: 'input',
820
+ name: 'customModel',
821
+ message: 'Enter model name:'
822
+ }]);
823
+ model = customModel;
824
+ }
825
+
826
+ // Check for missing API Key (except for Ollama)
827
+ let newApiKey = undefined;
828
+ const currentKey = (config as any)[selectedProvider]?.apiKey;
829
+
830
+ if (selectedProvider !== 'ollama' && !currentKey) {
831
+ console.log(chalk.yellow(`\n⚠️ No API Key found for ${provider}.`));
832
+ const { apiKey } = await inquirer.prompt([{
833
+ type: 'password',
834
+ name: 'apiKey',
835
+ message: `Enter API Key for ${provider} (or leave empty to skip):`,
836
+ mask: '*'
837
+ }]);
838
+ if (apiKey && apiKey.trim()) {
839
+ newApiKey = apiKey.trim();
840
+ }
841
+ }
842
+
843
+ const updates: any = {};
844
+ updates.defaultProvider = selectedProvider;
845
+ updates[selectedProvider] = {
846
+ ...((config as any)[selectedProvider] || {}),
847
+ model: model
848
+ };
849
+
850
+ if (newApiKey) {
851
+ updates[selectedProvider].apiKey = newApiKey;
852
+ }
853
+
854
+ this.configManager.updateConfig(updates);
855
+ this.initializeClient();
856
+ console.log(chalk.green(`\nSwitched to ${chalk.bold(provider)} (${model})!`));
857
+ }
858
+
859
+ private async handleConnectCommand(args: string[]) {
860
+ if (args.length < 1) {
861
+ console.log(chalk.red('Usage: /connect <provider> [key_or_url]'));
862
+ return;
863
+ }
864
+
865
+ const provider = args[0].toLowerCase();
866
+ const value = args[1]; // Optional for ollama (defaults), required for others usually
867
+
868
+ const config = this.configManager.getConfig();
869
+
870
+ if (provider === 'gemini') {
871
+ if (!value) {
872
+ console.log(chalk.red('Error: API Key required for Gemini. usage: /connect gemini <api_key>'));
873
+ return;
874
+ }
875
+ this.configManager.updateConfig({
876
+ gemini: { ...config.gemini, apiKey: value },
877
+ defaultProvider: 'gemini'
878
+ });
879
+ console.log(chalk.green(`Connected to Gemini with key: ${value.substring(0, 8)}...`));
880
+ } else if (provider === 'ollama') {
881
+ const url = value || 'http://localhost:11434/v1';
882
+ this.configManager.updateConfig({
883
+ ollama: { ...config.ollama, baseUrl: url },
884
+ defaultProvider: 'ollama'
885
+ });
886
+ console.log(chalk.green(`Connected to Ollama at ${url}`));
887
+ } else if (provider === 'openai') { // Support OpenAI since client supports it
888
+ if (!value) {
889
+ console.log(chalk.red('Error: API Key required for OpenAI. usage: /connect openai <api_key>'));
890
+ return;
891
+ }
892
+ this.configManager.updateConfig({
893
+ openai: { ...config.openai, apiKey: value },
894
+ defaultProvider: 'openai' // We might need to handle 'openai' in initializeClient if we add it officially
895
+ });
896
+ console.log(chalk.green(`Connected to OpenAI.`));
897
+ } else if (provider === 'glm') {
898
+ if (!value) {
899
+ console.log(chalk.red('Error: API Key required for GLM. usage: /connect glm <api_key>'));
900
+ return;
901
+ }
902
+ this.configManager.updateConfig({
903
+ glm: { ...config.glm, apiKey: value },
904
+ defaultProvider: 'glm'
905
+ });
906
+ console.log(chalk.green(`Connected to GLM (ZhipuAI).`));
907
+ } else {
908
+ console.log(chalk.red(`Unknown provider: ${provider}. Use 'gemini', 'ollama', 'openai', or 'glm'.`));
909
+ return;
910
+ }
911
+
912
+ this.initializeClient();
913
+ }
914
+
915
+ private async handleUseCommand(args: string[]) {
916
+ if (args.length < 1) {
917
+ console.log(chalk.red('Usage: /use <provider> [model_name]'));
918
+ return;
919
+ }
920
+
921
+ const provider = args[0].toLowerCase();
922
+ const model = args[1]; // Optional
923
+
924
+ const config = this.configManager.getConfig();
925
+
926
+ if (provider === 'gemini') {
927
+ const updates: any = { defaultProvider: 'gemini' };
928
+ if (model) {
929
+ updates.gemini = { ...config.gemini, model: model };
930
+ }
931
+ this.configManager.updateConfig(updates);
932
+ } else if (provider === 'ollama') {
933
+ const updates: any = { defaultProvider: 'ollama' };
934
+ if (model) {
935
+ updates.ollama = { ...config.ollama, model: model };
936
+ }
937
+ this.configManager.updateConfig(updates);
938
+ } else if (provider === 'glm') {
939
+ const updates: any = { defaultProvider: 'glm' };
940
+ if (model) {
941
+ updates.glm = { ...config.glm, model: model };
942
+ }
943
+ this.configManager.updateConfig(updates);
944
+
945
+ // Auto switch if connecting to a new provider
946
+ if ((provider as string) === 'gemini') {
947
+ updates.defaultProvider = 'gemini';
948
+ this.configManager.updateConfig(updates);
949
+ } else if ((provider as string) === 'ollama') {
950
+ updates.defaultProvider = 'ollama';
951
+ this.configManager.updateConfig(updates);
952
+ }
953
+ } else {
954
+ console.log(chalk.red(`Unknown provider: ${provider}`));
955
+ return;
956
+ }
957
+
958
+ this.initializeClient();
959
+ console.log(chalk.green(`Switched to ${provider} ${model ? `using model ${model}` : ''}`));
960
+ }
961
+
962
+ private async handleMcpCommand(args: string[]) {
963
+ if (args.length === 0) {
964
+ console.log(chalk.red('Usage: /mcp <list|connect|disconnect|add|remove|test|config> [args]'));
965
+ console.log(chalk.dim('\nExamples:'));
966
+ console.log(chalk.dim(' /mcp list - List all MCP servers'));
967
+ console.log(chalk.dim(' /mcp connect Exa\\ Search - Connect to Exa Search'));
968
+ console.log(chalk.dim(' /mcp disconnect all - Disconnect all servers'));
969
+ console.log(chalk.dim(' /mcp add MyServer npx -y @my/mcp-server'));
970
+ console.log(chalk.dim(' /mcp test Exa\\ Search - Test connection'));
971
+ console.log(chalk.dim(' /mcp config - Show configuration'));
972
+ return;
973
+ }
974
+
975
+ const action = args[0];
976
+
977
+ switch (action) {
978
+ case 'list':
979
+ await this.mcpManager.listServers();
980
+ break;
981
+
982
+ case 'connect':
983
+ if (args.length < 2) {
984
+ // Show interactive list of available servers
985
+ const availableServers = this.mcpManager.getAvailableServers();
986
+ if (availableServers.length === 0) {
987
+ console.log(chalk.yellow('No MCP servers configured. Use /mcp add to add one.'));
988
+ return;
989
+ }
990
+
991
+ const { serverName } = await inquirer.prompt([{
992
+ type: 'list',
993
+ name: 'serverName',
994
+ message: 'Select server to connect:',
995
+ choices: availableServers.map(s => s.name)
996
+ }]);
997
+
998
+ await this.mcpManager.connectToServer(serverName);
999
+ this.refreshToolsFromMcp();
1000
+ } else {
1001
+ const serverName = args.slice(1).join(' ');
1002
+ await this.mcpManager.connectToServer(serverName);
1003
+ this.refreshToolsFromMcp();
1004
+ }
1005
+ break;
1006
+
1007
+ case 'disconnect':
1008
+ if (args.length < 2) {
1009
+ // Interactive disconnect
1010
+ const connectedServers = this.mcpManager.getServerNames();
1011
+ if (connectedServers.length === 0) {
1012
+ console.log(chalk.yellow('No MCP connections to disconnect.'));
1013
+ return;
1014
+ }
1015
+
1016
+ const { serverName } = await inquirer.prompt([{
1017
+ type: 'list',
1018
+ name: 'serverName',
1019
+ message: 'Select server to disconnect:',
1020
+ choices: [...connectedServers, 'all']
1021
+ }]);
1022
+
1023
+ if (serverName === 'all') {
1024
+ this.mcpManager.disconnectAll();
1025
+ this.refreshToolsFromMcp();
1026
+ } else {
1027
+ await this.mcpManager.disconnectFromServer(serverName);
1028
+ this.refreshToolsFromMcp();
1029
+ }
1030
+ } else {
1031
+ const serverName = args.slice(1).join(' ');
1032
+ if (serverName === 'all') {
1033
+ this.mcpManager.disconnectAll();
1034
+ this.refreshToolsFromMcp();
1035
+ } else {
1036
+ await this.mcpManager.disconnectFromServer(serverName);
1037
+ this.refreshToolsFromMcp();
1038
+ }
1039
+ }
1040
+ break;
1041
+
1042
+ case 'add':
1043
+ if (args.length < 3) {
1044
+ console.log(chalk.red('Usage: /mcp add <name> <command> [args...]'));
1045
+ console.log(chalk.dim('\nExamples:'));
1046
+ console.log(chalk.dim(' /mcp add "My Server" npx -y @my/mcp-server'));
1047
+ console.log(chalk.dim(' /mcp add "Local Server" node /path/to/server.js'));
1048
+ return;
1049
+ }
1050
+
1051
+ const name = args[1];
1052
+ const command = args[2];
1053
+ const mcpArgs = args.slice(3);
1054
+
1055
+ const { description } = await inquirer.prompt([{
1056
+ type: 'input',
1057
+ name: 'description',
1058
+ message: 'Description (optional):',
1059
+ default: ''
1060
+ }]);
1061
+
1062
+ await this.mcpManager.addServer(name, command, mcpArgs, description);
1063
+ break;
1064
+
1065
+ case 'remove':
1066
+ if (args.length < 2) {
1067
+ // Interactive remove
1068
+ const availableServers = this.mcpManager.getAvailableServers();
1069
+ if (availableServers.length === 0) {
1070
+ console.log(chalk.yellow('No MCP servers configured.'));
1071
+ return;
1072
+ }
1073
+
1074
+ const { serverName } = await inquirer.prompt([{
1075
+ type: 'list',
1076
+ name: 'serverName',
1077
+ message: 'Select server to remove:',
1078
+ choices: availableServers.map(s => s.name)
1079
+ }]);
1080
+
1081
+ await this.mcpManager.removeServer(serverName);
1082
+ } else {
1083
+ const serverName = args.slice(1).join(' ');
1084
+ await this.mcpManager.removeServer(serverName);
1085
+ }
1086
+ break;
1087
+
1088
+ case 'test':
1089
+ if (args.length < 2) {
1090
+ // Interactive test
1091
+ const connectedServers = this.mcpManager.getServerNames();
1092
+ if (connectedServers.length === 0) {
1093
+ console.log(chalk.yellow('No MCP connections to test.'));
1094
+ return;
1095
+ }
1096
+
1097
+ const { serverName } = await inquirer.prompt([{
1098
+ type: 'list',
1099
+ name: 'serverName',
1100
+ message: 'Select server to test:',
1101
+ choices: connectedServers
1102
+ }]);
1103
+
1104
+ await this.mcpManager.testConnection(serverName);
1105
+ } else {
1106
+ const serverName = args.slice(1).join(' ');
1107
+ await this.mcpManager.testConnection(serverName);
1108
+ }
1109
+ break;
1110
+
1111
+ case 'config':
1112
+ const config = this.mcpManager.getConfig().getConfig();
1113
+ console.log(chalk.cyan('\nMCP Configuration:\n'));
1114
+ console.log(JSON.stringify(config, null, 2));
1115
+ console.log(chalk.dim(`\nConfig file: ${require('os').homedir()}/.mentis/mcp.json`));
1116
+ break;
1117
+
1118
+ default:
1119
+ console.log(chalk.red(`Unknown MCP action: ${action}`));
1120
+ console.log(chalk.yellow('Available actions: list, connect, disconnect, add, remove, test, config'));
1121
+ }
1122
+ }
1123
+
1124
+ private async handleResumeCommand() {
1125
+ const cwd = process.cwd();
1126
+ const localSessions = this.checkpointManager.listLocalSessions(cwd);
1127
+ const globalCheckpoints = this.checkpointManager.list();
1128
+
1129
+ if (localSessions.length === 0 && globalCheckpoints.length === 0) {
1130
+ console.log(chalk.yellow('No previous sessions found to resume.'));
1131
+ return;
1132
+ }
1133
+
1134
+ // Header like Claude's "Resume Session"
1135
+ console.log('');
1136
+ console.log(chalk.cyan.bold('Resume Session'));
1137
+ console.log('');
1138
+
1139
+ // Helper for relative time
1140
+ const relativeTime = (ts: number) => {
1141
+ const diff = Date.now() - ts;
1142
+ const mins = Math.floor(diff / 60000);
1143
+ const hours = Math.floor(diff / 3600000);
1144
+ const days = Math.floor(diff / 86400000);
1145
+ if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
1146
+ if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
1147
+ if (mins > 0) return `${mins} min${mins > 1 ? 's' : ''} ago`;
1148
+ return 'just now';
1149
+ };
1150
+
1151
+ // Build choices with simple single-line formatting
1152
+ const choices: Array<{ name: string; value: { type: string; id: string } }> = [];
1153
+
1154
+ // Local sessions first
1155
+ for (const session of localSessions) {
1156
+ const timeAgo = relativeTime(session.timestamp);
1157
+ const shortPreview = session.preview.substring(0, 30).replace(/\n/g, ' ');
1158
+ choices.push({
1159
+ name: `${shortPreview}... (${timeAgo}, ${session.messageCount} msgs)`,
1160
+ value: { type: 'local', id: session.id }
1161
+ });
1162
+ }
1163
+
1164
+ // Global checkpoints as fallback
1165
+ if (localSessions.length === 0 && globalCheckpoints.length > 0) {
1166
+ for (const name of globalCheckpoints) {
1167
+ const cp = this.checkpointManager.load(name);
1168
+ const timeAgo = cp ? relativeTime(cp.timestamp) : '';
1169
+ const msgCount = cp?.history?.length || 0;
1170
+ const preview = cp?.history?.find(m => m.role === 'user')?.content?.substring(0, 40)?.replace(/\n/g, ' ') || name;
1171
+ choices.push({
1172
+ name: `${preview}${preview.length >= 40 ? '...' : ''} — ${timeAgo} · ${msgCount} msgs`,
1173
+ value: { type: 'global', id: name }
1174
+ });
1175
+ }
1176
+ }
1177
+
1178
+ // Show hint
1179
+ console.log(chalk.dim(' ↑/↓ to navigate · Enter to select · Esc to cancel'));
1180
+ console.log('');
1181
+
1182
+ // Guard against empty choices (would crash inquirer)
1183
+ if (choices.length === 0) {
1184
+ console.log(chalk.yellow('No sessions available. Start a conversation and /exit to save.'));
1185
+ return;
1186
+ }
1187
+
1188
+ const { selected } = await inquirer.prompt([{
1189
+ type: 'rawlist',
1190
+ name: 'selected',
1191
+ message: 'Pick a session:',
1192
+ choices,
1193
+ pageSize: 10
1194
+ }]);
1195
+
1196
+ if (selected.type === 'local') {
1197
+ await this.loadLocalCheckpoint(cwd, selected.id);
1198
+ } else if (selected.type === 'global') {
1199
+ await this.loadCheckpoint(selected.id);
1200
+ }
1201
+ }
1202
+
1203
+ private async loadLocalCheckpoint(cwd: string, sessionId?: string) {
1204
+ const cp = this.checkpointManager.loadLocalSession(cwd, sessionId);
1205
+ if (!cp) {
1206
+ console.log(chalk.red('Session not found.'));
1207
+ return;
1208
+ }
1209
+
1210
+ this.history = cp.history;
1211
+ this.contextManager.clear();
1212
+
1213
+ // Restore context files
1214
+ if (cp.files && cp.files.length > 0) {
1215
+ console.log(chalk.dim('Restoring context files...'));
1216
+ for (const file of cp.files) {
1217
+ await this.contextManager.addFile(file);
1218
+ }
1219
+ }
1220
+ console.log(chalk.green(`✓ Resumed session (${new Date(cp.timestamp).toLocaleString()})`));
1221
+ console.log(chalk.dim(` Messages: ${this.history.length}`));
1222
+
1223
+ // Re-display last assistant message if any
1224
+ const lastMsg = this.history[this.history.length - 1];
1225
+ if (lastMsg && lastMsg.role === 'assistant' && lastMsg.content) {
1226
+ console.log(chalk.blue('\nLast response:'));
1227
+ const preview = lastMsg.content.length > 200
1228
+ ? lastMsg.content.substring(0, 200) + '...'
1229
+ : lastMsg.content;
1230
+ console.log(chalk.dim(preview));
1231
+ }
1232
+ console.log('');
1233
+ }
1234
+
1235
+ private async loadCheckpoint(name: string) {
1236
+ const cp = this.checkpointManager.load(name);
1237
+ if (!cp) {
1238
+ console.log(chalk.red(`Checkpoint '${name}' not found.`));
1239
+ return;
1240
+ }
1241
+
1242
+ this.history = cp.history;
1243
+ this.contextManager.clear();
1244
+
1245
+ // Restore context files
1246
+ if (cp.files && cp.files.length > 0) {
1247
+ console.log(chalk.dim('Restoring context files...'));
1248
+ for (const file of cp.files) {
1249
+ await this.contextManager.addFile(file);
1250
+ }
1251
+ }
1252
+ console.log(chalk.green(`Resumed session '${name}' (${new Date(cp.timestamp).toLocaleString()})`));
1253
+ // Re-display last assistant message if any
1254
+ const lastMsg = this.history[this.history.length - 1];
1255
+ if (lastMsg && lastMsg.role === 'assistant' && lastMsg.content) {
1256
+ console.log(chalk.blue('\nLast message:'));
1257
+ console.log(lastMsg.content);
1258
+ }
1259
+ }
1260
+
1261
+ private async handleSkillsCommand(args: string[]) {
1262
+ const { SkillCreator, validateSkills } = await import('../skills/SkillCreator');
1263
+
1264
+ if (args.length < 1) {
1265
+ // Show skills list by default
1266
+ await this.handleSkillsCommand(['list']);
1267
+ return;
1268
+ }
1269
+
1270
+ const action = args[0];
1271
+
1272
+ switch (action) {
1273
+ case 'list':
1274
+ await this.handleSkillsList();
1275
+ break;
1276
+ case 'show':
1277
+ if (args.length < 2) {
1278
+ console.log(chalk.red('Usage: /skills show <name>'));
1279
+ return;
1280
+ }
1281
+ await this.handleSkillsShow(args[1]);
1282
+ break;
1283
+ case 'create':
1284
+ const creator = new SkillCreator(this.skillsManager);
1285
+ await creator.run(args[1]);
1286
+ // Re-discover skills after creation
1287
+ await this.skillsManager.discoverSkills();
1288
+ break;
1289
+ case 'validate':
1290
+ await validateSkills(this.skillsManager);
1291
+ break;
1292
+ default:
1293
+ console.log(chalk.red(`Unknown skills action: ${action}`));
1294
+ console.log(chalk.yellow('Available actions: list, show, create, validate'));
1295
+ }
1296
+ }
1297
+
1298
+ private async handleSkillsList(): Promise<void> {
1299
+ const skills = this.skillsManager.getAllSkills();
1300
+
1301
+ if (skills.length === 0) {
1302
+ console.log(chalk.yellow('No skills available.'));
1303
+ console.log(chalk.dim('Create skills with: /skills create'));
1304
+ console.log(chalk.dim('Add skills to: ~/.mentis/skills/ or .mentis/skills/'));
1305
+ return;
1306
+ }
1307
+
1308
+ console.log(chalk.cyan(`\nAvailable Skills (${skills.length}):\n`));
1309
+
1310
+ for (const skill of skills) {
1311
+ const statusIcon = skill.isValid ? '✓' : '✗';
1312
+ const typeLabel = skill.type === 'personal' ? 'Personal' : 'Project';
1313
+
1314
+ console.log(`${statusIcon} ${chalk.bold(skill.name)} (${typeLabel})`);
1315
+ console.log(` ${skill.description}`);
1316
+
1317
+ if (skill.allowedTools && skill.allowedTools.length > 0) {
1318
+ console.log(chalk.dim(` Allowed tools: ${skill.allowedTools.join(', ')}`));
1319
+ }
1320
+
1321
+ if (!skill.isValid && skill.errors) {
1322
+ console.log(chalk.red(` Errors: ${skill.errors.join(', ')}`));
1323
+ }
1324
+
1325
+ console.log('');
1326
+ }
1327
+ }
1328
+
1329
+ private async handleSkillsShow(name: string): Promise<void> {
1330
+ const skill = await this.skillsManager.loadFullSkill(name);
1331
+
1332
+ if (!skill) {
1333
+ console.log(chalk.red(`Skill "${name}" not found.`));
1334
+ return;
1335
+ }
1336
+
1337
+ console.log(chalk.cyan(`\n# ${skill.name}\n`));
1338
+ console.log(chalk.dim(`Type: ${skill.type}`));
1339
+ console.log(chalk.dim(`Path: ${skill.path}`));
1340
+
1341
+ if (skill.allowedTools && skill.allowedTools.length > 0) {
1342
+ console.log(chalk.dim(`Allowed tools: ${skill.allowedTools.join(', ')}`));
1343
+ }
1344
+
1345
+ console.log('');
1346
+ console.log(skill.content || 'No content available');
1347
+
1348
+ // List supporting files
1349
+ const files = this.skillsManager.listSkillFiles(name);
1350
+ if (files.length > 0) {
1351
+ console.log(chalk.dim(`\nSupporting files: ${files.join(', ')}`));
1352
+ }
1353
+ }
1354
+
1355
+ private async handleInitCommand(): Promise<void> {
1356
+ const initializer = new ProjectInitializer();
1357
+ await initializer.run();
1358
+ }
1359
+
1360
+ private async handleCommandsCommand(args: string[]) {
1361
+ if (args.length < 1) {
1362
+ // Show commands list by default
1363
+ await this.handleCommandsCommand(['list']);
1364
+ return;
1365
+ }
1366
+
1367
+ const action = args[0];
1368
+
1369
+ switch (action) {
1370
+ case 'list':
1371
+ await this.handleCommandsList();
1372
+ break;
1373
+ case 'create':
1374
+ await this.handleCommandsCreate(args[1]);
1375
+ break;
1376
+ case 'validate':
1377
+ await this.handleCommandsValidate();
1378
+ break;
1379
+ default:
1380
+ console.log(chalk.red(`Unknown commands action: ${action}`));
1381
+ console.log(chalk.yellow('Available actions: list, create, validate'));
1382
+ }
1383
+ }
1384
+
1385
+ private async handleCommandsList(): Promise<void> {
1386
+ const commands = this.commandManager.getAllCommands();
1387
+
1388
+ if (commands.length === 0) {
1389
+ console.log(chalk.yellow('No custom commands available.'));
1390
+ console.log(chalk.dim('Create commands with: /commands create'));
1391
+ console.log(chalk.dim('Add commands to: ~/.mentis/commands/ or .mentis/commands/'));
1392
+ return;
1393
+ }
1394
+
1395
+ console.log(chalk.cyan(`\nCustom Commands (${commands.length}):\n`));
1396
+
1397
+ // Group by namespace
1398
+ const grouped = new Map<string, any[]>();
1399
+ for (const cmd of commands) {
1400
+ const ns = cmd.description.match(/\(([^)]+)\)/)?.[1] || cmd.type;
1401
+ if (!grouped.has(ns)) {
1402
+ grouped.set(ns, []);
1403
+ }
1404
+ grouped.get(ns)!.push(cmd);
1405
+ }
1406
+
1407
+ for (const [namespace, cmds] of grouped) {
1408
+ console.log(chalk.bold(`\n${namespace}`));
1409
+ for (const cmd of cmds) {
1410
+ const params = cmd.frontmatter['argument-hint'] ? ` ${cmd.frontmatter['argument-hint']}` : '';
1411
+ console.log(` /${cmd.name}${params}`);
1412
+ console.log(` ${cmd.description.replace(/\s*\([^)]+\)/, '')}`);
1413
+
1414
+ if (cmd.frontmatter['allowed-tools'] && cmd.frontmatter['allowed-tools'].length > 0) {
1415
+ console.log(chalk.dim(` Allowed tools: ${cmd.frontmatter['allowed-tools'].join(', ')}`));
1416
+ }
1417
+ }
1418
+ }
1419
+ console.log('');
1420
+ }
1421
+
1422
+ private async handleCommandsCreate(name?: string): Promise<void> {
1423
+ const { CommandCreator } = await import('../commands/CommandCreator');
1424
+ const creator = new CommandCreator(this.commandManager);
1425
+ await creator.run(name);
1426
+ // Re-discover commands after creation
1427
+ await this.commandManager.discoverCommands();
1428
+ }
1429
+
1430
+ private async handleCommandsValidate(): Promise<void> {
1431
+ const { validateCommands } = await import('../commands/CommandCreator');
1432
+ await validateCommands(this.commandManager);
1433
+ }
1434
+
1435
+ /**
1436
+ * Handle write_file approval with diff preview
1437
+ */
1438
+ private async handleWriteApproval(filePath: string, content: string): Promise<boolean> {
1439
+ // Check if file exists and show diff
1440
+ const fullPath = path.resolve(filePath);
1441
+ if (fs.existsSync(fullPath)) {
1442
+ const oldContent = fs.readFileSync(fullPath, 'utf-8');
1443
+ DiffViewer.showDiff(filePath, oldContent, content);
1444
+ } else {
1445
+ console.log('');
1446
+ console.log(chalk.cyan(`📄 Creating new file: ${filePath}`));
1447
+ console.log(chalk.dim('─'.repeat(60)));
1448
+ console.log(chalk.green('New file content:'));
1449
+ const preview = content.split('\n').slice(0, 10).join('\n');
1450
+ console.log(chalk.dim(preview));
1451
+ if (content.split('\n').length > 10) {
1452
+ console.log(chalk.dim('...'));
1453
+ }
1454
+ console.log(chalk.dim('─'.repeat(60)));
1455
+ }
1456
+
1457
+ const { confirm } = await inquirer.prompt([
1458
+ {
1459
+ type: 'confirm',
1460
+ name: 'confirm',
1461
+ message: `Allow writing to ${chalk.yellow(filePath)}?`,
1462
+ default: true
1463
+ }
1464
+ ]);
1465
+
1466
+ return confirm;
1467
+ }
1468
+
1469
+ /**
1470
+ * Handle edit_file approval with diff preview
1471
+ */
1472
+ private async handleEditApproval(args: { file_path: string; old_string: string; new_string: string }): Promise<boolean> {
1473
+ // Get diff from EditFileTool (which shows preview)
1474
+ const editTool = this.tools.find(t => t.name === 'edit_file') as any;
1475
+ if (editTool) {
1476
+ const diffResult = await editTool.execute(args);
1477
+ console.log(diffResult);
1478
+ }
1479
+
1480
+ const { confirm } = await inquirer.prompt([
1481
+ {
1482
+ type: 'confirm',
1483
+ name: 'confirm',
1484
+ message: `Apply edit to ${chalk.yellow(args.file_path)}?`,
1485
+ default: true
1486
+ }
1487
+ ]);
1488
+
1489
+ return confirm;
1490
+ }
1491
+
1492
+ /**
1493
+ * Handle read_file approval (currently auto-approves, but can be enhanced)
1494
+ */
1495
+ private async handleReadApproval(filePath: string): Promise<boolean> {
1496
+ // For now, auto-approve single file reads
1497
+ // In the future, could batch multiple reads into a single selector
1498
+ return true;
1499
+ }
1500
+
1501
+ private estimateCost(input: number, output: number): number {
1502
+ const config = this.configManager.getConfig();
1503
+ const provider = config.defaultProvider;
1504
+
1505
+ let rateIn = 0;
1506
+ let rateOut = 0;
1507
+
1508
+ if (provider === 'openai') {
1509
+ rateIn = 5.00 / 1000000;
1510
+ rateOut = 15.00 / 1000000;
1511
+ } else if (provider === 'gemini') {
1512
+ rateIn = 0.35 / 1000000;
1513
+ rateOut = 0.70 / 1000000;
1514
+ } else if (provider === 'glm') {
1515
+ rateIn = 14.00 / 1000000; // Approximate for GLM-4
1516
+ rateOut = 14.00 / 1000000;
1517
+ }
1518
+
1519
+ return (input * rateIn) + (output * rateOut);
1520
+ }
1521
+ }