@indiccoder/mentis-cli 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +90 -0
  2. package/console.log(tick) +0 -0
  3. package/debug_fs.js +12 -0
  4. package/dist/checkpoint/CheckpointManager.js +53 -0
  5. package/dist/config/ConfigManager.js +55 -0
  6. package/dist/context/ContextManager.js +55 -0
  7. package/dist/context/RepoMapper.js +112 -0
  8. package/dist/index.js +12 -0
  9. package/dist/llm/AnthropicClient.js +70 -0
  10. package/dist/llm/ModelInterface.js +2 -0
  11. package/dist/llm/OpenAIClient.js +58 -0
  12. package/dist/mcp/JsonRpcClient.js +117 -0
  13. package/dist/mcp/McpClient.js +59 -0
  14. package/dist/repl/PersistentShell.js +75 -0
  15. package/dist/repl/ReplManager.js +813 -0
  16. package/dist/tools/FileTools.js +100 -0
  17. package/dist/tools/GitTools.js +127 -0
  18. package/dist/tools/PersistentShellTool.js +30 -0
  19. package/dist/tools/SearchTools.js +83 -0
  20. package/dist/tools/Tool.js +2 -0
  21. package/dist/tools/WebSearchTool.js +60 -0
  22. package/dist/ui/UIManager.js +40 -0
  23. package/package.json +63 -0
  24. package/screenshot_1765779883482_9b30.png +0 -0
  25. package/scripts/test_features.ts +48 -0
  26. package/scripts/test_glm.ts +53 -0
  27. package/scripts/test_models.ts +38 -0
  28. package/src/checkpoint/CheckpointManager.ts +61 -0
  29. package/src/config/ConfigManager.ts +77 -0
  30. package/src/context/ContextManager.ts +63 -0
  31. package/src/context/RepoMapper.ts +119 -0
  32. package/src/index.ts +12 -0
  33. package/src/llm/ModelInterface.ts +47 -0
  34. package/src/llm/OpenAIClient.ts +64 -0
  35. package/src/mcp/JsonRpcClient.ts +103 -0
  36. package/src/mcp/McpClient.ts +75 -0
  37. package/src/repl/PersistentShell.ts +85 -0
  38. package/src/repl/ReplManager.ts +842 -0
  39. package/src/tools/FileTools.ts +89 -0
  40. package/src/tools/GitTools.ts +113 -0
  41. package/src/tools/PersistentShellTool.ts +32 -0
  42. package/src/tools/SearchTools.ts +74 -0
  43. package/src/tools/Tool.ts +6 -0
  44. package/src/tools/WebSearchTool.ts +63 -0
  45. package/src/ui/UIManager.ts +41 -0
  46. package/tsconfig.json +21 -0
