protoagent 0.1.3 → 0.1.5

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.
@@ -114,7 +114,7 @@ async function isSafe(command) {
114
114
  }
115
115
  return validateCommandPaths(tokens);
116
116
  }
117
- export async function runBash(command, timeoutMs = 30_000, sessionId) {
117
+ export async function runBash(command, timeoutMs = 30_000, sessionId, abortSignal) {
118
118
  // Layer 1: hard block
119
119
  if (isDangerous(command)) {
120
120
  return `Error: Command blocked for safety. "${command}" contains a dangerous pattern that cannot be executed.`;
@@ -139,6 +139,7 @@ export async function runBash(command, timeoutMs = 30_000, sessionId) {
139
139
  let stdout = '';
140
140
  let stderr = '';
141
141
  let timedOut = false;
142
+ let aborted = false;
142
143
  const child = spawn(command, [], {
143
144
  shell: true,
144
145
  cwd: process.cwd(),
@@ -147,13 +148,31 @@ export async function runBash(command, timeoutMs = 30_000, sessionId) {
147
148
  });
148
149
  child.stdout?.on('data', (data) => { stdout += data.toString(); });
149
150
  child.stderr?.on('data', (data) => { stderr += data.toString(); });
150
- const timer = setTimeout(() => {
151
- timedOut = true;
151
+ const terminateChild = () => {
152
152
  child.kill('SIGTERM');
153
153
  setTimeout(() => child.kill('SIGKILL'), 2000);
154
+ };
155
+ const onAbort = () => {
156
+ aborted = true;
157
+ terminateChild();
158
+ };
159
+ if (abortSignal?.aborted) {
160
+ onAbort();
161
+ }
162
+ else {
163
+ abortSignal?.addEventListener('abort', onAbort, { once: true });
164
+ }
165
+ const timer = setTimeout(() => {
166
+ timedOut = true;
167
+ terminateChild();
154
168
  }, timeoutMs);
155
169
  child.on('close', (code) => {
156
170
  clearTimeout(timer);
171
+ abortSignal?.removeEventListener('abort', onAbort);
172
+ if (aborted) {
173
+ resolve(`Command aborted by user.\nPartial stdout:\n${stdout.slice(0, 5000)}\nPartial stderr:\n${stderr.slice(0, 2000)}`);
174
+ return;
175
+ }
157
176
  if (timedOut) {
158
177
  resolve(`Command timed out after ${timeoutMs / 1000}s.\nPartial stdout:\n${stdout.slice(0, 5000)}\nPartial stderr:\n${stderr.slice(0, 2000)}`);
159
178
  return;
@@ -172,6 +191,7 @@ export async function runBash(command, timeoutMs = 30_000, sessionId) {
172
191
  });
173
192
  child.on('error', (err) => {
174
193
  clearTimeout(timer);
194
+ abortSignal?.removeEventListener('abort', onAbort);
175
195
  resolve(`Error executing command: ${err.message}`);
176
196
  });
177
197
  });
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * edit_file tool — Find-and-replace in an existing file. Requires approval.
3
+ *
4
+ * Uses a fuzzy match cascade of 5 strategies to find the old_string,
5
+ * tolerating minor whitespace discrepancies from the model.
6
+ * Returns a unified diff on success so the model can verify its edit.
3
7
  */
4
8
  import fs from 'node:fs/promises';
5
9
  import path from 'node:path';
6
10
  import { validatePath } from '../utils/path-validation.js';
7
11
  import { requestApproval } from '../utils/approval.js';
12
+ import { assertReadBefore, recordRead } from '../utils/file-time.js';
8
13
  export const editFileTool = {
9
14
  type: 'function',
10
15
  function: {
@@ -27,32 +32,247 @@ export const editFileTool = {
27
32
  },
28
33
  },
29
34
  };
30
- export async function editFile(filePath, oldString, newString, expectedReplacements = 1, sessionId) {
31
- if (oldString.length === 0) {
32
- return 'Error: old_string cannot be empty.';
35
+ /** Strategy 1: Exact verbatim match (current behavior). */
36
+ const exactReplacer = {
37
+ name: 'exact',
38
+ findMatch(content, oldString) {
39
+ return content.includes(oldString) ? oldString : null;
40
+ },
41
+ };
42
+ /** Strategy 2: Per-line .trim() comparison — uses file's actual indentation. */
43
+ const lineTrimmedReplacer = {
44
+ name: 'line-trimmed',
45
+ findMatch(content, oldString) {
46
+ const searchLines = oldString.split('\n').map(l => l.trim());
47
+ const contentLines = content.split('\n');
48
+ for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
49
+ let match = true;
50
+ for (let j = 0; j < searchLines.length; j++) {
51
+ if (contentLines[i + j].trim() !== searchLines[j]) {
52
+ match = false;
53
+ break;
54
+ }
55
+ }
56
+ if (match) {
57
+ // Return the actual lines from the file content
58
+ return contentLines.slice(i, i + searchLines.length).join('\n');
59
+ }
60
+ }
61
+ return null;
62
+ },
63
+ };
64
+ /** Strategy 3: Strip common leading indent from both before comparing. */
65
+ const indentFlexReplacer = {
66
+ name: 'indent-flexible',
67
+ findMatch(content, oldString) {
68
+ const oldLines = oldString.split('\n');
69
+ const commonIndent = getCommonIndent(oldLines);
70
+ if (commonIndent === 0)
71
+ return null;
72
+ const stripped = oldLines.map(l => l.slice(commonIndent)).join('\n');
73
+ const contentLines = content.split('\n');
74
+ for (let i = 0; i <= contentLines.length - oldLines.length; i++) {
75
+ const fileSlice = contentLines.slice(i, i + oldLines.length);
76
+ const fileCommonIndent = getCommonIndent(fileSlice);
77
+ const fileStripped = fileSlice.map(l => l.slice(fileCommonIndent)).join('\n');
78
+ if (fileStripped === stripped) {
79
+ return fileSlice.join('\n');
80
+ }
81
+ }
82
+ return null;
83
+ },
84
+ };
85
+ /** Strategy 4: Collapse all whitespace runs to single space before comparing. */
86
+ const whitespaceNormReplacer = {
87
+ name: 'whitespace-normalized',
88
+ findMatch(content, oldString) {
89
+ const normalize = (s) => s.replace(/\s+/g, ' ').trim();
90
+ const target = normalize(oldString);
91
+ if (!target)
92
+ return null;
93
+ const contentLines = content.split('\n');
94
+ const oldLineCount = oldString.split('\n').length;
95
+ // Slide a window of oldLineCount lines across the file
96
+ for (let i = 0; i <= contentLines.length - oldLineCount; i++) {
97
+ const window = contentLines.slice(i, i + oldLineCount).join('\n');
98
+ if (normalize(window) === target) {
99
+ return window;
100
+ }
101
+ }
102
+ return null;
103
+ },
104
+ };
105
+ /** Strategy 5: .trim() the entire oldString before searching. */
106
+ const trimmedBoundaryReplacer = {
107
+ name: 'trimmed-boundary',
108
+ findMatch(content, oldString) {
109
+ const trimmed = oldString.trim();
110
+ if (trimmed === oldString)
111
+ return null; // no change from exact
112
+ return content.includes(trimmed) ? trimmed : null;
113
+ },
114
+ };
115
+ const STRATEGIES = [
116
+ exactReplacer,
117
+ lineTrimmedReplacer,
118
+ indentFlexReplacer,
119
+ whitespaceNormReplacer,
120
+ trimmedBoundaryReplacer,
121
+ ];
122
+ function getCommonIndent(lines) {
123
+ let min = Infinity;
124
+ for (const line of lines) {
125
+ if (line.trim().length === 0)
126
+ continue; // skip blank lines
127
+ const indent = line.length - line.trimStart().length;
128
+ min = Math.min(min, indent);
33
129
  }
34
- const validated = await validatePath(filePath);
35
- const content = await fs.readFile(validated, 'utf8');
36
- // Count occurrences
130
+ return min === Infinity ? 0 : min;
131
+ }
132
+ /**
133
+ * Count non-overlapping occurrences of `needle` in `haystack`.
134
+ */
135
+ function countOccurrences(haystack, needle) {
37
136
  let count = 0;
38
137
  let idx = 0;
39
- while ((idx = content.indexOf(oldString, idx)) !== -1) {
138
+ while ((idx = haystack.indexOf(needle, idx)) !== -1) {
40
139
  count++;
41
- idx += oldString.length;
140
+ idx += needle.length;
42
141
  }
43
- if (count === 0) {
44
- return `Error: old_string not found in ${filePath}. Make sure you read the file first and use the exact text.`;
142
+ return count;
143
+ }
144
+ /**
145
+ * Try each strategy in order. Return the actual substring to replace,
146
+ * the strategy name used, and how many occurrences exist.
147
+ * Only accepts strategies that find exactly one match (unambiguous).
148
+ */
149
+ function findWithCascade(content, oldString, expectedReplacements) {
150
+ for (const strategy of STRATEGIES) {
151
+ const actual = strategy.findMatch(content, oldString);
152
+ if (!actual)
153
+ continue;
154
+ const count = countOccurrences(content, actual);
155
+ if (count === expectedReplacements) {
156
+ return { actual, strategy: strategy.name, count };
157
+ }
158
+ // If count doesn't match expected, skip this strategy
159
+ }
160
+ return null;
161
+ }
162
+ // ─── Unified Diff ───
163
+ function computeUnifiedDiff(oldContent, newContent, filePath) {
164
+ const oldLines = oldContent.split('\n');
165
+ const newLines = newContent.split('\n');
166
+ // Find changed regions
167
+ const hunks = [];
168
+ let i = 0;
169
+ let j = 0;
170
+ while (i < oldLines.length || j < newLines.length) {
171
+ // Skip matching lines
172
+ if (i < oldLines.length && j < newLines.length && oldLines[i] === newLines[j]) {
173
+ i++;
174
+ j++;
175
+ continue;
176
+ }
177
+ // Found a difference — collect the changed region
178
+ const oldStart = i;
179
+ const newStart = j;
180
+ const hunkOld = [];
181
+ const hunkNew = [];
182
+ // Collect differing lines
183
+ while (i < oldLines.length && j < newLines.length && oldLines[i] !== newLines[j]) {
184
+ hunkOld.push(oldLines[i]);
185
+ hunkNew.push(newLines[j]);
186
+ i++;
187
+ j++;
188
+ }
189
+ // Handle remaining lines in either side
190
+ while (i < oldLines.length && (j >= newLines.length || oldLines[i] !== newLines[j])) {
191
+ hunkOld.push(oldLines[i]);
192
+ i++;
193
+ }
194
+ while (j < newLines.length && (i >= oldLines.length || oldLines[i] !== newLines[j])) {
195
+ hunkNew.push(newLines[j]);
196
+ j++;
197
+ }
198
+ hunks.push({ oldStart, oldLines: hunkOld, newStart, newLines: hunkNew });
45
199
  }
46
- if (count !== expectedReplacements) {
47
- return `Error: found ${count} occurrence(s) of old_string, but expected ${expectedReplacements}. Be more specific or set expected_replacements=${count}.`;
200
+ if (hunks.length === 0)
201
+ return '';
202
+ // Build unified diff output with 2 lines of context
203
+ const CONTEXT = 2;
204
+ const diffLines = [
205
+ `--- a/${filePath}`,
206
+ `+++ b/${filePath}`,
207
+ ];
208
+ let totalChanged = 0;
209
+ for (const hunk of hunks) {
210
+ totalChanged += hunk.oldLines.length + hunk.newLines.length;
211
+ if (totalChanged > 50) {
212
+ // Add what we have so far, then truncate
213
+ break;
214
+ }
215
+ const ctxBefore = Math.max(0, hunk.oldStart - CONTEXT);
216
+ const ctxAfterOld = Math.min(oldLines.length, hunk.oldStart + hunk.oldLines.length + CONTEXT);
217
+ const ctxAfterNew = Math.min(newLines.length, hunk.newStart + hunk.newLines.length + CONTEXT);
218
+ const oldHunkSize = (hunk.oldStart - ctxBefore) + hunk.oldLines.length + (ctxAfterOld - hunk.oldStart - hunk.oldLines.length);
219
+ const newHunkSize = (hunk.newStart - ctxBefore) + hunk.newLines.length + (ctxAfterNew - hunk.newStart - hunk.newLines.length);
220
+ diffLines.push(`@@ -${ctxBefore + 1},${oldHunkSize} +${ctxBefore + 1},${newHunkSize} @@`);
221
+ // Context before
222
+ for (let k = ctxBefore; k < hunk.oldStart; k++) {
223
+ diffLines.push(` ${oldLines[k]}`);
224
+ }
225
+ // Removed lines
226
+ for (const line of hunk.oldLines) {
227
+ diffLines.push(`-${line}`);
228
+ }
229
+ // Added lines
230
+ for (const line of hunk.newLines) {
231
+ diffLines.push(`+${line}`);
232
+ }
233
+ // Context after
234
+ for (let k = hunk.oldStart + hunk.oldLines.length; k < ctxAfterOld; k++) {
235
+ diffLines.push(` ${oldLines[k]}`);
236
+ }
48
237
  }
238
+ if (totalChanged > 50) {
239
+ diffLines.push('... (truncated)');
240
+ }
241
+ return diffLines.join('\n');
242
+ }
243
+ // ─── Main editFile function ───
244
+ export async function editFile(filePath, oldString, newString, expectedReplacements = 1, sessionId) {
245
+ if (oldString.length === 0) {
246
+ return 'Error: old_string cannot be empty.';
247
+ }
248
+ const validated = await validatePath(filePath);
249
+ // Staleness guard: must have read file before editing
250
+ if (sessionId) {
251
+ assertReadBefore(sessionId, validated);
252
+ }
253
+ const content = await fs.readFile(validated, 'utf8');
254
+ // Use fuzzy match cascade
255
+ const match = findWithCascade(content, oldString, expectedReplacements);
256
+ if (!match) {
257
+ // Check if we found it with wrong count
258
+ for (const strategy of STRATEGIES) {
259
+ const actual = strategy.findMatch(content, oldString);
260
+ if (actual) {
261
+ const count = countOccurrences(content, actual);
262
+ return `Error: found ${count} occurrence(s) of old_string (via ${strategy.name} match), but expected ${expectedReplacements}. Be more specific or set expected_replacements=${count}.`;
263
+ }
264
+ }
265
+ return `Error: old_string not found in ${filePath}. Strategies exhausted (exact, line-trimmed, indent-flexible, whitespace-normalized, trimmed-boundary). Re-read the file and try again.`;
266
+ }
267
+ const { actual, strategy, count } = match;
49
268
  // Request approval
50
269
  const oldPreview = oldString.length > 200 ? oldString.slice(0, 200) + '...' : oldString;
51
270
  const newPreview = newString.length > 200 ? newString.slice(0, 200) + '...' : newString;
271
+ const strategyNote = strategy !== 'exact' ? ` [matched via ${strategy}]` : '';
52
272
  const approved = await requestApproval({
53
273
  id: `edit-${Date.now()}`,
54
274
  type: 'file_edit',
55
- description: `Edit file: ${filePath} (${count} replacement${count > 1 ? 's' : ''})`,
275
+ description: `Edit file: ${filePath} (${count} replacement${count > 1 ? 's' : ''})${strategyNote}`,
56
276
  detail: `Replace:\n${oldPreview}\n\nWith:\n${newPreview}`,
57
277
  sessionId,
58
278
  sessionScopeKey: `file_edit:${validated}`,
@@ -60,8 +280,8 @@ export async function editFile(filePath, oldString, newString, expectedReplaceme
60
280
  if (!approved) {
61
281
  return `Operation cancelled: edit to ${filePath} was rejected by user.`;
62
282
  }
63
- // Perform replacement
64
- const newContent = content.split(oldString).join(newString);
283
+ // Perform replacement using the actual matched string (not the model's version)
284
+ const newContent = content.split(actual).join(newString);
65
285
  const directory = path.dirname(validated);
66
286
  const tempPath = path.join(directory, `.protoagent-edit-${process.pid}-${Date.now()}-${path.basename(validated)}`);
67
287
  try {
@@ -71,5 +291,17 @@ export async function editFile(filePath, oldString, newString, expectedReplaceme
71
291
  finally {
72
292
  await fs.rm(tempPath, { force: true }).catch(() => undefined);
73
293
  }
74
- return `Successfully edited ${filePath}: ${count} replacement(s) made.`;
294
+ // Re-read file after write (captures any formatter changes)
295
+ // Also record the read so subsequent edits don't fail the mtime check
296
+ const finalContent = await fs.readFile(validated, 'utf8');
297
+ if (sessionId) {
298
+ recordRead(sessionId, validated);
299
+ }
300
+ // Compute and return unified diff
301
+ const diff = computeUnifiedDiff(content, finalContent, filePath);
302
+ const header = `Successfully edited ${filePath}: ${count} replacement(s) made.`;
303
+ if (diff) {
304
+ return `${header}\n${diff}`;
305
+ }
306
+ return header;
75
307
  }
@@ -61,7 +61,7 @@ export async function handleToolCall(toolName, args, context = {}) {
61
61
  try {
62
62
  switch (toolName) {
63
63
  case 'read_file':
64
- return await readFile(args.file_path, args.offset, args.limit);
64
+ return await readFile(args.file_path, args.offset, args.limit, context.sessionId);
65
65
  case 'write_file':
66
66
  return await writeFile(args.file_path, args.content, context.sessionId);
67
67
  case 'edit_file':
@@ -71,7 +71,7 @@ export async function handleToolCall(toolName, args, context = {}) {
71
71
  case 'search_files':
72
72
  return await searchFiles(args.search_term, args.directory_path, args.case_sensitive, args.file_extensions);
73
73
  case 'bash':
74
- return await runBash(args.command, args.timeout_ms, context.sessionId);
74
+ return await runBash(args.command, args.timeout_ms, context.sessionId, context.abortSignal);
75
75
  case 'todo_read':
76
76
  return readTodos(context.sessionId);
77
77
  case 'todo_write':
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * read_file tool — Read file contents with optional offset and limit.
3
+ *
4
+ * When a file is not found, suggests similar paths to help the model
5
+ * recover from typos without repeated failed attempts.
3
6
  */
4
7
  import fs from 'node:fs/promises';
5
8
  import { createReadStream } from 'node:fs';
6
9
  import readline from 'node:readline';
7
- import { validatePath } from '../utils/path-validation.js';
10
+ import path from 'node:path';
11
+ import { validatePath, getWorkingDirectory } from '../utils/path-validation.js';
12
+ import { recordRead } from '../utils/file-time.js';
8
13
  export const readFileTool = {
9
14
  type: 'function',
10
15
  function: {
@@ -21,8 +26,85 @@ export const readFileTool = {
21
26
  },
22
27
  },
23
28
  };
24
- export async function readFile(filePath, offset = 0, limit = 2000) {
25
- const validated = await validatePath(filePath);
29
+ /**
30
+ * Find similar paths when a requested file doesn't exist.
31
+ * Walks from the repo root, matching segments case-insensitively.
32
+ */
33
+ async function findSimilarPaths(requestedPath) {
34
+ const cwd = getWorkingDirectory();
35
+ const segments = requestedPath.split('/').filter(Boolean);
36
+ const MAX_DEPTH = 6;
37
+ const MAX_ENTRIES = 200;
38
+ const MAX_SUGGESTIONS = 3;
39
+ const candidates = [];
40
+ async function walkSegments(dir, segIndex, currentPath) {
41
+ if (segIndex >= segments.length || segIndex >= MAX_DEPTH || candidates.length >= MAX_SUGGESTIONS)
42
+ return;
43
+ const targetSegment = segments[segIndex].toLowerCase();
44
+ let entries;
45
+ try {
46
+ const dirEntries = await fs.readdir(dir, { withFileTypes: true });
47
+ entries = dirEntries
48
+ .slice(0, MAX_ENTRIES)
49
+ .map(e => e.name);
50
+ }
51
+ catch {
52
+ return;
53
+ }
54
+ const isLastSegment = segIndex === segments.length - 1;
55
+ for (const entry of entries) {
56
+ if (candidates.length >= MAX_SUGGESTIONS)
57
+ break;
58
+ const entryLower = entry.toLowerCase();
59
+ // Match if entry contains the target segment as a substring (case-insensitive)
60
+ if (!entryLower.includes(targetSegment) && !targetSegment.includes(entryLower))
61
+ continue;
62
+ const entryPath = path.join(currentPath, entry);
63
+ const fullPath = path.join(dir, entry);
64
+ if (isLastSegment) {
65
+ // Check if this file/dir actually exists
66
+ try {
67
+ await fs.stat(fullPath);
68
+ candidates.push(entryPath);
69
+ }
70
+ catch {
71
+ // skip
72
+ }
73
+ }
74
+ else {
75
+ // Continue walking deeper
76
+ try {
77
+ const stat = await fs.stat(fullPath);
78
+ if (stat.isDirectory()) {
79
+ await walkSegments(fullPath, segIndex + 1, entryPath);
80
+ }
81
+ }
82
+ catch {
83
+ // skip
84
+ }
85
+ }
86
+ }
87
+ }
88
+ await walkSegments(cwd, 0, '');
89
+ return candidates;
90
+ }
91
+ export async function readFile(filePath, offset = 0, limit = 2000, sessionId) {
92
+ let validated;
93
+ try {
94
+ validated = await validatePath(filePath);
95
+ }
96
+ catch (err) {
97
+ // If file not found, try to suggest similar paths
98
+ if (err.message?.includes('does not exist') || err.code === 'ENOENT') {
99
+ const suggestions = await findSimilarPaths(filePath);
100
+ let msg = `File not found: '${filePath}'`;
101
+ if (suggestions.length > 0) {
102
+ msg += '\nDid you mean one of these?\n' + suggestions.map(s => ` ${s}`).join('\n');
103
+ }
104
+ return msg;
105
+ }
106
+ throw err;
107
+ }
26
108
  const start = Math.max(0, offset);
27
109
  const maxLines = Math.max(0, limit);
28
110
  const lines = [];
@@ -59,6 +141,10 @@ export async function readFile(filePath, offset = 0, limit = 2000) {
59
141
  const truncated = line.length > 2000 ? line.slice(0, 2000) + '... (truncated)' : line;
60
142
  return `${lineNum} | ${truncated}`;
61
143
  });
144
+ // Record successful read for staleness tracking
145
+ if (sessionId) {
146
+ recordRead(sessionId, validated);
147
+ }
62
148
  const rangeLabel = lines.length === 0
63
149
  ? 'none'
64
150
  : `${Math.min(start + 1, totalLines)}-${end}`;
@@ -1,8 +1,13 @@
1
1
  /**
2
2
  * search_files tool — Recursive text search across files.
3
+ *
4
+ * Uses ripgrep (rg) when available for fast, .gitignore-aware searching.
5
+ * Falls back to a pure JS recursive directory walk if rg is not found.
3
6
  */
4
7
  import fs from 'node:fs/promises';
8
+ import { statSync } from 'node:fs';
5
9
  import path from 'node:path';
10
+ import { execFileSync } from 'node:child_process';
6
11
  import { validatePath } from '../utils/path-validation.js';
7
12
  export const searchFilesTool = {
8
13
  type: 'function',
@@ -25,8 +30,95 @@ export const searchFilesTool = {
25
30
  },
26
31
  },
27
32
  };
33
+ // Detect ripgrep availability at module load
34
+ let hasRipgrep = false;
35
+ try {
36
+ execFileSync('rg', ['--version'], { stdio: 'pipe' });
37
+ hasRipgrep = true;
38
+ }
39
+ catch {
40
+ // ripgrep not available, will use JS fallback
41
+ }
42
+ const MAX_RESULTS = 100;
28
43
  export async function searchFiles(searchTerm, directoryPath = '.', caseSensitive = true, fileExtensions) {
29
44
  const validated = await validatePath(directoryPath);
45
+ if (hasRipgrep) {
46
+ return searchWithRipgrep(searchTerm, validated, directoryPath, caseSensitive, fileExtensions);
47
+ }
48
+ return searchWithJs(searchTerm, validated, directoryPath, caseSensitive, fileExtensions);
49
+ }
50
+ // ─── Ripgrep implementation ───
51
+ function searchWithRipgrep(searchTerm, validated, directoryPath, caseSensitive, fileExtensions) {
52
+ const args = [
53
+ '--line-number',
54
+ '--with-filename',
55
+ '--no-heading',
56
+ '--color=never',
57
+ '--max-count=1',
58
+ '--max-filesize=1M',
59
+ ];
60
+ if (!caseSensitive) {
61
+ args.push('--ignore-case');
62
+ }
63
+ if (fileExtensions && fileExtensions.length > 0) {
64
+ for (const ext of fileExtensions) {
65
+ // rg glob expects *.ext format
66
+ const globExt = ext.startsWith('.') ? `*${ext}` : `*.${ext}`;
67
+ args.push(`--glob=${globExt}`);
68
+ }
69
+ }
70
+ args.push('--regexp', searchTerm, validated);
71
+ try {
72
+ const output = execFileSync('rg', args, {
73
+ encoding: 'utf8',
74
+ maxBuffer: 10 * 1024 * 1024,
75
+ timeout: 30_000,
76
+ });
77
+ const lines = output.trim().split('\n').filter(Boolean);
78
+ if (lines.length === 0) {
79
+ return `No matches found for "${searchTerm}" in ${directoryPath}`;
80
+ }
81
+ // Parse rg output and sort by mtime
82
+ const parsed = lines.slice(0, MAX_RESULTS).map(line => {
83
+ // rg output: filepath:linenum:content
84
+ const firstColon = line.indexOf(':');
85
+ const secondColon = line.indexOf(':', firstColon + 1);
86
+ const filePath = line.slice(0, firstColon);
87
+ const lineNum = line.slice(firstColon + 1, secondColon);
88
+ let content = line.slice(secondColon + 1).trim();
89
+ if (content.length > 500) {
90
+ content = content.slice(0, 500) + '... (truncated)';
91
+ }
92
+ const relativePath = path.relative(validated, filePath);
93
+ let mtime = 0;
94
+ try {
95
+ mtime = statSync(filePath).mtimeMs;
96
+ }
97
+ catch { /* ignore */ }
98
+ return { display: `${relativePath}:${lineNum}: ${content}`, mtime };
99
+ });
100
+ // Sort by mtime descending (most recently modified first)
101
+ parsed.sort((a, b) => b.mtime - a.mtime);
102
+ const results = parsed.map(r => r.display);
103
+ const suffix = lines.length > MAX_RESULTS ? `\n(results truncated at ${MAX_RESULTS})` : '';
104
+ return `Found ${results.length} match(es) for "${searchTerm}":\n${results.join('\n')}${suffix}`;
105
+ }
106
+ catch (err) {
107
+ // rg exits with code 1 if no matches found (not an error)
108
+ if (err.status === 1) {
109
+ return `No matches found for "${searchTerm}" in ${directoryPath}`;
110
+ }
111
+ // rg exits with code 2 for actual errors
112
+ if (err.status === 2) {
113
+ const msg = err.stderr?.toString() || err.message;
114
+ return `Error: ripgrep error: ${msg}`;
115
+ }
116
+ // Fall back to JS search on any other error
117
+ return `Error: ripgrep failed: ${err.message}`;
118
+ }
119
+ }
120
+ // ─── JS fallback implementation ───
121
+ async function searchWithJs(searchTerm, validated, directoryPath, caseSensitive, fileExtensions) {
30
122
  const flags = caseSensitive ? 'g' : 'gi';
31
123
  let regex;
32
124
  try {
@@ -37,7 +129,6 @@ export async function searchFiles(searchTerm, directoryPath = '.', caseSensitive
37
129
  return `Error: invalid regex pattern "${searchTerm}": ${message}`;
38
130
  }
39
131
  const results = [];
40
- const MAX_RESULTS = 100;
41
132
  async function search(dir) {
42
133
  if (results.length >= MAX_RESULTS)
43
134
  return;
@@ -43,7 +43,7 @@ export async function compactIfNeeded(client, model, messages, contextWindow, cu
43
43
  return messages;
44
44
  }
45
45
  }
46
- async function compactConversation(client, model, messages, _requestDefaults, sessionId) {
46
+ async function compactConversation(client, model, messages, requestDefaults, sessionId) {
47
47
  // Separate system message, history to compress, and recent messages
48
48
  const systemMessage = messages[0];
49
49
  const recentMessages = messages.slice(-RECENT_MESSAGES_TO_KEEP);
@@ -69,6 +69,7 @@ async function compactConversation(client, model, messages, _requestDefaults, se
69
69
  },
70
70
  ];
71
71
  const response = await client.chat.completions.create({
72
+ ...requestDefaults,
72
73
  model,
73
74
  messages: compressionMessages,
74
75
  max_tokens: 2000,