osai-agent 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/LICENSE +7 -0
  2. package/package.json +72 -0
  3. package/src/agent/context.js +141 -0
  4. package/src/agent/loop/context-summary.js +196 -0
  5. package/src/agent/loop/directory-utils.js +102 -0
  6. package/src/agent/loop/local.js +196 -0
  7. package/src/agent/loop/loop-detection.js +288 -0
  8. package/src/agent/loop/stream-parser.js +515 -0
  9. package/src/agent/loop/tool-executor.js +470 -0
  10. package/src/agent/loop/verification.js +263 -0
  11. package/src/agent/loop/websocket.js +80 -0
  12. package/src/agent/prompt.js +259 -0
  13. package/src/agent/react-loop.js +697 -0
  14. package/src/agent/subagent.js +263 -0
  15. package/src/commands/config.js +53 -0
  16. package/src/commands/connect.js +190 -0
  17. package/src/commands/devices.js +121 -0
  18. package/src/commands/login.js +77 -0
  19. package/src/commands/logout.js +31 -0
  20. package/src/commands/mcp.js +258 -0
  21. package/src/commands/provider.js +633 -0
  22. package/src/commands/register.js +74 -0
  23. package/src/commands/run.js +150 -0
  24. package/src/commands/search.js +64 -0
  25. package/src/commands/session.js +57 -0
  26. package/src/commands/skills.js +54 -0
  27. package/src/commands/stop-subagent.js +58 -0
  28. package/src/index.js +208 -0
  29. package/src/llm/direct.js +317 -0
  30. package/src/memory/store.js +215 -0
  31. package/src/mock-readline.js +27 -0
  32. package/src/parser/dependencies.js +71 -0
  33. package/src/parser/markdown.js +505 -0
  34. package/src/parser/stream.js +96 -0
  35. package/src/prompts/modes/CODING.js +160 -0
  36. package/src/prompts/modes/GENERAL.js +105 -0
  37. package/src/prompts/modes/NETWORK.js +69 -0
  38. package/src/prompts/modes/SSH.js +53 -0
  39. package/src/prompts/systemPrompt.js +85 -0
  40. package/src/safety/check.js +210 -0
  41. package/src/services/crypto.js +78 -0
  42. package/src/services/executor.js +68 -0
  43. package/src/services/history.js +58 -0
  44. package/src/services/server-url.js +11 -0
  45. package/src/services/session.js +194 -0
  46. package/src/services/ssh.js +176 -0
  47. package/src/services/websocket.js +112 -0
  48. package/src/skills/loader.js +231 -0
  49. package/src/tools/browser.js +434 -0
  50. package/src/tools/local.js +1254 -0
  51. package/src/tools/mcp-client.js +209 -0
  52. package/src/tools/registry.js +132 -0
  53. package/src/tools/search-providers.js +237 -0
  54. package/src/tools/ssh.js +74 -0
  55. package/src/ui/App.js +2031 -0
  56. package/src/ui/animation.js +47 -0
  57. package/src/ui/components/AskUserDialog.js +33 -0
  58. package/src/ui/components/ConfirmationDialog.js +45 -0
  59. package/src/ui/components/DiffView.js +201 -0
  60. package/src/ui/components/Header.js +157 -0
  61. package/src/ui/components/HistoryPicker.js +130 -0
  62. package/src/ui/components/InputShell.js +22 -0
  63. package/src/ui/components/MessageHistory.js +1200 -0
  64. package/src/ui/components/ModalPanel.js +40 -0
  65. package/src/ui/components/ModePicker.js +161 -0
  66. package/src/ui/components/PlanDialog.js +48 -0
  67. package/src/ui/components/ProviderMenu.js +1095 -0
  68. package/src/ui/components/SavePicker.js +106 -0
  69. package/src/ui/components/SelectMenu.js +194 -0
  70. package/src/ui/components/SlashMenu.js +168 -0
  71. package/src/ui/components/SubagentPanel.js +138 -0
  72. package/src/ui/components/TextInputSafe.js +117 -0
  73. package/src/ui/components/TodoPanel.js +54 -0
  74. package/src/ui/components/ToolExecution.js +261 -0
  75. package/src/ui/components/TranscriptViewport.js +99 -0
  76. package/src/ui/diff.js +249 -0
  77. package/src/ui/h.js +7 -0
  78. package/src/ui/mouse-scroll.js +63 -0
  79. package/src/ui/slash-picker.js +58 -0
  80. package/src/ui/terminal.js +41 -0
  81. package/src/ui/theme.js +5 -0
  82. package/src/ui/welcome.js +12 -0
  83. package/src/utils/constants.js +231 -0
  84. package/src/utils/helpers.js +154 -0
  85. package/src/utils/logger.js +81 -0
  86. package/src/utils/sound.js +33 -0
