@taj-special/dravix-code 1.1.27 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands.js +34 -8
- package/dist/cli/index.js +24 -84
- package/dist/cli/repl.js +215 -65
- package/dist/services/ai.js +89 -11
- package/dist/services/auth.js +2 -2
- package/dist/services/context.js +107 -8
- package/dist/services/executor.js +244 -8
- package/dist/services/undo.js +181 -0
- package/dist/utils/display.js +58 -47
- package/package.json +1 -1
package/dist/services/ai.js
CHANGED
|
@@ -2,6 +2,36 @@ const AI_PROXY = 'https://dravix.app/ai-proxy.php';
|
|
|
2
2
|
const PROMPT_URL = 'https://dravix.app/cli-prompt.php';
|
|
3
3
|
export const FLASH_MODEL = 'deepseek/deepseek-v4-flash'; // fast, cheap — default for all tasks
|
|
4
4
|
export const PRO_MODEL = 'deepseek/deepseek-r1'; // high-reasoning — used after errors/retries
|
|
5
|
+
export const CLAUDE_MODEL = 'anthropic/claude-sonnet-4'; // high-quality — for complex tasks
|
|
6
|
+
// Model routing: decide which model to use based on task complexity
|
|
7
|
+
export function selectModel(userMessage, retryCount = 0) {
|
|
8
|
+
// Always use PRO_MODEL for retries (better at fixing errors)
|
|
9
|
+
if (retryCount > 0)
|
|
10
|
+
return PRO_MODEL;
|
|
11
|
+
const msg = userMessage.toLowerCase();
|
|
12
|
+
// Complex task indicators
|
|
13
|
+
const COMPLEX_INDICATORS = [
|
|
14
|
+
'refactor', 'architecture', 'complex', 'redesign', 'migrate',
|
|
15
|
+
'debug', 'fix error', 'error handling', 'optimization', 'performance',
|
|
16
|
+
'security', 'authentication', 'database', 'deploy', 'infrastructure',
|
|
17
|
+
'test', 'testing', 'integration', 'api', 'microservice',
|
|
18
|
+
'typescript', 'type safety', 'interface', 'generic',
|
|
19
|
+
];
|
|
20
|
+
// Long messages are usually more complex
|
|
21
|
+
if (userMessage.length > 500)
|
|
22
|
+
return PRO_MODEL;
|
|
23
|
+
// Check for complex indicators
|
|
24
|
+
for (const indicator of COMPLEX_INDICATORS) {
|
|
25
|
+
if (msg.includes(indicator))
|
|
26
|
+
return PRO_MODEL;
|
|
27
|
+
}
|
|
28
|
+
// Multi-file tasks (mentions multiple files)
|
|
29
|
+
const fileMentions = (userMessage.match(/\.\w{2,5}/g) ?? []).length;
|
|
30
|
+
if (fileMentions > 5)
|
|
31
|
+
return PRO_MODEL;
|
|
32
|
+
// Default: use flash for speed
|
|
33
|
+
return FLASH_MODEL;
|
|
34
|
+
}
|
|
5
35
|
export async function fetchSystemPrompt(token) {
|
|
6
36
|
try {
|
|
7
37
|
const res = await fetch(`${PROMPT_URL}?token=${encodeURIComponent(token)}`, {
|
|
@@ -24,20 +54,68 @@ const TIMEOUT_MS = 300_000; // 5 min — AI may generate large responses
|
|
|
24
54
|
const CHUNK_TIMEOUT_MS = 60_000; // 1 min without any chunk = abort
|
|
25
55
|
const MAX_CONTINUATIONS = 5; // auto-continue up to 5 times on length cutoff
|
|
26
56
|
const MAX_429_RETRIES = 3; // retry on rate-limit with exponential backoff
|
|
27
|
-
//
|
|
28
|
-
// Only the first message can be role:system — everything else must alternate user/assistant.
|
|
29
|
-
// Mid-conversation system messages (file contents, /add files) become role:user.
|
|
30
|
-
// Consecutive user/system-as-user messages are merged to maintain proper alternation.
|
|
57
|
+
// Smart context management: priority-based trimming with summarization
|
|
31
58
|
function prepareMessages(messages) {
|
|
32
|
-
|
|
33
|
-
const
|
|
59
|
+
const MAX_CHARS = 150_000; // ~45k tokens
|
|
60
|
+
const MIN_KEEP = 6; // Always keep at least last 6 messages (3 user + 3 assistant)
|
|
34
61
|
let totalChars = messages.reduce((s, m) => s + String(m.content).length, 0);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
62
|
+
// If under limit, just convert and return
|
|
63
|
+
if (totalChars <= MAX_CHARS) {
|
|
64
|
+
return convertAndMerge(messages);
|
|
65
|
+
}
|
|
66
|
+
// Smart trimming strategy:
|
|
67
|
+
// 1. Keep system prompt (index 0)
|
|
68
|
+
// 2. Keep last MIN_KEEP messages
|
|
69
|
+
// 3. Summarize middle messages
|
|
70
|
+
// 4. Drop oldest middle messages if still over limit
|
|
71
|
+
const systemMsg = messages[0];
|
|
72
|
+
const recentMessages = messages.slice(-MIN_KEEP);
|
|
73
|
+
const middleMessages = messages.slice(1, messages.length - MIN_KEEP);
|
|
74
|
+
// Calculate chars for system + recent
|
|
75
|
+
const systemChars = String(systemMsg.content).length;
|
|
76
|
+
const recentChars = recentMessages.reduce((s, m) => s + String(m.content).length, 0);
|
|
77
|
+
const availableForMiddle = MAX_CHARS - systemChars - recentChars;
|
|
78
|
+
if (availableForMiddle <= 0 || middleMessages.length === 0) {
|
|
79
|
+
// No room for middle messages — just use system + recent
|
|
80
|
+
return convertAndMerge([systemMsg, ...recentMessages]);
|
|
39
81
|
}
|
|
40
|
-
|
|
82
|
+
// Summarize middle messages into a compact summary
|
|
83
|
+
const summary = summarizeMessages(middleMessages, availableForMiddle);
|
|
84
|
+
const summarizedMessages = [
|
|
85
|
+
systemMsg,
|
|
86
|
+
{ role: 'system', content: `[Previous conversation summary]\n${summary}` },
|
|
87
|
+
...recentMessages,
|
|
88
|
+
];
|
|
89
|
+
return convertAndMerge(summarizedMessages);
|
|
90
|
+
}
|
|
91
|
+
// Summarize messages into a compact format
|
|
92
|
+
function summarizeMessages(messages, maxChars) {
|
|
93
|
+
const parts = [];
|
|
94
|
+
let totalChars = 0;
|
|
95
|
+
for (const msg of messages) {
|
|
96
|
+
const content = String(msg.content);
|
|
97
|
+
const role = msg.role === 'user' ? 'User' : 'AI';
|
|
98
|
+
// For short messages, include them fully
|
|
99
|
+
if (content.length < 200) {
|
|
100
|
+
const line = `${role}: ${content.slice(0, 200)}`;
|
|
101
|
+
if (totalChars + line.length < maxChars) {
|
|
102
|
+
parts.push(line);
|
|
103
|
+
totalChars += line.length;
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// For long messages, include first 100 chars + "... [truncated]"
|
|
108
|
+
const truncated = `${role}: ${content.slice(0, 100)}... [${content.length} chars total]`;
|
|
109
|
+
if (totalChars + truncated.length < maxChars) {
|
|
110
|
+
parts.push(truncated);
|
|
111
|
+
totalChars += truncated.length;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return parts.join('\n') || '[No previous context available]';
|
|
115
|
+
}
|
|
116
|
+
// Convert messages to OpenAI format and merge consecutive user messages
|
|
117
|
+
function convertAndMerge(messages) {
|
|
118
|
+
const converted = messages.map((msg, i) => {
|
|
41
119
|
if (msg.role === 'system' && i > 0)
|
|
42
120
|
return { role: 'user', content: msg.content };
|
|
43
121
|
return msg;
|
package/dist/services/auth.js
CHANGED
|
@@ -3,8 +3,8 @@ import * as path from 'path';
|
|
|
3
3
|
import * as os from 'os';
|
|
4
4
|
const CONFIG_DIR = path.join(os.homedir(), '.dravix-code');
|
|
5
5
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
6
|
-
const AUTH_URL = 'https://dravix.app/cli-auth';
|
|
7
|
-
const VERIFY_URL = 'https://dravix.app/cli-auth?action=verify&token=';
|
|
6
|
+
const AUTH_URL = 'https://dravix.app/cli-auth.php';
|
|
7
|
+
const VERIFY_URL = 'https://dravix.app/cli-auth.php?action=verify&token=';
|
|
8
8
|
function readConfig() {
|
|
9
9
|
try {
|
|
10
10
|
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
package/dist/services/context.js
CHANGED
|
@@ -3,14 +3,26 @@ import * as path from 'path';
|
|
|
3
3
|
import { execSync } from 'child_process';
|
|
4
4
|
const IGNORE = new Set([
|
|
5
5
|
'node_modules', '.git', 'dist', 'build', '.next', 'out',
|
|
6
|
-
'__pycache__', '.cache', 'coverage', '.turbo',
|
|
6
|
+
'__pycache__', '.cache', 'coverage', '.turbo', 'vendor',
|
|
7
|
+
'.tox', '.eggs', '.venv', 'venv', 'env', '.env',
|
|
7
8
|
]);
|
|
8
9
|
const CODE_EXTS = new Set([
|
|
9
10
|
'.ts', '.tsx', '.js', '.jsx', '.py', '.html', '.css', '.scss',
|
|
10
11
|
'.json', '.md', '.txt', '.sh', '.php', '.sql', '.yaml', '.yml',
|
|
11
12
|
'.java', '.go', '.rs', '.c', '.cpp', '.h', '.vue', '.svelte',
|
|
13
|
+
'.env', '.env.example', '.env.local',
|
|
12
14
|
]);
|
|
13
|
-
|
|
15
|
+
const CONFIG_FILES = new Set([
|
|
16
|
+
'package.json', 'tsconfig.json', 'vite.config.ts', 'vite.config.js',
|
|
17
|
+
'next.config.js', 'next.config.ts', 'nuxt.config.ts', 'nuxt.config.js',
|
|
18
|
+
'composer.json', 'requirements.txt', 'pyproject.toml', 'Cargo.toml',
|
|
19
|
+
'go.mod', 'pom.xml', 'build.gradle', 'Dockerfile', 'docker-compose.yml',
|
|
20
|
+
'.env', '.env.example', '.eslintrc.js', '.prettierrc',
|
|
21
|
+
'tailwind.config.js', 'tailwind.config.ts', 'postcss.config.js',
|
|
22
|
+
'webpack.config.js', 'rollup.config.js', 'babel.config.js',
|
|
23
|
+
'.gitignore', 'README.md',
|
|
24
|
+
]);
|
|
25
|
+
export function getProjectFiles(root, maxFiles = 50) {
|
|
14
26
|
const files = [];
|
|
15
27
|
function walk(dir, depth = 0) {
|
|
16
28
|
if (depth > 4 || files.length >= maxFiles)
|
|
@@ -22,14 +34,24 @@ export function getProjectFiles(root, maxFiles = 30) {
|
|
|
22
34
|
catch {
|
|
23
35
|
return;
|
|
24
36
|
}
|
|
25
|
-
|
|
37
|
+
// Sort: config files first, then directories, then files
|
|
38
|
+
const sorted = entries.sort((a, b) => {
|
|
39
|
+
const aConfig = CONFIG_FILES.has(a.name) ? 0 : 1;
|
|
40
|
+
const bConfig = CONFIG_FILES.has(b.name) ? 0 : 1;
|
|
41
|
+
if (aConfig !== bConfig)
|
|
42
|
+
return aConfig - bConfig;
|
|
43
|
+
if (a.isDirectory() !== b.isDirectory())
|
|
44
|
+
return a.isDirectory() ? -1 : 1;
|
|
45
|
+
return a.name.localeCompare(b.name);
|
|
46
|
+
});
|
|
47
|
+
for (const e of sorted) {
|
|
26
48
|
if (IGNORE.has(e.name) || e.name.startsWith('.'))
|
|
27
49
|
continue;
|
|
28
50
|
const fullPath = path.join(dir, e.name);
|
|
29
51
|
if (e.isDirectory()) {
|
|
30
52
|
walk(fullPath, depth + 1);
|
|
31
53
|
}
|
|
32
|
-
else if (CODE_EXTS.has(path.extname(e.name).toLowerCase())) {
|
|
54
|
+
else if (CODE_EXTS.has(path.extname(e.name).toLowerCase()) || CONFIG_FILES.has(e.name)) {
|
|
33
55
|
files.push(path.relative(root, fullPath));
|
|
34
56
|
}
|
|
35
57
|
}
|
|
@@ -61,6 +83,19 @@ export function getGitStatus(root) {
|
|
|
61
83
|
return '';
|
|
62
84
|
}
|
|
63
85
|
}
|
|
86
|
+
export function getGitBranch(root) {
|
|
87
|
+
try {
|
|
88
|
+
return execSync('git branch --show-current', {
|
|
89
|
+
cwd: root,
|
|
90
|
+
encoding: 'utf-8',
|
|
91
|
+
timeout: 5000,
|
|
92
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
93
|
+
}).trim();
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
64
99
|
const STOP_WORDS = new Set([
|
|
65
100
|
'this', 'that', 'with', 'from', 'have', 'will', 'what', 'when', 'where', 'make', 'into',
|
|
66
101
|
'does', 'should', 'would', 'could', 'then', 'they', 'your', 'just', 'also', 'more',
|
|
@@ -130,6 +165,7 @@ function detectSeparateProjects(root, files) {
|
|
|
130
165
|
const PROJECT_FILES = new Set([
|
|
131
166
|
'package.json', 'composer.json', 'config.php', 'index.php', 'index.js',
|
|
132
167
|
'index.ts', 'main.py', 'app.py', 'bot.php', 'main.go', 'pom.xml',
|
|
168
|
+
'requirements.txt', 'pyproject.toml', 'Cargo.toml', 'go.mod',
|
|
133
169
|
]);
|
|
134
170
|
const projectDirs = [];
|
|
135
171
|
for (const [dir, dirFiles] of topDirFiles) {
|
|
@@ -138,9 +174,50 @@ function detectSeparateProjects(root, files) {
|
|
|
138
174
|
}
|
|
139
175
|
return projectDirs;
|
|
140
176
|
}
|
|
177
|
+
// Detect project type from files
|
|
178
|
+
function detectProjectType(files) {
|
|
179
|
+
const fileNames = files.map(f => path.basename(f).toLowerCase());
|
|
180
|
+
const fileExts = files.map(f => path.extname(f).toLowerCase());
|
|
181
|
+
if (fileNames.includes('package.json')) {
|
|
182
|
+
if (fileNames.includes('next.config.js') || fileNames.includes('next.config.ts'))
|
|
183
|
+
return 'Next.js';
|
|
184
|
+
if (fileNames.includes('nuxt.config.ts') || fileNames.includes('nuxt.config.js'))
|
|
185
|
+
return 'Nuxt.js';
|
|
186
|
+
if (fileNames.includes('vite.config.ts') || fileNames.includes('vite.config.js'))
|
|
187
|
+
return 'Vite';
|
|
188
|
+
if (fileNames.includes('angular.json'))
|
|
189
|
+
return 'Angular';
|
|
190
|
+
if (fileNames.includes('vue.config.js'))
|
|
191
|
+
return 'Vue.js';
|
|
192
|
+
if (fileExts.includes('.tsx') || fileExts.includes('.jsx'))
|
|
193
|
+
return 'React';
|
|
194
|
+
return 'Node.js';
|
|
195
|
+
}
|
|
196
|
+
if (fileNames.includes('composer.json'))
|
|
197
|
+
return 'Laravel/PHP';
|
|
198
|
+
if (fileNames.includes('requirements.txt') || fileNames.includes('pyproject.toml')) {
|
|
199
|
+
if (fileNames.includes('manage.py'))
|
|
200
|
+
return 'Django';
|
|
201
|
+
if (fileNames.includes('app.py') || fileNames.includes('flask'))
|
|
202
|
+
return 'Flask';
|
|
203
|
+
return 'Python';
|
|
204
|
+
}
|
|
205
|
+
if (fileNames.includes('go.mod'))
|
|
206
|
+
return 'Go';
|
|
207
|
+
if (fileNames.includes('Cargo.toml'))
|
|
208
|
+
return 'Rust';
|
|
209
|
+
if (fileNames.includes('pom.xml') || fileNames.includes('build.gradle'))
|
|
210
|
+
return 'Java';
|
|
211
|
+
if (fileExts.includes('.vue'))
|
|
212
|
+
return 'Vue.js';
|
|
213
|
+
if (fileExts.includes('.svelte'))
|
|
214
|
+
return 'Svelte';
|
|
215
|
+
return 'Unknown';
|
|
216
|
+
}
|
|
141
217
|
export function buildContext(root) {
|
|
142
218
|
const files = getProjectFiles(root);
|
|
143
219
|
const git = getGitStatus(root);
|
|
220
|
+
const branch = getGitBranch(root);
|
|
144
221
|
const projectName = path.basename(root);
|
|
145
222
|
const shell = process.platform === 'win32' ? 'Windows — PowerShell'
|
|
146
223
|
: process.platform === 'darwin' ? 'macOS — bash/zsh'
|
|
@@ -151,11 +228,33 @@ export function buildContext(root) {
|
|
|
151
228
|
`RULE: NEVER modify files inside existing project folders (${separateProjects.join(', ')}) unless the user explicitly asks to work on that specific project.\n` +
|
|
152
229
|
`When creating a NEW project, ALWAYS create a NEW dedicated subfolder.`
|
|
153
230
|
: '';
|
|
154
|
-
|
|
231
|
+
const projectType = detectProjectType(files);
|
|
232
|
+
// Auto-read key config files for better context
|
|
233
|
+
const configContents = [];
|
|
234
|
+
const configToRead = ['package.json', 'tsconfig.json', 'composer.json', 'requirements.txt'];
|
|
235
|
+
for (const cfg of configToRead) {
|
|
236
|
+
const cfgPath = path.join(root, cfg);
|
|
237
|
+
if (fs.existsSync(cfgPath)) {
|
|
238
|
+
try {
|
|
239
|
+
const content = fs.readFileSync(cfgPath, 'utf-8');
|
|
240
|
+
if (content.length < 3000) {
|
|
241
|
+
configContents.push(`[${cfg}]:\n${content}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch { }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const gitInfo = branch ? `Branch: ${branch}\nGit status:\n${git || '(clean)'}` : `Git status:\n${git || '(clean)'}`;
|
|
248
|
+
let context = `Project: ${projectName}
|
|
249
|
+
Type: ${projectType}
|
|
155
250
|
Working dir: ${root}
|
|
156
251
|
OS: ${shell}
|
|
157
|
-
|
|
158
|
-
${git || '(clean)'}
|
|
252
|
+
${gitInfo}
|
|
159
253
|
Files:
|
|
160
|
-
${files.length > 0 ? files.join('\n') : '(empty project)'}
|
|
254
|
+
${files.length > 0 ? files.join('\n') : '(empty project)'}`;
|
|
255
|
+
if (configContents.length > 0) {
|
|
256
|
+
context += `\n\nKey config files:\n${configContents.join('\n\n')}`;
|
|
257
|
+
}
|
|
258
|
+
context += parentDirWarning;
|
|
259
|
+
return context;
|
|
161
260
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { spawn } from 'child_process';
|
|
4
|
+
import { backupFile, recordWrite, recordEdit, recordDelete, startUndoSession, saveUndoSession } from './undo.js';
|
|
4
5
|
const SKIP_DIRS = new Set([
|
|
5
6
|
'node_modules', '.git', 'dist', 'build', '.next', 'out',
|
|
6
7
|
'.cache', 'coverage', '__pycache__', '.venv', 'venv', 'env',
|
|
@@ -139,9 +140,20 @@ export function parseOps(text) {
|
|
|
139
140
|
while ((m = readFolderRe.exec(text)) !== null) {
|
|
140
141
|
ops.push({ type: 'read_folder', path: m[2] });
|
|
141
142
|
}
|
|
142
|
-
const searchRe = /<search_code\s+pattern=(["'])([^"']+)\1(?:\s+path=(["'])([^"']*)\3)?\s*(?:\/>|><\/search_code>)/g;
|
|
143
|
+
const searchRe = /<search_code\s+pattern=(["'])([^"']+)\1(?:\s+path=(["'])([^"']*)\3)?(?:\s+include=(["'])([^"']*)\5)?(?:\s+context=(["'])(\d+)\7)?(?:\s+regex=(["'])(true|false)\9)?\s*(?:\/>|><\/search_code>)/g;
|
|
143
144
|
while ((m = searchRe.exec(text)) !== null) {
|
|
144
|
-
ops.push({
|
|
145
|
+
ops.push({
|
|
146
|
+
type: 'search_code',
|
|
147
|
+
pattern: m[2],
|
|
148
|
+
path: m[4] || undefined,
|
|
149
|
+
include: m[6] || undefined,
|
|
150
|
+
context: m[8] ? parseInt(m[8]) : undefined,
|
|
151
|
+
regex: m[10] === 'true' ? true : m[10] === 'false' ? false : undefined,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const globRe = /<glob_files\s+pattern=(["'])([^"']+)\1(?:\s+path=(["'])([^"']*)\3)?\s*(?:\/>|><\/glob_files>)/g;
|
|
155
|
+
while ((m = globRe.exec(text)) !== null) {
|
|
156
|
+
ops.push({ type: 'glob_files', pattern: m[2], path: m[4] || undefined });
|
|
145
157
|
}
|
|
146
158
|
// suppress unused-variable warnings for Q / AV helpers used only for readability
|
|
147
159
|
void Q;
|
|
@@ -158,6 +170,10 @@ export async function executeSingleOp(op, cwd, onStage) {
|
|
|
158
170
|
return { type: 'error', message: `Access denied: ${op.path} is outside the project directory` };
|
|
159
171
|
const exists = fs.existsSync(fullPath);
|
|
160
172
|
stage(exists ? `Writing ${op.path}` : `Creating ${op.path}`);
|
|
173
|
+
// Backup before write (for undo)
|
|
174
|
+
const backupPath = exists ? backupFile(fullPath, cwd) : null;
|
|
175
|
+
if (exists)
|
|
176
|
+
recordWrite(fullPath, backupPath);
|
|
161
177
|
const oldContent = exists ? fs.readFileSync(fullPath, 'utf-8') : '';
|
|
162
178
|
if (exists && oldContent === op.content)
|
|
163
179
|
return { type: 'skipped', path: op.path };
|
|
@@ -305,6 +321,9 @@ export async function executeSingleOp(op, cwd, onStage) {
|
|
|
305
321
|
};
|
|
306
322
|
}
|
|
307
323
|
stage(`Applying ${resolvedPath}`);
|
|
324
|
+
// Backup before edit (for undo)
|
|
325
|
+
const editBackupPath = backupFile(fullPath, cwd);
|
|
326
|
+
recordEdit(fullPath, editBackupPath);
|
|
308
327
|
const newContent = oldContent.replace(actualFind, op.replace);
|
|
309
328
|
// No actual change — skip write and return skipped
|
|
310
329
|
if (newContent === oldContent)
|
|
@@ -374,6 +393,8 @@ export async function executeSingleOp(op, cwd, onStage) {
|
|
|
374
393
|
fullPath = foundFull;
|
|
375
394
|
}
|
|
376
395
|
stage(`Deleting ${resolvedPath}`);
|
|
396
|
+
// Record for undo before deleting
|
|
397
|
+
recordDelete(fullPath);
|
|
377
398
|
const stat = fs.statSync(fullPath);
|
|
378
399
|
if (stat.isDirectory()) {
|
|
379
400
|
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
@@ -627,9 +648,29 @@ export async function executeSingleOp(op, cwd, onStage) {
|
|
|
627
648
|
'.json', '.yaml', '.yml', '.toml', '.xml', '.md', '.txt', '.sh', '.bash', '.zsh',
|
|
628
649
|
'.env', '.sql', '.graphql', '.prisma', '.proto', '.swift', '.kt', '.dart',
|
|
629
650
|
]);
|
|
651
|
+
// Build regex or literal matcher
|
|
652
|
+
let matcher;
|
|
653
|
+
let useRegex = op.regex ?? false;
|
|
654
|
+
if (useRegex) {
|
|
655
|
+
try {
|
|
656
|
+
const re = new RegExp(op.pattern, 'i');
|
|
657
|
+
matcher = (line) => re.test(line);
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
// Invalid regex — fall back to literal
|
|
661
|
+
const patternLower = op.pattern.toLowerCase();
|
|
662
|
+
matcher = (line) => line.toLowerCase().includes(patternLower);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
const patternLower = op.pattern.toLowerCase();
|
|
667
|
+
matcher = (line) => line.toLowerCase().includes(patternLower);
|
|
668
|
+
}
|
|
669
|
+
// File extension filter
|
|
670
|
+
const includeFilter = op.include ? op.include.toLowerCase().replace(/^\*\./, '.') : null;
|
|
671
|
+
const contextLines = op.context ?? 0;
|
|
630
672
|
const matches = [];
|
|
631
673
|
const MAX_MATCHES = 200;
|
|
632
|
-
const patternLower = op.pattern.toLowerCase();
|
|
633
674
|
function walkSearch(dir) {
|
|
634
675
|
if (matches.length >= MAX_MATCHES)
|
|
635
676
|
return;
|
|
@@ -652,7 +693,10 @@ export async function executeSingleOp(op, cwd, onStage) {
|
|
|
652
693
|
walkSearch(eFull);
|
|
653
694
|
}
|
|
654
695
|
else {
|
|
655
|
-
|
|
696
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
697
|
+
if (!TEXT_EXTS.has(ext))
|
|
698
|
+
continue;
|
|
699
|
+
if (includeFilter && ext !== includeFilter)
|
|
656
700
|
continue;
|
|
657
701
|
let content;
|
|
658
702
|
try {
|
|
@@ -666,8 +710,14 @@ export async function executeSingleOp(op, cwd, onStage) {
|
|
|
666
710
|
for (let i = 0; i < fileLines.length; i++) {
|
|
667
711
|
if (matches.length >= MAX_MATCHES)
|
|
668
712
|
break;
|
|
669
|
-
if (fileLines[i]
|
|
670
|
-
|
|
713
|
+
if (matcher(fileLines[i])) {
|
|
714
|
+
const before = contextLines > 0
|
|
715
|
+
? fileLines.slice(Math.max(0, i - contextLines), i).map(l => ` ${l}`)
|
|
716
|
+
: undefined;
|
|
717
|
+
const after = contextLines > 0
|
|
718
|
+
? fileLines.slice(i + 1, Math.min(fileLines.length, i + 1 + contextLines)).map(l => ` ${l}`)
|
|
719
|
+
: undefined;
|
|
720
|
+
matches.push({ file: relFile, line: i + 1, content: fileLines[i].trim().slice(0, 200), before, after });
|
|
671
721
|
}
|
|
672
722
|
}
|
|
673
723
|
}
|
|
@@ -686,7 +736,11 @@ export async function executeSingleOp(op, cwd, onStage) {
|
|
|
686
736
|
const outLines = [];
|
|
687
737
|
for (const [file, fileMatches] of byFile) {
|
|
688
738
|
for (const fm of fileMatches) {
|
|
739
|
+
if (fm.before)
|
|
740
|
+
outLines.push(...fm.before);
|
|
689
741
|
outLines.push(`${file}:${fm.line}: ${fm.content}`);
|
|
742
|
+
if (fm.after)
|
|
743
|
+
outLines.push(...fm.after);
|
|
690
744
|
}
|
|
691
745
|
}
|
|
692
746
|
if (matches.length >= MAX_MATCHES)
|
|
@@ -697,12 +751,194 @@ export async function executeSingleOp(op, cwd, onStage) {
|
|
|
697
751
|
catch (e) {
|
|
698
752
|
return { type: 'error', message: `Search failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
699
753
|
}
|
|
754
|
+
// ── glob_files ────────────────────────────────────────────────
|
|
755
|
+
}
|
|
756
|
+
else if (op.type === 'glob_files' && op.pattern) {
|
|
757
|
+
try {
|
|
758
|
+
const searchRoot = safeResolvePath(cwd, op.path ?? '.');
|
|
759
|
+
if (!searchRoot)
|
|
760
|
+
return { type: 'error', message: `Access denied: path is outside the project directory` };
|
|
761
|
+
stage(`Globbing ${op.pattern.slice(0, 45)}`);
|
|
762
|
+
// Parse glob pattern: support **, *, ?, and negation with !
|
|
763
|
+
const pattern = op.pattern;
|
|
764
|
+
const isRecursive = pattern.includes('**');
|
|
765
|
+
// Extract file extension or name pattern from glob
|
|
766
|
+
// e.g., "**/*.ts" → match all .ts files recursively
|
|
767
|
+
// e.g., "src/**/*.tsx" → match .tsx files in src/ recursively
|
|
768
|
+
// e.g., "*.json" → match .json files in current dir only
|
|
769
|
+
const extMatch = pattern.match(/\*(?:\.\w+)?$/);
|
|
770
|
+
const targetExt = extMatch ? extMatch[0].replace(/^\*\./, '.') : null;
|
|
771
|
+
const nameMatch = pattern.match(/\/([^/*?]+)$/);
|
|
772
|
+
const targetName = nameMatch ? nameMatch[1] : null;
|
|
773
|
+
// Extract base directory from pattern
|
|
774
|
+
const baseDir = pattern.includes('/')
|
|
775
|
+
? pattern.split('*')[0].replace(/\/$/, '')
|
|
776
|
+
: '';
|
|
777
|
+
const resolvedBase = baseDir ? path.join(searchRoot, baseDir) : searchRoot;
|
|
778
|
+
const files = [];
|
|
779
|
+
const MAX_FILES = 500;
|
|
780
|
+
function walkGlob(dir, depth = 0) {
|
|
781
|
+
if (files.length >= MAX_FILES)
|
|
782
|
+
return;
|
|
783
|
+
if (depth > (isRecursive ? 20 : 1))
|
|
784
|
+
return;
|
|
785
|
+
let de;
|
|
786
|
+
try {
|
|
787
|
+
de = fs.readdirSync(dir, { withFileTypes: true });
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
for (const entry of de) {
|
|
793
|
+
if (files.length >= MAX_FILES)
|
|
794
|
+
return;
|
|
795
|
+
if (SKIP_DIRS.has(entry.name))
|
|
796
|
+
continue;
|
|
797
|
+
if (entry.name.startsWith('.') && entry.name !== '.env' && entry.name !== '.gitignore')
|
|
798
|
+
continue;
|
|
799
|
+
const eFull = path.join(dir, entry.name);
|
|
800
|
+
if (entry.isDirectory()) {
|
|
801
|
+
if (isRecursive || depth === 0)
|
|
802
|
+
walkGlob(eFull, depth + 1);
|
|
803
|
+
}
|
|
804
|
+
else {
|
|
805
|
+
// Match against pattern
|
|
806
|
+
let matches = false;
|
|
807
|
+
if (targetName) {
|
|
808
|
+
matches = entry.name === targetName;
|
|
809
|
+
}
|
|
810
|
+
else if (targetExt) {
|
|
811
|
+
matches = path.extname(entry.name).toLowerCase() === targetExt;
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
// Simple wildcard match
|
|
815
|
+
const nameParts = pattern.split('*');
|
|
816
|
+
if (nameParts.length === 2) {
|
|
817
|
+
matches = entry.name.startsWith(nameParts[0]) && entry.name.endsWith(nameParts[1]);
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
matches = true;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (matches) {
|
|
824
|
+
try {
|
|
825
|
+
const stat = fs.statSync(eFull);
|
|
826
|
+
files.push({
|
|
827
|
+
path: path.relative(cwd, eFull).replace(/\\/g, '/'),
|
|
828
|
+
size: stat.size,
|
|
829
|
+
modified: stat.mtime.toISOString().split('T')[0],
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
files.push({
|
|
834
|
+
path: path.relative(cwd, eFull).replace(/\\/g, '/'),
|
|
835
|
+
size: 0,
|
|
836
|
+
modified: '',
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (fs.existsSync(resolvedBase)) {
|
|
844
|
+
walkGlob(resolvedBase);
|
|
845
|
+
}
|
|
846
|
+
// Sort by path
|
|
847
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
848
|
+
if (files.length === 0) {
|
|
849
|
+
return { type: 'run', message: `glob:${op.pattern}`, output: `No files matched pattern "${op.pattern}"` };
|
|
850
|
+
}
|
|
851
|
+
const outLines = files.map(f => {
|
|
852
|
+
const sizeStr = f.size > 1024 * 1024 ? `${(f.size / 1024 / 1024).toFixed(1)}MB`
|
|
853
|
+
: f.size > 1024 ? `${(f.size / 1024).toFixed(1)}KB`
|
|
854
|
+
: `${f.size}B`;
|
|
855
|
+
return `${f.path} ${sizeStr} ${f.modified}`;
|
|
856
|
+
});
|
|
857
|
+
if (files.length >= MAX_FILES)
|
|
858
|
+
outLines.push(`... truncated at ${MAX_FILES} files`);
|
|
859
|
+
outLines.push(`\nTotal: ${files.length} file${files.length !== 1 ? 's' : ''}`);
|
|
860
|
+
return { type: 'run', message: `glob:${op.pattern}`, output: outLines.join('\n') };
|
|
861
|
+
}
|
|
862
|
+
catch (e) {
|
|
863
|
+
return { type: 'error', message: `Glob failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
864
|
+
}
|
|
700
865
|
}
|
|
701
866
|
return { type: 'error', message: 'Unknown operation' };
|
|
702
867
|
}
|
|
703
868
|
export async function executeOps(ops, cwd) {
|
|
869
|
+
// Start undo session for this batch of operations
|
|
870
|
+
startUndoSession();
|
|
871
|
+
const results = [];
|
|
872
|
+
// Separate read-only ops from write/mutation ops
|
|
873
|
+
const READ_OPS = new Set(['read_file', 'read_folder', 'search_code', 'glob_files']);
|
|
874
|
+
const readOps = [];
|
|
875
|
+
const writeOps = [];
|
|
876
|
+
for (let i = 0; i < ops.length; i++) {
|
|
877
|
+
if (READ_OPS.has(ops[i].type)) {
|
|
878
|
+
readOps.push({ idx: i, op: ops[i] });
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
writeOps.push({ idx: i, op: ops[i] });
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
// Initialize results array
|
|
885
|
+
for (let i = 0; i < ops.length; i++) {
|
|
886
|
+
results[i] = { type: 'skipped', message: 'pending' };
|
|
887
|
+
}
|
|
888
|
+
// Execute read-only ops in parallel (max 6 concurrent)
|
|
889
|
+
const MAX_CONCURRENT = 6;
|
|
890
|
+
for (let i = 0; i < readOps.length; i += MAX_CONCURRENT) {
|
|
891
|
+
const batch = readOps.slice(i, i + MAX_CONCURRENT);
|
|
892
|
+
const batchResults = await Promise.all(batch.map(({ op }) => executeSingleOp(op, cwd)));
|
|
893
|
+
for (let j = 0; j < batch.length; j++) {
|
|
894
|
+
results[batch[j].idx] = batchResults[j];
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
// Execute write ops sequentially with atomic rollback on failure
|
|
898
|
+
const executedWrites = [];
|
|
899
|
+
let atomicFailed = false;
|
|
900
|
+
for (const { idx, op } of writeOps) {
|
|
901
|
+
const result = await executeSingleOp(op, cwd);
|
|
902
|
+
results[idx] = result;
|
|
903
|
+
if (result.type === 'error') {
|
|
904
|
+
// Atomic mode: if any write fails, rollback all previous writes
|
|
905
|
+
atomicFailed = true;
|
|
906
|
+
for (const prev of executedWrites) {
|
|
907
|
+
// Try to rollback each previous operation
|
|
908
|
+
try {
|
|
909
|
+
if (prev.op.type === 'write' && prev.op.path) {
|
|
910
|
+
const fullPath = path.resolve(cwd, prev.op.path);
|
|
911
|
+
if (prev.result.type === 'modified' || prev.result.type === 'created') {
|
|
912
|
+
// Restore from backup if available
|
|
913
|
+
const backupDir = path.join(require('os').homedir(), '.dravix-code', 'undo');
|
|
914
|
+
const files = fs.readdirSync(backupDir).filter(f => f.endsWith('.json')).sort().reverse();
|
|
915
|
+
if (files.length > 0) {
|
|
916
|
+
const session = JSON.parse(fs.readFileSync(path.join(backupDir, files[0]), 'utf-8'));
|
|
917
|
+
const backup = session.operations?.find((o) => o.path === fullPath && o.backupPath);
|
|
918
|
+
if (backup?.backupPath && fs.existsSync(backup.backupPath)) {
|
|
919
|
+
fs.copyFileSync(backup.backupPath, fullPath);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
catch { /* ignore rollback errors */ }
|
|
926
|
+
}
|
|
927
|
+
break; // Stop executing remaining writes
|
|
928
|
+
}
|
|
929
|
+
executedWrites.push({ idx, op, result });
|
|
930
|
+
}
|
|
931
|
+
// Save undo session after all operations complete
|
|
932
|
+
saveUndoSession();
|
|
933
|
+
return results;
|
|
934
|
+
}
|
|
935
|
+
// Execute all ops in parallel (for use when order doesn't matter, e.g., all reads)
|
|
936
|
+
export async function executeOpsParallel(ops, cwd, maxConcurrent = 6) {
|
|
704
937
|
const results = [];
|
|
705
|
-
for (
|
|
706
|
-
|
|
938
|
+
for (let i = 0; i < ops.length; i += maxConcurrent) {
|
|
939
|
+
const batch = ops.slice(i, i + maxConcurrent);
|
|
940
|
+
const batchResults = await Promise.all(batch.map(op => executeSingleOp(op, cwd)));
|
|
941
|
+
results.push(...batchResults);
|
|
942
|
+
}
|
|
707
943
|
return results;
|
|
708
944
|
}
|