dsh-wsl-workspace 0.4.3 → 0.7.2
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 +131 -3
- package/README.zh.md +101 -2
- package/TESTING.md +32 -6
- package/lib/client.js +44 -6
- package/lib/client.js.map +1 -1
- package/lib/fs.js +135 -4
- package/lib/fs.js.map +1 -1
- package/lib/index.js +508 -83
- package/lib/index.js.map +1 -1
- package/lib/links-BQxdJOMy.js +66 -0
- package/lib/links-BQxdJOMy.js.map +1 -0
- package/lib/shell.js +2 -2
- package/lib/{wsl-6VcgOvF9.js → wsl-CECXiGZH.js} +15 -2
- package/lib/{wsl-6VcgOvF9.js.map → wsl-CECXiGZH.js.map} +1 -1
- package/lib/{wsl-credentials-B6wIXsKt.js → wsl-credentials-BJXc9tfi.js} +2 -2
- package/lib/{wsl-credentials-B6wIXsKt.js.map → wsl-credentials-BJXc9tfi.js.map} +1 -1
- package/lib/wsl-jobs.js +139 -0
- package/lib/wsl-jobs.js.map +1 -0
- package/lib/wsl-relay.js +64 -0
- package/lib/wsl-relay.js.map +1 -0
- package/lib/wsl-sandbox.js +27 -0
- package/lib/wsl-sandbox.js.map +1 -0
- package/lib/wsl-search.js +957 -0
- package/lib/wsl-search.js.map +1 -0
- package/package.json +24 -4
- package/src/client/help.tsx +11 -0
- package/src/client/locales.ts +14 -6
- package/src/client/styles.ts +9 -0
- package/src/fs.ts +210 -6
- package/src/host/variants.ts +211 -24
- package/src/host/wsl-jobs.ts +229 -0
- package/src/host/wsl-relay.ts +89 -0
- package/src/host/wsl-sandbox.ts +47 -0
- package/src/host/wsl-search.ts +1166 -0
- package/src/host/wsl-skills.ts +353 -66
- package/src/index.ts +120 -6
- package/src/shared/links.ts +90 -0
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
import { defaultDistroSync, isAbsoluteLinuxPath, parseWslUnc, windowsToMntPath } from "./wsl-CECXiGZH.js";
|
|
2
|
+
import { getWorkspaceUsername } from "./wsl-credentials-BJXc9tfi.js";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { posix } from "node:path";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
7
|
+
import { GLOB_MAX_RESULTS, GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, RAW_OUTPUT_MAX_BYTES, SEARCH_META_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, formatGlobOutput, formatGrepMatches, formatGrepOutput, parseGlobArgs, parseGrepArgs, presentGlobCall, presentGlobResult, presentGrepCall, presentGrepResult, previewLine, sampleAcrossTopLevel, trySaveFormattedResult } from "@deepseek-ai/dsh-tool-fs-search";
|
|
8
|
+
|
|
9
|
+
//#region src/host/wsl-search.ts
|
|
10
|
+
/**
|
|
11
|
+
* The fixed `grep` script the distribution runs. Model-controlled values arrive
|
|
12
|
+
* as positional parameters, so no value is ever parsed by a shell; `$0` is the
|
|
13
|
+
* literal {@link SCRIPT_ARGV0} marker (not a path).
|
|
14
|
+
*
|
|
15
|
+
* `-r` recurses, `-n` numbers lines, `-I` skips binary files, `-E` is POSIX ERE,
|
|
16
|
+
* `-H` forces the file name onto every record (grep omits it when handed a single
|
|
17
|
+
* file, which would break the framing below), and `-Z` terminates each file name
|
|
18
|
+
* with NUL so the Node side can frame `path\0line:text` records unambiguously.
|
|
19
|
+
*
|
|
20
|
+
* The exclusions stand in for ripgrep's default hidden-entry skipping, which GNU
|
|
21
|
+
* grep cannot express (it has no `.gitignore` support either). Three GNU quirks
|
|
22
|
+
* shape how they are written, each measured against grep 3.12 on Ubuntu:
|
|
23
|
+
* - A file `--exclude` pattern silently cancels `--include` entirely, so the
|
|
24
|
+
* hidden-file guard rides `--include='[!.]*'` instead — and only when the
|
|
25
|
+
* caller passed no filter of its own (repeated includes are OR-ed) and only
|
|
26
|
+
* for a *directory* target, because a file the caller named explicitly is
|
|
27
|
+
* exactly what it asked for.
|
|
28
|
+
* - `--exclude-dir` excludes the search root itself when its base name starts
|
|
29
|
+
* with a dot, so that flag is skipped for a dot-rooted target.
|
|
30
|
+
* - `head -c` bounds the transfer in-distro, `PIPESTATUS[0]` keeps grep's own
|
|
31
|
+
* status rather than head's, and the search root is resolved physically first
|
|
32
|
+
* so a linked-in directory is searched where the file tools would read it.
|
|
33
|
+
*
|
|
34
|
+
* The search root is resolved physically first, so the printed paths are
|
|
35
|
+
* absolute and a linked-in directory is searched at its real path — the same
|
|
36
|
+
* place the file tools read and write.
|
|
37
|
+
*
|
|
38
|
+
* A distribution whose `grep` is not GNU's exits 3 with a diagnostic instead of
|
|
39
|
+
* framing records the parser cannot read. Every mainstream WSL distribution
|
|
40
|
+
* (Debian, Ubuntu, Fedora, Arch, openSUSE) ships GNU grep; Alpine's busybox grep
|
|
41
|
+
* would report that clearly.
|
|
42
|
+
*/
|
|
43
|
+
const GREP_SCRIPT = [
|
|
44
|
+
"set -u",
|
|
45
|
+
"pattern=$1; include=$2; target=$3; cap=$4",
|
|
46
|
+
"[ -n \"$target\" ] || target=.",
|
|
47
|
+
"if [ -d \"$target\" ]; then",
|
|
48
|
+
" target=$(cd -- \"$target\" && pwd -P)",
|
|
49
|
+
" dir=1",
|
|
50
|
+
"else",
|
|
51
|
+
" case $target in /*) ;; *) target=$PWD/$target ;; esac",
|
|
52
|
+
" dir=0",
|
|
53
|
+
"fi",
|
|
54
|
+
"if ! grep --version 2>/dev/null | head -n 1 | grep -q GNU; then",
|
|
55
|
+
" printf \"grep is not GNU grep: %s\\n\" \"$(grep --version 2>/dev/null | head -n 1)\" >&2",
|
|
56
|
+
" exit 3",
|
|
57
|
+
"fi",
|
|
58
|
+
"opts=(-rnIEH -Z)",
|
|
59
|
+
"if [ \"$dir\" = 1 ]; then",
|
|
60
|
+
" opts+=(--exclude-dir=node_modules)",
|
|
61
|
+
" case ${target##*/} in .*) ;; *) opts+=(--exclude-dir=\".*\") ;; esac",
|
|
62
|
+
" [ -n \"$include\" ] || opts+=(--include=\"[!.]*\")",
|
|
63
|
+
"fi",
|
|
64
|
+
"if [ -n \"$include\" ]; then",
|
|
65
|
+
" while IFS= read -r one; do [ -n \"$one\" ] && opts+=(--include=\"$one\"); done <<<\"$include\"",
|
|
66
|
+
"fi",
|
|
67
|
+
"grep \"${opts[@]}\" --regexp=\"$pattern\" -- \"$target\" | head -c \"$cap\"",
|
|
68
|
+
"exit \"${PIPESTATUS[0]}\""
|
|
69
|
+
].join("\n");
|
|
70
|
+
/**
|
|
71
|
+
* The fixed `glob` script: an absolute file listing with modification times.
|
|
72
|
+
*
|
|
73
|
+
* `%T@` is the mtime in seconds (GNU find only), so the caller can order the
|
|
74
|
+
* result the way ripgrep's `--sort=modified` does — oldest first — without a
|
|
75
|
+
* stat per file and without a 9P round trip per file. VCS metadata directories
|
|
76
|
+
* are pruned; the pattern itself is matched in Node.
|
|
77
|
+
*
|
|
78
|
+
* The header is `G`/`P` (GNU `-printf`, else the plain `-print0` fallback)
|
|
79
|
+
* immediately followed by the physically resolved root, **NUL terminated** so a
|
|
80
|
+
* path containing a newline cannot split it; every record after it is NUL framed
|
|
81
|
+
* too. `PIPESTATUS[0]` is what the script exits with, because a `find` that
|
|
82
|
+
* cannot read the target must not look like an empty directory.
|
|
83
|
+
*/
|
|
84
|
+
const GLOB_SCRIPT = [
|
|
85
|
+
"set -u",
|
|
86
|
+
"target=$1; cap=$2",
|
|
87
|
+
"[ -n \"$target\" ] || target=.",
|
|
88
|
+
"if [ -d \"$target\" ]; then target=$(cd -- \"$target\" && pwd -P); else",
|
|
89
|
+
" case $target in /*) ;; *) target=$PWD/$target ;; esac",
|
|
90
|
+
"fi",
|
|
91
|
+
"prune=(-name .git -o -name .hg -o -name .svn -o -name .bzr -o -name .jj -o -name .sl)",
|
|
92
|
+
"if find --version 2>/dev/null | head -n 1 | grep -q GNU; then",
|
|
93
|
+
" { printf 'G%s\\0' \"$target\"; find \"$target\" \\( \"${prune[@]}\" \\) -prune -o -type f -printf '%T@\\t%p\\0'; } | head -c \"$cap\"",
|
|
94
|
+
"else",
|
|
95
|
+
" { printf 'P%s\\0' \"$target\"; find \"$target\" \\( \"${prune[@]}\" \\) -prune -o -type f -print0; } | head -c \"$cap\"",
|
|
96
|
+
"fi",
|
|
97
|
+
"exit \"${PIPESTATUS[0]}\""
|
|
98
|
+
].join("\n");
|
|
99
|
+
/** The marker `$0` of both scripts; a fixed string, never a path from the model. */
|
|
100
|
+
const SCRIPT_ARGV0 = "dsh";
|
|
101
|
+
/** GNU grep stderr shapes that mean "the pattern is not a valid regex". */
|
|
102
|
+
const INVALID_PATTERN = /Unmatched|Invalid (?:regular expression|range end|character class|back reference|preceding regular expression)|unrecognized|trailing backslash|parentheses not balanced|Invalid collating element/i;
|
|
103
|
+
/**
|
|
104
|
+
* The defaults, kept as data as well as schema fields. A world row that mounts
|
|
105
|
+
* this plugin without a `config:` block hands `apply` an *undefined* config —
|
|
106
|
+
* schemastery's defaults are not applied on that path — and a live session
|
|
107
|
+
* caught exactly that as `Cannot read properties of undefined (reading
|
|
108
|
+
* 'grepMaxMatches')`, which failed the whole world. Reading the defaults from one
|
|
109
|
+
* place keeps the schema and the fallback from drifting apart.
|
|
110
|
+
*/
|
|
111
|
+
const DEFAULTS = {
|
|
112
|
+
grepMaxMatches: GREP_MAX_MATCHES,
|
|
113
|
+
grepMaxLineBytes: GREP_MAX_LINE_BYTES,
|
|
114
|
+
globMaxResults: GLOB_MAX_RESULTS,
|
|
115
|
+
sampleOverCapGlobResults: false,
|
|
116
|
+
searchMetaMaxBytes: SEARCH_META_MAX_BYTES,
|
|
117
|
+
rawOutputMaxBytes: RAW_OUTPUT_MAX_BYTES,
|
|
118
|
+
timeoutMs: SEARCH_TIMEOUT_MS,
|
|
119
|
+
wslPath: "wsl.exe"
|
|
120
|
+
};
|
|
121
|
+
/** Validated plugin config. */
|
|
122
|
+
const Config = z.object({
|
|
123
|
+
grepMaxMatches: z.number().default(DEFAULTS.grepMaxMatches),
|
|
124
|
+
grepMaxLineBytes: z.number().default(DEFAULTS.grepMaxLineBytes),
|
|
125
|
+
globMaxResults: z.number().default(DEFAULTS.globMaxResults),
|
|
126
|
+
sampleOverCapGlobResults: z.boolean().default(DEFAULTS.sampleOverCapGlobResults),
|
|
127
|
+
searchMetaMaxBytes: z.number().default(DEFAULTS.searchMetaMaxBytes),
|
|
128
|
+
rawOutputMaxBytes: z.number().default(DEFAULTS.rawOutputMaxBytes),
|
|
129
|
+
timeoutMs: z.number().default(DEFAULTS.timeoutMs),
|
|
130
|
+
wslPath: z.string().default(DEFAULTS.wslPath),
|
|
131
|
+
distro: z.string()
|
|
132
|
+
});
|
|
133
|
+
/** Services these tools register into. */
|
|
134
|
+
const inject = ["tools"];
|
|
135
|
+
/**
|
|
136
|
+
* Frame a raw `grep -rnIE -Z` stdout buffer into matches.
|
|
137
|
+
*
|
|
138
|
+
* GNU grep prints `<path>\0<line>:<text>\n` per match, so splitting on NUL and
|
|
139
|
+
* then on the first newline of each following segment recovers all three fields
|
|
140
|
+
* even when a path contains spaces, colons or newlines (the text cannot, because
|
|
141
|
+
* grep reports one line at a time and `-I` keeps NUL bytes out of the stream).
|
|
142
|
+
* @param stdout - the complete raw stdout bytes.
|
|
143
|
+
* @returns the matches in output order.
|
|
144
|
+
*/
|
|
145
|
+
function parseGrepRecords(stdout) {
|
|
146
|
+
const records = [];
|
|
147
|
+
const segments = stdout.toString("utf8").split("\0");
|
|
148
|
+
let path = segments[0] ?? "";
|
|
149
|
+
for (let index = 1; index < segments.length; index += 1) {
|
|
150
|
+
const segment = segments[index] ?? "";
|
|
151
|
+
if (segment === "") continue;
|
|
152
|
+
const newline = segment.indexOf("\n");
|
|
153
|
+
const head = newline < 0 ? segment : segment.slice(0, newline);
|
|
154
|
+
const colon = head.indexOf(":");
|
|
155
|
+
const lineNumber = Number.parseInt(colon < 0 ? head : head.slice(0, colon), 10);
|
|
156
|
+
if (Number.isFinite(lineNumber)) records.push({
|
|
157
|
+
path,
|
|
158
|
+
lineNumber,
|
|
159
|
+
line: colon < 0 ? "" : head.slice(colon + 1)
|
|
160
|
+
});
|
|
161
|
+
path = newline < 0 ? "" : segment.slice(newline + 1);
|
|
162
|
+
}
|
|
163
|
+
return records;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Frame a raw `glob` stdout buffer into a listing mode, a resolved root and
|
|
167
|
+
* entries.
|
|
168
|
+
*
|
|
169
|
+
* The script prefixes its output with a NUL-terminated `<mode><root>` header
|
|
170
|
+
* (`G` = GNU `-printf`: `mtime\tpath` records; `P` = plain `-print0`: paths only,
|
|
171
|
+
* no modification times). NUL framing keeps a root whose own path contains a
|
|
172
|
+
* newline in one piece.
|
|
173
|
+
* @param stdout - the complete raw stdout bytes.
|
|
174
|
+
* @returns the listing; `plain` entries carry `mtimeMs: 0`.
|
|
175
|
+
*/
|
|
176
|
+
function parseGlobRecords(stdout) {
|
|
177
|
+
const headerEnd = stdout.indexOf(0);
|
|
178
|
+
const header = (headerEnd < 0 ? stdout : stdout.subarray(0, headerEnd)).toString("utf8");
|
|
179
|
+
const mode = header.startsWith("G") ? "gnu" : "plain";
|
|
180
|
+
const root = header.length > 0 ? header.slice(1) : "";
|
|
181
|
+
const files = [];
|
|
182
|
+
const body = headerEnd < 0 ? Buffer.alloc(0) : stdout.subarray(headerEnd + 1);
|
|
183
|
+
for (const entry of body.toString("utf8").split("\0")) {
|
|
184
|
+
if (entry === "") continue;
|
|
185
|
+
if (mode === "plain") {
|
|
186
|
+
files.push({
|
|
187
|
+
path: entry,
|
|
188
|
+
mtimeMs: 0
|
|
189
|
+
});
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const tab = entry.indexOf(" ");
|
|
193
|
+
if (tab < 0) continue;
|
|
194
|
+
const seconds = Number.parseFloat(entry.slice(0, tab));
|
|
195
|
+
files.push({
|
|
196
|
+
path: entry.slice(tab + 1),
|
|
197
|
+
mtimeMs: Number.isFinite(seconds) ? Math.round(seconds * 1e3) : 0
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
mode,
|
|
202
|
+
root,
|
|
203
|
+
files
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Expand one glob's `{a,b,c}` alternations into the equivalent list of plain
|
|
208
|
+
* globs. ripgrep's globset understands braces; GNU grep's `--include` does not,
|
|
209
|
+
* so the tool passes one `--include` per expansion (repeated includes are OR-ed).
|
|
210
|
+
* @param glob - the glob to expand.
|
|
211
|
+
* @returns every brace-free alternative, in source order.
|
|
212
|
+
*/
|
|
213
|
+
function expandBraces(glob) {
|
|
214
|
+
const open = glob.indexOf("{");
|
|
215
|
+
if (open < 0) return [glob];
|
|
216
|
+
const close = glob.indexOf("}", open + 1);
|
|
217
|
+
if (close < 0) return [glob];
|
|
218
|
+
const head = glob.slice(0, open);
|
|
219
|
+
const tail = glob.slice(close + 1);
|
|
220
|
+
return glob.slice(open + 1, close).split(",").flatMap((part) => expandBraces(`${head}${part}${tail}`));
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Translate one gitignore-style glob into a regular-expression source (no
|
|
224
|
+
* anchors). `*` and `?` never cross a separator, `**` does (a `**` followed by a
|
|
225
|
+
* separator also matches zero segments), `[...]` is a character class (negated
|
|
226
|
+
* by a leading `!` or `^`), and `{a,b}` is an alternation.
|
|
227
|
+
* @param pattern - the glob source.
|
|
228
|
+
* @returns the unanchored regex source.
|
|
229
|
+
*/
|
|
230
|
+
function globSource(pattern) {
|
|
231
|
+
let source = "";
|
|
232
|
+
let index = 0;
|
|
233
|
+
while (index < pattern.length) {
|
|
234
|
+
const char = pattern[index] ?? "";
|
|
235
|
+
if (char === "*") {
|
|
236
|
+
if (pattern[index + 1] === "*") {
|
|
237
|
+
if (pattern[index + 2] === "/") {
|
|
238
|
+
source += "(?:[^/]*/)*";
|
|
239
|
+
index += 3;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
source += ".*";
|
|
243
|
+
index += 2;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
source += "[^/]*";
|
|
247
|
+
index += 1;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (char === "?") {
|
|
251
|
+
source += "[^/]";
|
|
252
|
+
index += 1;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (char === "[") {
|
|
256
|
+
const close = pattern.indexOf("]", index + 2);
|
|
257
|
+
if (close > index + 1) {
|
|
258
|
+
const body = pattern.slice(index + 1, close);
|
|
259
|
+
const negated = body.startsWith("!") || body.startsWith("^");
|
|
260
|
+
const chars = (negated ? body.slice(1) : body).replace(/[\\^\]]/g, "\\$&");
|
|
261
|
+
source += `[${negated ? "^" : ""}${chars}]`;
|
|
262
|
+
index = close + 1;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
source += "\\[";
|
|
266
|
+
index += 1;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (char === "{") {
|
|
270
|
+
const close = pattern.indexOf("}", index + 1);
|
|
271
|
+
if (close > index + 1) {
|
|
272
|
+
const alternatives = pattern.slice(index + 1, close).split(",").map((part) => globSource(part));
|
|
273
|
+
source += `(?:${alternatives.join("|")})`;
|
|
274
|
+
index = close + 1;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
source += "\\{";
|
|
278
|
+
index += 1;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
source += /[.+^$()|\\]/.test(char) ? `\\${char}` : char;
|
|
282
|
+
index += 1;
|
|
283
|
+
}
|
|
284
|
+
return source;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Compile one glob into a whole-string matcher.
|
|
288
|
+
* @param pattern - the glob source.
|
|
289
|
+
* @returns the anchored regular expression.
|
|
290
|
+
*/
|
|
291
|
+
function globToRegExp(pattern) {
|
|
292
|
+
return /* @__PURE__ */ new RegExp(`^${globSource(pattern)}$`);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Whether one file passes a glob filter. A pattern containing `/` matches the
|
|
296
|
+
* path relative to the search root; one without matches the basename at any
|
|
297
|
+
* depth. A leading `!` negates the match, so `!*.log` keeps everything that is
|
|
298
|
+
* not a log file.
|
|
299
|
+
* @param pattern - the glob filter.
|
|
300
|
+
* @param relativePath - the candidate's path relative to the search root.
|
|
301
|
+
* @returns true when the file is selected.
|
|
302
|
+
*/
|
|
303
|
+
function matchGlob(pattern, relativePath) {
|
|
304
|
+
let body = pattern;
|
|
305
|
+
let negated = false;
|
|
306
|
+
if (body.startsWith("!")) {
|
|
307
|
+
negated = true;
|
|
308
|
+
body = body.slice(1);
|
|
309
|
+
}
|
|
310
|
+
const candidate = body.includes("/") ? relativePath : posix.basename(relativePath);
|
|
311
|
+
const matched = globToRegExp(body).test(candidate);
|
|
312
|
+
return negated ? !matched : matched;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Map one absolute Linux path to what the model should see: relative to the
|
|
316
|
+
* session workdir when it is inside it, absolute otherwise. The POSIX twin of
|
|
317
|
+
* the host suite's `toWorkdirRelative`, which is Windows-flavored
|
|
318
|
+
* (`path.relative` prints `src\a.ts`).
|
|
319
|
+
* @param absolutePath - the search's absolute Linux path.
|
|
320
|
+
* @param workdir - the session workdir in Linux coordinates.
|
|
321
|
+
* @returns the display path.
|
|
322
|
+
*/
|
|
323
|
+
function displayPath(absolutePath, workdir) {
|
|
324
|
+
const relative = posix.relative(workdir, absolutePath);
|
|
325
|
+
if (relative === "") return ".";
|
|
326
|
+
return relative.startsWith("..") || posix.isAbsolute(relative) ? absolutePath : relative;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Apply DSH's inline caps to a match list: preview each line and keep the first
|
|
330
|
+
* `maxMatches`. Byte-for-byte the outcome of the host suite's private
|
|
331
|
+
* `retainGrepMatches` (`ItemRetainer` head + `previewLine`), which that package
|
|
332
|
+
* does not export.
|
|
333
|
+
* @param matches - every match the search parsed.
|
|
334
|
+
* @param maxMatches - the inline match cap.
|
|
335
|
+
* @param maxLineBytes - the per-line preview budget.
|
|
336
|
+
* @returns the retention outcome, with exact omission metadata.
|
|
337
|
+
*/
|
|
338
|
+
function retainMatches(matches, maxMatches, maxLineBytes) {
|
|
339
|
+
const items = matches.slice(0, Math.max(0, maxMatches)).map((match) => ({
|
|
340
|
+
...match,
|
|
341
|
+
line: previewLine(match.line, maxLineBytes)
|
|
342
|
+
}));
|
|
343
|
+
const omitted = Math.max(0, matches.length - items.length);
|
|
344
|
+
return {
|
|
345
|
+
items,
|
|
346
|
+
truncated: omitted > 0,
|
|
347
|
+
seen: matches.length,
|
|
348
|
+
kept: items.length,
|
|
349
|
+
omitted: omitted > 0 ? {
|
|
350
|
+
kind: "exact",
|
|
351
|
+
count: omitted
|
|
352
|
+
} : { kind: "none" }
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Apply DSH's inline cap to a path list (the host suite's private
|
|
357
|
+
* `retainGlobPaths`).
|
|
358
|
+
* @param paths - every discovered path.
|
|
359
|
+
* @param maxResults - the inline path cap.
|
|
360
|
+
* @returns the retention outcome, with exact omission metadata.
|
|
361
|
+
*/
|
|
362
|
+
function retainPaths(paths, maxResults) {
|
|
363
|
+
const items = paths.slice(0, Math.max(0, maxResults));
|
|
364
|
+
const omitted = Math.max(0, paths.length - items.length);
|
|
365
|
+
return {
|
|
366
|
+
items,
|
|
367
|
+
truncated: omitted > 0,
|
|
368
|
+
seen: paths.length,
|
|
369
|
+
kept: items.length,
|
|
370
|
+
omitted: omitted > 0 ? {
|
|
371
|
+
kind: "exact",
|
|
372
|
+
count: omitted
|
|
373
|
+
} : { kind: "none" }
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/** The serialized UTF-8 byte size of one card payload (the size persisted and re-sent). */
|
|
377
|
+
function metaBytes(meta) {
|
|
378
|
+
return Buffer.byteLength(JSON.stringify(meta), "utf8");
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Drop trailing file groups (or paths) until the serialized card metadata fits
|
|
382
|
+
* `maxMetaBytes`, marking it truncated. Mirrors the host suite's private
|
|
383
|
+
* `capMetaBytes`: `total` keeps counting what the search found, and a single
|
|
384
|
+
* oversized item survives rather than producing an empty card.
|
|
385
|
+
* @param meta - the projected metadata, already capped to the inline item count.
|
|
386
|
+
* @param maxMetaBytes - the serialized byte budget.
|
|
387
|
+
* @returns the same metadata when it fits, else a byte-bounded copy.
|
|
388
|
+
*/
|
|
389
|
+
function capMetaBytes(meta, maxMetaBytes) {
|
|
390
|
+
if (metaBytes(meta) <= maxMetaBytes) return meta;
|
|
391
|
+
if (meta.shape === "matches") {
|
|
392
|
+
const files = [...meta.files];
|
|
393
|
+
while (files.length > 1 && metaBytes({
|
|
394
|
+
...meta,
|
|
395
|
+
files,
|
|
396
|
+
truncated: true
|
|
397
|
+
}) > maxMetaBytes) files.pop();
|
|
398
|
+
return {
|
|
399
|
+
...meta,
|
|
400
|
+
files,
|
|
401
|
+
truncated: true
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
const paths = [...meta.paths];
|
|
405
|
+
while (paths.length > 1 && metaBytes({
|
|
406
|
+
...meta,
|
|
407
|
+
paths,
|
|
408
|
+
truncated: true
|
|
409
|
+
}) > maxMetaBytes) paths.pop();
|
|
410
|
+
return {
|
|
411
|
+
...meta,
|
|
412
|
+
paths,
|
|
413
|
+
truncated: true
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Project a retained match page into the `matches`-shaped search card: the host
|
|
418
|
+
* suite's private `grepSearchMeta` + `groupMatchesByFile`, reproduced verbatim
|
|
419
|
+
* (first-seen file order, then the byte cap).
|
|
420
|
+
* @param page - the retained page (previewed, capped) or any equivalent subset.
|
|
421
|
+
* @param maxMetaBytes - the serialized metadata budget.
|
|
422
|
+
* @returns the card metadata.
|
|
423
|
+
*/
|
|
424
|
+
function grepSearchMeta(page, maxMetaBytes) {
|
|
425
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
426
|
+
for (const match of page.items) {
|
|
427
|
+
const entry = {
|
|
428
|
+
lineNumber: match.lineNumber,
|
|
429
|
+
line: match.line
|
|
430
|
+
};
|
|
431
|
+
const group = byFile.get(match.path);
|
|
432
|
+
if (group === void 0) byFile.set(match.path, [entry]);
|
|
433
|
+
else group.push(entry);
|
|
434
|
+
}
|
|
435
|
+
return capMetaBytes({
|
|
436
|
+
shape: "matches",
|
|
437
|
+
files: Array.from(byFile, ([path, matches]) => ({
|
|
438
|
+
path,
|
|
439
|
+
matches
|
|
440
|
+
})),
|
|
441
|
+
truncated: page.truncated,
|
|
442
|
+
total: page.seen
|
|
443
|
+
}, maxMetaBytes);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Project a retained path page into the `paths`-shaped search card (the host
|
|
447
|
+
* suite's private `globSearchMeta`).
|
|
448
|
+
* @param page - the retained page (capped) or any equivalent subset.
|
|
449
|
+
* @param maxMetaBytes - the serialized metadata budget.
|
|
450
|
+
* @returns the card metadata.
|
|
451
|
+
*/
|
|
452
|
+
function globSearchMeta(page, maxMetaBytes) {
|
|
453
|
+
return capMetaBytes({
|
|
454
|
+
shape: "paths",
|
|
455
|
+
paths: [...page.items],
|
|
456
|
+
truncated: page.truncated,
|
|
457
|
+
total: page.seen
|
|
458
|
+
}, maxMetaBytes);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Format one capped `glob` page in the flat (head) style, mirroring the host
|
|
462
|
+
* suite's private `formatGlobPage`.
|
|
463
|
+
* @param items - the page shown inline.
|
|
464
|
+
* @param seen - how many paths the complete result holds.
|
|
465
|
+
* @param spillRef - the saved complete-result reference, or undefined when unsaved.
|
|
466
|
+
* @returns the model-facing text.
|
|
467
|
+
*/
|
|
468
|
+
function formatGlobPage(items, seen, spillRef) {
|
|
469
|
+
const recovery = spillRef !== void 0 ? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}` : "The complete result could not be saved; narrow pattern or path to see more.";
|
|
470
|
+
return `${items.join("\n")}\n\n(Showing ${items.length} of ${seen} paths. ${recovery})`;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Normalize a spill reference for rendering.
|
|
474
|
+
*
|
|
475
|
+
* The canonical value is validated against the tool's output schema, and a
|
|
476
|
+
* backend's `SpillRef` carries fields of its own (`@deepseek-ai/dsh-spill`'s
|
|
477
|
+
* `SpillRef` is `{locator, bytes, retrievalHint}`), so the schema cannot close
|
|
478
|
+
* the object. This keeps the renderer's contract to the two fields it reads and
|
|
479
|
+
* survives a backend that omits the hint.
|
|
480
|
+
* @param spill - the backend's reference, as returned into the canonical value.
|
|
481
|
+
* @returns a render-ready reference, or undefined when there is nothing to name.
|
|
482
|
+
*/
|
|
483
|
+
function normalizeSpill(spill) {
|
|
484
|
+
if (spill === void 0 || spill === null) return void 0;
|
|
485
|
+
const locator = spill.locator;
|
|
486
|
+
if (typeof locator !== "string" || locator === "") return void 0;
|
|
487
|
+
const retrievalHint = spill.retrievalHint;
|
|
488
|
+
return {
|
|
489
|
+
...spill,
|
|
490
|
+
locator,
|
|
491
|
+
retrievalHint: typeof retrievalHint === "string" ? retrievalHint : ""
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* The spill reference's schema: an object whose extra fields are the backend's
|
|
496
|
+
* business. Requiring only what the footer prints keeps a version's `SpillRef`
|
|
497
|
+
* (which carries `bytes` too, and is branded) from failing the tool's own output
|
|
498
|
+
* validation — which is why `additionalProperties` is `true` here.
|
|
499
|
+
*/
|
|
500
|
+
const SPILL_SCHEMA = {
|
|
501
|
+
type: "object",
|
|
502
|
+
additionalProperties: true,
|
|
503
|
+
properties: {
|
|
504
|
+
locator: { type: "string" },
|
|
505
|
+
retrievalHint: { type: "string" }
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
/**
|
|
509
|
+
* Format one `glob` result exactly as the host suite's private `renderGlobPaths`
|
|
510
|
+
* does: whole when within the cap, else the modification-time head or the
|
|
511
|
+
* top-level sample, with the same footer.
|
|
512
|
+
* @param paths - the complete display-path list, in modification-time order.
|
|
513
|
+
* @param caps - the inline cap and the sampling switch.
|
|
514
|
+
* @param root - the search root in the same display-path space as `paths`.
|
|
515
|
+
* @param spillRef - the saved complete-result reference, or undefined when unsaved.
|
|
516
|
+
* @returns the model-facing text.
|
|
517
|
+
*/
|
|
518
|
+
function renderGlobText(paths, caps, root, spillRef) {
|
|
519
|
+
if (paths.length === 0) return "No files found";
|
|
520
|
+
if (paths.length <= caps.maxResults) return paths.join("\n");
|
|
521
|
+
if (!caps.sampleOverCapGlobResults) return formatGlobPage(paths.slice(0, caps.maxResults), paths.length, spillRef);
|
|
522
|
+
return formatGlobOutput(sampleAcrossTopLevel([...paths], caps.maxResults, root), paths.length, spillRef);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* The inline page a `glob` card shows, computed the same way {@link renderGlobText}
|
|
526
|
+
* computes its model page so text and card never disagree (the host suite's
|
|
527
|
+
* private `globCardPage`).
|
|
528
|
+
* @param paths - the complete display-path list.
|
|
529
|
+
* @param caps - the inline cap and the sampling switch.
|
|
530
|
+
* @param root - the search root in the same display-path space as `paths`.
|
|
531
|
+
* @returns the page and whether the complete result was capped.
|
|
532
|
+
*/
|
|
533
|
+
function globCardPage(paths, caps, root) {
|
|
534
|
+
if (paths.length <= caps.maxResults) return {
|
|
535
|
+
items: [...paths],
|
|
536
|
+
truncated: false
|
|
537
|
+
};
|
|
538
|
+
if (!caps.sampleOverCapGlobResults) return {
|
|
539
|
+
items: paths.slice(0, caps.maxResults),
|
|
540
|
+
truncated: true
|
|
541
|
+
};
|
|
542
|
+
return {
|
|
543
|
+
items: sampleAcrossTopLevel([...paths], caps.maxResults, root).items,
|
|
544
|
+
truncated: true
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Format one `grep` result exactly as the host suite's private
|
|
549
|
+
* `formatRetainedGrep` does, including its zero-match wording.
|
|
550
|
+
* @param matches - every match the search returned, with display paths.
|
|
551
|
+
* @param caps - the inline caps.
|
|
552
|
+
* @param spillRef - the saved complete-result reference, or undefined when unsaved.
|
|
553
|
+
* @returns the model-facing text.
|
|
554
|
+
*/
|
|
555
|
+
function renderGrepText(matches, caps, spillRef) {
|
|
556
|
+
const retained = retainMatches(matches, caps.maxMatches, caps.maxLineBytes);
|
|
557
|
+
if (retained.seen === 0) return "No matches found";
|
|
558
|
+
return formatGrepOutput(retained, spillRef);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Resolve the distribution and Linux workdir one search runs in, mirroring the
|
|
562
|
+
* shell and filesystem providers' chain: a WSL UNC cwd carries both, an absolute
|
|
563
|
+
* Linux cwd uses the configured distribution (then the host default).
|
|
564
|
+
* @param cwd - the calling session's cwd.
|
|
565
|
+
* @param configured - the world's configured distribution, when any.
|
|
566
|
+
* @returns the target, or undefined when the cwd is not in a WSL world.
|
|
567
|
+
*/
|
|
568
|
+
function resolveTarget(cwd, configured) {
|
|
569
|
+
if (cwd === void 0 || cwd === "") return void 0;
|
|
570
|
+
const unc = parseWslUnc(cwd);
|
|
571
|
+
if (unc !== null) {
|
|
572
|
+
const linuxCwd = unc.linuxPath === "" ? "/" : unc.linuxPath;
|
|
573
|
+
const username = getWorkspaceUsername(cwd);
|
|
574
|
+
return {
|
|
575
|
+
distro: unc.distro,
|
|
576
|
+
linuxCwd,
|
|
577
|
+
...username === void 0 ? {} : { username }
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
if (!isAbsoluteLinuxPath(cwd)) return void 0;
|
|
581
|
+
const distro = configured !== void 0 && configured.trim() !== "" ? configured.trim() : defaultDistroSync();
|
|
582
|
+
if (distro === void 0 || distro === "") return void 0;
|
|
583
|
+
return {
|
|
584
|
+
distro,
|
|
585
|
+
linuxCwd: cwd
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Express a model-supplied search target in Linux coordinates: a WSL UNC path
|
|
590
|
+
* becomes its Linux form, a Windows drive path becomes its `/mnt/<drive>` form
|
|
591
|
+
* (the same translation the file tools apply, so `grep path='D:\proj'` searches
|
|
592
|
+
* what `read D:\proj\a.ts` reads), and everything else — absolute, relative,
|
|
593
|
+
* empty — is used as typed and resolved by the distribution against the session
|
|
594
|
+
* workdir.
|
|
595
|
+
* @param path - the tool call's `path`, when given.
|
|
596
|
+
* @returns the script's `target` parameter.
|
|
597
|
+
*/
|
|
598
|
+
function linuxTarget(path) {
|
|
599
|
+
if (path === void 0) return "";
|
|
600
|
+
const unc = parseWslUnc(path);
|
|
601
|
+
if (unc !== null) return unc.linuxPath === "" ? "/" : unc.linuxPath;
|
|
602
|
+
return windowsToMntPath(path) ?? path;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Build the `wsl.exe` argv for one search. Values ride as positional parameters
|
|
606
|
+
* after the fixed script, so nothing the model typed is ever parsed by a shell.
|
|
607
|
+
* @param target - where to run.
|
|
608
|
+
* @param script - the fixed script.
|
|
609
|
+
* @param values - the script's positional parameters.
|
|
610
|
+
* @returns the complete argv vector.
|
|
611
|
+
*/
|
|
612
|
+
function buildWslArgv(target, script, values) {
|
|
613
|
+
return [
|
|
614
|
+
"-d",
|
|
615
|
+
target.distro,
|
|
616
|
+
...target.username === void 0 ? [] : ["-u", target.username],
|
|
617
|
+
"--cd",
|
|
618
|
+
target.linuxCwd,
|
|
619
|
+
"-e",
|
|
620
|
+
"bash",
|
|
621
|
+
"-c",
|
|
622
|
+
script,
|
|
623
|
+
SCRIPT_ARGV0,
|
|
624
|
+
...values
|
|
625
|
+
];
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Run one fixed script inside the distribution.
|
|
629
|
+
*
|
|
630
|
+
* Errors stay in the host suite's two domains: a spawn failure (a missing
|
|
631
|
+
* `wsl.exe`) rejects, while every completed or killed run comes back for the
|
|
632
|
+
* caller to classify.
|
|
633
|
+
* @param argv - the complete `wsl.exe` argv.
|
|
634
|
+
* @param wslPath - the executable to run.
|
|
635
|
+
* @param timeoutMs - the hard kill deadline.
|
|
636
|
+
* @param rawOutputMaxBytes - the stdout budget the script already bounded.
|
|
637
|
+
* @param signal - caller cancellation.
|
|
638
|
+
* @returns the run outcome.
|
|
639
|
+
*/
|
|
640
|
+
function runInDistro(argv, wslPath, timeoutMs, rawOutputMaxBytes, signal) {
|
|
641
|
+
return new Promise((settle, fail) => {
|
|
642
|
+
execFile(wslPath, [...argv], {
|
|
643
|
+
maxBuffer: rawOutputMaxBytes + 1048576,
|
|
644
|
+
encoding: "buffer",
|
|
645
|
+
timeout: timeoutMs,
|
|
646
|
+
killSignal: "SIGKILL",
|
|
647
|
+
windowsHide: true,
|
|
648
|
+
...signal === void 0 ? {} : { signal }
|
|
649
|
+
}, (error, stdout, stderr) => {
|
|
650
|
+
const out = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout ?? "");
|
|
651
|
+
const err = Buffer.isBuffer(stderr) ? stderr.toString("utf8") : String(stderr ?? "");
|
|
652
|
+
if (error === null || error === void 0) {
|
|
653
|
+
settle({
|
|
654
|
+
code: 0,
|
|
655
|
+
stdout: out,
|
|
656
|
+
stderr: err,
|
|
657
|
+
aborted: false,
|
|
658
|
+
signal: null
|
|
659
|
+
});
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const aborted = error.name === "AbortError";
|
|
663
|
+
const code = typeof error.code === "number" ? error.code : null;
|
|
664
|
+
const killSignal = error.signal ?? null;
|
|
665
|
+
if (code === null && !aborted && killSignal === null) {
|
|
666
|
+
fail(error);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
settle({
|
|
670
|
+
code,
|
|
671
|
+
stdout: out,
|
|
672
|
+
stderr: err,
|
|
673
|
+
aborted,
|
|
674
|
+
signal: killSignal
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Classify one completed run into raw stdout or a `SEARCH_*` failure, using the
|
|
681
|
+
* host suite's error vocabulary so retry/permission layers branch identically.
|
|
682
|
+
*
|
|
683
|
+
* The two engines disagree on exit 1: it is grep's "searched, found nothing",
|
|
684
|
+
* while `find` returns non-zero when it could not read the target at all — which
|
|
685
|
+
* must not be reported as an empty directory.
|
|
686
|
+
* @param run - the completed run.
|
|
687
|
+
* @param toolName - `grep` or `glob`, for the message.
|
|
688
|
+
* @param rawOutputMaxBytes - the budget the script bounded stdout to.
|
|
689
|
+
* @returns the raw stdout bytes.
|
|
690
|
+
*/
|
|
691
|
+
function acceptRun(run, toolName, rawOutputMaxBytes) {
|
|
692
|
+
if (run.aborted || run.signal !== null) throw new SearchError(`${toolName} was cancelled or timed out before it finished`, "SEARCH_ABORTED");
|
|
693
|
+
if (run.stdout.length > rawOutputMaxBytes) throw new SearchError(`${toolName} produced more than ${rawOutputMaxBytes} bytes of raw output; narrow pattern or path`, "SEARCH_RAW_OUTPUT_OVERFLOW");
|
|
694
|
+
const code = run.code ?? -1;
|
|
695
|
+
if (code === 0 || code === 1 && toolName === "grep") return run.stdout;
|
|
696
|
+
const detail = (run.stderr.trim().split("\n")[0] ?? "").slice(0, 300);
|
|
697
|
+
if (code === 2 && toolName === "grep" && INVALID_PATTERN.test(run.stderr)) throw new SearchError(`${toolName} pattern rejected by the distribution's grep${detail === "" ? "" : `: ${detail}`}`, "SEARCH_INVALID_PATTERN");
|
|
698
|
+
if (code === 3) throw new SearchError(`${toolName} needs a GNU grep inside the distribution${detail === "" ? "" : ` (${detail})`}`, "SEARCH_FAILED");
|
|
699
|
+
if (code === 127) throw new SearchError(`${toolName} could not start its search command inside the distribution${detail === "" ? "" : `: ${detail}`}`, "SEARCH_FAILED");
|
|
700
|
+
throw new SearchError(`${toolName} search failed inside the distribution (exit ${code})${detail === "" ? "" : `: ${detail}`}`, "SEARCH_FAILED");
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Register the `grep` and `glob` tools plus their system-prompt guidance.
|
|
704
|
+
*
|
|
705
|
+
* Both keep the host suite's split of responsibilities: `execute` returns the
|
|
706
|
+
* complete canonical value (display paths included) and may spill it, `render`
|
|
707
|
+
* applies the inline caps and formats, and `presentationMeta` projects the search
|
|
708
|
+
* card from the same retained page so text and card agree.
|
|
709
|
+
* @param ctx - plugin context; registrations are effects scoped to it.
|
|
710
|
+
* @param config - plugin configuration; a row without a `config:` block mounts
|
|
711
|
+
* this plugin with none, and {@link DEFAULTS} then supplies every knob.
|
|
712
|
+
*/
|
|
713
|
+
function apply(ctx, config) {
|
|
714
|
+
const resolved = {
|
|
715
|
+
...DEFAULTS,
|
|
716
|
+
...config === void 0 ? {} : config
|
|
717
|
+
};
|
|
718
|
+
const tools = ctx.get("tools");
|
|
719
|
+
if (tools === void 0) return;
|
|
720
|
+
registerGrep(ctx, tools, resolved);
|
|
721
|
+
registerGlob(ctx, tools, resolved);
|
|
722
|
+
registerGuidance(ctx);
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Register the model-facing `grep` tool.
|
|
726
|
+
* @param ctx - plugin context.
|
|
727
|
+
* @param tools - the tool registry.
|
|
728
|
+
* @param config - resolved configuration.
|
|
729
|
+
*/
|
|
730
|
+
function registerGrep(ctx, tools, config) {
|
|
731
|
+
const caps = {
|
|
732
|
+
maxMatches: config.grepMaxMatches,
|
|
733
|
+
maxLineBytes: config.grepMaxLineBytes,
|
|
734
|
+
maxMetaBytes: config.searchMetaMaxBytes
|
|
735
|
+
};
|
|
736
|
+
const tool = defineTool({
|
|
737
|
+
name: "grep",
|
|
738
|
+
description: `Search file contents inside this WSL distribution with a GNU grep extended regular expression. Returns matching lines with line numbers, grouped by file. Returns the first ${caps.maxMatches} matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.`,
|
|
739
|
+
parameters: {
|
|
740
|
+
pattern: {
|
|
741
|
+
type: "string",
|
|
742
|
+
required: true,
|
|
743
|
+
description: "Regular expression to search for (GNU grep -E syntax: \\d, \\w, \\b and (?i) work; lookaround and backreferences do not)."
|
|
744
|
+
},
|
|
745
|
+
path: {
|
|
746
|
+
type: "string",
|
|
747
|
+
description: "File or Linux path to search. Defaults to the session workspace; a relative path resolves against it."
|
|
748
|
+
},
|
|
749
|
+
include: {
|
|
750
|
+
type: "string",
|
|
751
|
+
description: "One glob filter on file names for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). A filter containing \"/\" is matched against the path relative to the search root. Not a list; negation is not supported."
|
|
752
|
+
}
|
|
753
|
+
},
|
|
754
|
+
timeoutMs: config.timeoutMs,
|
|
755
|
+
output: {
|
|
756
|
+
schema: {
|
|
757
|
+
type: "object",
|
|
758
|
+
additionalProperties: false,
|
|
759
|
+
properties: {
|
|
760
|
+
matches: {
|
|
761
|
+
type: "array",
|
|
762
|
+
required: true,
|
|
763
|
+
items: {
|
|
764
|
+
type: "object",
|
|
765
|
+
additionalProperties: false,
|
|
766
|
+
properties: {
|
|
767
|
+
path: {
|
|
768
|
+
type: "string",
|
|
769
|
+
required: true
|
|
770
|
+
},
|
|
771
|
+
lineNumber: {
|
|
772
|
+
type: "integer",
|
|
773
|
+
required: true
|
|
774
|
+
},
|
|
775
|
+
line: {
|
|
776
|
+
type: "string",
|
|
777
|
+
required: true
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
spill: SPILL_SCHEMA
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
render: (_args, value) => [{
|
|
786
|
+
type: "text",
|
|
787
|
+
text: renderGrepText(value.matches, caps, normalizeSpill(value.spill))
|
|
788
|
+
}],
|
|
789
|
+
presentationMeta: (_args, value) => grepSearchMeta(retainMatches(value.matches, caps.maxMatches, caps.maxLineBytes), caps.maxMetaBytes)
|
|
790
|
+
},
|
|
791
|
+
async execute(args, exec) {
|
|
792
|
+
const input = parseGrepArgs(args);
|
|
793
|
+
const target = resolveTarget(exec.agent?.session.header.cwd, config.distro);
|
|
794
|
+
if (target === void 0) throw new SearchError("grep is only available in a WSL workspace session", "SEARCH_FAILED");
|
|
795
|
+
const globs = expandBraces(input.include ?? "");
|
|
796
|
+
const pathShaped = globs.filter((glob) => glob.includes("/"));
|
|
797
|
+
const argv = buildWslArgv(target, GREP_SCRIPT, [
|
|
798
|
+
input.pattern,
|
|
799
|
+
pathShaped.length > 0 ? "" : globs.join("\n"),
|
|
800
|
+
linuxTarget(input.path),
|
|
801
|
+
String(config.rawOutputMaxBytes + 1)
|
|
802
|
+
]);
|
|
803
|
+
const run = await runInDistro(argv, config.wslPath, config.timeoutMs, config.rawOutputMaxBytes, exec.signal);
|
|
804
|
+
const stdout = acceptRun(run, "grep", config.rawOutputMaxBytes);
|
|
805
|
+
const matches = parseGrepRecords(stdout).filter((record) => {
|
|
806
|
+
if (pathShaped.length === 0) return true;
|
|
807
|
+
const relative = posix.relative(target.linuxCwd, record.path);
|
|
808
|
+
const candidate = relative.startsWith("..") ? record.path : relative;
|
|
809
|
+
return pathShaped.some((glob) => matchGlob(glob, candidate));
|
|
810
|
+
}).map((record) => ({
|
|
811
|
+
path: displayPath(record.path, target.linuxCwd),
|
|
812
|
+
lineNumber: record.lineNumber,
|
|
813
|
+
line: record.line
|
|
814
|
+
}));
|
|
815
|
+
if (matches.length <= caps.maxMatches) return { matches };
|
|
816
|
+
const previewed = matches.map((match) => ({
|
|
817
|
+
...match,
|
|
818
|
+
line: previewLine(match.line, caps.maxLineBytes)
|
|
819
|
+
}));
|
|
820
|
+
const spill = await trySaveFormattedResult(ctx, exec, "grep-results.txt", `Found ${matches.length} matches\n\n${formatGrepMatches(previewed)}`);
|
|
821
|
+
return spill === void 0 ? { matches } : {
|
|
822
|
+
matches,
|
|
823
|
+
spill
|
|
824
|
+
};
|
|
825
|
+
},
|
|
826
|
+
presentCall: presentGrepCall,
|
|
827
|
+
presentResult: presentGrepResult
|
|
828
|
+
});
|
|
829
|
+
tools.register(tool);
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Register the model-facing `glob` tool.
|
|
833
|
+
* @param ctx - plugin context.
|
|
834
|
+
* @param tools - the tool registry.
|
|
835
|
+
* @param config - resolved configuration.
|
|
836
|
+
*/
|
|
837
|
+
function registerGlob(ctx, tools, config) {
|
|
838
|
+
const caps = {
|
|
839
|
+
maxResults: config.globMaxResults,
|
|
840
|
+
sampleOverCapGlobResults: config.sampleOverCapGlobResults
|
|
841
|
+
};
|
|
842
|
+
const overCap = caps.sampleOverCapGlobResults ? `a larger result instead returns ${caps.maxResults} paths sampled across top-level entries` : `a larger result returns the first ${caps.maxResults} paths in modification-time order`;
|
|
843
|
+
const tool = defineTool({
|
|
844
|
+
name: "glob",
|
|
845
|
+
description: `Find files inside this WSL distribution whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to ${caps.maxResults} paths come back in modification-time order (oldest first); ${overCap}, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.`,
|
|
846
|
+
parameters: {
|
|
847
|
+
pattern: {
|
|
848
|
+
type: "string",
|
|
849
|
+
required: true,
|
|
850
|
+
description: "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth. A leading \"!\" negates the pattern."
|
|
851
|
+
},
|
|
852
|
+
path: {
|
|
853
|
+
type: "string",
|
|
854
|
+
description: "Directory to search in. Defaults to the session workspace; a relative path resolves against it."
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
timeoutMs: config.timeoutMs,
|
|
858
|
+
output: {
|
|
859
|
+
schema: {
|
|
860
|
+
type: "object",
|
|
861
|
+
additionalProperties: false,
|
|
862
|
+
properties: {
|
|
863
|
+
root: {
|
|
864
|
+
type: "string",
|
|
865
|
+
required: true
|
|
866
|
+
},
|
|
867
|
+
paths: {
|
|
868
|
+
type: "array",
|
|
869
|
+
required: true,
|
|
870
|
+
items: { type: "string" }
|
|
871
|
+
},
|
|
872
|
+
spill: SPILL_SCHEMA
|
|
873
|
+
}
|
|
874
|
+
},
|
|
875
|
+
render: (_args, value) => [{
|
|
876
|
+
type: "text",
|
|
877
|
+
text: renderGlobText(value.paths, caps, value.root, normalizeSpill(value.spill))
|
|
878
|
+
}],
|
|
879
|
+
presentationMeta: (_args, value) => {
|
|
880
|
+
const page = globCardPage(value.paths, caps, value.root);
|
|
881
|
+
return globSearchMeta({
|
|
882
|
+
items: page.items,
|
|
883
|
+
truncated: page.truncated,
|
|
884
|
+
seen: value.paths.length
|
|
885
|
+
}, config.searchMetaMaxBytes);
|
|
886
|
+
}
|
|
887
|
+
},
|
|
888
|
+
async execute(args, exec) {
|
|
889
|
+
const input = parseGlobArgs(args);
|
|
890
|
+
const target = resolveTarget(exec.agent?.session.header.cwd, config.distro);
|
|
891
|
+
if (target === void 0) throw new SearchError("glob is only available in a WSL workspace session", "SEARCH_FAILED");
|
|
892
|
+
const argv = buildWslArgv(target, GLOB_SCRIPT, [linuxTarget(input.path), String(config.rawOutputMaxBytes + 1)]);
|
|
893
|
+
const run = await runInDistro(argv, config.wslPath, config.timeoutMs, config.rawOutputMaxBytes, exec.signal);
|
|
894
|
+
const stdout = acceptRun(run, "glob", config.rawOutputMaxBytes);
|
|
895
|
+
const listing = parseGlobRecords(stdout);
|
|
896
|
+
const rootAbsolute = listing.root === "" ? target.linuxCwd : listing.root;
|
|
897
|
+
listing.files.sort(listing.mode === "gnu" ? (left, right) => left.mtimeMs - right.mtimeMs || left.path.localeCompare(right.path) : (left, right) => left.path.localeCompare(right.path));
|
|
898
|
+
const root = input.path === void 0 ? "." : displayPath(rootAbsolute, target.linuxCwd);
|
|
899
|
+
const paths = [];
|
|
900
|
+
for (const file of listing.files) {
|
|
901
|
+
const relative = posix.relative(rootAbsolute, file.path);
|
|
902
|
+
if (!matchGlob(input.pattern, relative === "" ? posix.basename(file.path) : relative)) continue;
|
|
903
|
+
paths.push(displayPath(file.path, target.linuxCwd));
|
|
904
|
+
}
|
|
905
|
+
if (paths.length <= caps.maxResults) return {
|
|
906
|
+
root,
|
|
907
|
+
paths
|
|
908
|
+
};
|
|
909
|
+
const spill = await trySaveFormattedResult(ctx, exec, "glob-results.txt", paths.join("\n"));
|
|
910
|
+
return spill === void 0 ? {
|
|
911
|
+
root,
|
|
912
|
+
paths
|
|
913
|
+
} : {
|
|
914
|
+
root,
|
|
915
|
+
paths,
|
|
916
|
+
spill
|
|
917
|
+
};
|
|
918
|
+
},
|
|
919
|
+
presentCall: presentGlobCall,
|
|
920
|
+
presentResult: presentGlobResult
|
|
921
|
+
});
|
|
922
|
+
tools.register(tool);
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Register the tools' scope-aware system-prompt guidance, mirroring the host
|
|
926
|
+
* suite's sections. Best-effort: a release whose `systemPrompt` surface differs
|
|
927
|
+
* loses the hint, never the tools.
|
|
928
|
+
* @param ctx - plugin context.
|
|
929
|
+
*/
|
|
930
|
+
function registerGuidance(ctx) {
|
|
931
|
+
const systemPrompt = ctx.get("systemPrompt");
|
|
932
|
+
if (systemPrompt === void 0 || typeof systemPrompt.section !== "function") return;
|
|
933
|
+
const order = (name) => {
|
|
934
|
+
try {
|
|
935
|
+
return systemPrompt.getSectionOrder?.(name);
|
|
936
|
+
} catch {
|
|
937
|
+
return void 0;
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
try {
|
|
941
|
+
systemPrompt.section({
|
|
942
|
+
name: "tool:grep",
|
|
943
|
+
order: order("TOOL_GREP"),
|
|
944
|
+
text: () => "Use the grep tool — not shell grep or rg — to search file contents inside this WSL workspace. Use read on a matched file when you need surrounding context."
|
|
945
|
+
});
|
|
946
|
+
systemPrompt.section({
|
|
947
|
+
name: "tool:glob",
|
|
948
|
+
order: order("TOOL_GLOB"),
|
|
949
|
+
text: () => "Use the glob tool — not shell find — to discover files by path pattern inside this WSL workspace. A pattern with no \"/\" matches basenames at any depth, so \"*\" matches every file in the tree rather than its top level. Results are files only, never directories."
|
|
950
|
+
});
|
|
951
|
+
} catch {}
|
|
952
|
+
}
|
|
953
|
+
var wsl_search_default = apply;
|
|
954
|
+
|
|
955
|
+
//#endregion
|
|
956
|
+
export { Config, apply, buildWslArgv, capMetaBytes, wsl_search_default as default, displayPath, expandBraces, globCardPage, globSearchMeta, globToRegExp, grepSearchMeta, inject, linuxTarget, matchGlob, normalizeSpill, parseGlobRecords, parseGrepRecords, registerGuidance, renderGlobText, renderGrepText, resolveTarget, retainMatches, retainPaths };
|
|
957
|
+
//# sourceMappingURL=wsl-search.js.map
|