@@ -0,0 +1,1254 @@
1
+ // =============================================================================
2
+ // OS AI Agent — Local Tool System (v4.0 — Coding Mode + Todos)
3
+ // =============================================================================
4
+ import { exec, spawn } from 'child_process';
5
+ import { promisify } from 'util';
6
+ import fs from 'fs/promises';
7
+ import fsSync from 'fs';
8
+ import path from 'path';
9
+ import os from 'os';
10
+ import { DEFAULTS, TOOLS } from '../utils/constants.js';
11
+ import { logger } from '../utils/logger.js';
12
+ import { searchDDG, searchSerpAPI, searchTavily, searchDDGHttp, formatSearchOutput } from './search-providers.js';
13
+ import { extractExports, extractLocalImports } from '../parser/dependencies.js';
14
+
15
+ const execAsync = promisify(exec);
16
+ const COMMAND_TIMEOUT = parseInt(process.env.OSAI_COMMAND_TIMEOUT) || DEFAULTS.COMMAND_TIMEOUT;
17
+ const FETCH_TIMEOUT = parseInt(process.env.OSAI_FETCH_TIMEOUT) || DEFAULTS.FETCH_TIMEOUT;
18
+ const MAX_COMMAND_OUTPUT_CHARS = parseInt(process.env.OSAI_MAX_COMMAND_OUTPUT_CHARS || '40000', 10);
19
+
20
+ /** Commands that spawn interactive shells — must be blocked */
21
+ const INTERACTIVE_COMMANDS = [
22
+ /^cmd(\s+\/k)?$/i, /^powershell(\s+-noexit)?$/i, /^bash$/i, /^sh$/i,
23
+ /^python(\s*\.exe)?$/i, /^node$/i, /^wsl$/i, /^irb$/i, /^ruby$/i,
24
+ /^php$/i, /^mysql$/i, /^psql$/i, /^sqlite3$/i, /^telnet$/i, /^ftp$/i,
25
+ /^vim?$/i, /^nano$/i, /^emacs$/i, /^less$/i, /^more$/i,
26
+ ];
27
+
28
+ // ─── Todo Store (scoped per working directory) ───────────────────────────
29
+ const TODO_DIR = path.join(os.homedir(), '.osai-agent', 'todos');
30
+
31
+ /**
32
+ * Derive a safe filename from the current working directory.
33
+ * e.g. D:\Projects\MyApp → d__projects__myapp.json
34
+ * /home/sam/myproject → _home_sam_myproject.json
35
+ */
36
+ const _cwdToKey = (cwd) => {
37
+ return cwd
38
+ .toLowerCase()
39
+ .replace(/^[a-z]:[\\]/, (m) => m[0] + '__') // Windows drive letter
40
+ .replace(/[/\\:]/g, '__')
41
+ .replace(/[^a-z0-9_]/g, '_')
42
+ .replace(/__+/g, '__')
43
+ .slice(0, 120);
44
+ };
45
+
46
+ const _getTodoFile = () => {
47
+ const key = _cwdToKey(process.cwd());
48
+ return path.join(TODO_DIR, `${key}.json`);
49
+ };
50
+
51
+ const _saveTodos = async (todos, counter) => {
52
+ try {
53
+ const file = _getTodoFile();
54
+ await fs.mkdir(path.dirname(file), { recursive: true });
55
+ await fs.writeFile(file, JSON.stringify({ todos, counter }, null, 2), 'utf-8');
56
+ } catch {}
57
+ };
58
+
59
+ const _loadTodos = () => {
60
+ try {
61
+ const file = _getTodoFile();
62
+ const data = JSON.parse(fsSync.readFileSync(file, 'utf-8'));
63
+ return { todos: data.todos || [], counter: data.counter || 0 };
64
+ } catch {
65
+ return { todos: [], counter: 0 };
66
+ }
67
+ };
68
+
69
+ const _initial = _loadTodos();
70
+ const _todos = _initial.todos;
71
+ let _todoIdCounter = _initial.counter;
72
+
73
+ // Reload todos from disk for the current cwd (called when cwd changes)
74
+ export const reloadTodos = () => {
75
+ try {
76
+ const fresh = _loadTodos();
77
+ _todos.length = 0;
78
+ for (const t of fresh.todos) _todos.push(t);
79
+ _todoIdCounter = fresh.counter;
80
+ } catch {
81
+ // Corrupted state — reset to empty rather than crash
82
+ _todos.length = 0;
83
+ _todoIdCounter = 0;
84
+ }
85
+ };
86
+
87
+ export const todoStore = {
88
+ add(text, priority = 'MEDIUM', mode = null) {
89
+ const id = ++_todoIdCounter;
90
+ _todos.push({ id, text, priority, mode, status: 'pending', createdAt: new Date().toISOString() });
91
+ _saveTodos(_todos, _todoIdCounter);
92
+ return { id, text, priority, mode, status: 'pending' };
93
+ },
94
+ complete(id) {
95
+ const todo = _todos.find(t => t.id === id);
96
+ if (!todo) return { success: false, error: `Todo #${id} not found` };
97
+ todo.status = 'done';
98
+ todo.completedAt = new Date().toISOString();
99
+ _saveTodos(_todos, _todoIdCounter);
100
+ return { success: true, todo };
101
+ },
102
+ update(id, updates) {
103
+ const todo = _todos.find(t => t.id === id);
104
+ if (!todo) return { success: false, error: `Todo #${id} not found` };
105
+ if (updates.text) todo.text = updates.text;
106
+ if (updates.priority) todo.priority = updates.priority;
107
+ if (updates.mode) todo.mode = updates.mode;
108
+ if (updates.status) {
109
+ const s = updates.status.toLowerCase();
110
+ if (s === 'done' || s === 'completed' || s === 'complete') {
111
+ todo.status = 'done';
112
+ todo.completedAt = new Date().toISOString();
113
+ } else if (s === 'pending' || s === 'open') {
114
+ todo.status = 'pending';
115
+ delete todo.completedAt;
116
+ }
117
+ }
118
+ _saveTodos(_todos, _todoIdCounter);
119
+ return { success: true, todo };
120
+ },
121
+ list(mode = null) {
122
+ if (!mode) return [..._todos];
123
+ return _todos.filter(t => !t.mode || t.mode === mode);
124
+ },
125
+ clear() {
126
+ _todos.length = 0;
127
+ _todoIdCounter = 0;
128
+ _saveTodos(_todos, _todoIdCounter);
129
+ return { success: true, message: 'All todos cleared' };
130
+ },
131
+ getStats(mode = null) {
132
+ const filtered = mode ? _todos.filter(t => !t.mode || t.mode === mode) : _todos;
133
+ const pending = filtered.filter(t => t.status === 'pending').length;
134
+ const done = filtered.filter(t => t.status === 'done').length;
135
+ return { total: filtered.length, pending, done, projectPath: process.cwd(), projectKey: _cwdToKey(process.cwd()) };
136
+ },
137
+ };
138
+
139
+ // ─── Core Functions ────────────────────────────────────────────────────────
140
+
141
+ /**
142
+ * Detect the current operating system
143
+ */
144
+ export const detectOS = () => {
145
+ const p = process.platform;
146
+ if (p === 'win32') return 'windows';
147
+ if (p === 'darwin') return 'macos';
148
+ return 'linux';
149
+ };
150
+
151
+ /**
152
+ * Resolve a path that may contain environment variables or ~
153
+ */
154
+ export const resolvePath = (p) => {
155
+ if (!p || typeof p !== 'string') return p;
156
+ let resolved = p;
157
+
158
+ // Windows environment variables
159
+ resolved = resolved.replace(/%USERPROFILE%/gi, os.homedir());
160
+ resolved = resolved.replace(/%APPDATA%/gi, process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'));
161
+ resolved = resolved.replace(/%LOCALAPPDATA%/gi, process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'));
162
+ resolved = resolved.replace(/%TEMP%/gi, process.env.TEMP || os.tmpdir());
163
+ resolved = resolved.replace(/%TMP%/gi, process.env.TMP || os.tmpdir());
164
+ resolved = resolved.replace(/%SYSTEMDRIVE%/gi, process.env.SYSTEMDRIVE || 'C:');
165
+ resolved = resolved.replace(/%WINDIR%/gi, process.env.WINDIR || 'C:\\Windows');
166
+ resolved = resolved.replace(/%HOMEPATH%/gi, os.homedir());
167
+ resolved = resolved.replace(/%USERNAME%/gi, process.env.USERNAME || process.env.USER || 'user');
168
+
169
+ // Unix tilde and $HOME
170
+ resolved = resolved.replace(/^~/, os.homedir());
171
+ resolved = resolved.replace(/\$HOME/g, os.homedir());
172
+ resolved = resolved.replace(/\$USER/g, process.env.USER || 'user');
173
+
174
+ return resolved;
175
+ };
176
+
177
+ /**
178
+ * Get basic system information for prompt context
179
+ */
180
+ export const getSystemInfo = () => ({
181
+ os: detectOS(),
182
+ platform: process.platform,
183
+ arch: process.arch,
184
+ nodeVersion: process.version,
185
+ hostname: os.hostname(),
186
+ username: process.env.USERNAME || process.env.USER || 'unknown',
187
+ homedir: os.homedir(),
188
+ cwd: process.cwd(),
189
+ cpus: os.cpus().length,
190
+ totalMemory: `${Math.round(os.totalmem() / 1024 / 1024 / 1024)}GB`,
191
+ freeMemory: `${Math.round(os.freemem() / 1024 / 1024 / 1024)}GB`,
192
+ });
193
+
194
+ // ─── Existing Tool Implementations ─────────────────────────────────────────
195
+
196
+ /**
197
+ * Execute a local shell command with timeout and safety checks.
198
+ */
199
+ export const executeLocal = async (command) => {
200
+ const trimmedCmd = (command || '').trim();
201
+ if (!trimmedCmd) {
202
+ return { success: false, output: '', error: 'Empty command' };
203
+ }
204
+
205
+ // Block interactive shells
206
+ for (const pattern of INTERACTIVE_COMMANDS) {
207
+ if (pattern.test(trimmedCmd)) {
208
+ return {
209
+ success: false,
210
+ output: '',
211
+ error: 'Cannot open interactive shell. Describe what you want and the agent will run the appropriate command.',
212
+ };
213
+ }
214
+ }
215
+
216
+ logger.debug('Executing local command', { cmd: trimmedCmd, timeout: COMMAND_TIMEOUT });
217
+
218
+ const isWindows = detectOS() === 'windows';
219
+ const finalCommand = isWindows ? `chcp 65001 >nul 2>&1 && ${trimmedCmd}` : trimmedCmd;
220
+
221
+ return await new Promise((resolve) => {
222
+ let output = '';
223
+ let truncatedChars = 0;
224
+ let timedOut = false;
225
+
226
+ const appendOutput = (chunk) => {
227
+ const text = String(chunk || '');
228
+ if (!text) return;
229
+ const remaining = Math.max(0, MAX_COMMAND_OUTPUT_CHARS - output.length);
230
+ if (remaining > 0) output += text.slice(0, remaining);
231
+ if (text.length > remaining) truncatedChars += text.length - remaining;
232
+ };
233
+
234
+ const child = spawn(finalCommand, {
235
+ shell: isWindows ? 'cmd.exe' : '/bin/sh',
236
+ windowsHide: true,
237
+ env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'en_US.UTF-8' },
238
+ });
239
+
240
+ const timeoutId = setTimeout(() => {
241
+ timedOut = true;
242
+ try { child.kill('SIGTERM'); } catch {}
243
+ }, COMMAND_TIMEOUT);
244
+
245
+ child.stdout?.setEncoding('utf8');
246
+ child.stderr?.setEncoding('utf8');
247
+ child.stdout?.on('data', appendOutput);
248
+ child.stderr?.on('data', appendOutput);
249
+
250
+ child.on('error', (error) => {
251
+ clearTimeout(timeoutId);
252
+ resolve({
253
+ success: false,
254
+ output: '',
255
+ error: error.message || 'Command failed to start',
256
+ });
257
+ });
258
+
259
+ child.on('close', (code, signal) => {
260
+ clearTimeout(timeoutId);
261
+
262
+ if (isWindows) {
263
+ output = output
264
+ .replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F]/g, '')
265
+ .replace(/\uFFFD/g, '')
266
+ .replace(/\r\n/g, '\n')
267
+ .replace(/\r/g, '\n');
268
+ }
269
+
270
+ output = output.trim();
271
+ if (truncatedChars > 0) {
272
+ output += `\n\n... output truncated (${truncatedChars} chars hidden; set OSAI_MAX_COMMAND_OUTPUT_CHARS to adjust)`;
273
+ }
274
+
275
+ const tokens = Math.ceil((output || '').length / 4);
276
+ if (tokens > 4000) {
277
+ logger.warn('Large output detected', { tokens, chars: output.length, lines: output.split('\n').length });
278
+ }
279
+
280
+ if (timedOut) {
281
+ resolve({ success: false, output, error: `Command timed out after ${COMMAND_TIMEOUT / 1000}s` });
282
+ return;
283
+ }
284
+
285
+ if (code !== 0) {
286
+ const signalText = signal ? ` (signal: ${signal})` : '';
287
+ resolve({
288
+ success: false,
289
+ output,
290
+ error: output || `Command failed with exit code ${code}${signalText}`,
291
+ tokens,
292
+ });
293
+ return;
294
+ }
295
+
296
+ resolve({
297
+ success: true,
298
+ output: output || '(command executed successfully — no output)',
299
+ error: null,
300
+ tokens,
301
+ });
302
+ });
303
+ });
304
+ };
305
+
306
+ /**
307
+ * Read a file and return its content with metadata
308
+ */
309
+ export const readFile = async (filePath) => {
310
+ try {
311
+ const resolved = resolvePath(filePath);
312
+ const content = await fs.readFile(resolved, 'utf-8');
313
+ const lines = content.split('\n').length;
314
+ const stats = await fs.stat(resolved);
315
+ return {
316
+ success: true,
317
+ output: `File: ${resolved}\nLines: ${lines}\nSize: ${stats.size} bytes\n\n${content}`,
318
+ error: null,
319
+ path: resolved,
320
+ content,
321
+ tokens: Math.ceil(content.length / 4),
322
+ };
323
+ } catch (e) {
324
+ return { success: false, output: '', error: `Cannot read file "${filePath}": ${e.message}`, path: resolvePath(filePath) };
325
+ }
326
+ };
327
+
328
+ /**
329
+ * Write content to a file (creates parent directories automatically)
330
+ */
331
+ export const writeFile = async (filePath, content) => {
332
+ try {
333
+ const resolved = resolvePath(filePath);
334
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
335
+ await fs.writeFile(resolved, content || '', 'utf-8');
336
+ return {
337
+ success: true,
338
+ output: `File created/written: ${resolved} (${(content || '').length} chars, ${(content || '').split('\n').length} lines)`,
339
+ error: null,
340
+ path: resolved,
341
+ };
342
+ } catch (e) {
343
+ return { success: false, output: '', error: `Cannot write file "${filePath}": ${e.message}`, path: resolvePath(filePath) };
344
+ }
345
+ };
346
+
347
+ /**
348
+ * Edit a file using find-and-replace. Supports exact match and fuzzy match.
349
+ */
350
+ export const editFile = async (filePath, find, replace) => {
351
+ try {
352
+ if (!find) return { success: false, output: '', error: 'No find pattern provided', path: resolvePath(filePath) };
353
+
354
+ const resolved = resolvePath(filePath);
355
+ const original = await fs.readFile(resolved, 'utf-8');
356
+
357
+ if (!original.includes(find)) {
358
+ const findTrimmed = find.trim();
359
+ const findEscaped = findTrimmed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
360
+ const fuzzyPattern = new RegExp(findEscaped.replace(/\s+/g, '\\s+'), 'i');
361
+ if (fuzzyPattern.test(original)) {
362
+ const match = original.match(fuzzyPattern);
363
+ const count = original.split(match[0]).length - 1;
364
+ const modified = original.split(match[0]).join(replace || '');
365
+ await fs.writeFile(resolved, modified, 'utf-8');
366
+ return {
367
+ success: true,
368
+ output: `File edited: ${resolved}\n${count} replacement(s) made (fuzzy match).`,
369
+ error: null,
370
+ path: resolved,
371
+ diff: { find: match[0], replace, count },
372
+ };
373
+ }
374
+
375
+ const preview = original; // Always return full content on EDIT_FILE failure
376
+
377
+ return {
378
+ success: false,
379
+ output: '',
380
+ error: `Pattern not found in "${resolved}".\n\nThe EXACT text you searched for was not found. Full current file content is shown below — extract the exact block you want to change and retry EDIT_FILE with it.\n\nFull file content:\n---\n${preview}\n---`,
381
+ path: resolved,
382
+ };
383
+ }
384
+
385
+ const modified = original.split(find).join(replace || '');
386
+ const count = original.split(find).length - 1;
387
+ await fs.writeFile(resolved, modified, 'utf-8');
388
+ return {
389
+ success: true,
390
+ output: `File edited: ${resolved}\n${count} replacement(s) made.`,
391
+ error: null,
392
+ path: resolved,
393
+ diff: { find, replace, count },
394
+ };
395
+ } catch (e) {
396
+ return { success: false, output: '', error: `Cannot edit file "${filePath}": ${e.message}`, path: resolvePath(filePath) };
397
+ }
398
+ };
399
+
400
+ /**
401
+ * List directory contents with [DIR]/[FILE] prefixes
402
+ */
403
+ export const listDir = async (dirPath) => {
404
+ try {
405
+ const resolved = resolvePath(dirPath || '.');
406
+ let stat;
407
+ try { stat = await fs.stat(resolved); } catch { stat = null; }
408
+ if (stat && stat.isFile()) {
409
+ const content = await fs.readFile(resolved, 'utf-8');
410
+ const lines = content.split('\n').length;
411
+ return {
412
+ success: true,
413
+ output: `File: ${resolved}\nLines: ${lines}\nSize: ${stat.size} bytes\n\n${content}`,
414
+ error: null,
415
+ path: resolved,
416
+ content,
417
+ };
418
+ }
419
+ const entries = await fs.readdir(resolved, { withFileTypes: true });
420
+ const lines = entries.map(e => `${e.isDirectory() ? '[DIR] ' : '[FILE]'} ${e.name}`);
421
+ const dirs = entries.filter(e => e.isDirectory()).length;
422
+ const files = entries.filter(e => !e.isDirectory()).length;
423
+ return {
424
+ success: true,
425
+ output: `Directory: ${resolved}\n${dirs} folder(s), ${files} file(s)\n\n${lines.join('\n')}`,
426
+ error: null,
427
+ path: resolved,
428
+ };
429
+ } catch (e) {
430
+ return { success: false, output: '', error: `Cannot list directory "${dirPath}": ${e.message}`, path: resolvePath(dirPath) };
431
+ }
432
+ };
433
+
434
+ // ─── NEW CODING TOOLS ─────────────────────────────────────────────────────
435
+
436
+ /**
437
+ * Search for text/regex pattern in files within a directory.
438
+ * {"tool":"SEARCH_FILE","path":"<directory>","pattern":"<regex pattern>","filePattern":"<optional glob like *.js>"}
439
+ */
440
+ export const searchInFiles = async (dirPath, pattern, filePattern = null) => {
441
+ try {
442
+ const resolved = resolvePath(dirPath || '.');
443
+ const stat = await fs.stat(resolved);
444
+ if (!stat.isDirectory()) {
445
+ return { success: false, output: '', error: `"${resolved}" is not a directory` };
446
+ }
447
+
448
+ // Use grep (Linux/Mac) or findstr (Windows)
449
+ const isWindows = detectOS() === 'windows';
450
+ let cmd;
451
+ if (isWindows) {
452
+ cmd = `findstr /s /n /i "${pattern}" "${resolved}\\*"`;
453
+ } else {
454
+ cmd = `grep -rn "${pattern}" "${resolved}" --include="${filePattern || '*'}" 2>/dev/null | head -100`;
455
+ }
456
+
457
+ const { stdout, stderr } = await execAsync(cmd, {
458
+ timeout: COMMAND_TIMEOUT,
459
+ maxBuffer: 1024 * 1024 * 10,
460
+ encoding: 'utf8',
461
+ });
462
+
463
+ const output = (stdout || '').trim();
464
+ if (!output) {
465
+ return { success: true, output: `No matches found for "${pattern}" in ${resolved}`, error: null };
466
+ }
467
+
468
+ const lines = output.split('\n');
469
+ const matchCount = lines.length;
470
+ const truncated = lines.length >= 100 ? `\n... (showing first 100 of possible more matches)` : '';
471
+ return {
472
+ success: true,
473
+ output: `Found ${matchCount} match(es) for "${pattern}" in ${resolved}:\n\n${output}${truncated}`,
474
+ error: null,
475
+ matchCount,
476
+ };
477
+ } catch (e) {
478
+ // grep returns exit code 1 when no matches found
479
+ if (e.stderr?.includes('No match') || e.code === 1) {
480
+ return { success: true, output: `No matches found for "${pattern}" in ${resolvePath(dirPath)}`, error: null };
481
+ }
482
+ return { success: false, output: '', error: `Search failed: ${e.message}` };
483
+ }
484
+ };
485
+
486
+ /**
487
+ * Append content to an existing file.
488
+ * {"tool":"APPEND_FILE","path":"<path>","content":"<text to append>"}
489
+ */
490
+ export const appendFile = async (filePath, content) => {
491
+ try {
492
+ const resolved = resolvePath(filePath);
493
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
494
+ await fs.appendFile(resolved, content || '', 'utf-8');
495
+ const stats = await fs.stat(resolved);
496
+ return {
497
+ success: true,
498
+ output: `Appended ${(content || '').length} chars to: ${resolved} (total size: ${stats.size} bytes)`,
499
+ error: null,
500
+ path: resolved,
501
+ };
502
+ } catch (e) {
503
+ return { success: false, output: '', error: `Cannot append to file "${filePath}": ${e.message}`, path: resolvePath(filePath) };
504
+ }
505
+ };
506
+
507
+ /**
508
+ * Delete a file.
509
+ * {"tool":"DELETE_FILE","path":"<path>"}
510
+ */
511
+ export const deleteFile = async (filePath) => {
512
+ try {
513
+ const resolved = resolvePath(filePath);
514
+ const stat = await fs.stat(resolved);
515
+ if (stat.isDirectory()) {
516
+ await fs.rm(resolved, { recursive: true, force: true });
517
+ return { success: true, output: `Directory deleted: ${resolved}`, error: null, path: resolved };
518
+ }
519
+ await fs.unlink(resolved);
520
+ return { success: true, output: `File deleted: ${resolved}`, error: null, path: resolved };
521
+ } catch (e) {
522
+ return { success: false, output: '', error: `Cannot delete "${filePath}": ${e.message}`, path: resolvePath(filePath) };
523
+ }
524
+ };
525
+
526
+ /**
527
+ * Create a directory (with parents).
528
+ * {"tool":"CREATE_DIR","path":"<path>"}
529
+ */
530
+ export const createDir = async (dirPath) => {
531
+ try {
532
+ const resolved = resolvePath(dirPath);
533
+ await fs.mkdir(resolved, { recursive: true });
534
+ return { success: true, output: `Directory created: ${resolved}`, error: null, path: resolved };
535
+ } catch (e) {
536
+ return { success: false, output: '', error: `Cannot create directory "${dirPath}": ${e.message}`, path: resolvePath(dirPath) };
537
+ }
538
+ };
539
+
540
+ /**
541
+ * Display directory tree structure.
542
+ * {"tool":"TREE_VIEW","path":"<path>","maxDepth":"<number, default 3>","includeHidden":"<boolean, default false>"}
543
+ */
544
+ export const treeView = async (dirPath, maxDepth = 3, includeHidden = false) => {
545
+ try {
546
+ const resolved = resolvePath(dirPath || '.');
547
+ const depth = Math.min(parseInt(maxDepth) || 3, 5);
548
+ const showHidden = includeHidden === true || String(includeHidden).toLowerCase() === 'true';
549
+ const root = path.resolve(resolved);
550
+
551
+ const lines = [];
552
+ const MAX_TOTAL_LINES = 200;
553
+ const MAX_FILES_PER_DIR = 50;
554
+ const IGNORED_NAMES = new Set([
555
+ '.git', '.svn', '.hg', '.ds_store',
556
+ 'node_modules', '.next', 'dist', 'build', 'coverage', '__pycache__',
557
+ ]);
558
+
559
+ const shouldIncludeEntry = (name) => {
560
+ if (!showHidden && name.startsWith('.')) return false;
561
+ return !IGNORED_NAMES.has(name.toLowerCase());
562
+ };
563
+
564
+ async function walk(dir, currentDepth, prefix) {
565
+ if (currentDepth > depth) return;
566
+ if (lines.length >= MAX_TOTAL_LINES) return;
567
+
568
+ let entries;
569
+ try {
570
+ entries = await fs.readdir(dir, { withFileTypes: true });
571
+ } catch {
572
+ return;
573
+ }
574
+
575
+ // Hide technical/hidden entries by default (e.g. .git)
576
+ const filtered = entries.filter((e) => shouldIncludeEntry(e.name));
577
+
578
+ // Sort: dirs first, then files, alphabetically
579
+ filtered.sort((a, b) => {
580
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
581
+ return a.name.localeCompare(b.name);
582
+ });
583
+
584
+ const showAll = filtered.length <= MAX_FILES_PER_DIR;
585
+ const shown = showAll ? filtered : filtered.slice(0, MAX_FILES_PER_DIR);
586
+
587
+ for (let i = 0; i < shown.length; i++) {
588
+ if (lines.length >= MAX_TOTAL_LINES) return;
589
+ const entry = shown[i];
590
+ const isLast = i === shown.length - 1;
591
+ const connector = isLast ? '└── ' : '├── ';
592
+ const childPrefix = isLast ? ' ' : '│ ';
593
+
594
+ if (entry.isDirectory()) {
595
+ lines.push(`${prefix}${connector}${entry.name}/`);
596
+ await walk(path.join(dir, entry.name), currentDepth + 1, `${prefix}${childPrefix}`);
597
+ } else {
598
+ lines.push(`${prefix}${connector}${entry.name}`);
599
+ }
600
+ }
601
+
602
+ if (!showAll) {
603
+ lines.push(`${prefix}└── ... (${filtered.length - MAX_FILES_PER_DIR} more entries)`);
604
+ }
605
+ }
606
+
607
+ lines.push(root);
608
+ const stat = await fs.stat(root).catch(() => null);
609
+ if (!stat || !stat.isDirectory()) {
610
+ return { success: false, output: '', error: `Not a directory: "${root}"`, path: root };
611
+ }
612
+
613
+ await walk(root, 1, '');
614
+
615
+ const truncated = lines.length >= MAX_TOTAL_LINES;
616
+ let output = lines.join('\n');
617
+ if (truncated) output += '\n... (tree truncated at 200 lines)';
618
+ if (lines.length <= 1) output += '\n(Aucun fichier ou dossier trouvé)';
619
+
620
+ return {
621
+ success: true,
622
+ output: `Tree: ${root} (depth: ${depth})\n\n${output}`,
623
+ error: null,
624
+ path: root,
625
+ };
626
+ } catch (e) {
627
+ return { success: false, output: '', error: `Cannot display tree for "${dirPath}": ${e.message}`, path: resolvePath(dirPath) };
628
+ }
629
+ };
630
+
631
+ /**
632
+ * Run a script file (Python, Node.js, shell, etc.)
633
+ * {"tool":"RUN_SCRIPT","path":"<script path>","args":"<optional arguments>","interpreter":"<optional: python3, node, bash, etc.>"}
634
+ */
635
+ export const runScript = async (scriptPath, args = '', interpreter = null) => {
636
+ try {
637
+ const resolved = resolvePath(scriptPath);
638
+ const stat = await fs.stat(resolved);
639
+ if (!stat.isFile()) {
640
+ return { success: false, output: '', error: `"${resolved}" is not a file` };
641
+ }
642
+
643
+ let cmd;
644
+ const ext = path.extname(resolved).toLowerCase();
645
+
646
+ if (interpreter) {
647
+ cmd = `${interpreter} "${resolved}" ${args}`;
648
+ } else if (ext === '.py' || ext === '.pyw') {
649
+ cmd = detectOS() === 'windows' ? `python "${resolved}" ${args}` : `python3 "${resolved}" ${args}`;
650
+ } else if (ext === '.js' || ext === '.mjs') {
651
+ cmd = `node "${resolved}" ${args}`;
652
+ } else if (ext === '.sh' || ext === '.bash') {
653
+ cmd = detectOS() === 'windows' ? `bash "${resolved}" ${args}` : `bash "${resolved}" ${args}`;
654
+ } else if (ext === '.ps1') {
655
+ cmd = `powershell -ExecutionPolicy Bypass -File "${resolved}" ${args}`;
656
+ } else if (ext === '.rb') {
657
+ cmd = `ruby "${resolved}" ${args}`;
658
+ } else if (ext === '.pl') {
659
+ cmd = `perl "${resolved}" ${args}`;
660
+ } else {
661
+ // Try to make executable and run
662
+ const isWindows = detectOS() === 'windows';
663
+ cmd = isWindows ? `"${resolved}" ${args}` : `chmod +x "${resolved}" && "${resolved}" ${args}`;
664
+ }
665
+
666
+ const result = await executeLocal(cmd);
667
+ const body = result.success
668
+ ? (result.output || '(no output)')
669
+ : (result.output || result.error || '');
670
+
671
+ return {
672
+ success: result.success,
673
+ output: `Script: ${resolved}\nCommand: ${cmd}\n\n${body}`,
674
+ error: result.success ? null : result.error,
675
+ path: resolved,
676
+ };
677
+ } catch (e) {
678
+ const isTimeout = e.killed || e.message?.includes('timed out');
679
+ return {
680
+ success: false,
681
+ output: e.stdout || '',
682
+ error: isTimeout ? `Script timed out after ${COMMAND_TIMEOUT / 1000}s` : (e.stderr || e.message),
683
+ path: resolvePath(scriptPath),
684
+ };
685
+ }
686
+ };
687
+
688
+ /**
689
+ * Move or rename a file/directory.
690
+ * {"tool":"MOVE_FILE","source":"<source path>","destination":"<dest path>"}
691
+ */
692
+ export const moveFile = async (source, destination) => {
693
+ try {
694
+ const src = resolvePath(source);
695
+ const dest = resolvePath(destination);
696
+ await fs.mkdir(path.dirname(dest), { recursive: true });
697
+ await fs.rename(src, dest);
698
+ return { success: true, output: `Moved: ${src} → ${dest}`, error: null };
699
+ } catch (e) {
700
+ return { success: false, output: '', error: `Cannot move "${source}" to "${destination}": ${e.message}` };
701
+ }
702
+ };
703
+
704
+ /**
705
+ * Copy a file.
706
+ * {"tool":"COPY_FILE","source":"<source path>","destination":"<dest path>"}
707
+ */
708
+ export const copyFile = async (source, destination) => {
709
+ try {
710
+ const src = resolvePath(source);
711
+ const dest = resolvePath(destination);
712
+ await fs.mkdir(path.dirname(dest), { recursive: true });
713
+ await fs.copyFile(src, dest);
714
+ return { success: true, output: `Copied: ${src} → ${dest}`, error: null };
715
+ } catch (e) {
716
+ return { success: false, output: '', error: `Cannot copy "${source}" to "${destination}": ${e.message}` };
717
+ }
718
+ };
719
+
720
+ /**
721
+ * Get detailed file information (size, type, permissions, dates).
722
+ * {"tool":"FILE_INFO","path":"<path>"}
723
+ */
724
+ export const getFileInfo = async (filePath) => {
725
+ try {
726
+ const resolved = resolvePath(filePath);
727
+ const stat = await fs.stat(resolved);
728
+ const isDir = stat.isDirectory();
729
+ const info = [
730
+ `Path: ${resolved}`,
731
+ `Type: ${isDir ? 'Directory' : 'File'}`,
732
+ `Size: ${stat.size} bytes (${(stat.size / 1024).toFixed(1)} KB)`,
733
+ `Created: ${stat.birthtime?.toISOString() || 'N/A'}`,
734
+ `Modified: ${stat.mtime.toISOString()}`,
735
+ `Accessed: ${stat.atime.toISOString()}`,
736
+ `Permissions: ${stat.mode?.toString(8).padStart(6, '0') || 'N/A'}`,
737
+ ];
738
+ if (!isDir) {
739
+ const ext = path.extname(resolved);
740
+ info.splice(1, 0, `Extension: ${ext || 'none'}`);
741
+ }
742
+ return { success: true, output: info.join('\n'), error: null, path: resolved };
743
+ } catch (e) {
744
+ return { success: false, output: '', error: `Cannot get info for "${filePath}": ${e.message}`, path: resolvePath(filePath) };
745
+ }
746
+ };
747
+
748
+ // ─── WEB TOOLS ─────────────────────────────────────────────────────────────
749
+
750
+ /**
751
+ * Fetch content from a URL (for documentation, API data, etc.)
752
+ * {"tool":"FETCH_URL","url":"<url>","description":"<why>"}
753
+ */
754
+ export const fetchUrl = async (url, description = '') => {
755
+ try {
756
+ if (!url || !url.startsWith('http')) {
757
+ return { success: false, output: '', error: 'Invalid URL. Must start with http:// or https://' };
758
+ }
759
+
760
+ const controller = new AbortController();
761
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
762
+
763
+ const response = await fetch(url, {
764
+ signal: controller.signal,
765
+ headers: {
766
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
767
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,application/json;q=0.8,*/*;q=0.7',
768
+ 'Accept-Language': 'en-US,en;q=0.9',
769
+ },
770
+ });
771
+ clearTimeout(timeoutId);
772
+
773
+ const contentType = response.headers.get('content-type') || '';
774
+ let body = await response.text();
775
+
776
+ // Truncate large responses
777
+ const maxLen = 30000;
778
+ if (body.length > maxLen) {
779
+ body = body.slice(0, maxLen) + `\n\n... (truncated, ${(body.length / 1024).toFixed(0)}KB total, showing first ${(maxLen / 1024).toFixed(0)}KB)`;
780
+ }
781
+
782
+ // Try to strip HTML tags for cleaner output
783
+ if (contentType.includes('text/html')) {
784
+ body = body
785
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
786
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
787
+ .replace(/<[^>]+>/g, ' ')
788
+ .replace(/&amp;/g, '&')
789
+ .replace(/&lt;/g, '<')
790
+ .replace(/&gt;/g, '>')
791
+ .replace(/&quot;/g, '"')
792
+ .replace(/&#39;/g, "'")
793
+ .replace(/\s{2,}/g, ' ')
794
+ .trim();
795
+ }
796
+
797
+ return {
798
+ success: response.ok,
799
+ output: `URL: ${url}\nStatus: ${response.status} ${response.statusText}\nContent-Type: ${contentType}\nSize: ${(body.length / 1024).toFixed(1)}KB\n\n${body}`,
800
+ error: response.ok ? null : `HTTP ${response.status}: ${response.statusText}`,
801
+ };
802
+ } catch (e) {
803
+ if (e.name === 'AbortError') {
804
+ return { success: false, output: '', error: `Fetch timed out after ${FETCH_TIMEOUT / 1000}s` };
805
+ }
806
+ return { success: false, output: '', error: `Fetch failed: ${e.message}` };
807
+ }
808
+ };
809
+
810
+ /**
811
+ * Web search using a search query — works cross-platform without API keys.
812
+ * Uses a fallback chain: server search → DuckDuckGo Scrape → SerpAPI → Tavily → DDG HTTP
813
+ * SerpAPI and Tavily support multi-key rotation (comma-separated keys).
814
+ * {"tool":"WEB_SEARCH","query":"<search query>","description":"<why>"}
815
+ */
816
+ export const webSearch = async (query, server = null, token = null) => {
817
+ if (!query || query.trim().length < 2) {
818
+ return { success: false, output: '', error: 'Search query too short (minimum 2 characters)' };
819
+ }
820
+
821
+ const q = query.trim();
822
+ let results = null;
823
+
824
+ // 1. Server-side search
825
+ if (server && token) {
826
+ try {
827
+ const response = await fetch(`${server}/search`, {
828
+ method: 'POST',
829
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
830
+ body: JSON.stringify({ query: q, limit: 5 }),
831
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
832
+ });
833
+ if (response.ok) {
834
+ const data = await response.json();
835
+ if (data.results && data.results.length > 0) {
836
+ results = data.results.map(r => ({ title: r.title || 'Untitled', url: r.url || '', snippet: r.snippet || r.description || '' }));
837
+ }
838
+ }
839
+ } catch {}
840
+ }
841
+
842
+ // 2. DuckDuckGo Scrape (primaire, gratuit)
843
+ if (!results) {
844
+ try { results = await searchDDG(q, 5); } catch {}
845
+ }
846
+
847
+ // 3. SerpAPI (fallback, rotation multi-clés)
848
+ if (!results) {
849
+ try { results = await searchSerpAPI(q, 5); } catch {}
850
+ }
851
+
852
+ // 4. Tavily (fallback, rotation multi-clés)
853
+ if (!results) {
854
+ try { results = await searchTavily(q, 5); } catch {}
855
+ }
856
+
857
+ // 5. Dernier recours: scraping HTTP DDG
858
+ if (!results) {
859
+ try { results = await searchDDGHttp(q, 5); } catch {}
860
+ }
861
+
862
+ if (results && results.length > 0) {
863
+ return formatSearchOutput(q, results);
864
+ }
865
+
866
+ return {
867
+ success: false,
868
+ output: '',
869
+ error: `No web search results found for "${q}". Try rephrasing your query or use FETCH_URL with a specific URL.`,
870
+ };
871
+ };
872
+
873
+ // ─── TODO TOOLS ────────────────────────────────────────────────────────────
874
+
875
+ export const todoAdd = (text, priority = 'MEDIUM', mode = null) => {
876
+ if (!text || !text.trim()) return { success: false, output: '', error: 'Todo text is required' };
877
+ const validPriorities = ['HIGH', 'MEDIUM', 'LOW'];
878
+ const prio = validPriorities.includes(priority.toUpperCase()) ? priority.toUpperCase() : 'MEDIUM';
879
+ const result = todoStore.add(text.trim(), prio, mode);
880
+ return { success: true, output: `Todo added: #${result.id} [${prio}] ${result.text}`, error: null, todo: result };
881
+ };
882
+
883
+ export const todoComplete = (id) => {
884
+ const numId = parseInt(id);
885
+ if (isNaN(numId)) return { success: false, output: '', error: 'Todo ID must be a number' };
886
+ const result = todoStore.complete(numId);
887
+ if (!result.success) return { success: false, output: '', error: result.error };
888
+ return { success: true, output: `Todo completed: #${numId} - ${result.todo.text}`, error: null, todo: result.todo };
889
+ };
890
+
891
+ export const todoUpdate = (id, updates) => {
892
+ const numId = parseInt(id);
893
+ if (isNaN(numId)) return { success: false, output: '', error: 'Todo ID must be a number' };
894
+ const result = todoStore.update(numId, updates);
895
+ if (!result.success) return { success: false, output: '', error: result.error };
896
+ return { success: true, output: `Todo updated: #${numId}`, error: null, todo: result.todo };
897
+ };
898
+
899
+ export const todoList = () => {
900
+ const todos = todoStore.list();
901
+ if (todos.length === 0) {
902
+ return { success: true, output: 'No todos yet. Use TODO_ADD to create tasks.', error: null, todos: [] };
903
+ }
904
+ const stats = todoStore.getStats();
905
+ const lines = [
906
+ `Todos: ${stats.total} total, ${stats.pending} pending, ${stats.done} completed`,
907
+ '',
908
+ ...todos.map(t => {
909
+ const status = t.status === 'done' ? '[x]' : '[ ]';
910
+ const prio = t.status === 'done' ? '' : ` (${t.priority})`;
911
+ return ` ${status} #${t.id}${prio} ${t.text}`;
912
+ }),
913
+ ];
914
+ return { success: true, output: lines.join('\n'), error: null, todos, stats };
915
+ };
916
+
917
+ export const todoClear = () => {
918
+ const result = todoStore.clear();
919
+ return { success: true, output: result.message, error: null };
920
+ };
921
+
922
+ /**
923
+ * Validate a local command for dangerous patterns
924
+ */
925
+ export const validateLocalCommand = (command) => {
926
+ const dangerousPatterns = [
927
+ /rm\s+-rf\s+\//, /dd\s+if=/, />\s*\/dev\/sda/, /mkfs\./,
928
+ /format\s+c:/i, /:\(\)\{\s*:\|:&\s*};/, /curl\s+.*\|\s*(ba)?sh/,
929
+ /\bnc\s+-[elp]/, /\/dev\/tcp\//, /python\s+-c\s+.*import\s+socket/,
930
+ /powershell.*-enc/i, /certutil.*-urlcache/i,
931
+ ];
932
+ for (const pattern of dangerousPatterns) {
933
+ if (pattern.test(command)) {
934
+ return { safe: false, reason: 'Command contains dangerous pattern' };
935
+ }
936
+ }
937
+ return { safe: true };
938
+ };
939
+
940
+ // =============================================================================
941
+ // NEW TOOLS — GLOB, GREP, GIT, READ_FILE_RANGE, ASK_USER, DIAG_POST_EDIT
942
+ // =============================================================================
943
+
944
+ import fastGlob from 'fast-glob';
945
+ import { execFile } from 'child_process';
946
+
947
+ const execFileAsync = promisify(execFile);
948
+
949
+ // ─── GLOB — fichiers par pattern ─────────────────────────────────────────────
950
+ // {"tool":"GLOB","pattern":"src/**/*.ts","cwd":"<path>","ignore":["node_modules"]}
951
+ export const globFiles = async (pattern, cwd = process.cwd(), ignore = []) => {
952
+ try {
953
+ const resolved = resolvePath(cwd);
954
+ const defaultIgnore = ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/.next/**', '**/__pycache__/**'];
955
+ const results = await fastGlob(pattern, {
956
+ cwd: resolved,
957
+ ignore: [...defaultIgnore, ...ignore],
958
+ dot: false,
959
+ absolute: false,
960
+ followSymbolicLinks: false,
961
+ });
962
+ return {
963
+ success: true,
964
+ output: results.length > 0
965
+ ? `Found ${results.length} file(s):\n${results.join('\n')}`
966
+ : `No files matched pattern: ${pattern}`,
967
+ files: results,
968
+ count: results.length,
969
+ };
970
+ } catch (e) {
971
+ return { success: false, output: '', error: e.message };
972
+ }
973
+ };
974
+
975
+ // ─── GREP — recherche ripgrep/grep natif ──────────────────────────────────────
976
+ // {"tool":"GREP","pattern":"useState","path":"./src","filePattern":"*.tsx","caseSensitive":false}
977
+ export const grepFiles = async (pattern, searchPath = '.', filePattern = null, caseSensitive = false) => {
978
+ try {
979
+ // Reject URLs — GREP is for local files only
980
+ if (typeof searchPath === 'string' && /^https?:\/\//i.test(searchPath.trim())) {
981
+ return {
982
+ success: false,
983
+ output: '',
984
+ error: `GREP cannot search URLs. Use FETCH_URL to fetch the page content or WEB_SEARCH to find information online. Path was: ${searchPath}`
985
+ };
986
+ }
987
+ const resolved = resolvePath(searchPath);
988
+ const isWindows = process.platform === 'win32';
989
+
990
+ // Préférer ripgrep si disponible, sinon fallback grep
991
+ let cmd, args;
992
+ try {
993
+ await execFileAsync('rg', ['--version'], { timeout: 2000 });
994
+ // ripgrep disponible
995
+ args = ['--line-number', '--color=never', '--max-count=5'];
996
+ if (!caseSensitive) args.push('--ignore-case');
997
+ if (filePattern) args.push('--glob', filePattern);
998
+ args.push('--', pattern, resolved);
999
+ cmd = 'rg';
1000
+ } catch {
1001
+ // fallback grep
1002
+ if (isWindows) {
1003
+ args = ['/S', '/N', pattern, filePattern ? `${resolved}\\${filePattern}` : `${resolved}\\*`];
1004
+ cmd = 'findstr';
1005
+ } else {
1006
+ args = ['-rn', '--include', filePattern || '*', caseSensitive ? '' : '-i', '--', pattern, resolved].filter(Boolean);
1007
+ cmd = 'grep';
1008
+ }
1009
+ }
1010
+
1011
+ const { stdout, stderr } = await execFileAsync(cmd, args, {
1012
+ timeout: 15000,
1013
+ maxBuffer: 1024 * 1024 * 2,
1014
+ }).catch(e => ({ stdout: e.stdout || '', stderr: e.stderr || '' }));
1015
+
1016
+ const lines = (stdout || '').trim().split('\n').filter(Boolean);
1017
+ const MAX = 100;
1018
+ const truncated = lines.length > MAX;
1019
+ const displayed = truncated ? lines.slice(0, MAX) : lines;
1020
+
1021
+ return {
1022
+ success: true,
1023
+ output: lines.length === 0
1024
+ ? `No matches for "${pattern}"${filePattern ? ` in ${filePattern}` : ''}`
1025
+ : `${lines.length} match(es)${truncated ? ` (showing first ${MAX})` : ''}:\n${displayed.join('\n')}`,
1026
+ matches: lines.length,
1027
+ };
1028
+ } catch (e) {
1029
+ return { success: false, output: '', error: e.message };
1030
+ }
1031
+ };
1032
+
1033
+ // ─── GIT — opérations git intégrées ──────────────────────────────────────────
1034
+ // {"tool":"GIT","op":"status|diff|log|add|commit|branch|checkout|push|pull|stash","args":"<optional>","path":"<repo root>"}
1035
+ const SAFE_GIT_OPS = ['status', 'diff', 'log', 'branch', 'show', 'stash list', 'remote -v'];
1036
+ const WRITE_GIT_OPS = ['add', 'commit', 'checkout', 'stash', 'merge', 'rebase', 'pull', 'push', 'reset'];
1037
+
1038
+ export const gitOp = async (op, args = '', repoPath = process.cwd()) => {
1039
+ try {
1040
+ const resolved = resolvePath(repoPath);
1041
+
1042
+ // Sécurité : bloquer les ops destructives sans confirmation (géré par safety/check.js)
1043
+ const opBase = op.split(' ')[0].toLowerCase();
1044
+ const isDangerous = ['reset --hard', 'clean -fd', 'push --force'].some(d => `${op} ${args}`.toLowerCase().includes(d));
1045
+ if (isDangerous) {
1046
+ return { success: false, output: '', error: `Dangerous git op refused: "${op} ${args}". Use LOCAL_CMD with DANGEROUS type if intentional.` };
1047
+ }
1048
+
1049
+ const fullArgs = [op, ...args.split(' ').filter(Boolean)];
1050
+ const { stdout, stderr } = await execFileAsync('git', fullArgs, {
1051
+ cwd: resolved,
1052
+ timeout: 30000,
1053
+ maxBuffer: 1024 * 1024 * 5,
1054
+ }).catch(e => ({ stdout: e.stdout || '', stderr: e.stderr || e.message || '' }));
1055
+
1056
+ const output = (stdout || '').trim() || (stderr || '').trim();
1057
+ return {
1058
+ success: true,
1059
+ output: output || `git ${op}: OK (no output)`,
1060
+ op,
1061
+ args,
1062
+ };
1063
+ } catch (e) {
1064
+ return { success: false, output: '', error: e.message };
1065
+ }
1066
+ };
1067
+
1068
+ // ─── READ_FILE_RANGE — lecture partielle ──────────────────────────────────────
1069
+ // {"tool":"READ_FILE","path":"<path>","startLine":120,"endLine":180}
1070
+ export const readFileRange = async (filePath, startLine = null, endLine = null) => {
1071
+ try {
1072
+ const resolved = resolvePath(filePath);
1073
+ const content = await fs.readFile(resolved, 'utf-8');
1074
+ const lines = content.split('\n');
1075
+ const total = lines.length;
1076
+
1077
+ if (startLine === null && endLine === null) {
1078
+ // No range: return full file with line numbers.
1079
+ return {
1080
+ success: true,
1081
+ output: lines.map((l, i) => `${i + 1}: ${l}`).join('\n'),
1082
+ totalLines: total,
1083
+ };
1084
+ }
1085
+
1086
+ const s = Math.max(0, (startLine || 1) - 1);
1087
+ const e = Math.min(total, (endLine || total));
1088
+ const slice = lines.slice(s, e);
1089
+ return {
1090
+ success: true,
1091
+ output: `[Lines ${s + 1}–${e} of ${total}]\n\n` + slice.map((l, i) => `${s + i + 1}: ${l}`).join('\n'),
1092
+ totalLines: total,
1093
+ startLine: s + 1,
1094
+ endLine: e,
1095
+ };
1096
+ } catch (e) {
1097
+ return { success: false, output: '', error: e.message };
1098
+ }
1099
+ };
1100
+
1101
+ // ─── GET_DEPENDENCIES — export/import summary for local dependencies ─────────
1102
+ // {"tool":"GET_DEPENDENCIES","path":"<file path>"}
1103
+ // Returns a compact summary of what each local imported file exports,
1104
+ // WITHOUT reading the full file contents. The agent uses this to decide
1105
+ // whether it needs a full READ_FILE on a dependency.
1106
+ export const getDependencies = (filePath) => {
1107
+ try {
1108
+ const resolved = resolvePath(filePath);
1109
+ if (!resolved) {
1110
+ return { success: false, output: '', error: `Cannot resolve path: "${filePath}"` };
1111
+ }
1112
+ const content = fsSync.readFileSync(resolved, 'utf-8');
1113
+ const localImports = extractLocalImports(content);
1114
+ if (localImports.length === 0) {
1115
+ return { success: true, output: `[Dependencies for ${path.basename(resolved)}]\n (no local imports found)`, dependencies: [] };
1116
+ }
1117
+
1118
+ const baseDir = path.dirname(resolved);
1119
+ const depExts = ['.js', '.mjs', '.jsx', '.ts', '.tsx', '.json', ''];
1120
+ const results = [];
1121
+
1122
+ for (const spec of localImports) {
1123
+ const depBase = path.resolve(baseDir, spec);
1124
+ let depPath = null;
1125
+ for (const ext of depExts) {
1126
+ const attempt = depBase + ext;
1127
+ if (fsSync.existsSync(attempt)) {
1128
+ depPath = attempt;
1129
+ break;
1130
+ }
1131
+ }
1132
+ if (!depPath) {
1133
+ results.push({ name: path.basename(spec), path: spec, lines: 0, exports: [], error: 'not found' });
1134
+ continue;
1135
+ }
1136
+ try {
1137
+ const depContent = fsSync.readFileSync(depPath, 'utf-8');
1138
+ const depLines = depContent.split('\n').length;
1139
+ const depExports = extractExports(depContent);
1140
+ const depImports = extractLocalImports(depContent);
1141
+ results.push({
1142
+ name: path.basename(depPath),
1143
+ path: depPath,
1144
+ lines: depLines,
1145
+ exports: depExports,
1146
+ imports: depImports.map(p => path.basename(p)),
1147
+ });
1148
+ } catch {
1149
+ results.push({ name: path.basename(depPath || spec), path: depPath || spec, lines: 0, exports: [], error: 'read error' });
1150
+ }
1151
+ }
1152
+
1153
+ const lines = results.map(r => {
1154
+ let line = ` ${r.name} (${r.lines} lines)`;
1155
+ if (r.exports && r.exports.length > 0) {
1156
+ line += ` — exported: ${r.exports.join(', ')}`;
1157
+ }
1158
+ if (r.imports && r.imports.length > 0) {
1159
+ line += ` | imports: ${r.imports.join(', ')}`;
1160
+ }
1161
+ if (r.error) {
1162
+ line += ` [${r.error}]`;
1163
+ }
1164
+ return line;
1165
+ });
1166
+
1167
+ return {
1168
+ success: true,
1169
+ output: `[Dependencies for ${path.basename(resolved)}]\n${lines.join('\n')}`,
1170
+ dependencies: results,
1171
+ };
1172
+ } catch (e) {
1173
+ return { success: false, output: '', error: `GET_DEPENDENCIES failed: ${e.message}` };
1174
+ }
1175
+ };
1176
+
1177
+ // ─── ASK_USER — questions à choix multiple ───────────────────────────────────
1178
+ // Utilisé uniquement comme signal au loop — la réponse est capturée par repl-base
1179
+ // {"tool":"ASK_USER","question":"<question>","options":["A","B","C"]}
1180
+ export const askUserQuestion = (question, options = []) => {
1181
+ return {
1182
+ success: true,
1183
+ output: `[ASK_USER]\n${question}${options.length > 0 ? '\n\nOptions:\n' + options.map((o, i) => ` ${i + 1}. ${o}`).join('\n') : ''}`,
1184
+ isQuestion: true,
1185
+ question,
1186
+ options,
1187
+ };
1188
+ };
1189
+
1190
+ // ─── DIAG_POST_EDIT — lint/tsc après édition ──────────────────────────────────
1191
+ // {"tool":"DIAG_POST_EDIT","path":"<file or dir>","lang":"ts|js|py|go|rust|auto"}
1192
+ export const diagPostEdit = async (targetPath, lang = 'auto') => {
1193
+ try {
1194
+ const resolved = resolvePath(targetPath);
1195
+ const ext = path.extname(targetPath).toLowerCase();
1196
+
1197
+ // Détection automatique du langage
1198
+ const detected = lang !== 'auto' ? lang :
1199
+ ['.ts', '.tsx'].includes(ext) ? 'ts' :
1200
+ ['.js', '.jsx', '.mjs'].includes(ext) ? 'js' :
1201
+ ['.py'].includes(ext) ? 'py' :
1202
+ ['.go'].includes(ext) ? 'go' :
1203
+ ['.rs'].includes(ext) ? 'rust' : null;
1204
+
1205
+ if (!detected) {
1206
+ return { success: true, output: `[DIAG] No diagnostic tool configured for extension "${ext}". Skipping.` };
1207
+ }
1208
+
1209
+ const isWindows = process.platform === 'win32';
1210
+ const npxBin = isWindows ? 'npx.cmd' : 'npx';
1211
+
1212
+ const cmds = {
1213
+ ts: [npxBin, ['tsc', '--noEmit', '--pretty', 'false'], { timeout: 30000 }],
1214
+ js: [npxBin, ['eslint', '--max-warnings=0', resolved], { timeout: 20000 }],
1215
+ py: ['python3', ['-m', 'py_compile', resolved], { timeout: 10000 }],
1216
+ go: ['go', ['vet', './...'], { timeout: 20000 }],
1217
+ rust: ['cargo', ['check', '--message-format=short'], { timeout: 60000 }],
1218
+ };
1219
+
1220
+ const [cmd, args, opts] = cmds[detected];
1221
+ const cwd = resolved.endsWith(ext) ? path.dirname(resolved) : resolved;
1222
+
1223
+ try {
1224
+ const { stdout, stderr } = await execFileAsync(cmd, args, {
1225
+ ...opts,
1226
+ cwd,
1227
+ maxBuffer: 1024 * 1024,
1228
+ }).catch(e => ({ stdout: e.stdout || '', stderr: e.stderr || e.message }));
1229
+
1230
+ const output = [stdout, stderr].filter(Boolean).join('\n').trim();
1231
+
1232
+ if (!output || output.includes('Found 0 errors')) {
1233
+ return { success: true, output: `[DIAG:${detected.toUpperCase()}] ✓ No errors detected.` };
1234
+ }
1235
+
1236
+ const lines = output.split('\n').slice(0, 30);
1237
+ return {
1238
+ success: true,
1239
+ output: `[DIAG:${detected.toUpperCase()}] Issues found:\n${lines.join('\n')}${output.split('\n').length > 30 ? '\n... (truncated)' : ''}`,
1240
+ hasErrors: true,
1241
+ };
1242
+ } catch (err) {
1243
+ if (err.code === 'ENOENT') {
1244
+ return {
1245
+ success: true,
1246
+ output: `[DIAG:${detected.toUpperCase()}] Linter not found (${cmd} not in PATH). Install it to enable diagnostics. Skipping — this is NOT a sign the file is error-free.`,
1247
+ };
1248
+ }
1249
+ throw err;
1250
+ }
1251
+ } catch (e) {
1252
+ return { success: true, output: `[DIAG] Could not run diagnostics: ${e.message}` };
1253
+ }
1254
+ };