tinker-agent 2.8.0 → 2.10.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.
- package/CHANGELOG.md +79 -1
- package/README.md +81 -11
- package/package.json +5 -3
- package/src/agent/runtime-context-capabilities.ts +19 -0
- package/src/agent/runtime-context-events.ts +127 -0
- package/src/agent/runtime-context-maintenance.ts +780 -0
- package/src/agent/runtime-hosted-session.ts +443 -0
- package/src/agent/runtime-interactions.ts +291 -0
- package/src/agent/runtime-prompt-scheduler.ts +182 -0
- package/src/agent/runtime-session-contracts.ts +317 -0
- package/src/agent/runtime-session.ts +250 -2130
- package/src/agent/runtime-skills.ts +544 -0
- package/src/cli/command-line.ts +26 -2
- package/src/cli/connect-runner.tsx +26 -0
- package/src/cli/main.ts +26 -0
- package/src/cli/output.ts +1 -1
- package/src/cli/public-cli-contract.ts +18 -0
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/runner-dependencies.ts +6 -5
- package/src/cli/serve-runner.ts +45 -0
- package/src/cli/serve-runtime.ts +100 -0
- package/src/context/context-automation-policy.ts +12 -118
- package/src/context/context-swap-renderer.ts +14 -0
- package/src/events/types.ts +12 -0
- package/src/memory/memory-get-tool.ts +1 -1
- package/src/observation/observation-builder.ts +128 -48
- package/src/remote/client.ts +350 -0
- package/src/remote/config.ts +95 -0
- package/src/remote/http-server.ts +240 -0
- package/src/remote/protocol.ts +228 -0
- package/src/remote/service-store.ts +175 -0
- package/src/remote/service.ts +219 -0
- package/src/remote/sync-hub.ts +95 -0
- package/src/session/remote-history-reader.ts +143 -0
- package/src/session/resume-projection.ts +47 -21
- package/src/session/session-history-access.ts +238 -0
- package/src/session/session-store-context-readers.ts +183 -0
- package/src/session/session-store-ledger-writer.ts +315 -0
- package/src/session/session-store-record-writer.ts +318 -0
- package/src/session/session-store-recovery.ts +225 -0
- package/src/session/session-store-revisions.ts +1004 -0
- package/src/session/session-store-sql.ts +40 -0
- package/src/session/session-store-validation.ts +657 -0
- package/src/session/session-store.ts +756 -3186
- package/src/tools/bash-task.ts +46 -18
- package/src/tools/bash.ts +44 -2
- package/src/tools/glob.ts +107 -19
- package/src/tools/grep-output.ts +130 -0
- package/src/tools/grep-pagination.ts +73 -0
- package/src/tools/grep-path.ts +11 -0
- package/src/tools/grep-snippets.ts +111 -0
- package/src/tools/grep.ts +139 -154
- package/src/tools/read.ts +0 -9
- package/src/tools/recall.ts +106 -50
- package/src/tools/registry.ts +4 -6
- package/src/tools/ripgrep.ts +19 -26
- package/src/tools/shell-process.ts +30 -4
- package/src/tools/task-output-range.ts +146 -0
- package/src/tools/task-output-tool.ts +35 -5
- package/src/tools/task-output.ts +35 -0
- package/src/tools/task-stop.ts +2 -1
- package/src/tools/task-tool-args.ts +34 -0
- package/src/tools/terminal-screen.ts +11 -2
- package/src/tools/types.ts +39 -2
- package/src/tui/event-store.ts +23 -5
- package/src/tui/remote-app.tsx +210 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const maxLineCodePoints = 500;
|
|
2
|
+
const matchContextCodePoints = 100;
|
|
3
|
+
|
|
4
|
+
type MatchRange = { start: number; end: number };
|
|
5
|
+
|
|
6
|
+
/** rg submatch offsets address the original bytes of the entire JSON event. */
|
|
7
|
+
export function excerptGrepLines(bytes: Buffer, submatches: unknown): string[] {
|
|
8
|
+
const matches = parseSubmatches(submatches, bytes.length);
|
|
9
|
+
const lines: string[] = [];
|
|
10
|
+
let start = 0;
|
|
11
|
+
let matchIndex = 0;
|
|
12
|
+
while (start < bytes.length) {
|
|
13
|
+
const newline = bytes.indexOf(10, start);
|
|
14
|
+
const end = newline === -1 ? bytes.length : newline;
|
|
15
|
+
const textEnd = end > start && bytes[end - 1] === 13 ? end - 1 : end;
|
|
16
|
+
while (matchIndex < matches.length && matches[matchIndex].end < start) {
|
|
17
|
+
matchIndex++;
|
|
18
|
+
}
|
|
19
|
+
lines.push(excerptLine(bytes.subarray(start, textEnd), matches, matchIndex, start));
|
|
20
|
+
start = end + 1;
|
|
21
|
+
}
|
|
22
|
+
return lines;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function excerptLine(
|
|
26
|
+
bytes: Buffer,
|
|
27
|
+
matches: MatchRange[],
|
|
28
|
+
matchIndex: number,
|
|
29
|
+
lineStart: number,
|
|
30
|
+
): string {
|
|
31
|
+
const points = [...bytes.toString("utf8")];
|
|
32
|
+
if (points.length <= maxLineCodePoints) return points.join("");
|
|
33
|
+
|
|
34
|
+
const windows: MatchRange[] = [];
|
|
35
|
+
const codePointOffset = createCodePointOffsetReader(bytes);
|
|
36
|
+
let remaining = maxLineCodePoints;
|
|
37
|
+
for (let index = matchIndex; index < matches.length && remaining > 0; index++) {
|
|
38
|
+
const match = matches[index];
|
|
39
|
+
if (match.start > lineStart + bytes.length) break;
|
|
40
|
+
if (match.end === lineStart && match.start < lineStart) continue;
|
|
41
|
+
const matchStart = codePointOffset(match.start - lineStart);
|
|
42
|
+
const matchEnd = codePointOffset(match.end - lineStart);
|
|
43
|
+
const previous = windows.at(-1);
|
|
44
|
+
// Reserve room for the match even when earlier windows used most of the budget.
|
|
45
|
+
const before = Math.min(matchContextCodePoints, Math.floor(remaining / 2));
|
|
46
|
+
const start = Math.max(0, matchStart - before);
|
|
47
|
+
const end = Math.min(points.length, matchEnd + matchContextCodePoints);
|
|
48
|
+
if (previous !== undefined && start <= previous.end) {
|
|
49
|
+
const extension = Math.min(remaining, Math.max(0, end - previous.end));
|
|
50
|
+
previous.end += extension;
|
|
51
|
+
remaining -= extension;
|
|
52
|
+
} else {
|
|
53
|
+
const keptEnd = Math.min(end, start + remaining);
|
|
54
|
+
windows.push({ start, end: keptEnd });
|
|
55
|
+
remaining -= keptEnd - start;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Context events (and legacy fixtures without submatches) still retain useful text.
|
|
59
|
+
if (windows.length === 0) windows.push({ start: 0, end: maxLineCodePoints });
|
|
60
|
+
|
|
61
|
+
const parts: string[] = [];
|
|
62
|
+
let cursor = 0;
|
|
63
|
+
for (const window of windows) {
|
|
64
|
+
if (window.start > cursor) parts.push(omission(window.start - cursor));
|
|
65
|
+
parts.push(points.slice(window.start, window.end).join(""));
|
|
66
|
+
cursor = window.end;
|
|
67
|
+
}
|
|
68
|
+
if (cursor < points.length) parts.push(omission(points.length - cursor));
|
|
69
|
+
return parts.join("");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createCodePointOffsetReader(bytes: Buffer): (offset: number) => number {
|
|
73
|
+
let byteCursor = 0;
|
|
74
|
+
let pointCursor = 0;
|
|
75
|
+
// Submatches are ordered and non-overlapping; scan each prefix only once.
|
|
76
|
+
return (offset) => {
|
|
77
|
+
const end = Math.max(0, Math.min(offset, bytes.length));
|
|
78
|
+
pointCursor += [...bytes.subarray(byteCursor, end).toString("utf8")].length;
|
|
79
|
+
byteCursor = end;
|
|
80
|
+
return pointCursor;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function omission(count: number): string {
|
|
85
|
+
return `[... ${count} code points omitted ...]`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseSubmatches(value: unknown, byteLength: number): MatchRange[] {
|
|
89
|
+
if (value === undefined) return [];
|
|
90
|
+
if (!Array.isArray(value)) throw new Error("Invalid ripgrep submatches.");
|
|
91
|
+
let previousEnd = 0;
|
|
92
|
+
return value.map((item: unknown) => {
|
|
93
|
+
if (
|
|
94
|
+
typeof item !== "object" ||
|
|
95
|
+
item === null ||
|
|
96
|
+
!("start" in item) ||
|
|
97
|
+
!("end" in item) ||
|
|
98
|
+
typeof item.start !== "number" ||
|
|
99
|
+
typeof item.end !== "number" ||
|
|
100
|
+
!Number.isSafeInteger(item.start) ||
|
|
101
|
+
!Number.isSafeInteger(item.end) ||
|
|
102
|
+
item.start < previousEnd ||
|
|
103
|
+
item.end < item.start ||
|
|
104
|
+
item.end > byteLength
|
|
105
|
+
) {
|
|
106
|
+
throw new Error("Invalid ripgrep submatch offsets.");
|
|
107
|
+
}
|
|
108
|
+
previousEnd = item.end;
|
|
109
|
+
return { start: item.start, end: item.end };
|
|
110
|
+
});
|
|
111
|
+
}
|
package/src/tools/grep.ts
CHANGED
|
@@ -4,6 +4,9 @@ import { throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
|
4
4
|
import { isWorkspaceLocalCwd, type CwdState } from "./cwd-state";
|
|
5
5
|
import { resolveWorkspacePath, toDisplayPath } from "./path-safety";
|
|
6
6
|
import { ripGrep } from "./ripgrep";
|
|
7
|
+
import { parseGrepOutput } from "./grep-output";
|
|
8
|
+
import { applyContentHeadLimit, applyHeadLimit } from "./grep-pagination";
|
|
9
|
+
import { formatGrepPath } from "./grep-path";
|
|
7
10
|
import { defineToolExecutor } from "./types";
|
|
8
11
|
import type {
|
|
9
12
|
GrepOutputMode,
|
|
@@ -39,7 +42,7 @@ export type GrepToolOptions = {
|
|
|
39
42
|
};
|
|
40
43
|
};
|
|
41
44
|
|
|
42
|
-
const
|
|
45
|
+
const defaultExcludedDirectories = [
|
|
43
46
|
".git",
|
|
44
47
|
".svn",
|
|
45
48
|
".hg",
|
|
@@ -50,35 +53,37 @@ const ignoredDirectories = [
|
|
|
50
53
|
".tinker",
|
|
51
54
|
];
|
|
52
55
|
|
|
53
|
-
const defaultHeadLimit = 250;
|
|
54
|
-
|
|
55
56
|
export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
|
|
56
57
|
return defineToolExecutor("grep", {
|
|
57
58
|
definition: {
|
|
58
59
|
name: "Grep",
|
|
59
|
-
description:
|
|
60
|
+
description:
|
|
61
|
+
"Search file contents with ripgrep. Directory searches include hidden files, respect ignore rules (including .gitignore and .ignore), and exclude .git, .svn, .hg, .bzr, .jj, .sl, node_modules, and .tinker by default. Directory traversal does not follow symlinks; binary detection can stop searching a file. No matches means no matches within the effective search scope. Results use a consistent file-path order. Pagination is not a snapshot: file changes between calls may cause skipped or repeated results. Quoted paths use JSON escaping; pass the decoded path to file tools.",
|
|
60
62
|
parameters: {
|
|
61
63
|
type: "object",
|
|
62
64
|
additionalProperties: false,
|
|
63
65
|
properties: {
|
|
64
66
|
pattern: {
|
|
65
67
|
type: "string",
|
|
68
|
+
minLength: 1,
|
|
66
69
|
description:
|
|
67
70
|
"The regular expression pattern to search for in file contents.",
|
|
68
71
|
},
|
|
69
72
|
path: {
|
|
70
73
|
type: "string",
|
|
71
74
|
description:
|
|
72
|
-
"Optional workspace-relative or absolute file or directory to search in. Defaults to the current workspace-local cwd.",
|
|
75
|
+
"Optional workspace-relative or absolute file or directory to search in. Defaults to the current workspace-local cwd. For directories, ripgrep runs in that directory with . as its search path; explicitly selecting an excluded directory allows searching inside it. Files are passed as absolute paths: explicit files bypass ignore/glob/type filtering, and explicit symlink files are followed. Explicit binary files may yield matches but are not guaranteed to be searched completely.",
|
|
73
76
|
},
|
|
74
77
|
glob: {
|
|
75
78
|
type: "string",
|
|
76
|
-
description:
|
|
79
|
+
description:
|
|
80
|
+
"Passed unchanged to ripgrep's --glob option after default exclusions. A glob that matches files inside an excluded directory does not necessarily allow traversal into that directory: **/node_modules/** can return no matches because node_modules itself is still excluded. To search an excluded directory, set path to that directory (for example, node_modules/pkg), then use glob to filter files within it. Broad globs such as **/* can also match excluded directory entries and allow traversal. Does not enable symlink traversal or disable binary detection.",
|
|
77
81
|
},
|
|
78
82
|
output_mode: {
|
|
79
83
|
type: "string",
|
|
80
|
-
enum: ["content", "files_with_matches", "count"],
|
|
81
|
-
description:
|
|
84
|
+
enum: ["content", "files_with_matches", "count", "count-matches"],
|
|
85
|
+
description:
|
|
86
|
+
'Defaults to "files_with_matches" (matching file paths). "content" returns matching lines. Lines longer than 500 Unicode code points are excerpted: match windows include up to 100 code points on each side, with at most 500 source code points retained per line; later matches or long matches may be omitted. Long context lines retain their first 500 code points. Omission markers report skipped code points; use Read for full lines. "count" returns matching lines per file, counting a line once even with multiple matches; cannot be combined with multiline=true. "count-matches" returns non-overlapping matches per file, including multiple matches on one line. Count summaries cover only the displayed files when paginated.',
|
|
82
87
|
},
|
|
83
88
|
"-B": {
|
|
84
89
|
type: "integer",
|
|
@@ -113,24 +118,24 @@ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
|
|
|
113
118
|
},
|
|
114
119
|
type: {
|
|
115
120
|
type: "string",
|
|
116
|
-
description: "
|
|
121
|
+
description: "Passed unchanged to ripgrep's --type option.",
|
|
117
122
|
},
|
|
118
123
|
head_limit: {
|
|
119
124
|
type: "integer",
|
|
120
125
|
minimum: 0,
|
|
121
126
|
description:
|
|
122
|
-
"Limit
|
|
127
|
+
"Limit selected results. In content mode, counts matching lines (one per line even with multiple matches), or complete ripgrep match events with multiline=true. Each selected result includes its requested context when the search completes; context does not consume the limit. Nearby matches shown within that context are not consumed or expanded and may reappear on later pages. Overlapping context is deduplicated within a page. Other modes count file entries. Defaults to 250. Pass 0 for unlimited.",
|
|
123
128
|
},
|
|
124
129
|
offset: {
|
|
125
130
|
type: "integer",
|
|
126
131
|
minimum: 0,
|
|
127
132
|
description:
|
|
128
|
-
"Skip
|
|
133
|
+
"Skip N results in the same units as head_limit, excluding context. Defaults to 0. To continue, pass nextOffset with the same search parameters. Pages may repeat context lines.",
|
|
129
134
|
},
|
|
130
135
|
multiline: {
|
|
131
136
|
type: "boolean",
|
|
132
137
|
description:
|
|
133
|
-
|
|
138
|
+
'Enable multiline mode where dot matches newlines. Defaults to false. For counting, use output_mode="count-matches"; "count" rejects multiline=true.',
|
|
134
139
|
},
|
|
135
140
|
},
|
|
136
141
|
required: ["pattern"],
|
|
@@ -178,10 +183,16 @@ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
|
|
|
178
183
|
});
|
|
179
184
|
}
|
|
180
185
|
|
|
181
|
-
const
|
|
186
|
+
const searchCwd = pathCheck.isDirectory ? absoluteSearchPath : undefined;
|
|
187
|
+
const rgArgs = buildRipgrepArgs(
|
|
188
|
+
input,
|
|
189
|
+
mode,
|
|
190
|
+
pathCheck.isDirectory ? "." : absoluteSearchPath,
|
|
191
|
+
);
|
|
182
192
|
const rg = await ripGrep(rgArgs, {
|
|
183
193
|
signal: context.signal,
|
|
184
194
|
...options.ripgrep,
|
|
195
|
+
cwd: searchCwd,
|
|
185
196
|
});
|
|
186
197
|
|
|
187
198
|
if (!rg.ok) {
|
|
@@ -201,67 +212,111 @@ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
|
|
|
201
212
|
searchPath,
|
|
202
213
|
absoluteSearchPath,
|
|
203
214
|
mode,
|
|
204
|
-
ignored: ignoredDirectories,
|
|
205
215
|
};
|
|
206
216
|
const partialWarning = rg.truncated ? rg.error : undefined;
|
|
207
217
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
218
|
+
let records;
|
|
219
|
+
try {
|
|
220
|
+
records = parseGrepOutput(
|
|
221
|
+
rg.stdout,
|
|
222
|
+
mode,
|
|
223
|
+
options.workspaceRoot,
|
|
224
|
+
rg.truncated,
|
|
225
|
+
searchCwd,
|
|
213
226
|
);
|
|
214
|
-
|
|
215
|
-
return {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return grepFailure({
|
|
229
|
+
pattern: input.pattern,
|
|
230
|
+
searchPath,
|
|
231
|
+
absoluteSearchPath,
|
|
232
|
+
mode,
|
|
233
|
+
error: `Invalid ripgrep output: ${errorMessage(error)}`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
if (rg.truncated && records.length === 0) {
|
|
237
|
+
return grepFailure({
|
|
238
|
+
pattern: input.pattern,
|
|
239
|
+
searchPath,
|
|
240
|
+
absoluteSearchPath,
|
|
241
|
+
mode,
|
|
242
|
+
truncated: true,
|
|
243
|
+
error:
|
|
244
|
+
partialWarning ??
|
|
245
|
+
"Incomplete ripgrep output contained no complete records.",
|
|
246
|
+
});
|
|
224
247
|
}
|
|
225
248
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
249
|
+
const requestedContext = resolveGrepContext(input);
|
|
250
|
+
const page =
|
|
251
|
+
mode === "content"
|
|
252
|
+
? applyContentHeadLimit(
|
|
253
|
+
records,
|
|
254
|
+
input.head_limit,
|
|
255
|
+
input.offset ?? 0,
|
|
256
|
+
requestedContext,
|
|
257
|
+
)
|
|
258
|
+
: applyHeadLimit(records, input.head_limit, input.offset ?? 0);
|
|
259
|
+
const filenames = [...new Set(page.items.map((record) => record.filePath))];
|
|
260
|
+
const pagination = {
|
|
261
|
+
totalResults: page.totalResults,
|
|
262
|
+
returnedResults: page.returnedResults,
|
|
263
|
+
paginationUnit:
|
|
264
|
+
mode === "content"
|
|
265
|
+
? input.multiline === true
|
|
266
|
+
? ("match_events" as const)
|
|
267
|
+
: ("matching_lines" as const)
|
|
268
|
+
: ("files" as const),
|
|
269
|
+
hasMore: page.hasMore,
|
|
270
|
+
nextOffset: page.nextOffset,
|
|
271
|
+
appliedLimit: page.appliedLimit,
|
|
272
|
+
appliedOffset: appliedOffset(input.offset),
|
|
273
|
+
searchIncomplete: rg.truncated,
|
|
274
|
+
contextMayBeIncomplete:
|
|
275
|
+
mode === "content" &&
|
|
276
|
+
rg.truncated &&
|
|
277
|
+
(requestedContext.before > 0 || requestedContext.after > 0),
|
|
278
|
+
truncated: rg.truncated || page.appliedLimit !== undefined || undefined,
|
|
279
|
+
error: partialWarning,
|
|
280
|
+
};
|
|
281
|
+
if (mode === "files_with_matches") {
|
|
282
|
+
return { ...base, ...pagination, filenames, numFiles: filenames.length };
|
|
283
|
+
}
|
|
284
|
+
if (mode === "count" || mode === "count-matches") {
|
|
285
|
+
const counts = page.items.flatMap((record) =>
|
|
286
|
+
record.kind === "count"
|
|
287
|
+
? [{ filePath: record.filePath, count: record.count }]
|
|
288
|
+
: [],
|
|
231
289
|
);
|
|
232
|
-
const filenames = entries.map((entry) => entry.filePath);
|
|
233
|
-
|
|
234
290
|
return {
|
|
235
291
|
...base,
|
|
292
|
+
...pagination,
|
|
236
293
|
filenames,
|
|
237
294
|
numFiles: filenames.length,
|
|
238
|
-
|
|
239
|
-
|
|
295
|
+
counts,
|
|
296
|
+
content: counts
|
|
297
|
+
.map((entry) => `${formatGrepPath(entry.filePath)}:${entry.count}`)
|
|
240
298
|
.join("\n"),
|
|
241
|
-
numMatches:
|
|
242
|
-
appliedLimit: page.appliedLimit,
|
|
243
|
-
appliedOffset: appliedOffset(input.offset),
|
|
244
|
-
truncated: rg.truncated || page.appliedLimit !== undefined || undefined,
|
|
245
|
-
error: partialWarning,
|
|
299
|
+
numMatches: counts.reduce((sum, entry) => sum + entry.count, 0),
|
|
246
300
|
};
|
|
247
301
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
302
|
+
const contentLines = page.items.flatMap((record) => {
|
|
303
|
+
if (record.kind !== "match" && record.kind !== "context") return [];
|
|
304
|
+
const separator = record.kind === "match" ? ":" : "-";
|
|
305
|
+
return record.lines.map((text, index) => {
|
|
306
|
+
const position =
|
|
307
|
+
input.lineNumbers === false
|
|
308
|
+
? ""
|
|
309
|
+
: `${record.lineNumber + index}${separator}`;
|
|
310
|
+
return `${formatGrepPath(record.filePath)}${separator}${position}${text}`;
|
|
311
|
+
});
|
|
312
|
+
});
|
|
255
313
|
return {
|
|
256
314
|
...base,
|
|
315
|
+
...pagination,
|
|
257
316
|
filenames,
|
|
258
317
|
numFiles: filenames.length,
|
|
259
318
|
content: contentLines.join("\n"),
|
|
260
319
|
numLines: contentLines.length,
|
|
261
|
-
appliedLimit: page.appliedLimit,
|
|
262
|
-
appliedOffset: appliedOffset(input.offset),
|
|
263
|
-
truncated: rg.truncated || page.appliedLimit !== undefined || undefined,
|
|
264
|
-
error: partialWarning,
|
|
265
320
|
};
|
|
266
321
|
},
|
|
267
322
|
});
|
|
@@ -270,36 +325,24 @@ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
|
|
|
270
325
|
export function buildRipgrepArgs(
|
|
271
326
|
input: GrepArgs,
|
|
272
327
|
mode: GrepOutputMode,
|
|
273
|
-
|
|
328
|
+
searchPathArgument: string,
|
|
274
329
|
): string[] {
|
|
275
|
-
|
|
330
|
+
// Sorting in rg fixes cross-file order before any pagination, including partial output.
|
|
331
|
+
const args = ["--no-config", "--hidden", "--sort", "path", "--color", "never"];
|
|
276
332
|
|
|
277
|
-
for (const directory of
|
|
333
|
+
for (const directory of defaultExcludedDirectories) {
|
|
278
334
|
args.push("--glob", `!${directory}`);
|
|
279
335
|
}
|
|
280
336
|
|
|
281
337
|
if (mode === "files_with_matches") {
|
|
282
|
-
args.push("-l");
|
|
283
|
-
} else if (mode === "count") {
|
|
284
|
-
args.push("-c", "--with-filename");
|
|
338
|
+
args.push("-l", "--null");
|
|
339
|
+
} else if (mode === "count" || mode === "count-matches") {
|
|
340
|
+
args.push(mode === "count" ? "-c" : "--count-matches", "--with-filename", "--null");
|
|
285
341
|
} else {
|
|
286
|
-
args.push("--
|
|
287
|
-
if (input.lineNumbers !== false) {
|
|
288
|
-
args.push("-n");
|
|
289
|
-
}
|
|
342
|
+
args.push("--json");
|
|
290
343
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
} else if (input.contextAlias !== undefined) {
|
|
294
|
-
args.push("-C", String(input.contextAlias));
|
|
295
|
-
} else {
|
|
296
|
-
if (input.before !== undefined) {
|
|
297
|
-
args.push("-B", String(input.before));
|
|
298
|
-
}
|
|
299
|
-
if (input.after !== undefined) {
|
|
300
|
-
args.push("-A", String(input.after));
|
|
301
|
-
}
|
|
302
|
-
}
|
|
344
|
+
const context = resolveGrepContext(input);
|
|
345
|
+
args.push("-B", String(context.before), "-A", String(context.after));
|
|
303
346
|
}
|
|
304
347
|
|
|
305
348
|
if (input.multiline === true) {
|
|
@@ -318,26 +361,15 @@ export function buildRipgrepArgs(
|
|
|
318
361
|
args.push("--glob", input.glob);
|
|
319
362
|
}
|
|
320
363
|
|
|
321
|
-
args.push("-e", input.pattern,
|
|
364
|
+
args.push("-e", input.pattern, searchPathArgument);
|
|
322
365
|
return args;
|
|
323
366
|
}
|
|
324
367
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
limit: number | undefined,
|
|
328
|
-
offset = 0,
|
|
329
|
-
): { items: T[]; appliedLimit?: number } {
|
|
330
|
-
if (limit === 0) {
|
|
331
|
-
return { items: items.slice(offset) };
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const effectiveLimit = limit ?? defaultHeadLimit;
|
|
335
|
-
const itemsPage = items.slice(offset, offset + effectiveLimit);
|
|
336
|
-
const wasTruncated = items.length - offset > effectiveLimit;
|
|
337
|
-
|
|
368
|
+
function resolveGrepContext(input: GrepArgs) {
|
|
369
|
+
const both = input.context ?? input.contextAlias;
|
|
338
370
|
return {
|
|
339
|
-
|
|
340
|
-
|
|
371
|
+
before: both ?? input.before ?? 0,
|
|
372
|
+
after: both ?? input.after ?? 0,
|
|
341
373
|
};
|
|
342
374
|
}
|
|
343
375
|
|
|
@@ -368,18 +400,29 @@ function parseGrepArgs(args: unknown): ParsedGrepArgs {
|
|
|
368
400
|
args.output_mode !== undefined &&
|
|
369
401
|
args.output_mode !== "content" &&
|
|
370
402
|
args.output_mode !== "files_with_matches" &&
|
|
371
|
-
args.output_mode !== "count"
|
|
403
|
+
args.output_mode !== "count" &&
|
|
404
|
+
args.output_mode !== "count-matches"
|
|
372
405
|
) {
|
|
373
406
|
return {
|
|
374
407
|
ok: false,
|
|
375
408
|
pattern,
|
|
376
409
|
error:
|
|
377
|
-
'Grep.output_mode must be one of "content", "files_with_matches", or "count".',
|
|
410
|
+
'Grep.output_mode must be one of "content", "files_with_matches", "count", or "count-matches".',
|
|
378
411
|
};
|
|
379
412
|
}
|
|
380
413
|
|
|
381
414
|
const mode = args.output_mode;
|
|
382
415
|
|
|
416
|
+
if (mode === "count" && args.multiline === true) {
|
|
417
|
+
return {
|
|
418
|
+
ok: false,
|
|
419
|
+
pattern,
|
|
420
|
+
mode,
|
|
421
|
+
error:
|
|
422
|
+
'Grep output_mode="count" counts matching lines and cannot be combined with multiline=true. Use output_mode="count-matches" to count multiline matches.',
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
383
426
|
for (const name of ["-B", "-A", "-C", "context", "head_limit", "offset"]) {
|
|
384
427
|
const value = args[name];
|
|
385
428
|
if (value !== undefined && !isNonNegativeInteger(value)) {
|
|
@@ -449,74 +492,17 @@ function resolveSearchPath(options: GrepToolOptions, inputPath?: string): string
|
|
|
449
492
|
|
|
450
493
|
async function ensureFileOrDirectory(
|
|
451
494
|
targetPath: string,
|
|
452
|
-
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
495
|
+
): Promise<{ ok: true; isDirectory: boolean } | { ok: false; error: string }> {
|
|
453
496
|
try {
|
|
454
497
|
const info = await stat(targetPath);
|
|
455
498
|
return info.isFile() || info.isDirectory()
|
|
456
|
-
? { ok: true }
|
|
499
|
+
? { ok: true, isDirectory: info.isDirectory() }
|
|
457
500
|
: { ok: false, error: "Grep.path must be a file or directory." };
|
|
458
501
|
} catch {
|
|
459
502
|
return { ok: false, error: "Grep.path does not exist." };
|
|
460
503
|
}
|
|
461
504
|
}
|
|
462
505
|
|
|
463
|
-
async function sortFilesForOutput(files: string[]): Promise<string[]> {
|
|
464
|
-
if (process.env.NODE_ENV === "test") {
|
|
465
|
-
return [...files].sort((left, right) => left.localeCompare(right));
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const withMtime = await Promise.all(
|
|
469
|
-
files.map(async (file) => {
|
|
470
|
-
try {
|
|
471
|
-
const info = await stat(file);
|
|
472
|
-
return { file, mtimeMs: info.mtimeMs };
|
|
473
|
-
} catch {
|
|
474
|
-
return { file, mtimeMs: 0 };
|
|
475
|
-
}
|
|
476
|
-
}),
|
|
477
|
-
);
|
|
478
|
-
|
|
479
|
-
return withMtime
|
|
480
|
-
.sort((left, right) =>
|
|
481
|
-
right.mtimeMs !== left.mtimeMs
|
|
482
|
-
? right.mtimeMs - left.mtimeMs
|
|
483
|
-
: left.file.localeCompare(right.file),
|
|
484
|
-
)
|
|
485
|
-
.map((entry) => entry.file);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
function parseCountLine(
|
|
489
|
-
line: string,
|
|
490
|
-
workspaceRoot: string,
|
|
491
|
-
): { filePath: string; count: number } {
|
|
492
|
-
const separator = line.lastIndexOf(":");
|
|
493
|
-
const absolutePath = separator === -1 ? line : line.slice(0, separator);
|
|
494
|
-
const count = separator === -1 ? 0 : Number(line.slice(separator + 1));
|
|
495
|
-
|
|
496
|
-
return {
|
|
497
|
-
filePath: toDisplayPath(workspaceRoot, absolutePath),
|
|
498
|
-
count: Number.isFinite(count) ? count : 0,
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
function relativizeContentLine(line: string, workspaceRoot: string): string {
|
|
503
|
-
const prefix = path.resolve(workspaceRoot) + path.sep;
|
|
504
|
-
return line.startsWith(prefix) ? line.slice(prefix.length) : line;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
function extractContentFilenames(lines: string[]): string[] {
|
|
508
|
-
const filenames = new Set<string>();
|
|
509
|
-
|
|
510
|
-
for (const line of lines) {
|
|
511
|
-
const match = /^(.+?):\d+[:-]/.exec(line);
|
|
512
|
-
if (match?.[1] !== undefined) {
|
|
513
|
-
filenames.add(match[1]);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
return [...filenames];
|
|
518
|
-
}
|
|
519
|
-
|
|
520
506
|
function appliedOffset(offset: number | undefined): number | undefined {
|
|
521
507
|
return offset !== undefined && offset > 0 ? offset : undefined;
|
|
522
508
|
}
|
|
@@ -537,7 +523,6 @@ function grepFailure(input: {
|
|
|
537
523
|
mode: input.mode,
|
|
538
524
|
filenames: [],
|
|
539
525
|
numFiles: 0,
|
|
540
|
-
ignored: ignoredDirectories,
|
|
541
526
|
truncated: input.truncated,
|
|
542
527
|
error: input.error,
|
|
543
528
|
};
|
package/src/tools/read.ts
CHANGED
|
@@ -121,15 +121,6 @@ export function createReadToolExecutor(options: ReadToolOptions): ToolExecutor {
|
|
|
121
121
|
const offset = input.offset ?? 1;
|
|
122
122
|
|
|
123
123
|
if (lines.length === 0) {
|
|
124
|
-
if (input.offset !== undefined || input.limit !== undefined) {
|
|
125
|
-
return {
|
|
126
|
-
ok: false,
|
|
127
|
-
filePath: input.file_path,
|
|
128
|
-
absolutePath,
|
|
129
|
-
error: "File is empty. Omit offset and limit to read the empty file.",
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
124
|
options.snapshots.set(absolutePath, {
|
|
134
125
|
sha256,
|
|
135
126
|
mtimeMs: currentInfo.mtimeMs,
|