tinker-agent 2.9.0 → 2.11.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 +69 -1
- package/README.md +30 -1
- package/package.json +5 -3
- package/src/agent/loop.ts +50 -13
- package/src/agent/runtime-hosted-session.ts +443 -0
- package/src/agent/runtime-provider-retry.ts +115 -0
- package/src/agent/runtime-session-contracts.ts +11 -0
- package/src/agent/runtime-session.ts +29 -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/serve-runner.ts +45 -0
- package/src/cli/serve-runtime.ts +100 -0
- package/src/cli/tui-runner.tsx +1 -0
- package/src/context/context-swap-renderer.ts +14 -0
- package/src/events/types.ts +8 -0
- package/src/image/abortable-file-open.ts +54 -0
- package/src/image/image-asset-store.ts +7 -1
- package/src/model/fake-model-client.ts +20 -1
- package/src/model/openai-model-utils.ts +45 -1
- package/src/model/openai-responses-mapping.ts +11 -0
- package/src/model/openai-responses-stream.ts +14 -0
- package/src/observation/observation-builder.ts +87 -37
- 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/scoped-query-database.ts +27 -0
- package/src/session/session-history-access.ts +4 -3
- package/src/session/session-store.ts +7 -3
- package/src/tools/bash-task.ts +26 -16
- 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 +148 -155
- package/src/tools/read.ts +0 -9
- package/src/tools/ripgrep.ts +19 -26
- package/src/tools/shell-process.ts +30 -4
- package/src/tools/task-stop.ts +2 -1
- package/src/tools/terminal-screen.ts +11 -2
- package/src/tools/types.ts +30 -2
- package/src/tui/app.tsx +45 -3
- package/src/tui/components/ask-user.tsx +15 -8
- package/src/tui/components/prompt-input.tsx +14 -6
- package/src/tui/components/timeline.tsx +17 -9
- package/src/tui/event-store.ts +25 -2
- package/src/tui/file-mention.ts +29 -5
- package/src/tui/remote-app.tsx +210 -0
- package/src/tui/tui-projection-store.ts +5 -2
- package/src/tui/tui-session-controller.ts +8 -0
- package/src/tui/workspace-file-search.ts +21 -0
package/src/tools/bash.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { TaskOutputSnapshot } from "./task-output";
|
|
|
13
13
|
import type { BashRawResult, ToolExecutionContext, ToolExecutor } from "./types";
|
|
14
14
|
import { DEFAULT_PUBLIC_TOOLING_CONFIG } from "../cli/public-config-contract";
|
|
15
15
|
import { classifyBashRisk } from "./bash-guard";
|
|
16
|
+
import { MAX_TERMINAL_DIMENSION, MIN_TERMINAL_COLUMNS } from "./terminal-screen";
|
|
16
17
|
|
|
17
18
|
type BashArgs = {
|
|
18
19
|
command: string;
|
|
@@ -20,6 +21,8 @@ type BashArgs = {
|
|
|
20
21
|
description?: string;
|
|
21
22
|
run_in_background?: boolean;
|
|
22
23
|
tty?: boolean;
|
|
24
|
+
cols?: number;
|
|
25
|
+
rows?: number;
|
|
23
26
|
};
|
|
24
27
|
|
|
25
28
|
export type BashToolOptions = {
|
|
@@ -44,7 +47,8 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
|
|
|
44
47
|
return defineToolExecutor("bash", {
|
|
45
48
|
definition: {
|
|
46
49
|
name: "Bash",
|
|
47
|
-
description:
|
|
50
|
+
description:
|
|
51
|
+
"Run a shell command locally. If the foreground timeout expires while the command is still running, it continues as a background task and returns a task ID; it is not killed. Use TaskOutput to inspect progress, then decide whether to keep waiting or stop it with TaskStop.",
|
|
48
52
|
parameters: {
|
|
49
53
|
type: "object",
|
|
50
54
|
additionalProperties: false,
|
|
@@ -57,7 +61,8 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
|
|
|
57
61
|
type: "integer",
|
|
58
62
|
minimum: 1,
|
|
59
63
|
maximum: maxTimeoutMs,
|
|
60
|
-
description:
|
|
64
|
+
description:
|
|
65
|
+
"Optional foreground wait duration in milliseconds. On timeout, a still-running command continues in the background instead of being killed.",
|
|
61
66
|
},
|
|
62
67
|
description: {
|
|
63
68
|
type: "string",
|
|
@@ -72,6 +77,20 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
|
|
|
72
77
|
description:
|
|
73
78
|
"Run the command in a pseudo-terminal so it can receive interactive input.",
|
|
74
79
|
},
|
|
80
|
+
cols: {
|
|
81
|
+
type: "integer",
|
|
82
|
+
minimum: MIN_TERMINAL_COLUMNS,
|
|
83
|
+
maximum: MAX_TERMINAL_DIMENSION,
|
|
84
|
+
description:
|
|
85
|
+
"Initial PTY width in columns. Defaults to 80. Ignored unless tty=true.",
|
|
86
|
+
},
|
|
87
|
+
rows: {
|
|
88
|
+
type: "integer",
|
|
89
|
+
minimum: 1,
|
|
90
|
+
maximum: MAX_TERMINAL_DIMENSION,
|
|
91
|
+
description:
|
|
92
|
+
"Initial PTY height in rows. Defaults to 24. Ignored unless tty=true.",
|
|
93
|
+
},
|
|
75
94
|
},
|
|
76
95
|
required: ["command"],
|
|
77
96
|
},
|
|
@@ -137,6 +156,8 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
|
|
|
137
156
|
description: input.description ?? input.command,
|
|
138
157
|
origin: call,
|
|
139
158
|
tty: input.tty === true,
|
|
159
|
+
cols: input.cols,
|
|
160
|
+
rows: input.rows,
|
|
140
161
|
});
|
|
141
162
|
|
|
142
163
|
if (input.run_in_background === true) {
|
|
@@ -232,6 +253,25 @@ export function parseBashArgs(
|
|
|
232
253
|
return { ok: false, error: "Bash.tty must be a boolean." };
|
|
233
254
|
}
|
|
234
255
|
|
|
256
|
+
if (args.tty === true) {
|
|
257
|
+
for (const name of ["cols", "rows"] as const) {
|
|
258
|
+
const value = args[name];
|
|
259
|
+
const minimum = name === "cols" ? MIN_TERMINAL_COLUMNS : 1;
|
|
260
|
+
if (
|
|
261
|
+
value !== undefined &&
|
|
262
|
+
(typeof value !== "number" ||
|
|
263
|
+
!Number.isInteger(value) ||
|
|
264
|
+
value < minimum ||
|
|
265
|
+
value > MAX_TERMINAL_DIMENSION)
|
|
266
|
+
) {
|
|
267
|
+
return {
|
|
268
|
+
ok: false,
|
|
269
|
+
error: `Bash.${name} must be an integer between ${minimum} and ${MAX_TERMINAL_DIMENSION}.`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
235
275
|
return {
|
|
236
276
|
ok: true,
|
|
237
277
|
value: {
|
|
@@ -243,6 +283,8 @@ export function parseBashArgs(
|
|
|
243
283
|
: args.description,
|
|
244
284
|
run_in_background: args.run_in_background,
|
|
245
285
|
tty: args.tty,
|
|
286
|
+
cols: args.tty === true ? (args.cols as number | undefined) : undefined,
|
|
287
|
+
rows: args.tty === true ? (args.rows as number | undefined) : undefined,
|
|
246
288
|
},
|
|
247
289
|
};
|
|
248
290
|
}
|
package/src/tools/glob.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { stat } from "node:fs/promises";
|
|
3
|
-
import { glob } from "glob";
|
|
2
|
+
import { realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { glob, type Path } from "glob";
|
|
4
4
|
import { cancellationError, throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
5
5
|
import { resolveWorkspacePath, toDisplayPath } from "./path-safety";
|
|
6
6
|
import { defineToolExecutor } from "./types";
|
|
@@ -9,6 +9,8 @@ import type { GlobRawResult, ToolExecutionContext, ToolExecutor } from "./types"
|
|
|
9
9
|
type GlobArgs = {
|
|
10
10
|
pattern: string;
|
|
11
11
|
path?: string;
|
|
12
|
+
head_limit: number;
|
|
13
|
+
offset: number;
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
export type GlobToolOptions = {
|
|
@@ -16,12 +18,15 @@ export type GlobToolOptions = {
|
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
const ignoredDirectories = ["node_modules", ".git"];
|
|
21
|
+
const defaultHeadLimit = 200;
|
|
22
|
+
const maxHeadLimit = 500;
|
|
19
23
|
|
|
20
24
|
export function createGlobToolExecutor(options: GlobToolOptions): ToolExecutor {
|
|
21
25
|
return defineToolExecutor("glob", {
|
|
22
26
|
definition: {
|
|
23
27
|
name: "Glob",
|
|
24
|
-
description:
|
|
28
|
+
description:
|
|
29
|
+
"Find regular files by glob pattern, including symbolic links to regular files. Case sensitivity follows platform defaults. Directory links and broken links are excluded. node_modules and .git are skipped during traversal; to search inside either, set path directly to that directory.",
|
|
25
30
|
parameters: {
|
|
26
31
|
type: "object",
|
|
27
32
|
additionalProperties: false,
|
|
@@ -33,7 +38,21 @@ export function createGlobToolExecutor(options: GlobToolOptions): ToolExecutor {
|
|
|
33
38
|
path: {
|
|
34
39
|
type: "string",
|
|
35
40
|
description:
|
|
36
|
-
"Optional workspace-relative or absolute search directory. Defaults to the workspace root.",
|
|
41
|
+
"Optional workspace-relative or absolute search directory. Defaults to the workspace root. A directory symbolic link is allowed as the search root.",
|
|
42
|
+
},
|
|
43
|
+
head_limit: {
|
|
44
|
+
type: "integer",
|
|
45
|
+
minimum: 1,
|
|
46
|
+
maximum: maxHeadLimit,
|
|
47
|
+
description:
|
|
48
|
+
"Maximum paths to return. Defaults to 200; must be between 1 and 500.",
|
|
49
|
+
},
|
|
50
|
+
offset: {
|
|
51
|
+
type: "integer",
|
|
52
|
+
minimum: 0,
|
|
53
|
+
maximum: Number.MAX_SAFE_INTEGER,
|
|
54
|
+
description:
|
|
55
|
+
"Skip the first N sorted matches. Defaults to 0. To continue, pass nextOffset from the previous result with the same pattern and path.",
|
|
37
56
|
},
|
|
38
57
|
},
|
|
39
58
|
required: ["pattern"],
|
|
@@ -46,8 +65,16 @@ export function createGlobToolExecutor(options: GlobToolOptions): ToolExecutor {
|
|
|
46
65
|
if (!parsed.ok) {
|
|
47
66
|
return {
|
|
48
67
|
ok: false,
|
|
49
|
-
pattern:
|
|
50
|
-
|
|
68
|
+
pattern:
|
|
69
|
+
isRecord(args) && typeof args.pattern === "string"
|
|
70
|
+
? args.pattern
|
|
71
|
+
: undefined,
|
|
72
|
+
searchPath:
|
|
73
|
+
isRecord(args) && args.path !== undefined
|
|
74
|
+
? typeof args.path === "string"
|
|
75
|
+
? args.path
|
|
76
|
+
: "(invalid path)"
|
|
77
|
+
: ".",
|
|
51
78
|
ignored: ignoredDirectories,
|
|
52
79
|
error: parsed.error,
|
|
53
80
|
};
|
|
@@ -87,27 +114,43 @@ export function createGlobToolExecutor(options: GlobToolOptions): ToolExecutor {
|
|
|
87
114
|
}
|
|
88
115
|
|
|
89
116
|
try {
|
|
117
|
+
// Resolve the root only; keep the traversal policy for links below it.
|
|
118
|
+
const realSearchPath = await realpath(absoluteSearchPath);
|
|
119
|
+
throwIfTurnCancelled(context.signal);
|
|
90
120
|
const matches = await glob(input.pattern, {
|
|
91
|
-
cwd:
|
|
121
|
+
cwd: realSearchPath,
|
|
92
122
|
nodir: true,
|
|
93
123
|
dot: true,
|
|
94
124
|
follow: false,
|
|
125
|
+
withFileTypes: true,
|
|
95
126
|
signal: context.signal,
|
|
96
127
|
ignore: ["**/node_modules/**", "**/.git/**"],
|
|
97
128
|
});
|
|
98
|
-
const displayMatches = toDisplayMatches({
|
|
129
|
+
const displayMatches = await toDisplayMatches({
|
|
99
130
|
workspaceRoot: options.workspaceRoot,
|
|
100
131
|
absoluteSearchPath,
|
|
132
|
+
realSearchPath,
|
|
101
133
|
matches,
|
|
134
|
+
signal: context.signal,
|
|
102
135
|
});
|
|
136
|
+
const page = displayMatches.slice(
|
|
137
|
+
input.offset,
|
|
138
|
+
input.offset + input.head_limit,
|
|
139
|
+
);
|
|
140
|
+
const hasMore = input.offset + page.length < displayMatches.length;
|
|
103
141
|
|
|
104
142
|
return {
|
|
105
143
|
ok: true,
|
|
106
144
|
pattern: input.pattern,
|
|
107
145
|
searchPath,
|
|
108
146
|
absoluteSearchPath,
|
|
109
|
-
matches:
|
|
110
|
-
matchCount:
|
|
147
|
+
matches: page,
|
|
148
|
+
matchCount: page.length,
|
|
149
|
+
totalMatches: displayMatches.length,
|
|
150
|
+
returnedCount: page.length,
|
|
151
|
+
appliedOffset: input.offset,
|
|
152
|
+
hasMore,
|
|
153
|
+
...(hasMore ? { nextOffset: input.offset + page.length } : {}),
|
|
111
154
|
ignored: ignoredDirectories,
|
|
112
155
|
};
|
|
113
156
|
} catch (error) {
|
|
@@ -151,11 +194,35 @@ function parseGlobArgs(
|
|
|
151
194
|
return { ok: false, error: "Glob.path must be a string." };
|
|
152
195
|
}
|
|
153
196
|
|
|
197
|
+
if (
|
|
198
|
+
args.head_limit !== undefined &&
|
|
199
|
+
(typeof args.head_limit !== "number" ||
|
|
200
|
+
!Number.isSafeInteger(args.head_limit) ||
|
|
201
|
+
args.head_limit < 1 ||
|
|
202
|
+
args.head_limit > maxHeadLimit)
|
|
203
|
+
) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
error: "Glob.head_limit must be an integer between 1 and 500.",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (
|
|
211
|
+
args.offset !== undefined &&
|
|
212
|
+
(typeof args.offset !== "number" ||
|
|
213
|
+
!Number.isSafeInteger(args.offset) ||
|
|
214
|
+
args.offset < 0)
|
|
215
|
+
) {
|
|
216
|
+
return { ok: false, error: "Glob.offset must be a non-negative safe integer." };
|
|
217
|
+
}
|
|
218
|
+
|
|
154
219
|
return {
|
|
155
220
|
ok: true,
|
|
156
221
|
value: {
|
|
157
222
|
pattern: args.pattern,
|
|
158
223
|
path: args.path,
|
|
224
|
+
head_limit: args.head_limit ?? defaultHeadLimit,
|
|
225
|
+
offset: args.offset ?? 0,
|
|
159
226
|
},
|
|
160
227
|
};
|
|
161
228
|
}
|
|
@@ -177,22 +244,43 @@ async function ensureDirectory(
|
|
|
177
244
|
}
|
|
178
245
|
}
|
|
179
246
|
|
|
180
|
-
function toDisplayMatches(input: {
|
|
247
|
+
async function toDisplayMatches(input: {
|
|
181
248
|
workspaceRoot: string;
|
|
182
249
|
absoluteSearchPath: string;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
250
|
+
realSearchPath: string;
|
|
251
|
+
matches: Path[];
|
|
252
|
+
signal: AbortSignal;
|
|
253
|
+
}): Promise<string[]> {
|
|
254
|
+
const normalized: string[] = [];
|
|
255
|
+
for (const match of input.matches) {
|
|
256
|
+
throwIfTurnCancelled(input.signal);
|
|
257
|
+
if (!(await isRegularFileMatch(match))) continue;
|
|
258
|
+
// Preserve the caller's root spelling, including directory links and aliases.
|
|
259
|
+
const absolutePath = path.resolve(
|
|
260
|
+
input.absoluteSearchPath,
|
|
261
|
+
path.relative(input.realSearchPath, match.fullpath()),
|
|
189
262
|
);
|
|
190
|
-
|
|
191
|
-
}
|
|
263
|
+
normalized.push(toDisplayPath(input.workspaceRoot, absolutePath));
|
|
264
|
+
}
|
|
265
|
+
throwIfTurnCancelled(input.signal);
|
|
192
266
|
|
|
193
267
|
return [...new Set(normalized)].sort((left, right) => left.localeCompare(right));
|
|
194
268
|
}
|
|
195
269
|
|
|
270
|
+
async function isRegularFileMatch(match: Path): Promise<boolean> {
|
|
271
|
+
if (!match.isSymbolicLink()) return match.isFile();
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
// Resolve only the type; keep the link's path in the returned matches.
|
|
275
|
+
return (await stat(match.fullpath())).isFile();
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (isRecord(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
196
284
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
197
285
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
198
286
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { toDisplayPath } from "./path-safety";
|
|
3
|
+
import type { GrepOutputMode } from "./types";
|
|
4
|
+
import { excerptGrepLines } from "./grep-snippets";
|
|
5
|
+
|
|
6
|
+
export type GrepContentRecord = {
|
|
7
|
+
kind: "match" | "context";
|
|
8
|
+
filePath: string;
|
|
9
|
+
lineNumber: number;
|
|
10
|
+
/** Keep each JSON event intact so pagination cannot split a multiline match. */
|
|
11
|
+
lines: string[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type GrepRecord =
|
|
15
|
+
| { kind: "file"; filePath: string }
|
|
16
|
+
| { kind: "count"; filePath: string; count: number }
|
|
17
|
+
| GrepContentRecord;
|
|
18
|
+
|
|
19
|
+
/** Decode complete protocol records only. A truncated tail is never a record. */
|
|
20
|
+
export function parseGrepOutput(
|
|
21
|
+
stdout: string,
|
|
22
|
+
mode: GrepOutputMode,
|
|
23
|
+
workspaceRoot: string,
|
|
24
|
+
truncated: boolean,
|
|
25
|
+
searchCwd: string = workspaceRoot,
|
|
26
|
+
): GrepRecord[] {
|
|
27
|
+
if (mode === "content") {
|
|
28
|
+
return parseJsonOutput(stdout, workspaceRoot, truncated, searchCwd);
|
|
29
|
+
}
|
|
30
|
+
const records: GrepRecord[] = [];
|
|
31
|
+
let start = 0;
|
|
32
|
+
while (start < stdout.length) {
|
|
33
|
+
const nul = stdout.indexOf("\0", start);
|
|
34
|
+
if (nul === -1) {
|
|
35
|
+
if (truncated) break;
|
|
36
|
+
throw new Error("Missing NUL path delimiter.");
|
|
37
|
+
}
|
|
38
|
+
const reportedPath = stdout.slice(start, nul);
|
|
39
|
+
if (reportedPath === "") throw new Error("Empty path in ripgrep output.");
|
|
40
|
+
const filePath = toDisplayPath(
|
|
41
|
+
workspaceRoot,
|
|
42
|
+
path.resolve(searchCwd, reportedPath),
|
|
43
|
+
);
|
|
44
|
+
if (mode === "files_with_matches") {
|
|
45
|
+
records.push({ kind: "file", filePath });
|
|
46
|
+
start = nul + 1;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const end = stdout.indexOf("\n", nul + 1);
|
|
50
|
+
if (end === -1) {
|
|
51
|
+
if (truncated) break;
|
|
52
|
+
throw new Error("Unterminated count record.");
|
|
53
|
+
}
|
|
54
|
+
const value = stdout.slice(nul + 1, end);
|
|
55
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) {
|
|
56
|
+
throw new Error("Invalid count in ripgrep output.");
|
|
57
|
+
}
|
|
58
|
+
records.push({ kind: "count", filePath, count: Number(value) });
|
|
59
|
+
start = end + 1;
|
|
60
|
+
}
|
|
61
|
+
return records;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseJsonOutput(
|
|
65
|
+
stdout: string,
|
|
66
|
+
workspaceRoot: string,
|
|
67
|
+
truncated: boolean,
|
|
68
|
+
searchCwd: string,
|
|
69
|
+
): GrepRecord[] {
|
|
70
|
+
const records: GrepRecord[] = [];
|
|
71
|
+
let start = 0;
|
|
72
|
+
while (start < stdout.length) {
|
|
73
|
+
const end = stdout.indexOf("\n", start);
|
|
74
|
+
if (end === -1) {
|
|
75
|
+
if (truncated) break;
|
|
76
|
+
throw new Error("Unterminated JSON event.");
|
|
77
|
+
}
|
|
78
|
+
const event: unknown = JSON.parse(stdout.slice(start, end));
|
|
79
|
+
start = end + 1;
|
|
80
|
+
if (!isRecord(event) || typeof event.type !== "string" || !isRecord(event.data)) {
|
|
81
|
+
throw new Error("Invalid ripgrep JSON event.");
|
|
82
|
+
}
|
|
83
|
+
if (["begin", "end", "summary"].includes(event.type)) continue;
|
|
84
|
+
if (event.type !== "match" && event.type !== "context") {
|
|
85
|
+
throw new Error("Unexpected ripgrep JSON event type.");
|
|
86
|
+
}
|
|
87
|
+
const data = event.data;
|
|
88
|
+
const reportedPath = decodeText(data.path, true);
|
|
89
|
+
if (reportedPath === "") throw new Error("Empty path in ripgrep JSON event.");
|
|
90
|
+
if (
|
|
91
|
+
typeof data.line_number !== "number" ||
|
|
92
|
+
!Number.isSafeInteger(data.line_number) ||
|
|
93
|
+
data.line_number < 1
|
|
94
|
+
) {
|
|
95
|
+
throw new Error("Invalid line number in ripgrep JSON event.");
|
|
96
|
+
}
|
|
97
|
+
const filePath = toDisplayPath(
|
|
98
|
+
workspaceRoot,
|
|
99
|
+
path.resolve(searchCwd, reportedPath),
|
|
100
|
+
);
|
|
101
|
+
records.push({
|
|
102
|
+
kind: event.type,
|
|
103
|
+
filePath,
|
|
104
|
+
lineNumber: data.line_number,
|
|
105
|
+
lines: excerptGrepLines(decodeBytes(data.lines), data.submatches),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return records;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function decodeText(value: unknown, isPath: boolean): string {
|
|
112
|
+
if (isRecord(value) && typeof value.text === "string") return value.text;
|
|
113
|
+
return new TextDecoder("utf-8", { fatal: isPath }).decode(decodeBytes(value));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function decodeBytes(value: unknown): Buffer {
|
|
117
|
+
if (!isRecord(value)) throw new Error("Invalid text field in ripgrep JSON event.");
|
|
118
|
+
if (typeof value.text === "string") return Buffer.from(value.text, "utf8");
|
|
119
|
+
if (
|
|
120
|
+
typeof value.bytes === "string" &&
|
|
121
|
+
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value.bytes)
|
|
122
|
+
) {
|
|
123
|
+
return Buffer.from(value.bytes, "base64");
|
|
124
|
+
}
|
|
125
|
+
throw new Error("Invalid text encoding in ripgrep JSON event.");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
129
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
130
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { GrepContentRecord, GrepRecord } from "./grep-output";
|
|
2
|
+
|
|
3
|
+
const defaultHeadLimit = 250;
|
|
4
|
+
|
|
5
|
+
export function applyHeadLimit<T>(items: T[], limit: number | undefined, offset = 0) {
|
|
6
|
+
const effectiveLimit = limit ?? defaultHeadLimit;
|
|
7
|
+
const selected =
|
|
8
|
+
effectiveLimit === 0
|
|
9
|
+
? items.slice(offset)
|
|
10
|
+
: items.slice(offset, offset + effectiveLimit);
|
|
11
|
+
const hasMore = offset + selected.length < items.length;
|
|
12
|
+
return {
|
|
13
|
+
items: selected,
|
|
14
|
+
totalResults: items.length,
|
|
15
|
+
returnedResults: selected.length,
|
|
16
|
+
hasMore,
|
|
17
|
+
nextOffset: hasMore ? offset + selected.length : undefined,
|
|
18
|
+
appliedLimit: hasMore ? effectiveLimit : undefined,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Match events select windows; matches encountered inside a window never expand it. */
|
|
23
|
+
export function applyContentHeadLimit(
|
|
24
|
+
records: GrepRecord[],
|
|
25
|
+
limit: number | undefined,
|
|
26
|
+
offset: number,
|
|
27
|
+
context: { before: number; after: number },
|
|
28
|
+
) {
|
|
29
|
+
const matches = records.filter(
|
|
30
|
+
(record): record is GrepContentRecord => record.kind === "match",
|
|
31
|
+
);
|
|
32
|
+
const page = applyHeadLimit(matches, limit, offset);
|
|
33
|
+
const windows = new Map<string, { start: number; end: number }[]>();
|
|
34
|
+
for (const match of page.items) {
|
|
35
|
+
const ranges = windows.get(match.filePath) ?? [];
|
|
36
|
+
const start = Math.max(1, match.lineNumber - context.before);
|
|
37
|
+
const end = match.lineNumber + match.lines.length - 1 + context.after;
|
|
38
|
+
const last = ranges.at(-1);
|
|
39
|
+
if (last !== undefined && start <= last.end + 1) {
|
|
40
|
+
last.end = Math.max(last.end, end);
|
|
41
|
+
} else {
|
|
42
|
+
ranges.push({ start, end });
|
|
43
|
+
}
|
|
44
|
+
windows.set(match.filePath, ranges);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const items: GrepContentRecord[] = [];
|
|
48
|
+
// rg emits each physical line once, in file/line order. Walk merged windows
|
|
49
|
+
// linearly rather than rescanning all records for every selected match.
|
|
50
|
+
const cursors = new Map<string, number>();
|
|
51
|
+
for (const record of records) {
|
|
52
|
+
if (record.kind !== "match" && record.kind !== "context") continue;
|
|
53
|
+
const ranges = windows.get(record.filePath);
|
|
54
|
+
if (ranges === undefined) continue;
|
|
55
|
+
let cursor = cursors.get(record.filePath) ?? 0;
|
|
56
|
+
for (const [index, text] of record.lines.entries()) {
|
|
57
|
+
const lineNumber = record.lineNumber + index;
|
|
58
|
+
while (cursor < ranges.length && ranges[cursor].end < lineNumber) cursor++;
|
|
59
|
+
const range = ranges[cursor];
|
|
60
|
+
if (range === undefined) break;
|
|
61
|
+
if (lineNumber >= range.start) {
|
|
62
|
+
items.push({
|
|
63
|
+
kind: record.kind,
|
|
64
|
+
filePath: record.filePath,
|
|
65
|
+
lineNumber,
|
|
66
|
+
lines: [text],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
cursors.set(record.filePath, cursor);
|
|
71
|
+
}
|
|
72
|
+
return { ...page, items };
|
|
73
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Quote only paths that need escaping; ordinary paths remain easy to scan/copy.
|
|
2
|
+
export function formatGrepPath(filePath: string): string {
|
|
3
|
+
return /[\p{Cc}\p{Cf}"\\\u2028\u2029]/u.test(filePath)
|
|
4
|
+
? JSON.stringify(filePath).replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, (character) =>
|
|
5
|
+
character
|
|
6
|
+
.split("")
|
|
7
|
+
.map((unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`)
|
|
8
|
+
.join(""),
|
|
9
|
+
)
|
|
10
|
+
: filePath;
|
|
11
|
+
}
|
|
@@ -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
|
+
}
|