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.
- package/README.md +34 -3
- package/dist/App.js +134 -81
- package/dist/agentic-loop.js +31 -12
- package/dist/cli.js +59 -2
- package/dist/components/CollapsibleBox.js +2 -2
- package/dist/components/ConsolidatedToolMessage.js +3 -11
- package/dist/components/FormattedMessage.js +80 -4
- package/dist/config.js +199 -71
- package/dist/runtime-config.js +29 -14
- package/dist/sub-agent.js +4 -1
- package/dist/system-prompt.js +3 -1
- package/dist/tools/bash.js +23 -3
- package/dist/tools/edit-file.js +248 -16
- package/dist/tools/index.js +2 -2
- package/dist/tools/read-file.js +89 -3
- package/dist/tools/search-files.js +92 -1
- package/dist/utils/compactor.js +2 -1
- package/dist/utils/file-time.js +54 -0
- package/package.json +1 -1
- package/dist/components/Table.js +0 -275
package/dist/tools/bash.js
CHANGED
|
@@ -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
|
|
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
|
});
|
package/dist/tools/edit-file.js
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 =
|
|
138
|
+
while ((idx = haystack.indexOf(needle, idx)) !== -1) {
|
|
40
139
|
count++;
|
|
41
|
-
idx +=
|
|
140
|
+
idx += needle.length;
|
|
42
141
|
}
|
|
43
|
-
|
|
44
|
-
|
|
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 (
|
|
47
|
-
return
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/dist/tools/index.js
CHANGED
|
@@ -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':
|
package/dist/tools/read-file.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
25
|
-
|
|
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;
|
package/dist/utils/compactor.js
CHANGED
|
@@ -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,
|
|
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,
|