@@ -0,0 +1,61 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { ChatMessage } from '../llm/ModelInterface';
5
+
6
+ export interface Checkpoint {
7
+ timestamp: number;
8
+ name: string;
9
+ history: ChatMessage[];
10
+ files: string[];
11
+ }
12
+
13
+ export class CheckpointManager {
14
+ private checkpointDir: string;
15
+
16
+ constructor() {
17
+ this.checkpointDir = path.join(os.homedir(), '.mentis', 'checkpoints');
18
+ fs.ensureDirSync(this.checkpointDir);
19
+ }
20
+
21
+ public save(name: string, history: ChatMessage[], files: string[]) {
22
+ const checkpoint: Checkpoint = {
23
+ timestamp: Date.now(),
24
+ name,
25
+ history,
26
+ files
27
+ };
28
+ const filePath = path.join(this.checkpointDir, `${name}.json`);
29
+ fs.writeJsonSync(filePath, checkpoint, { spaces: 2 });
30
+ return filePath;
31
+ }
32
+
33
+ public load(name: string): Checkpoint | null {
34
+ const filePath = path.join(this.checkpointDir, `${name}.json`);
35
+ if (fs.existsSync(filePath)) {
36
+ return fs.readJsonSync(filePath) as Checkpoint;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ public list(): string[] {
42
+ if (!fs.existsSync(this.checkpointDir)) return [];
43
+ return fs.readdirSync(this.checkpointDir)
44
+ .filter(f => f.endsWith('.json'))
45
+ .map(f => f.replace('.json', ''));
46
+ }
47
+
48
+ public delete(name: string): boolean {
49
+ const filePath = path.join(this.checkpointDir, `${name}.json`);
50
+ if (fs.existsSync(filePath)) {
51
+ fs.removeSync(filePath);
52
+ return true;
53
+ }
54
+ return false;
55
+ }
56
+
57
+ public exists(name: string): boolean {
58
+ const filePath = path.join(this.checkpointDir, `${name}.json`);
59
+ return fs.existsSync(filePath);
60
+ }
61
+ }
@@ -0,0 +1,77 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+
5
+ interface MentisConfig {
6
+ defaultProvider: string;
7
+ openai?: {
8
+ apiKey?: string;
9
+ baseUrl?: string;
10
+ model?: string;
11
+ };
12
+ ollama?: {
13
+ baseUrl?: string;
14
+ model?: string;
15
+ };
16
+ gemini?: {
17
+ apiKey?: string;
18
+ model?: string;
19
+ };
20
+ glm?: {
21
+ apiKey?: string;
22
+ model?: string; // e.g. glm-4
23
+ baseUrl?: string;
24
+ };
25
+
26
+ }
27
+
28
+ export class ConfigManager {
29
+ private configPath: string;
30
+ private config: MentisConfig;
31
+
32
+ constructor() {
33
+ this.configPath = path.join(os.homedir(), '.mentisrc');
34
+ this.config = {
35
+ defaultProvider: 'ollama',
36
+ ollama: {
37
+ baseUrl: 'http://localhost:11434/v1',
38
+ model: 'llama3:latest'
39
+ },
40
+ gemini: {
41
+ model: 'gemini-2.5-flash'
42
+ },
43
+ glm: {
44
+ model: 'glm-4.6',
45
+ },
46
+ };
47
+ this.loadConfig();
48
+ }
49
+
50
+ private loadConfig() {
51
+ try {
52
+ if (fs.existsSync(this.configPath)) {
53
+ const fileContent = fs.readFileSync(this.configPath, 'utf-8');
54
+ this.config = { ...this.config, ...JSON.parse(fileContent) };
55
+ }
56
+ } catch (error) {
57
+ console.error('Error loading config:', error);
58
+ }
59
+ }
60
+
61
+ public saveConfig() {
62
+ try {
63
+ fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
64
+ } catch (error) {
65
+ console.error('Error saving config:', error);
66
+ }
67
+ }
68
+
69
+ public getConfig(): MentisConfig {
70
+ return this.config;
71
+ }
72
+
73
+ public updateConfig(newConfig: Partial<MentisConfig>) {
74
+ this.config = { ...this.config, ...newConfig };
75
+ this.saveConfig();
76
+ }
77
+ }
@@ -0,0 +1,63 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+
4
+ export interface FileContext {
5
+ path: string;
6
+ content: string;
7
+ }
8
+
9
+ import { RepoMapper } from './RepoMapper';
10
+
11
+ export class ContextManager {
12
+ private files: Map<string, string> = new Map();
13
+ private repoMapper: RepoMapper;
14
+
15
+ constructor() {
16
+ this.repoMapper = new RepoMapper(process.cwd());
17
+ }
18
+
19
+ public async addFile(filePath: string): Promise<string> {
20
+ try {
21
+ const absolutePath = path.resolve(process.cwd(), filePath);
22
+ if (!fs.existsSync(absolutePath)) {
23
+ throw new Error(`File not found: ${filePath}`);
24
+ }
25
+ const content = await fs.readFile(absolutePath, 'utf-8');
26
+ this.files.set(absolutePath, content);
27
+ return `Added ${filePath} to context.`;
28
+ } catch (error: any) {
29
+ return `Error adding file: ${error.message}`;
30
+ }
31
+ }
32
+
33
+ public removeFile(filePath: string): string {
34
+ const absolutePath = path.resolve(process.cwd(), filePath);
35
+ if (this.files.has(absolutePath)) {
36
+ this.files.delete(absolutePath);
37
+ return `Removed ${filePath} from context.`;
38
+ }
39
+ return `File not in context: ${filePath}`;
40
+ }
41
+
42
+ public clear(): void {
43
+ this.files.clear();
44
+ }
45
+
46
+ public getContextString(): string {
47
+ const repoMap = this.repoMapper.generateTree();
48
+ let context = `Repository Structure:\n${repoMap}\n\n`;
49
+
50
+ if (this.files.size > 0) {
51
+ context += 'Current File Context:\n\n';
52
+ for (const [filePath, content] of this.files.entries()) {
53
+ context += `--- File: ${path.basename(filePath)} ---\n${content}\n\n`;
54
+ }
55
+ }
56
+
57
+ return context;
58
+ }
59
+
60
+ public getFiles(): string[] {
61
+ return Array.from(this.files.keys());
62
+ }
63
+ }
@@ -0,0 +1,119 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { execSync } from 'child_process';
4
+
5
+ export class RepoMapper {
6
+ private rootDir: string;
7
+ private ignorePatterns: Set<string>;
8
+
9
+ constructor(rootDir: string) {
10
+ this.rootDir = rootDir;
11
+ this.ignorePatterns = new Set(['.git', 'node_modules', 'dist', 'coverage', '.DS_Store']);
12
+ }
13
+
14
+ public generateTree(): string {
15
+ try {
16
+ // Try using git first for speed
17
+ const stdout = execSync('git ls-files --cached --others --exclude-standard', {
18
+ cwd: this.rootDir,
19
+ encoding: 'utf-8',
20
+ stdio: ['ignore', 'pipe', 'ignore'] // Ignore stderr to avoid noise
21
+ });
22
+ const files = stdout.split('\n').filter(Boolean);
23
+ return this.buildTreeFromPaths(files);
24
+ } catch (error) {
25
+ // Fallback to manual walk if not a git repo or git fails
26
+ return this.walk(this.rootDir, '');
27
+ }
28
+ }
29
+
30
+ private buildTreeFromPaths(paths: string[]): string {
31
+ const tree: any = {};
32
+
33
+ // Build object tree
34
+ for (const p of paths) {
35
+ const parts = p.split('/');
36
+ let current = tree;
37
+ for (const part of parts) {
38
+ if (!current[part]) {
39
+ current[part] = {};
40
+ }
41
+ current = current[part];
42
+ }
43
+ }
44
+
45
+ // Convert object tree to string
46
+ return this.renderTree(tree, '');
47
+ }
48
+
49
+ private renderTree(node: any, indent: string): string {
50
+ let result = '';
51
+ const keys = Object.keys(node).sort((a, b) => {
52
+ const aIsLeaf = Object.keys(node[a]).length === 0;
53
+ const bIsLeaf = Object.keys(node[b]).length === 0;
54
+ // Dirs first
55
+ if (!aIsLeaf && bIsLeaf) return -1;
56
+ if (aIsLeaf && !bIsLeaf) return 1;
57
+ return a.localeCompare(b);
58
+ });
59
+
60
+ keys.forEach((key, index) => {
61
+ const isLast = index === keys.length - 1;
62
+ const isDir = Object.keys(node[key]).length > 0;
63
+ const prefix = isLast ? '└── ' : '├── ';
64
+ const childIndent = isLast ? ' ' : '│ ';
65
+
66
+ result += `${indent}${prefix}${key}${isDir ? '/' : ''}\n`;
67
+
68
+ if (isDir) {
69
+ result += this.renderTree(node[key], indent + childIndent);
70
+ }
71
+ });
72
+
73
+ return result;
74
+ }
75
+
76
+ private walk(currentPath: string, indent: string): string {
77
+ try {
78
+ const files = fs.readdirSync(currentPath);
79
+ let result = '';
80
+
81
+ // Cache stats to avoid repeated calls during sort
82
+ const fileStats = files.map(file => {
83
+ try {
84
+ return {
85
+ name: file,
86
+ isDir: fs.statSync(path.join(currentPath, file)).isDirectory()
87
+ };
88
+ } catch {
89
+ return null;
90
+ }
91
+ }).filter(f => f) as { name: string, isDir: boolean }[];
92
+
93
+ // Sort: Directories first, then files
94
+ fileStats.sort((a, b) => {
95
+ if (a.isDir && !b.isDir) return -1;
96
+ if (!a.isDir && b.isDir) return 1;
97
+ return a.name.localeCompare(b.name);
98
+ });
99
+
100
+ const filteredFiles = fileStats.filter(f => !this.ignorePatterns.has(f.name));
101
+
102
+ filteredFiles.forEach((fileObj, index) => {
103
+ const isLast = index === filteredFiles.length - 1;
104
+ const prefix = isLast ? '└── ' : '├── ';
105
+ const childIndent = isLast ? ' ' : '│ ';
106
+
107
+ result += `${indent}${prefix}${fileObj.name}${fileObj.isDir ? '/' : ''}\n`;
108
+
109
+ if (fileObj.isDir) {
110
+ result += this.walk(path.join(currentPath, fileObj.name), indent + childIndent);
111
+ }
112
+ });
113
+
114
+ return result;
115
+ } catch (e) {
116
+ return `${indent}Error reading directory: ${e}\n`;
117
+ }
118
+ }
119
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import { ReplManager } from './repl/ReplManager';
3
+
4
+ async function main() {
5
+ const repl = new ReplManager();
6
+ await repl.start();
7
+ }
8
+
9
+ main().catch((error) => {
10
+ console.error('Fatal error:', error);
11
+ process.exit(1);
12
+ });
@@ -0,0 +1,47 @@
1
+ export interface ToolCall {
2
+ id: string;
3
+ type: 'function';
4
+ function: {
5
+ name: string;
6
+ arguments: string;
7
+ };
8
+ }
9
+
10
+ export interface ChatMessage {
11
+ role: 'system' | 'user' | 'assistant' | 'tool'; // Added 'tool' role
12
+ content: string | null; // Content can be null for tool calls
13
+ tool_calls?: ToolCall[];
14
+ tool_call_id?: string; // For tool role messages
15
+ name?: string; // For tool role messages
16
+ }
17
+
18
+ export interface ChatResponse {
19
+ content: string;
20
+ tool_calls?: ToolCall[];
21
+ usage?: {
22
+ input_tokens: number;
23
+ output_tokens: number;
24
+ };
25
+ }
26
+
27
+ export interface ModelResponse {
28
+ content: string | null;
29
+ tool_calls?: ToolCall[];
30
+ usage?: {
31
+ input_tokens: number;
32
+ output_tokens: number;
33
+ };
34
+ }
35
+
36
+ export interface ToolDefinition {
37
+ type: 'function';
38
+ function: {
39
+ name: string;
40
+ description: string;
41
+ parameters: object;
42
+ };
43
+ }
44
+
45
+ export interface ModelClient {
46
+ chat(messages: ChatMessage[], tools?: ToolDefinition[], signal?: AbortSignal): Promise<ModelResponse>;
47
+ }
@@ -0,0 +1,64 @@
1
+ import axios from 'axios';
2
+ import { ModelClient, ChatMessage, ModelResponse, ToolDefinition } from './ModelInterface';
3
+
4
+ export class OpenAIClient implements ModelClient {
5
+ private baseUrl: string;
6
+ private apiKey: string;
7
+ private model: string;
8
+
9
+ constructor(baseUrl: string, apiKey: string, model: string) {
10
+ this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash
11
+ this.apiKey = apiKey;
12
+ this.model = model;
13
+ }
14
+
15
+ async chat(messages: ChatMessage[], tools?: ToolDefinition[], signal?: AbortSignal): Promise<ModelResponse> {
16
+ try {
17
+ const requestBody: any = {
18
+ model: this.model,
19
+ messages: [
20
+ { role: 'system', content: 'You are Mentis, an expert AI coding assistant. You help users write code, debug issues, and explain concepts. You are concise, accurate, and professional.' },
21
+ ...messages
22
+ ],
23
+ temperature: 0.7,
24
+ };
25
+
26
+ if (tools && tools.length > 0) {
27
+ requestBody.tools = tools;
28
+ requestBody.tool_choice = 'auto';
29
+ }
30
+
31
+ const response = await axios.post(
32
+ `${this.baseUrl}/chat/completions`,
33
+ requestBody,
34
+ {
35
+ headers: {
36
+ 'Content-Type': 'application/json',
37
+ 'Authorization': `Bearer ${this.apiKey}`,
38
+ },
39
+ signal: signal // Pass AbortSignal to Axios
40
+ }
41
+ );
42
+
43
+ const choice = response.data.choices[0];
44
+ return {
45
+ content: response.data.choices[0].message.content,
46
+ tool_calls: response.data.choices[0].message.tool_calls,
47
+ usage: {
48
+ input_tokens: response.data.usage?.prompt_tokens || 0,
49
+ output_tokens: response.data.usage?.completion_tokens || 0
50
+ }
51
+ };
52
+ } catch (error: any) {
53
+ if (axios.isCancel(error) || error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
54
+ // Rethrow as specific cancellation to be handled by caller
55
+ throw new Error('Request cancelled by user');
56
+ }
57
+ console.error('Error calling model API:', error.message);
58
+ if (error.response) {
59
+ console.error('Response data:', error.response.data);
60
+ }
61
+ throw error;
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,103 @@
1
+ import { spawn, ChildProcess } from 'child_process';
2
+ import * as readline from 'readline';
3
+
4
+ export interface JsonRpcRequest {
5
+ jsonrpc: '2.0';
6
+ method: string;
7
+ params?: any;
8
+ id?: number | string;
9
+ }
10
+
11
+ export interface JsonRpcResponse {
12
+ jsonrpc: '2.0';
13
+ id: number | string | null;
14
+ result?: any;
15
+ error?: {
16
+ code: number;
17
+ message: string;
18
+ data?: any;
19
+ };
20
+ }
21
+
22
+ export class JsonRpcClient {
23
+ private process: ChildProcess;
24
+ private sequence = 0;
25
+ private pendingRequests = new Map<number | string, { resolve: Function; reject: Function }>();
26
+
27
+ constructor(command: string, args: string[]) {
28
+ this.process = spawn(command, args, {
29
+ stdio: ['pipe', 'pipe', 'inherit'], // stdin, stdout, stderr
30
+ });
31
+
32
+ if (!this.process.stdout) {
33
+ throw new Error('Failed to open stdout for MCP process');
34
+ }
35
+
36
+ const rl = readline.createInterface({ input: this.process.stdout });
37
+ rl.on('line', (line) => this.handleMessage(line));
38
+
39
+ this.process.on('error', (err) => console.error('MCP Process Error:', err));
40
+ this.process.on('exit', (code) => console.log('MCP Process Exited:', code));
41
+ }
42
+
43
+ private handleMessage(line: string) {
44
+ try {
45
+ if (!line.trim()) return;
46
+ const message = JSON.parse(line);
47
+
48
+ // Handle Response
49
+ if (message.id !== undefined && (message.result !== undefined || message.error !== undefined)) {
50
+ const handler = this.pendingRequests.get(message.id);
51
+ if (handler) {
52
+ this.pendingRequests.delete(message.id);
53
+ if (message.error) {
54
+ handler.reject(new Error(message.error.message));
55
+ } else {
56
+ handler.resolve(message.result);
57
+ }
58
+ }
59
+ } else {
60
+ // Handle Notification or Request from server (not implemented deep yet)
61
+ // console.log('Received notification from MCP:', message);
62
+ }
63
+ } catch (e) {
64
+ console.error('Failed to parse MCP message:', line, e);
65
+ }
66
+ }
67
+
68
+ public async sendRequest(method: string, params?: any): Promise<any> {
69
+ const id = this.sequence++;
70
+ const request: JsonRpcRequest = {
71
+ jsonrpc: '2.0',
72
+ method,
73
+ params,
74
+ id,
75
+ };
76
+
77
+ return new Promise((resolve, reject) => {
78
+ this.pendingRequests.set(id, { resolve, reject });
79
+ try {
80
+ if (!this.process.stdin) throw new Error('Stdin not available');
81
+ const data = JSON.stringify(request) + '\n';
82
+ this.process.stdin.write(data);
83
+ } catch (e) {
84
+ this.pendingRequests.delete(id);
85
+ reject(e);
86
+ }
87
+ });
88
+ }
89
+
90
+ public sendNotification(method: string, params?: any) {
91
+ if (!this.process.stdin) return;
92
+ const notification: JsonRpcRequest = {
93
+ jsonrpc: '2.0',
94
+ method,
95
+ params,
96
+ };
97
+ this.process.stdin.write(JSON.stringify(notification) + '\n');
98
+ }
99
+
100
+ public disconnect() {
101
+ this.process.kill();
102
+ }
103
+ }
@@ -0,0 +1,75 @@
1
+ import { JsonRpcClient } from './JsonRpcClient';
2
+ import { Tool } from '../tools/Tool';
3
+
4
+ export class McpClient {
5
+ private rpc: JsonRpcClient;
6
+ public serverName: string = 'unknown';
7
+
8
+ constructor(command: string, args: string[]) {
9
+ this.rpc = new JsonRpcClient(command, args);
10
+ }
11
+
12
+ async initialize() {
13
+ const result = await this.rpc.sendRequest('initialize', {
14
+ protocolVersion: '2024-11-05',
15
+ capabilities: {},
16
+ clientInfo: {
17
+ name: 'mentis-cli',
18
+ version: '1.0.0',
19
+ },
20
+ });
21
+
22
+ this.serverName = result.serverInfo.name;
23
+
24
+ // Notify initialized
25
+ this.rpc.sendNotification('notifications/initialized');
26
+ return result;
27
+ }
28
+
29
+ async listTools(): Promise<Tool[]> {
30
+ const result = await this.rpc.sendRequest('tools/list');
31
+ const mcpTools = result.tools || [];
32
+
33
+ return mcpTools.map((t: any) => new McpToolAdapter(this, t));
34
+ }
35
+
36
+ async callTool(name: string, args: any): Promise<any> {
37
+ return this.rpc.sendRequest('tools/call', {
38
+ name,
39
+ arguments: args,
40
+ });
41
+ }
42
+
43
+ disconnect() {
44
+ this.rpc.disconnect();
45
+ }
46
+ }
47
+
48
+ class McpToolAdapter implements Tool {
49
+ private client: McpClient;
50
+ public name: string;
51
+ public description: string;
52
+ public parameters: any;
53
+
54
+ constructor(client: McpClient, toolDef: any) {
55
+ this.client = client;
56
+ this.name = toolDef.name;
57
+ this.description = toolDef.description || '';
58
+ this.parameters = toolDef.inputSchema || {};
59
+ }
60
+
61
+ async execute(args: any): Promise<string> {
62
+ const result = await this.client.callTool(this.name, args);
63
+ // MCP returns { content: [ { type: 'text', text: '...' } ], isError: boolean }
64
+ if (result.isError) {
65
+ throw new Error('MCP Tool Error');
66
+ }
67
+
68
+ // Extract text content
69
+ if (result.content && Array.isArray(result.content)) {
70
+ return result.content.map((c: any) => c.text).join('\n');
71
+ }
72
+
73
+ return JSON.stringify(result);
74
+ }
75
+ }