dev-mcp-server 0.0.2 → 1.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 (58) hide show
  1. package/.env.example +23 -55
  2. package/README.md +609 -219
  3. package/cli.js +486 -160
  4. package/package.json +2 -2
  5. package/src/agents/BaseAgent.js +113 -0
  6. package/src/agents/dreamer.js +165 -0
  7. package/src/agents/improver.js +175 -0
  8. package/src/agents/specialists.js +202 -0
  9. package/src/agents/taskDecomposer.js +176 -0
  10. package/src/agents/teamCoordinator.js +153 -0
  11. package/src/api/routes/agents.js +172 -0
  12. package/src/api/routes/extras.js +115 -0
  13. package/src/api/routes/git.js +72 -0
  14. package/src/api/routes/ingest.js +60 -40
  15. package/src/api/routes/knowledge.js +59 -41
  16. package/src/api/routes/memory.js +41 -0
  17. package/src/api/routes/newRoutes.js +168 -0
  18. package/src/api/routes/pipelines.js +41 -0
  19. package/src/api/routes/planner.js +54 -0
  20. package/src/api/routes/query.js +24 -0
  21. package/src/api/routes/sessions.js +54 -0
  22. package/src/api/routes/tasks.js +67 -0
  23. package/src/api/routes/tools.js +85 -0
  24. package/src/api/routes/v5routes.js +196 -0
  25. package/src/api/server.js +133 -5
  26. package/src/context/compactor.js +151 -0
  27. package/src/context/contextEngineer.js +181 -0
  28. package/src/context/contextVisualizer.js +140 -0
  29. package/src/core/conversationEngine.js +231 -0
  30. package/src/core/indexer.js +169 -143
  31. package/src/core/ingester.js +141 -126
  32. package/src/core/queryEngine.js +286 -236
  33. package/src/cron/cronScheduler.js +260 -0
  34. package/src/dashboard/index.html +1181 -0
  35. package/src/lsp/symbolNavigator.js +220 -0
  36. package/src/memory/memoryManager.js +186 -0
  37. package/src/memory/teamMemory.js +111 -0
  38. package/src/messaging/messageBus.js +177 -0
  39. package/src/monitor/proactiveMonitor.js +337 -0
  40. package/src/pipelines/pipelineEngine.js +230 -0
  41. package/src/planner/plannerEngine.js +202 -0
  42. package/src/plugins/builtin/stats-plugin.js +29 -0
  43. package/src/plugins/pluginManager.js +144 -0
  44. package/src/prompts/promptEngineer.js +289 -0
  45. package/src/sessions/sessionManager.js +166 -0
  46. package/src/skills/skillsManager.js +263 -0
  47. package/src/storage/store.js +127 -105
  48. package/src/tasks/taskManager.js +151 -0
  49. package/src/tools/BashTool.js +154 -0
  50. package/src/tools/FileEditTool.js +280 -0
  51. package/src/tools/GitTool.js +212 -0
  52. package/src/tools/GrepTool.js +199 -0
  53. package/src/tools/registry.js +1380 -0
  54. package/src/utils/costTracker.js +69 -0
  55. package/src/utils/fileParser.js +176 -153
  56. package/src/utils/llmClient.js +355 -206
  57. package/src/watcher/fileWatcher.js +137 -0
  58. package/src/worktrees/worktreeManager.js +176 -0
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Lightweight task tracker that integrates with the query engine.
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const logger = require('../utils/logger');
8
+
9
+ const TASKS_FILE = path.join(process.cwd(), 'data', 'tasks.json');
10
+
11
+ const STATUS = { TODO: 'todo', IN_PROGRESS: 'in_progress', DONE: 'done', BLOCKED: 'blocked' };
12
+ const PRIORITY = { LOW: 'low', MEDIUM: 'medium', HIGH: 'high', CRITICAL: 'critical' };
13
+
14
+ class TaskManager {
15
+ constructor() {
16
+ this._data = this._load();
17
+ }
18
+
19
+ _load() {
20
+ try {
21
+ if (fs.existsSync(TASKS_FILE)) return JSON.parse(fs.readFileSync(TASKS_FILE, 'utf-8'));
22
+ } catch { }
23
+ return { tasks: [], nextId: 1 };
24
+ }
25
+
26
+ _save() {
27
+ fs.writeFileSync(TASKS_FILE, JSON.stringify(this._data, null, 2));
28
+ }
29
+
30
+ /**
31
+ * Create a new task
32
+ */
33
+ create(options = {}) {
34
+ const {
35
+ title,
36
+ description = '',
37
+ priority = PRIORITY.MEDIUM,
38
+ tags = [],
39
+ linkedFiles = [],
40
+ linkedQuery = null,
41
+ assignee = null,
42
+ } = options;
43
+
44
+ if (!title) throw new Error('Task title is required');
45
+
46
+ const task = {
47
+ id: this._data.nextId++,
48
+ title: title.trim(),
49
+ description: description.trim(),
50
+ status: STATUS.TODO,
51
+ priority,
52
+ tags,
53
+ linkedFiles,
54
+ linkedQuery,
55
+ assignee,
56
+ createdAt: new Date().toISOString(),
57
+ updatedAt: new Date().toISOString(),
58
+ completedAt: null,
59
+ notes: [],
60
+ };
61
+
62
+ this._data.tasks.push(task);
63
+ this._save();
64
+ logger.info(`[Tasks] Created #${task.id}: ${task.title}`);
65
+ return task;
66
+ }
67
+
68
+ /**
69
+ * Update a task
70
+ */
71
+ update(id, updates = {}) {
72
+ const task = this._data.tasks.find(t => t.id === id);
73
+ if (!task) throw new Error(`Task #${id} not found`);
74
+
75
+ const allowed = ['title', 'description', 'status', 'priority', 'tags', 'linkedFiles', 'assignee'];
76
+ for (const key of allowed) {
77
+ if (updates[key] !== undefined) task[key] = updates[key];
78
+ }
79
+
80
+ task.updatedAt = new Date().toISOString();
81
+ if (updates.status === STATUS.DONE && !task.completedAt) {
82
+ task.completedAt = new Date().toISOString();
83
+ }
84
+
85
+ this._save();
86
+ return task;
87
+ }
88
+
89
+ /**
90
+ * Add a note to a task
91
+ */
92
+ addNote(id, note) {
93
+ const task = this._data.tasks.find(t => t.id === id);
94
+ if (!task) throw new Error(`Task #${id} not found`);
95
+ task.notes.push({ text: note, addedAt: new Date().toISOString() });
96
+ task.updatedAt = new Date().toISOString();
97
+ this._save();
98
+ return task;
99
+ }
100
+
101
+ /**
102
+ * Get tasks with optional filters
103
+ */
104
+ list(filters = {}) {
105
+ let tasks = [...this._data.tasks];
106
+ if (filters.status) tasks = tasks.filter(t => t.status === filters.status);
107
+ if (filters.priority) tasks = tasks.filter(t => t.priority === filters.priority);
108
+ if (filters.tags?.length) tasks = tasks.filter(t => filters.tags.some(tag => t.tags.includes(tag)));
109
+ if (filters.assignee) tasks = tasks.filter(t => t.assignee === filters.assignee);
110
+ if (!filters.includeDone) tasks = tasks.filter(t => t.status !== STATUS.DONE);
111
+
112
+ // Sort: critical > high > medium > low, then by date
113
+ const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
114
+ tasks.sort((a, b) => {
115
+ const pdiff = (priorityOrder[a.priority] || 2) - (priorityOrder[b.priority] || 2);
116
+ if (pdiff !== 0) return pdiff;
117
+ return new Date(b.createdAt) - new Date(a.createdAt);
118
+ });
119
+
120
+ return tasks;
121
+ }
122
+
123
+ get(id) {
124
+ return this._data.tasks.find(t => t.id === id) || null;
125
+ }
126
+
127
+ delete(id) {
128
+ const before = this._data.tasks.length;
129
+ this._data.tasks = this._data.tasks.filter(t => t.id !== id);
130
+ this._save();
131
+ return before !== this._data.tasks.length;
132
+ }
133
+
134
+ getStats() {
135
+ const tasks = this._data.tasks;
136
+ const byStatus = {};
137
+ const byPriority = {};
138
+ for (const t of tasks) {
139
+ byStatus[t.status] = (byStatus[t.status] || 0) + 1;
140
+ byPriority[t.priority] = (byPriority[t.priority] || 0) + 1;
141
+ }
142
+ return {
143
+ total: tasks.length,
144
+ byStatus,
145
+ byPriority,
146
+ overdue: tasks.filter(t => t.status !== STATUS.DONE && t.dueDate && new Date(t.dueDate) < new Date()).length,
147
+ };
148
+ }
149
+ }
150
+
151
+ module.exports = { TaskManager: new TaskManager(), STATUS, PRIORITY };
@@ -0,0 +1,154 @@
1
+ /**
2
+ * BashTool — executes shell commands with a permission model.
3
+ * Supports: allow-once, allow-session, deny, auto-approve safe commands.
4
+ */
5
+
6
+ const { execSync, exec } = require('child_process');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const logger = require('../utils/logger');
10
+
11
+ // Commands that are always safe to run without asking
12
+ const SAFE_COMMANDS = new Set([
13
+ 'ls', 'pwd', 'echo', 'cat', 'head', 'tail', 'wc', 'grep', 'find',
14
+ 'git status', 'git log', 'git diff', 'git branch', 'git show',
15
+ 'node --version', 'npm list', 'npm outdated', 'npx --version',
16
+ 'which', 'env', 'printenv', 'date', 'uname', 'whoami', 'hostname',
17
+ ]);
18
+
19
+ // Commands that are always dangerous — never auto-approve
20
+ const DANGEROUS_PATTERNS = [
21
+ /rm\s+-rf?\s+\//,
22
+ /sudo\s+rm/,
23
+ />\s*\/dev\/(sd|hd|nvme)/,
24
+ /mkfs\./,
25
+ /dd\s+if=/,
26
+ /chmod\s+-R\s+777\s+\//,
27
+ /curl.*(sh|bash)\s*\|.*sh/,
28
+ /wget.*(sh|bash)\s*\|.*sh/,
29
+ /:(){ :|:& };:/, // fork bomb
30
+ ];
31
+
32
+ const PERMISSION_FILE = path.join(process.cwd(), 'data', 'bash-permissions.json');
33
+
34
+ class BashTool {
35
+ constructor() {
36
+ this._sessionPermissions = new Map(); // command -> 'allow' | 'deny'
37
+ this._loadPersisted();
38
+ }
39
+
40
+ _loadPersisted() {
41
+ try {
42
+ if (fs.existsSync(PERMISSION_FILE)) {
43
+ const data = JSON.parse(fs.readFileSync(PERMISSION_FILE, 'utf-8'));
44
+ // Only load 'always-allow' entries (not session ones)
45
+ for (const [cmd, perm] of Object.entries(data.alwaysAllow || {})) {
46
+ this._sessionPermissions.set(cmd, 'allow');
47
+ }
48
+ }
49
+ } catch { }
50
+ }
51
+
52
+ _savePersisted() {
53
+ const alwaysAllow = {};
54
+ for (const [cmd, perm] of this._sessionPermissions.entries()) {
55
+ if (perm === 'allow-always') alwaysAllow[cmd] = true;
56
+ }
57
+ fs.writeFileSync(PERMISSION_FILE, JSON.stringify({ alwaysAllow }, null, 2));
58
+ }
59
+
60
+ /**
61
+ * Check permission level for a command.
62
+ * Returns: 'auto-safe' | 'session-allowed' | 'needs-approval' | 'dangerous'
63
+ */
64
+ checkPermission(command) {
65
+ const cmd = command.trim();
66
+
67
+ // Check dangerous patterns first
68
+ for (const pattern of DANGEROUS_PATTERNS) {
69
+ if (pattern.test(cmd)) return 'dangerous';
70
+ }
71
+
72
+ // Check if first token is a safe command
73
+ const firstToken = cmd.split(/\s+/)[0];
74
+ if (SAFE_COMMANDS.has(firstToken) || SAFE_COMMANDS.has(cmd.slice(0, 20))) {
75
+ return 'auto-safe';
76
+ }
77
+
78
+ // Check session/persisted permissions
79
+ if (this._sessionPermissions.has(cmd)) {
80
+ return 'session-allowed';
81
+ }
82
+
83
+ return 'needs-approval';
84
+ }
85
+
86
+ /**
87
+ * Grant permission for a command (session or always)
88
+ */
89
+ grantPermission(command, level = 'session') {
90
+ if (level === 'always') {
91
+ this._sessionPermissions.set(command.trim(), 'allow-always');
92
+ this._savePersisted();
93
+ } else {
94
+ this._sessionPermissions.set(command.trim(), 'allow');
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Execute a command and return { stdout, stderr, exitCode, durationMs }
100
+ */
101
+ async execute(command, options = {}) {
102
+ const { cwd = process.cwd(), timeout = 30000, env = {} } = options;
103
+
104
+ const permission = this.checkPermission(command);
105
+
106
+ if (permission === 'dangerous') {
107
+ throw new Error(`⛔ Dangerous command blocked: ${command}`);
108
+ }
109
+
110
+ if (permission === 'needs-approval' && !options.approved) {
111
+ return {
112
+ needsApproval: true,
113
+ command,
114
+ permission,
115
+ message: `Command requires approval: ${command}`,
116
+ };
117
+ }
118
+
119
+ const start = Date.now();
120
+ logger.info(`[BashTool] exec: ${command.slice(0, 100)}`);
121
+
122
+ return new Promise((resolve) => {
123
+ exec(command, {
124
+ cwd,
125
+ timeout,
126
+ env: { ...process.env, ...env },
127
+ maxBuffer: 10 * 1024 * 1024, // 10MB
128
+ }, (error, stdout, stderr) => {
129
+ const durationMs = Date.now() - start;
130
+ resolve({
131
+ stdout: stdout || '',
132
+ stderr: stderr || '',
133
+ exitCode: error ? (error.code || 1) : 0,
134
+ durationMs,
135
+ command,
136
+ timedOut: error?.killed || false,
137
+ });
138
+ });
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Execute and throw if non-zero exit
144
+ */
145
+ async executeOrThrow(command, options = {}) {
146
+ const result = await this.execute(command, { ...options, approved: true });
147
+ if (result.exitCode !== 0) {
148
+ throw new Error(`Command failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
149
+ }
150
+ return result;
151
+ }
152
+ }
153
+
154
+ module.exports = new BashTool();
@@ -0,0 +1,280 @@
1
+ /**
2
+ * The most powerful tool in the system: applies AI-suggested edits to actual files.
3
+ *
4
+ * Safety model:
5
+ * 1. Always creates a .bak before editing
6
+ * 2. Validates the edit would produce a syntactically valid change
7
+ * 3. Supports three edit modes: str_replace, insert_after, full_rewrite
8
+ * 4. Dry-run mode shows the diff without writing
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { execSync } = require('child_process');
14
+ const llm = require('../utils/llmClient');
15
+ const logger = require('../utils/logger');
16
+ const costTracker = require('../utils/costTracker');
17
+
18
+ // Max file size we'll edit (safety check)
19
+ const MAX_EDIT_SIZE = 200 * 1024; // 200KB
20
+
21
+ class FileEditTool {
22
+ /**
23
+ * Apply a string replacement edit to a file.
24
+ *
25
+ * @param {string} filePath - Absolute or relative path
26
+ * @param {string} oldStr - Exact string to find (must be unique in the file)
27
+ * @param {string} newStr - Replacement string
28
+ * @param {object} opts - { dryRun, backup }
29
+ */
30
+ async strReplace(filePath, oldStr, newStr, opts = {}) {
31
+ const { dryRun = false, backup = true } = opts;
32
+ const abs = path.resolve(filePath);
33
+
34
+ this._assertSafe(abs);
35
+ const original = fs.readFileSync(abs, 'utf-8');
36
+
37
+ const occurrences = this._countOccurrences(original, oldStr);
38
+ if (occurrences === 0) {
39
+ throw new Error(`str_replace: oldStr not found in ${path.basename(abs)}`);
40
+ }
41
+ if (occurrences > 1) {
42
+ throw new Error(`str_replace: oldStr found ${occurrences} times — must be unique. Add more context around it.`);
43
+ }
44
+
45
+ const edited = original.replace(oldStr, newStr);
46
+ const diff = this._diffSummary(original, edited, abs);
47
+
48
+ if (dryRun) {
49
+ return { dryRun: true, diff, filePath: abs, wouldChange: original !== edited };
50
+ }
51
+
52
+ if (backup) this._backup(abs, original);
53
+ fs.writeFileSync(abs, edited, 'utf-8');
54
+
55
+ logger.info(`[FileEdit] str_replace in ${path.basename(abs)} (+${this._lineCount(newStr)} -${this._lineCount(oldStr)} lines)`);
56
+ return { success: true, filePath: abs, diff, linesAdded: this._lineCount(newStr), linesRemoved: this._lineCount(oldStr), backedUp: backup };
57
+ }
58
+
59
+ /**
60
+ * Insert text after a specific line number or after a matching string.
61
+ */
62
+ async insertAfter(filePath, afterStr, insertText, opts = {}) {
63
+ const { dryRun = false, backup = true } = opts;
64
+ const abs = path.resolve(filePath);
65
+
66
+ this._assertSafe(abs);
67
+ const original = fs.readFileSync(abs, 'utf-8');
68
+
69
+ if (!original.includes(afterStr)) {
70
+ throw new Error(`insert_after: anchor string not found in ${path.basename(abs)}`);
71
+ }
72
+
73
+ const edited = original.replace(afterStr, afterStr + '\n' + insertText);
74
+ const diff = this._diffSummary(original, edited, abs);
75
+
76
+ if (dryRun) return { dryRun: true, diff, filePath: abs };
77
+
78
+ if (backup) this._backup(abs, original);
79
+ fs.writeFileSync(abs, edited, 'utf-8');
80
+
81
+ logger.info(`[FileEdit] insert_after in ${path.basename(abs)}`);
82
+ return { success: true, filePath: abs, diff, backedUp: backup };
83
+ }
84
+
85
+ /**
86
+ * Full file rewrite. Use sparingly — prefers str_replace for smaller edits.
87
+ */
88
+ async rewrite(filePath, newContent, opts = {}) {
89
+ const { dryRun = false, backup = true } = opts;
90
+ const abs = path.resolve(filePath);
91
+
92
+ this._assertSafe(abs);
93
+
94
+ let original = '';
95
+ if (fs.existsSync(abs)) {
96
+ original = fs.readFileSync(abs, 'utf-8');
97
+ }
98
+
99
+ const diff = this._diffSummary(original, newContent, abs);
100
+
101
+ if (dryRun) return { dryRun: true, diff, filePath: abs };
102
+
103
+ if (backup && original) this._backup(abs, original);
104
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
105
+ fs.writeFileSync(abs, newContent, 'utf-8');
106
+
107
+ logger.info(`[FileWrite] wrote ${path.basename(abs)} (${newContent.length} chars)`);
108
+ return { success: true, filePath: abs, diff, isNew: !original, backedUp: backup && !!original };
109
+ }
110
+
111
+ /**
112
+ * AI-powered edit: describe what to change, LLM generates the edit.
113
+ *
114
+ * This is the killer feature — "add error handling to getUserById"
115
+ * and it will find the function, understand it, and apply the change.
116
+ */
117
+ async aiEdit(filePath, instruction, opts = {}) {
118
+ const { dryRun = false, sessionId = 'default' } = opts;
119
+ const abs = path.resolve(filePath);
120
+
121
+ this._assertSafe(abs);
122
+ const content = fs.readFileSync(abs, 'utf-8');
123
+ const filename = path.basename(abs);
124
+
125
+ logger.info(`[FileEdit] AI edit: "${instruction.slice(0, 60)}" on ${filename}`);
126
+
127
+ const response = await llm.chat({
128
+ model: llm.model('smart'),
129
+ max_tokens: 2000,
130
+ system: `You are a precise code editor. Apply the instruction to the file and return ONLY the edited file content.
131
+ Rules:
132
+ - Make the MINIMAL change that satisfies the instruction
133
+ - Preserve all existing code, formatting, and style
134
+ - Do NOT add explanations or markdown code fences
135
+ - Return the COMPLETE file content (not just the changed part)
136
+ - If you cannot safely make the change, return: ERROR: <reason>`,
137
+ messages: [{
138
+ role: 'user',
139
+ content: `File: ${filename}\nInstruction: ${instruction}\n\nCurrent content:\n${content}`,
140
+ }],
141
+ });
142
+
143
+ costTracker.record({
144
+ model: llm.model('smart'),
145
+ inputTokens: response.usage.input_tokens,
146
+ outputTokens: response.usage.output_tokens,
147
+ sessionId,
148
+ queryType: 'file-edit',
149
+ });
150
+
151
+ const newContent = response.content[0].text;
152
+
153
+ if (newContent.startsWith('ERROR:')) {
154
+ throw new Error(newContent);
155
+ }
156
+
157
+ const diff = this._diffSummary(content, newContent, abs);
158
+
159
+ if (dryRun) {
160
+ return { dryRun: true, diff, filePath: abs, instruction };
161
+ }
162
+
163
+ return this.rewrite(abs, newContent, { backup: true });
164
+ }
165
+
166
+ /**
167
+ * Undo the last edit by restoring from backup
168
+ */
169
+ undo(filePath) {
170
+ const abs = path.resolve(filePath);
171
+ const backupPath = abs + '.bak';
172
+
173
+ if (!fs.existsSync(backupPath)) {
174
+ throw new Error(`No backup found for ${path.basename(abs)}`);
175
+ }
176
+
177
+ const backup = fs.readFileSync(backupPath, 'utf-8');
178
+ const current = fs.readFileSync(abs, 'utf-8');
179
+
180
+ fs.writeFileSync(abs, backup, 'utf-8');
181
+ fs.unlinkSync(backupPath);
182
+
183
+ logger.info(`[FileEdit] Undone: ${path.basename(abs)}`);
184
+ return { success: true, filePath: abs, restoredLength: backup.length };
185
+ }
186
+
187
+ /**
188
+ * Read a file (FileReadTool equivalent)
189
+ */
190
+ read(filePath, opts = {}) {
191
+ const { startLine, endLine, maxChars = 50000 } = opts;
192
+ const abs = path.resolve(filePath);
193
+
194
+ if (!fs.existsSync(abs)) throw new Error(`File not found: ${abs}`);
195
+
196
+ let content = fs.readFileSync(abs, 'utf-8');
197
+
198
+ if (startLine || endLine) {
199
+ const lines = content.split('\n');
200
+ const start = (startLine || 1) - 1;
201
+ const end = endLine || lines.length;
202
+ content = lines.slice(start, end).join('\n');
203
+ }
204
+
205
+ if (content.length > maxChars) {
206
+ content = content.slice(0, maxChars) + `\n... [truncated at ${maxChars} chars]`;
207
+ }
208
+
209
+ return {
210
+ filePath: abs,
211
+ filename: path.basename(abs),
212
+ content,
213
+ lines: content.split('\n').length,
214
+ size: fs.statSync(abs).size,
215
+ };
216
+ }
217
+
218
+ // ── Private helpers ─────────────────────────────────────────────────────────
219
+
220
+ _assertSafe(abs) {
221
+ if (!fs.existsSync(abs) && !abs.endsWith('.js') && !abs.endsWith('.ts') && !abs.endsWith('.json') && !abs.endsWith('.md')) {
222
+ // Allow creating new files with known extensions, but existing files must exist for edits
223
+ }
224
+ if (fs.existsSync(abs)) {
225
+ const stat = fs.statSync(abs);
226
+ if (stat.size > MAX_EDIT_SIZE) throw new Error(`File too large to edit (${(stat.size / 1024).toFixed(0)}KB > ${MAX_EDIT_SIZE / 1024}KB)`);
227
+ }
228
+ // Prevent editing outside cwd
229
+ const cwd = process.cwd();
230
+ if (!abs.startsWith(cwd) && !abs.startsWith('/home') && !abs.startsWith('/tmp')) {
231
+ throw new Error(`Safety: refusing to edit outside working directory: ${abs}`);
232
+ }
233
+ }
234
+
235
+ _backup(abs, content) {
236
+ fs.writeFileSync(abs + '.bak', content, 'utf-8');
237
+ }
238
+
239
+ _countOccurrences(str, sub) {
240
+ let count = 0;
241
+ let pos = 0;
242
+ while ((pos = str.indexOf(sub, pos)) !== -1) { count++; pos += sub.length; }
243
+ return count;
244
+ }
245
+
246
+ _lineCount(text) {
247
+ return (text || '').split('\n').length;
248
+ }
249
+
250
+ _diffSummary(original, edited, filePath) {
251
+ const origLines = original.split('\n');
252
+ const editLines = edited.split('\n');
253
+ const added = editLines.length - origLines.length;
254
+
255
+ // Simple unified-diff-like summary
256
+ const changes = [];
257
+ const maxLines = Math.max(origLines.length, editLines.length);
258
+ let inChange = false;
259
+
260
+ for (let i = 0; i < Math.min(maxLines, 200); i++) {
261
+ const o = origLines[i] || '';
262
+ const e = editLines[i] || '';
263
+ if (o !== e) {
264
+ if (!inChange) { changes.push(`@@ line ${i + 1} @@`); inChange = true; }
265
+ if (o) changes.push(`- ${o}`);
266
+ if (e) changes.push(`+ ${e}`);
267
+ } else {
268
+ inChange = false;
269
+ }
270
+ }
271
+
272
+ return {
273
+ summary: `${Math.abs(added)} line(s) ${added >= 0 ? 'added' : 'removed'} in ${path.basename(filePath)}`,
274
+ changes: changes.slice(0, 50),
275
+ linesChanged: changes.filter(l => l.startsWith('+') || l.startsWith('-')).length,
276
+ };
277
+ }
278
+ }
279
+
280
+ module.exports = new FileEditTool();