@link-assistant/agent 0.26.15 → 0.27.1
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 +49 -7
- package/package.json +51 -42
- package/src/agent/agent.ts +12 -7
- package/src/auth/azure-login.ts +241 -0
- package/src/auth/plugins.ts +9 -0
- package/src/auth/provider-logins.ts +804 -0
- package/src/cli/argv.ts +1 -1
- package/src/cli/cmd/auth.ts +4 -0
- package/src/cli/cmd/models.ts +68 -13
- package/src/cli/cmd/run.ts +7 -1
- package/src/cli/continuous-mode.js +38 -12
- package/src/cli/event-handler.js +3 -1
- package/src/cli/model-config.js +27 -8
- package/src/cli/run-options.js +144 -78
- package/src/config/config.ts +306 -105
- package/src/config/defaults.ts +13 -5
- package/src/config/file-config.ts +16 -0
- package/src/file/ripgrep.ts +38 -5
- package/src/file/time.ts +86 -9
- package/src/index.js +42 -33
- package/src/mcp/index.ts +370 -106
- package/src/permission/index.ts +22 -0
- package/src/project/instance.ts +5 -1
- package/src/project/project.ts +35 -2
- package/src/provider/env-template.ts +115 -0
- package/src/provider/extra-providers.ts +347 -0
- package/src/provider/free-tier-error.ts +1 -1
- package/src/provider/kilo-deprecation.ts +30 -0
- package/src/provider/kilo.ts +44 -7
- package/src/provider/model-discovery.ts +690 -0
- package/src/provider/models.ts +11 -1
- package/src/provider/parity-loaders.ts +595 -0
- package/src/provider/provider.ts +309 -203
- package/src/provider/retry-fetch.ts +5 -0
- package/src/provider/transform.ts +168 -17
- package/src/session/compaction.ts +18 -2
- package/src/session/index.ts +30 -3
- package/src/session/message-v2.ts +318 -32
- package/src/session/processor.ts +274 -140
- package/src/session/prompt.ts +154 -128
- package/src/session/retry.ts +26 -13
- package/src/session/summary.ts +4 -1
- package/src/tool/bash.ts +232 -45
- package/src/tool/bash.txt +1 -1
- package/src/tool/codesearch.ts +15 -78
- package/src/tool/edit.ts +111 -30
- package/src/tool/glob.ts +2 -1
- package/src/tool/grep.ts +175 -57
- package/src/tool/ls.ts +2 -1
- package/src/tool/multiedit.ts +3 -1
- package/src/tool/read.ts +169 -41
- package/src/tool/task.ts +26 -18
- package/src/tool/tool.ts +4 -1
- package/src/tool/webfetch.ts +68 -56
- package/src/tool/webfetch.txt +4 -4
- package/src/tool/websearch.ts +20 -75
- package/src/tool/websearch.txt +2 -0
- package/src/tool/write.ts +23 -6
- package/src/util/bom.ts +32 -0
- package/src/util/exa-mcp.ts +82 -0
- package/src/util/legacy-error-names.ts +39 -0
- package/src/util/read-bounded.ts +32 -0
- package/src/util/sse-usage-extractor.ts +67 -13
- package/src/util/timeout.ts +3 -4
- package/src/util/verbose-fetch.ts +40 -1
package/src/tool/edit.ts
CHANGED
|
@@ -14,11 +14,29 @@ import { FileTime } from '../file/time';
|
|
|
14
14
|
import { Instance } from '../project/instance';
|
|
15
15
|
import { Snapshot } from '../snapshot';
|
|
16
16
|
import { Permission } from '../permission';
|
|
17
|
+
import { Bom } from '../util/bom';
|
|
18
|
+
import { config } from '../config/config';
|
|
17
19
|
|
|
18
20
|
function normalizeLineEndings(text: string): string {
|
|
19
21
|
return text.replaceAll('\r\n', '\n');
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
// The dominant line ending, so one stray CRLF line does not turn every
|
|
25
|
+
// inserted line of an LF file into CRLF (upstream #53166). Ties keep LF.
|
|
26
|
+
function detectLineEnding(text: string): '\n' | '\r\n' {
|
|
27
|
+
const crlf = text.split('\r\n').length - 1;
|
|
28
|
+
const lf = text.split('\n').length - 1 - crlf;
|
|
29
|
+
return crlf > lf ? '\r\n' : '\n';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function convertToLineEnding(text: string, ending: '\n' | '\r\n'): string {
|
|
33
|
+
if (ending === '\n') return text;
|
|
34
|
+
return text.replaceAll('\n', '\r\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const EMPTY_OLD_STRING_ERROR =
|
|
38
|
+
'oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.';
|
|
39
|
+
|
|
22
40
|
export const EditTool = Tool.define('edit', {
|
|
23
41
|
description: DESCRIPTION,
|
|
24
42
|
parameters: z.object({
|
|
@@ -61,16 +79,24 @@ export const EditTool = Tool.define('edit', {
|
|
|
61
79
|
let diff = '';
|
|
62
80
|
let contentOld = '';
|
|
63
81
|
let contentNew = '';
|
|
64
|
-
|
|
82
|
+
// Tool calls of one step run concurrently; serialize read-modify-write of
|
|
83
|
+
// the same file so parallel edits cannot drop each other's changes.
|
|
84
|
+
await FileTime.withLock(filePath, async () => {
|
|
65
85
|
if (params.oldString === '') {
|
|
66
|
-
|
|
86
|
+
// --legacy-edit-matching: an empty oldString overwrote the file.
|
|
87
|
+
if (!config.legacyEditMatching && (await Bun.file(filePath).exists())) {
|
|
88
|
+
throw new Error(EMPTY_OLD_STRING_ERROR);
|
|
89
|
+
}
|
|
90
|
+
const next = Bom.split(params.newString);
|
|
91
|
+
contentNew = next.text;
|
|
67
92
|
diff = trimDiff(
|
|
68
93
|
createTwoFilesPatch(filePath, filePath, contentOld, contentNew)
|
|
69
94
|
);
|
|
70
|
-
await Bun.write(filePath,
|
|
95
|
+
await Bun.write(filePath, Bom.join(contentNew, next.bom));
|
|
71
96
|
await Bus.publish(File.Event.Edited, {
|
|
72
97
|
file: filePath,
|
|
73
98
|
});
|
|
99
|
+
FileTime.read(ctx.sessionID, filePath);
|
|
74
100
|
return;
|
|
75
101
|
}
|
|
76
102
|
|
|
@@ -80,28 +106,30 @@ export const EditTool = Tool.define('edit', {
|
|
|
80
106
|
if (stats.isDirectory())
|
|
81
107
|
throw new Error(`Path is a directory, not a file: ${filePath}`);
|
|
82
108
|
await FileTime.assert(ctx.sessionID, filePath);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
109
|
+
const source = await Bom.readFile(filePath);
|
|
110
|
+
contentOld = source.text;
|
|
111
|
+
|
|
112
|
+
// Models usually send LF; keep the file's own line endings so a CRLF
|
|
113
|
+
// file is neither left unmatched nor partially converted to LF.
|
|
114
|
+
const ending = detectLineEnding(contentOld);
|
|
115
|
+
const oldString = convertToLineEnding(
|
|
116
|
+
normalizeLineEndings(params.oldString),
|
|
117
|
+
ending
|
|
89
118
|
);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
normalizeLineEndings(contentNew)
|
|
97
|
-
)
|
|
119
|
+
const newString = convertToLineEnding(
|
|
120
|
+
normalizeLineEndings(params.newString),
|
|
121
|
+
ending
|
|
122
|
+
);
|
|
123
|
+
const next = Bom.split(
|
|
124
|
+
replace(contentOld, oldString, newString, params.replaceAll)
|
|
98
125
|
);
|
|
126
|
+
contentNew = next.text;
|
|
99
127
|
|
|
100
|
-
await
|
|
128
|
+
await Bun.write(filePath, Bom.join(contentNew, source.bom || next.bom));
|
|
101
129
|
await Bus.publish(File.Event.Edited, {
|
|
102
130
|
file: filePath,
|
|
103
131
|
});
|
|
104
|
-
|
|
132
|
+
FileTime.read(ctx.sessionID, filePath);
|
|
105
133
|
diff = trimDiff(
|
|
106
134
|
createTwoFilesPatch(
|
|
107
135
|
filePath,
|
|
@@ -110,11 +138,10 @@ export const EditTool = Tool.define('edit', {
|
|
|
110
138
|
normalizeLineEndings(contentNew)
|
|
111
139
|
)
|
|
112
140
|
);
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
FileTime.read(ctx.sessionID, filePath);
|
|
141
|
+
});
|
|
116
142
|
|
|
117
|
-
|
|
143
|
+
// --legacy-tool-output: edit used to return an empty output.
|
|
144
|
+
const output = config.legacyToolOutput ? '' : 'Edit applied successfully.';
|
|
118
145
|
const diagnostics = {};
|
|
119
146
|
|
|
120
147
|
const filediff: Snapshot.FileDiff = {
|
|
@@ -147,8 +174,29 @@ export type Replacer = (
|
|
|
147
174
|
) => Generator<string, void, unknown>;
|
|
148
175
|
|
|
149
176
|
// Similarity thresholds for block anchor fallback matching
|
|
150
|
-
|
|
151
|
-
|
|
177
|
+
// (raised from 0.0 / 0.3 in upstream #30932: with 0.0 any block whose first
|
|
178
|
+
// and last lines matched was replaced, even when the middle was unrelated)
|
|
179
|
+
// --legacy-edit-matching / LINK_ASSISTANT_AGENT_LEGACY_EDIT_MATCHING restores
|
|
180
|
+
// the old thresholds and drops the block-size and disproportionate-match
|
|
181
|
+
// guards.
|
|
182
|
+
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.65;
|
|
183
|
+
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.65;
|
|
184
|
+
const LEGACY_SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0;
|
|
185
|
+
const LEGACY_MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3;
|
|
186
|
+
|
|
187
|
+
function thresholds() {
|
|
188
|
+
return config.legacyEditMatching
|
|
189
|
+
? {
|
|
190
|
+
single: LEGACY_SINGLE_CANDIDATE_SIMILARITY_THRESHOLD,
|
|
191
|
+
multiple: LEGACY_MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD,
|
|
192
|
+
maxLineDelta: (_size: number) => Infinity,
|
|
193
|
+
}
|
|
194
|
+
: {
|
|
195
|
+
single: SINGLE_CANDIDATE_SIMILARITY_THRESHOLD,
|
|
196
|
+
multiple: MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD,
|
|
197
|
+
maxLineDelta: (size: number) => Math.max(1, Math.floor(size * 0.25)),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
152
200
|
|
|
153
201
|
/**
|
|
154
202
|
* Levenshtein distance algorithm implementation
|
|
@@ -236,6 +284,8 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
|
236
284
|
const firstLineSearch = searchLines[0].trim();
|
|
237
285
|
const lastLineSearch = searchLines[searchLines.length - 1].trim();
|
|
238
286
|
const searchBlockSize = searchLines.length;
|
|
287
|
+
const limits = thresholds();
|
|
288
|
+
const maxLineDelta = limits.maxLineDelta(searchBlockSize);
|
|
239
289
|
|
|
240
290
|
// Collect all candidate positions where both anchors match
|
|
241
291
|
const candidates: Array<{ startLine: number; endLine: number }> = [];
|
|
@@ -247,7 +297,10 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
|
247
297
|
// Look for the matching last line after this first line
|
|
248
298
|
for (let j = i + 2; j < originalLines.length; j++) {
|
|
249
299
|
if (originalLines[j].trim() === lastLineSearch) {
|
|
250
|
-
|
|
300
|
+
const actualBlockSize = j - i + 1;
|
|
301
|
+
if (Math.abs(actualBlockSize - searchBlockSize) <= maxLineDelta) {
|
|
302
|
+
candidates.push({ startLine: i, endLine: j });
|
|
303
|
+
}
|
|
251
304
|
break; // Only match the first occurrence of the last line
|
|
252
305
|
}
|
|
253
306
|
}
|
|
@@ -278,7 +331,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
|
278
331
|
similarity += (1 - distance / maxLen) / linesToCheck;
|
|
279
332
|
|
|
280
333
|
// Exit early when threshold is reached
|
|
281
|
-
if (similarity >=
|
|
334
|
+
if (similarity >= limits.single) {
|
|
282
335
|
break;
|
|
283
336
|
}
|
|
284
337
|
}
|
|
@@ -287,7 +340,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
|
287
340
|
similarity = 1.0;
|
|
288
341
|
}
|
|
289
342
|
|
|
290
|
-
if (similarity >=
|
|
343
|
+
if (similarity >= limits.single) {
|
|
291
344
|
let matchStartIndex = 0;
|
|
292
345
|
for (let k = 0; k < startLine; k++) {
|
|
293
346
|
matchStartIndex += originalLines[k].length + 1;
|
|
@@ -339,7 +392,7 @@ export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
|
339
392
|
}
|
|
340
393
|
|
|
341
394
|
// Threshold judgment
|
|
342
|
-
if (maxSimilarity >=
|
|
395
|
+
if (maxSimilarity >= limits.multiple && bestMatch) {
|
|
343
396
|
const { startLine, endLine } = bestMatch;
|
|
344
397
|
let matchStartIndex = 0;
|
|
345
398
|
for (let k = 0; k < startLine; k++) {
|
|
@@ -631,6 +684,11 @@ export function replace(
|
|
|
631
684
|
if (oldString === newString) {
|
|
632
685
|
throw new Error('oldString and newString must be different');
|
|
633
686
|
}
|
|
687
|
+
if (oldString === '') {
|
|
688
|
+
// Every replacer would "match" the empty string (MultiOccurrenceReplacer
|
|
689
|
+
// never terminates on it), so reject it explicitly.
|
|
690
|
+
throw new Error(EMPTY_OLD_STRING_ERROR);
|
|
691
|
+
}
|
|
634
692
|
|
|
635
693
|
let notFound = true;
|
|
636
694
|
|
|
@@ -649,8 +707,18 @@ export function replace(
|
|
|
649
707
|
const index = content.indexOf(search);
|
|
650
708
|
if (index === -1) continue;
|
|
651
709
|
notFound = false;
|
|
710
|
+
if (
|
|
711
|
+
!config.legacyEditMatching &&
|
|
712
|
+
isDisproportionateMatch(search, oldString)
|
|
713
|
+
) {
|
|
714
|
+
throw new Error(
|
|
715
|
+
'Refusing replacement because the matched span is much larger than oldString. Re-read the file and provide the full exact oldString for the intended replacement.'
|
|
716
|
+
);
|
|
717
|
+
}
|
|
652
718
|
if (replaceAll) {
|
|
653
|
-
|
|
719
|
+
// split/join instead of String#replaceAll so `$&`, `$1`, `$'` in
|
|
720
|
+
// newString are inserted literally.
|
|
721
|
+
return content.split(search).join(newString);
|
|
654
722
|
}
|
|
655
723
|
const lastIndex = content.lastIndexOf(search);
|
|
656
724
|
if (index !== lastIndex) continue;
|
|
@@ -669,3 +737,16 @@ export function replace(
|
|
|
669
737
|
'Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match.'
|
|
670
738
|
);
|
|
671
739
|
}
|
|
740
|
+
|
|
741
|
+
// A fuzzy replacer matched far more text than the model asked to replace
|
|
742
|
+
// (upstream #30932): refuse instead of deleting unrelated code.
|
|
743
|
+
function isDisproportionateMatch(search: string, oldString: string) {
|
|
744
|
+
const oldLines = oldString.split('\n').length;
|
|
745
|
+
const searchLines = search.split('\n').length;
|
|
746
|
+
if (searchLines >= Math.max(oldLines + 3, oldLines * 2)) return true;
|
|
747
|
+
if (oldLines === 1) return false;
|
|
748
|
+
return (
|
|
749
|
+
search.trim().length >
|
|
750
|
+
Math.max(oldString.trim().length + 500, oldString.trim().length * 4)
|
|
751
|
+
);
|
|
752
|
+
}
|
package/src/tool/glob.ts
CHANGED
|
@@ -16,7 +16,7 @@ export const GlobTool = Tool.define('glob', {
|
|
|
16
16
|
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`
|
|
17
17
|
),
|
|
18
18
|
}),
|
|
19
|
-
async execute(params) {
|
|
19
|
+
async execute(params, ctx) {
|
|
20
20
|
let search = params.path ?? Instance.directory;
|
|
21
21
|
search = path.isAbsolute(search)
|
|
22
22
|
? search
|
|
@@ -28,6 +28,7 @@ export const GlobTool = Tool.define('glob', {
|
|
|
28
28
|
for await (const file of Ripgrep.files({
|
|
29
29
|
cwd: search,
|
|
30
30
|
glob: [params.pattern],
|
|
31
|
+
signal: ctx.abort,
|
|
31
32
|
})) {
|
|
32
33
|
if (files.length >= limit) {
|
|
33
34
|
truncated = true;
|
package/src/tool/grep.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import z from 'zod';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
2
4
|
import { Tool } from './tool';
|
|
3
5
|
import { Ripgrep } from '../file/ripgrep';
|
|
4
6
|
|
|
5
7
|
import DESCRIPTION from './grep.txt';
|
|
6
8
|
import { Instance } from '../project/instance';
|
|
9
|
+
import { config } from '../config/config';
|
|
7
10
|
import {
|
|
8
11
|
byteOffsetToColumnIndex,
|
|
9
12
|
formatFocusedLineWindow,
|
|
10
13
|
} from './text-window';
|
|
11
14
|
|
|
12
15
|
const MAX_MATCHING_LINE_LENGTH = 2000;
|
|
16
|
+
const RESULT_LIMIT = 100;
|
|
17
|
+
// rg output is parsed while it streams in, so memory is bounded by the
|
|
18
|
+
// matches kept, not by rg's total output. Past this many matches rg is
|
|
19
|
+
// stopped and the newest RESULT_LIMIT of the collected ones are returned.
|
|
20
|
+
const MAX_COLLECTED_MATCHES = 10_000;
|
|
13
21
|
|
|
14
22
|
export const GrepTool = Tool.define('grep', {
|
|
15
23
|
description: DESCRIPTION,
|
|
@@ -30,28 +38,94 @@ export const GrepTool = Tool.define('grep', {
|
|
|
30
38
|
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'
|
|
31
39
|
),
|
|
32
40
|
}),
|
|
33
|
-
async execute(params) {
|
|
41
|
+
async execute(params, ctx) {
|
|
34
42
|
if (!params.pattern) {
|
|
35
43
|
throw new Error('pattern is required');
|
|
36
44
|
}
|
|
37
45
|
|
|
38
|
-
|
|
46
|
+
// Relative paths are relative to the session directory, not to the
|
|
47
|
+
// process cwd that rg would otherwise use.
|
|
48
|
+
const searchPath = path.resolve(Instance.directory, params.path || '.');
|
|
49
|
+
// upstream 8f4b62eb49 (#35337): say so instead of passing rg's exit 2 on
|
|
50
|
+
if (!(await fs.stat(searchPath).catch(() => undefined))) {
|
|
51
|
+
throw new Error(`Search path does not exist: ${searchPath}`);
|
|
52
|
+
}
|
|
39
53
|
|
|
40
54
|
const rgPath = await Ripgrep.filepath();
|
|
41
|
-
|
|
55
|
+
// `--hidden` finds dotfiles such as `.github/` like glob/ls do (upstream
|
|
56
|
+
// d5738f542c #7501); --no-grep-hidden restores the previous behaviour.
|
|
57
|
+
// `--no-messages` keeps per-file errors out of stderr (upstream
|
|
58
|
+
// 4edb4fa4fa #8612); `--no-config` ignores RIPGREP_CONFIG_PATH.
|
|
59
|
+
const args = ['--json'];
|
|
60
|
+
if (config.grepHidden) args.push('--hidden');
|
|
61
|
+
args.push('--no-messages', '--no-config');
|
|
62
|
+
if (config.grepHidden) args.push('--glob=!**/.git/**');
|
|
63
|
+
args.push('--regexp', params.pattern);
|
|
42
64
|
if (params.include) {
|
|
43
65
|
args.push('--glob', params.include);
|
|
44
66
|
}
|
|
45
67
|
args.push(searchPath);
|
|
46
68
|
|
|
69
|
+
// upstream 4d2696e027 / 3bda0ce123 (#39238): honor the tool call's abort
|
|
70
|
+
// signal and do not let a huge search run forever
|
|
71
|
+
// (--grep-timeout-ms, 0 = no timeout).
|
|
72
|
+
ctx.abort.throwIfAborted();
|
|
73
|
+
const timeoutMs = config.grepTimeoutMs;
|
|
74
|
+
// rg gets its own controller: a listener on the long-lived ctx.abort
|
|
75
|
+
// would keep the process object reachable after the call returns.
|
|
76
|
+
const stop = new AbortController();
|
|
77
|
+
let timedOut = false;
|
|
78
|
+
const onAbort = () => stop.abort();
|
|
79
|
+
ctx.abort.addEventListener('abort', onAbort, { once: true });
|
|
80
|
+
const timer =
|
|
81
|
+
timeoutMs > 0
|
|
82
|
+
? setTimeout(() => {
|
|
83
|
+
timedOut = true;
|
|
84
|
+
stop.abort();
|
|
85
|
+
}, timeoutMs)
|
|
86
|
+
: undefined;
|
|
87
|
+
|
|
47
88
|
const proc = Bun.spawn([rgPath, ...args], {
|
|
48
89
|
stdout: 'pipe',
|
|
49
90
|
stderr: 'pipe',
|
|
91
|
+
signal: stop.signal,
|
|
50
92
|
});
|
|
51
93
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
94
|
+
const matches: Match[] = [];
|
|
95
|
+
let collectedAll = true;
|
|
96
|
+
let exitCode: number;
|
|
97
|
+
let errorOutput: string;
|
|
98
|
+
try {
|
|
99
|
+
// Drain both pipes concurrently: reading stdout to the end first
|
|
100
|
+
// deadlocks once rg fills the stderr pipe.
|
|
101
|
+
const stderr = new Response(proc.stderr).text();
|
|
102
|
+
const mtimes = new Map<string, number | null>();
|
|
103
|
+
for await (const line of lines(proc.stdout)) {
|
|
104
|
+
const match = await parseMatch(line, mtimes);
|
|
105
|
+
if (!match) continue;
|
|
106
|
+
matches.push(match);
|
|
107
|
+
if (matches.length >= MAX_COLLECTED_MATCHES) {
|
|
108
|
+
collectedAll = false;
|
|
109
|
+
proc.kill();
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
[errorOutput, exitCode] = await Promise.all([stderr, proc.exited]);
|
|
114
|
+
} finally {
|
|
115
|
+
if (timer) clearTimeout(timer);
|
|
116
|
+
ctx.abort.removeEventListener('abort', onAbort);
|
|
117
|
+
// A parse error must not leave rg running.
|
|
118
|
+
if (proc.exitCode === null) proc.kill();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
ctx.abort.throwIfAborted();
|
|
122
|
+
if (timedOut) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Search timed out after ${timeoutMs / 1000} seconds. Consider using a more specific path or pattern.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
// rg was stopped on purpose after enough matches.
|
|
128
|
+
if (!collectedAll) exitCode = 0;
|
|
55
129
|
|
|
56
130
|
if (exitCode === 1) {
|
|
57
131
|
return {
|
|
@@ -61,59 +135,36 @@ export const GrepTool = Tool.define('grep', {
|
|
|
61
135
|
};
|
|
62
136
|
}
|
|
63
137
|
|
|
64
|
-
|
|
65
|
-
|
|
138
|
+
// Exit codes: 0 = matches, 1 = no matches, 2 = an error occurred. With
|
|
139
|
+
// `--no-messages`, exit 2 without a regex error means some paths could
|
|
140
|
+
// not be read; the matches from readable files are still valid.
|
|
141
|
+
if (
|
|
142
|
+
exitCode === 2 &&
|
|
143
|
+
/regex parse error|error parsing regex/i.test(errorOutput)
|
|
144
|
+
) {
|
|
145
|
+
throw new Error(`Invalid regex pattern: ${errorOutput.trim()}`);
|
|
66
146
|
}
|
|
67
147
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
for (const line of output.trim().split('\n')) {
|
|
71
|
-
if (!line) continue;
|
|
72
|
-
|
|
73
|
-
const event = JSON.parse(line);
|
|
74
|
-
if (event.type !== 'match') continue;
|
|
75
|
-
|
|
76
|
-
const filePath = event.data?.path?.text;
|
|
77
|
-
const lineNum = event.data?.line_number;
|
|
78
|
-
const rawLineText = event.data?.lines?.text;
|
|
79
|
-
const submatch = event.data?.submatches?.[0];
|
|
80
|
-
if (!filePath || !lineNum || typeof rawLineText !== 'string') continue;
|
|
81
|
-
|
|
82
|
-
const lineText = stripTrailingLineEnding(rawLineText);
|
|
83
|
-
const matchStart = byteOffsetToColumnIndex(
|
|
84
|
-
lineText,
|
|
85
|
-
submatch?.start ?? 0
|
|
86
|
-
);
|
|
87
|
-
const matchEnd = byteOffsetToColumnIndex(
|
|
88
|
-
lineText,
|
|
89
|
-
submatch?.end ?? submatch?.start ?? 0
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
const file = Bun.file(filePath);
|
|
93
|
-
const stats = await file.stat().catch(() => null);
|
|
94
|
-
if (!stats) continue;
|
|
95
|
-
|
|
96
|
-
matches.push({
|
|
97
|
-
path: filePath,
|
|
98
|
-
modTime: stats.mtime.getTime(),
|
|
99
|
-
lineNum,
|
|
100
|
-
lineText,
|
|
101
|
-
matchStart,
|
|
102
|
-
matchEnd,
|
|
103
|
-
});
|
|
148
|
+
if (exitCode !== 0 && exitCode !== 2) {
|
|
149
|
+
throw new Error(`Exit code ${exitCode}\nripgrep failed: ${errorOutput}`);
|
|
104
150
|
}
|
|
105
151
|
|
|
152
|
+
const hasErrors = exitCode === 2;
|
|
153
|
+
|
|
106
154
|
matches.sort((a, b) => b.modTime - a.modTime);
|
|
107
155
|
|
|
108
|
-
const limit =
|
|
109
|
-
const truncated = matches.length > limit;
|
|
110
|
-
const finalMatches =
|
|
156
|
+
const limit = RESULT_LIMIT;
|
|
157
|
+
const truncated = matches.length > limit || !collectedAll;
|
|
158
|
+
const finalMatches =
|
|
159
|
+
matches.length > limit ? matches.slice(0, limit) : matches;
|
|
111
160
|
|
|
112
161
|
if (finalMatches.length === 0) {
|
|
113
162
|
return {
|
|
114
163
|
title: params.pattern,
|
|
115
164
|
metadata: { matches: 0, truncated: false },
|
|
116
|
-
output:
|
|
165
|
+
output: hasErrors
|
|
166
|
+
? 'No files found\n\n(Some paths were inaccessible and skipped)'
|
|
167
|
+
: 'No files found',
|
|
117
168
|
};
|
|
118
169
|
}
|
|
119
170
|
|
|
@@ -128,15 +179,7 @@ export const GrepTool = Tool.define('grep', {
|
|
|
128
179
|
currentFile = match.path;
|
|
129
180
|
outputLines.push(`${match.path}:`);
|
|
130
181
|
}
|
|
131
|
-
outputLines.push(
|
|
132
|
-
` Line ${match.lineNum}: ${formatFocusedLineWindow({
|
|
133
|
-
line: match.lineText,
|
|
134
|
-
lineNumber: match.lineNum,
|
|
135
|
-
focusStart: match.matchStart,
|
|
136
|
-
focusEnd: match.matchEnd,
|
|
137
|
-
limit: MAX_MATCHING_LINE_LENGTH,
|
|
138
|
-
})}`
|
|
139
|
-
);
|
|
182
|
+
outputLines.push(` Line ${match.lineNum}: ${match.text}`);
|
|
140
183
|
}
|
|
141
184
|
|
|
142
185
|
if (truncated) {
|
|
@@ -146,6 +189,11 @@ export const GrepTool = Tool.define('grep', {
|
|
|
146
189
|
);
|
|
147
190
|
}
|
|
148
191
|
|
|
192
|
+
if (hasErrors) {
|
|
193
|
+
outputLines.push('');
|
|
194
|
+
outputLines.push('(Some paths were inaccessible and skipped)');
|
|
195
|
+
}
|
|
196
|
+
|
|
149
197
|
return {
|
|
150
198
|
title: params.pattern,
|
|
151
199
|
metadata: {
|
|
@@ -157,6 +205,76 @@ export const GrepTool = Tool.define('grep', {
|
|
|
157
205
|
},
|
|
158
206
|
});
|
|
159
207
|
|
|
208
|
+
type Match = {
|
|
209
|
+
path: string;
|
|
210
|
+
modTime: number;
|
|
211
|
+
lineNum: number;
|
|
212
|
+
// Already windowed to MAX_MATCHING_LINE_LENGTH, so a minified file does not
|
|
213
|
+
// keep megabyte-long lines in memory.
|
|
214
|
+
text: string;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Split a byte stream into lines without buffering the whole stream.
|
|
218
|
+
async function* lines(stream: ReadableStream<Uint8Array>) {
|
|
219
|
+
const decoder = new TextDecoder();
|
|
220
|
+
let pending = '';
|
|
221
|
+
for await (const chunk of stream) {
|
|
222
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
223
|
+
let index: number;
|
|
224
|
+
while ((index = pending.indexOf('\n')) !== -1) {
|
|
225
|
+
yield pending.slice(0, index).replace(/\r$/, '');
|
|
226
|
+
pending = pending.slice(index + 1);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
pending += decoder.decode();
|
|
230
|
+
if (pending) yield pending;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function parseMatch(
|
|
234
|
+
line: string,
|
|
235
|
+
mtimes: Map<string, number | null>
|
|
236
|
+
): Promise<Match | undefined> {
|
|
237
|
+
if (!line) return undefined;
|
|
238
|
+
const event = JSON.parse(line);
|
|
239
|
+
if (event.type !== 'match') return undefined;
|
|
240
|
+
|
|
241
|
+
const filePath = event.data?.path?.text;
|
|
242
|
+
const lineNum = event.data?.line_number;
|
|
243
|
+
const rawLineText = event.data?.lines?.text;
|
|
244
|
+
const submatch = event.data?.submatches?.[0];
|
|
245
|
+
if (!filePath || !lineNum || typeof rawLineText !== 'string')
|
|
246
|
+
return undefined;
|
|
247
|
+
|
|
248
|
+
// One stat per file, not per matching line.
|
|
249
|
+
if (!mtimes.has(filePath)) {
|
|
250
|
+
const stats = await Bun.file(filePath)
|
|
251
|
+
.stat()
|
|
252
|
+
.catch(() => null);
|
|
253
|
+
mtimes.set(filePath, stats ? stats.mtime.getTime() : null);
|
|
254
|
+
}
|
|
255
|
+
const modTime = mtimes.get(filePath);
|
|
256
|
+
if (modTime === null || modTime === undefined) return undefined;
|
|
257
|
+
|
|
258
|
+
const lineText = stripTrailingLineEnding(rawLineText);
|
|
259
|
+
const matchStart = byteOffsetToColumnIndex(lineText, submatch?.start ?? 0);
|
|
260
|
+
const matchEnd = byteOffsetToColumnIndex(
|
|
261
|
+
lineText,
|
|
262
|
+
submatch?.end ?? submatch?.start ?? 0
|
|
263
|
+
);
|
|
264
|
+
return {
|
|
265
|
+
path: filePath,
|
|
266
|
+
modTime,
|
|
267
|
+
lineNum,
|
|
268
|
+
text: formatFocusedLineWindow({
|
|
269
|
+
line: lineText,
|
|
270
|
+
lineNumber: lineNum,
|
|
271
|
+
focusStart: matchStart,
|
|
272
|
+
focusEnd: matchEnd,
|
|
273
|
+
limit: MAX_MATCHING_LINE_LENGTH,
|
|
274
|
+
}),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
160
278
|
function stripTrailingLineEnding(line: string) {
|
|
161
279
|
return line.replace(/\r?\n$/, '');
|
|
162
280
|
}
|
package/src/tool/ls.ts
CHANGED
|
@@ -48,7 +48,7 @@ export const ListTool = Tool.define('list', {
|
|
|
48
48
|
.describe('List of glob patterns to ignore')
|
|
49
49
|
.optional(),
|
|
50
50
|
}),
|
|
51
|
-
async execute(params) {
|
|
51
|
+
async execute(params, ctx) {
|
|
52
52
|
const searchPath = path.resolve(Instance.directory, params.path || '.');
|
|
53
53
|
|
|
54
54
|
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(
|
|
@@ -58,6 +58,7 @@ export const ListTool = Tool.define('list', {
|
|
|
58
58
|
for await (const file of Ripgrep.files({
|
|
59
59
|
cwd: searchPath,
|
|
60
60
|
glob: ignoreGlobs,
|
|
61
|
+
signal: ctx.abort,
|
|
61
62
|
})) {
|
|
62
63
|
files.push(file);
|
|
63
64
|
if (files.length >= LIMIT) break;
|
package/src/tool/multiedit.ts
CHANGED
|
@@ -49,7 +49,9 @@ export const MultiEditTool = Tool.define('multiedit', {
|
|
|
49
49
|
metadata: {
|
|
50
50
|
results: results.map((r) => r.metadata),
|
|
51
51
|
},
|
|
52
|
-
|
|
52
|
+
// `edits: []` applies nothing; return an empty output like the Rust
|
|
53
|
+
// tool instead of throwing a TypeError (issue #335 parity audit).
|
|
54
|
+
output: results.at(-1)?.output ?? '',
|
|
53
55
|
};
|
|
54
56
|
},
|
|
55
57
|
});
|