@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.
@@ -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
- // Convert history to OpenAI-compatible format:
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
- // Trim context: if total chars exceed ~180k (~45k tokens), drop oldest non-system messages
33
- const MAX_CHARS = 150_000;
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
- const trimmed = [...messages];
36
- while (totalChars > MAX_CHARS && trimmed.length > 4) {
37
- const removed = trimmed.splice(1, 1)[0];
38
- totalChars -= String(removed.content).length;
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
- const converted = trimmed.map((msg, i) => {
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;
@@ -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'));
@@ -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
- export function getProjectFiles(root, maxFiles = 30) {
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
- for (const e of entries) {
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
- return `Project: ${projectName}
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
- Git status:
158
- ${git || '(clean)'}
252
+ ${gitInfo}
159
253
  Files:
160
- ${files.length > 0 ? files.join('\n') : '(empty project)'}${parentDirWarning}`;
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({ type: 'search_code', pattern: m[2], path: m[4] || undefined });
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
- if (!TEXT_EXTS.has(path.extname(entry.name).toLowerCase()))
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].toLowerCase().includes(patternLower)) {
670
- matches.push({ file: relFile, line: i + 1, content: fileLines[i].trim().slice(0, 200) });
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 (const op of ops)
706
- results.push(await executeSingleOp(op, cwd));
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
  }