dsh-wsl-desktop 0.3.6 → 0.4.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/README.en.md +9 -6
- package/README.md +8 -5
- package/lib/index.js +196 -1
- package/lib/wsl/confinement.js +5 -4
- package/lib/wsl/fs.js +28 -0
- package/lib/wsl/preset.js +18 -3
- package/lib/wsl/search-tool.js +436 -0
- package/lib/wsl/search.js +288 -0
- package/lib/wsl/watch.js +220 -0
- package/package.json +1 -1
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The realm's `grep` and `glob`: the host's two names, run by the distribution's own tools.
|
|
3
|
+
*
|
|
4
|
+
* A WSL realm removes the host's search tools ON PURPOSE (their `argv[0]` is a packaged
|
|
5
|
+
* WINDOWS ripgrep binary), and this module is what puts the two NAMES back — one row per
|
|
6
|
+
* tool inside the `wsl-world` group, exactly the way `bash` and `fs` are re-mounted there.
|
|
7
|
+
* Both rows name this ONE module, and `config.tool` is what says which of the two tools
|
|
8
|
+
* that instance registers: two rows naming one module would otherwise register the same
|
|
9
|
+
* tool twice, and a row that names neither is refused at mount time rather than mounting
|
|
10
|
+
* nothing in silence.
|
|
11
|
+
*
|
|
12
|
+
* What is deliberately NOT the host's, and each one is declared in the tool's own
|
|
13
|
+
* description as well (the wording lives THERE, where the model reads it before calling —
|
|
14
|
+
* repeating the list here would be a second contract to keep in sync):
|
|
15
|
+
*
|
|
16
|
+
* - **the backend**: `/usr/bin/grep -E` and `/usr/bin/find` as the distribution ships them;
|
|
17
|
+
* - **the pattern dialect**: POSIX extended for `grep`; for `find` the search root anchors
|
|
18
|
+
* the glob and `*` crosses `/` (measured, findutils 4.10.0);
|
|
19
|
+
* - **the ignore behaviour**: `.gitignore` is not read, only the six VCS directories are
|
|
20
|
+
* skipped, and — unlike the host, whose ripgrep skips every hidden entry — dotted
|
|
21
|
+
* directories ARE searched;
|
|
22
|
+
* - **`include`**: fnmatch over each file's BASE NAME, so braces and directory parts never
|
|
23
|
+
* match anything (`grep --include` semantics, measured in the distribution);
|
|
24
|
+
* - **the cap**: a result at the limit is TRUNCATED and says so, and nothing is spilled to
|
|
25
|
+
* a file (the host reports where it saved the complete result; this world keeps none).
|
|
26
|
+
*
|
|
27
|
+
* The VALUE of each tool is the host's own shape (`{matches:[{path,lineNumber,line}]}`,
|
|
28
|
+
* `{root,paths}`) — the parsers' `text` field is mapped to `line` here, and `truncated` is
|
|
29
|
+
* NOT a field: it is a fact about what the text withheld, so it is stated in the text, which
|
|
30
|
+
* is where the host states it too.
|
|
31
|
+
*
|
|
32
|
+
* Failures are classified by `searchFailure` (the host's two names) and raised as a
|
|
33
|
+
* {@link HarnessError} subclass, so the registry exposes `{ name, code }` on the result the
|
|
34
|
+
* same way it does for the host's own `SearchError`.
|
|
35
|
+
* @module dsh-wsl-desktop/wsl/search-tool
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
|
39
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
40
|
+
import { cutToBytes, globArgv, grepArgv, parseGlobOutput, parseGrepOutput, searchFailure } from './search.js'
|
|
41
|
+
|
|
42
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
43
|
+
export const name = 'wsl-search'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Services these tools use. `fs` is the REALM's filesystem provider — the `wsl-world`
|
|
47
|
+
* group isolates `fs` — which is what turns the model's path into the Linux absolute one
|
|
48
|
+
* the distribution's own tools can open; `workingDirectory` is the session workspace that
|
|
49
|
+
* a relative path resolves against.
|
|
50
|
+
*/
|
|
51
|
+
export const inject = ['tools', 'systemPrompt', 'subprocess', 'fs', 'workingDirectory']
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The caps, mirroring `@deepseek-ai/dsh-tool-fs-search`'s defaults field for field, so the
|
|
55
|
+
* two worlds' searches are the same SIZE even though their engines differ. They are module
|
|
56
|
+
* constants rather than a plugin config because this module is mounted twice in one group
|
|
57
|
+
* (one instance per tool) and a config that had to be repeated on both rows would be a
|
|
58
|
+
* second place to drift; a deployment that needs different numbers adds a config then.
|
|
59
|
+
*/
|
|
60
|
+
const MAX_MATCHES = 250
|
|
61
|
+
const MAX_LINE_BYTES = 2000
|
|
62
|
+
const MAX_RESULTS = 100
|
|
63
|
+
const RAW_OUTPUT_MAX_BYTES = 20_000_000
|
|
64
|
+
const GRACE_MS = 3_000
|
|
65
|
+
const STDERR_MAX_BYTES = 64 * 1024
|
|
66
|
+
const TIMEOUT_MS = 30_000
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The parsers are asked to RETAIN EVERYTHING; the cap is applied where the model's text is
|
|
70
|
+
* written ({@link renderGrep} / {@link renderGlob}).
|
|
71
|
+
*
|
|
72
|
+
* That split is forced, not preferred: `output.render(args, value)` is the only place a tool
|
|
73
|
+
* writes the text, the `value` it receives is the frozen snapshot the registry validated, and
|
|
74
|
+
* a cap applied any earlier would leave the text unable to tell "exactly at the cap" from
|
|
75
|
+
* "the cap withheld the rest" — the silent-cut shape this whole capability refuses. The host
|
|
76
|
+
* splits it in exactly the same place (its parsers return every record and its
|
|
77
|
+
* `retainGrepMatches`/`globCardPage` cut inside `render`).
|
|
78
|
+
*
|
|
79
|
+
* What bounds "keep everything" is therefore the stdout budget at the spawn seam
|
|
80
|
+
* ({@link RAW_OUTPUT_MAX_BYTES}): a run that produced more than that is refused loudly below
|
|
81
|
+
* rather than parsed partially, so "all the records" is always a number the seam retained.
|
|
82
|
+
*/
|
|
83
|
+
const RETAIN_ALL_MATCHES = { maxMatches: Number.POSITIVE_INFINITY, maxLineBytes: Number.POSITIVE_INFINITY }
|
|
84
|
+
const RETAIN_ALL_PATHS = { maxResults: Number.POSITIVE_INFINITY }
|
|
85
|
+
|
|
86
|
+
/** One failed search, carrying the host's classification code on the result. */
|
|
87
|
+
class SearchError extends HarnessError {}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Raise the classified failure for a failed run, a failed launch, or a print that could not
|
|
91
|
+
* be read. `searchFailure` owns WHICH class this is; this function only carries it.
|
|
92
|
+
* @param {unknown} error - the failure, with `exitCode` attached when the run exited.
|
|
93
|
+
* @param {string} toolName - `'grep'` or `'glob'`.
|
|
94
|
+
*/
|
|
95
|
+
function raise(error, toolName) {
|
|
96
|
+
const { code, message } = searchFailure(error, toolName)
|
|
97
|
+
throw new SearchError(message, code)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Raise for a run that COMPLETED with an exit code neither tool accepts, handing the code
|
|
102
|
+
* and the diagnostic tail to {@link searchFailure} — the exit code is what tells a rejected
|
|
103
|
+
* grep pattern apart from every other failure.
|
|
104
|
+
* @param {string} toolName - `'grep'` or `'glob'`.
|
|
105
|
+
* @param {number} exitCode - the process's exit code.
|
|
106
|
+
* @param {string} stderr - the retained stderr tail.
|
|
107
|
+
*/
|
|
108
|
+
function raiseExit(toolName, exitCode, stderr) {
|
|
109
|
+
const detail = stderr.trim()
|
|
110
|
+
const error = new Error(detail.length === 0 ? `${toolName} exited ${exitCode}` : detail)
|
|
111
|
+
error.exitCode = exitCode
|
|
112
|
+
raise(error, toolName)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the model's path to the search root both tools run against.
|
|
117
|
+
*
|
|
118
|
+
* The root is a LINUX ABSOLUTE path, and this is the site where that happens — the reason
|
|
119
|
+
* the vectors never meet a dash-leading path. MEASURED (findutils 4.10.0): `find -- -dashdir`
|
|
120
|
+
* reports "unknown predicate `-dashdir'" and exits 1, a LOUD failure, which is why the vector
|
|
121
|
+
* layer accepts such a path instead of guarding against it; and it cannot arrive here, because
|
|
122
|
+
* the resolution below turns the model's path into an absolute one and an absolute path never
|
|
123
|
+
* begins with `-`. (Task 2 left those two facts in `globArgv`'s comment and named this site —
|
|
124
|
+
* this is that site.)
|
|
125
|
+
*
|
|
126
|
+
* The resolution is the realm's OWN filesystem provider rather than a second path grammar
|
|
127
|
+
* grown here: `ctx.fs` is the service the `fs` row mounts into this group, and it is what
|
|
128
|
+
* knows the Windows/UNC spellings of the distribution's files.
|
|
129
|
+
* @param {object} ctx - the realm's plugin context.
|
|
130
|
+
* @param {object} exec - the tool-execution context (owner agent and abort signal).
|
|
131
|
+
* @param {{ path?: string }} input - the model's arguments.
|
|
132
|
+
* @returns {Promise<{ workdir: string, root: string }>} the session workspace (the spawn cwd)
|
|
133
|
+
* and the absolute Linux search root.
|
|
134
|
+
*/
|
|
135
|
+
async function resolveRoot(ctx, exec, input) {
|
|
136
|
+
const workdir = await ctx.workingDirectory.ensure(exec.agent, exec.signal)
|
|
137
|
+
const target = await ctx.fs.resolve(input.path ?? '.', { cwd: workdir, signal: exec.signal })
|
|
138
|
+
return { workdir, root: ctx.fs.processPath(target) }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Run one search command inside the distribution and return what it printed.
|
|
143
|
+
*
|
|
144
|
+
* Only the TRANSPORT failures are raised here (a launch that never happened, a kill, a print
|
|
145
|
+
* the seam could not retain whole): which exit codes are acceptable is the calling tool's own
|
|
146
|
+
* business, because `grep` and `find` disagree about it. A nonzero exit is returned to the
|
|
147
|
+
* caller, which raises it with its code attached — that code is the whole classification.
|
|
148
|
+
* @param {object} ctx - the realm's plugin context.
|
|
149
|
+
* @param {object} exec - the tool-execution context (abort signal).
|
|
150
|
+
* @param {string} toolName - `'grep'` or `'glob'`; named in every failure message.
|
|
151
|
+
* @param {string[]} argv - the vector from `grepArgv`/`globArgv`.
|
|
152
|
+
* @param {string} workdir - the session workspace, as the subprocess provider maps it.
|
|
153
|
+
* @returns {Promise<{ stdout: string, stderr: string, exitCode: number }>} the run's output.
|
|
154
|
+
*/
|
|
155
|
+
async function runCommand(ctx, exec, toolName, argv, workdir) {
|
|
156
|
+
let handle
|
|
157
|
+
try {
|
|
158
|
+
handle = ctx.subprocess.spawn({
|
|
159
|
+
argv,
|
|
160
|
+
cwd: workdir,
|
|
161
|
+
stdio: {
|
|
162
|
+
stdin: 'ignore',
|
|
163
|
+
stdout: { maxBytes: RAW_OUTPUT_MAX_BYTES },
|
|
164
|
+
stderr: { maxBytes: STDERR_MAX_BYTES },
|
|
165
|
+
},
|
|
166
|
+
graceMs: GRACE_MS,
|
|
167
|
+
signal: exec.signal,
|
|
168
|
+
})
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// A launch that never happened (a NUL in argv, a signal already aborted, a provider that
|
|
171
|
+
// refused) is reported without claiming execution began — and it is SEARCH_FAILED, the
|
|
172
|
+
// same class as the `ENOENT` of a distribution without grep/find.
|
|
173
|
+
raise(error, toolName)
|
|
174
|
+
}
|
|
175
|
+
let outcome
|
|
176
|
+
try {
|
|
177
|
+
outcome = await handle.done
|
|
178
|
+
} catch (error) {
|
|
179
|
+
raise(error, toolName)
|
|
180
|
+
}
|
|
181
|
+
const stdout = handle.collected.stdout?.readFrom(0)
|
|
182
|
+
const stderr = handle.collected.stderr?.readFrom(0)
|
|
183
|
+
if (stdout === undefined || stderr === undefined) raise(new Error('the run produced no collected output streams'), toolName)
|
|
184
|
+
if (stdout.lossy) {
|
|
185
|
+
// The seam retained less than the tool printed, and a partial print parses into a short
|
|
186
|
+
// answer that reads exactly like a complete one — the one outcome a search must never
|
|
187
|
+
// report quietly.
|
|
188
|
+
raise(new Error(`it printed more than the ${RAW_OUTPUT_MAX_BYTES}-byte output budget; narrow pattern, path, or include and retry`), toolName)
|
|
189
|
+
}
|
|
190
|
+
if (outcome.signal !== null || outcome.exitCode === null) {
|
|
191
|
+
raise(new Error(`it was killed by signal ${outcome.signal ?? '(unknown)'}`), toolName)
|
|
192
|
+
}
|
|
193
|
+
return { stdout: stdout.text, stderr: stderr.text, exitCode: outcome.exitCode }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Bound one matched line to {@link MAX_LINE_BYTES} and mark the cut.
|
|
198
|
+
*
|
|
199
|
+
* The marker is the host's own (` (line truncated)`, `search-core.ts:325-330`), and it is
|
|
200
|
+
* exact rather than a guess because the VALUE keeps the complete line: a cut line is a strict
|
|
201
|
+
* prefix of what the search read, so "was anything cut" is answered by comparing the two, not
|
|
202
|
+
* by comparing a length against a cap that a line may meet without being cut.
|
|
203
|
+
* @param {string} line - the complete matched line.
|
|
204
|
+
* @returns {string} the preview, suffixed when bytes were cut.
|
|
205
|
+
*/
|
|
206
|
+
function previewLine(line) {
|
|
207
|
+
const kept = cutToBytes(line, MAX_LINE_BYTES)
|
|
208
|
+
return kept === line ? kept : `${kept} (line truncated)`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The model-facing `grep` page: a found-count header, the retained matches grouped by file,
|
|
213
|
+
* and — when the cap cut the list — a footer that says so AND says the rest was not saved.
|
|
214
|
+
* The host's footer carries a recovery locator there; this world has no spill file, so the
|
|
215
|
+
* honest footer is the one that says so instead of pointing at a file that does not exist.
|
|
216
|
+
* @param {{ matches: Array<{path: string, lineNumber: number, line: string}> }} value - the tool's value.
|
|
217
|
+
* @returns {{ type: 'text', text: string }[]} the content blocks.
|
|
218
|
+
*/
|
|
219
|
+
function renderGrep(value) {
|
|
220
|
+
const seen = value.matches.length
|
|
221
|
+
if (seen === 0) return [{ type: 'text', text: 'No matches found' }]
|
|
222
|
+
const kept = value.matches.slice(0, MAX_MATCHES)
|
|
223
|
+
const truncated = seen > MAX_MATCHES
|
|
224
|
+
const header = truncated
|
|
225
|
+
? `Found ${kept.length} of ${seen} matches`
|
|
226
|
+
: `Found ${seen} ${seen === 1 ? 'match' : 'matches'}`
|
|
227
|
+
const byFile = new Map()
|
|
228
|
+
for (const match of kept) {
|
|
229
|
+
const group = byFile.get(match.path)
|
|
230
|
+
if (group !== undefined) group.push(match)
|
|
231
|
+
else byFile.set(match.path, [match])
|
|
232
|
+
}
|
|
233
|
+
const sections = [...byFile].map(([path, group]) => (
|
|
234
|
+
`${path}\n${group.map((match) => `Line ${match.lineNumber}: ${previewLine(match.line)}`).join('\n')}`
|
|
235
|
+
))
|
|
236
|
+
const footer = truncated
|
|
237
|
+
? '\n\n(The complete result was not saved; narrow pattern, path, or include to see more.)'
|
|
238
|
+
: ''
|
|
239
|
+
return [{ type: 'text', text: `${header}\n\n${sections.join('\n\n')}${footer}` }]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The model-facing `glob` page: the paths themselves when they fit, and the first page plus a
|
|
244
|
+
* footer that says how many were withheld and that the rest was not saved.
|
|
245
|
+
* @param {{ root: string, paths: string[] }} value - the tool's value.
|
|
246
|
+
* @returns {{ type: 'text', text: string }[]} the content blocks.
|
|
247
|
+
*/
|
|
248
|
+
function renderGlob(value) {
|
|
249
|
+
if (value.paths.length === 0) return [{ type: 'text', text: 'No files found' }]
|
|
250
|
+
if (value.paths.length <= MAX_RESULTS) return [{ type: 'text', text: value.paths.join('\n') }]
|
|
251
|
+
const page = value.paths.slice(0, MAX_RESULTS).join('\n')
|
|
252
|
+
return [{
|
|
253
|
+
type: 'text',
|
|
254
|
+
text: `${page}\n\n(Showing ${MAX_RESULTS} of ${value.paths.length} paths, oldest first. The complete list was not saved; narrow pattern or path to see more.)`,
|
|
255
|
+
}]
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The `grep` tool: which line of which file matches, with line numbers, grouped by file.
|
|
260
|
+
* @param {object} ctx - the realm's plugin context (the services `execute` reaches).
|
|
261
|
+
* @returns {object} the registry-ready tool definition.
|
|
262
|
+
*/
|
|
263
|
+
function grepTool(ctx) {
|
|
264
|
+
return defineTool({
|
|
265
|
+
name: 'grep',
|
|
266
|
+
description: 'Search file contents inside the WSL distribution, with the distribution\'s own grep. '
|
|
267
|
+
+ 'Returns matching lines with line numbers, grouped by file, each path exactly as grep printed it (absolute). '
|
|
268
|
+
+ '.gitignore is not read — only the six VCS directories are skipped — and dotted directories ARE searched. '
|
|
269
|
+
+ 'Binary files are never matched: the distribution\'s grep treats a binary file as non-matching, so its bytes never enter a result line — the host\'s rg reports a match for the same file. '
|
|
270
|
+
+ 'A path containing a colon can make a match wrong or missing: `path:line:text` is printed with no escaping, so a colon inside a path can be taken for a field separator, and the match then names the wrong file and line number with nothing in the result to mark it wrong — or the record is dropped. Narrow the path, or use `read`, when a name contains one. '
|
|
271
|
+
+ `Up to ${MAX_MATCHES} matches; a capped result says how many were withheld, and the complete list is not saved anywhere.`,
|
|
272
|
+
parameters: {
|
|
273
|
+
pattern: {
|
|
274
|
+
type: 'string',
|
|
275
|
+
required: true,
|
|
276
|
+
description: 'Regular expression to search for, in POSIX extended form — not ripgrep\'s: \\d and (?i) are not available (write [0-9], or spell out the case alternation). '
|
|
277
|
+
+ 'A pattern that matches nothing returns no matches, not an error.',
|
|
278
|
+
},
|
|
279
|
+
path: {
|
|
280
|
+
type: 'string',
|
|
281
|
+
description: 'File or directory to search. Defaults to the session workspace; a relative path resolves against it.',
|
|
282
|
+
},
|
|
283
|
+
include: {
|
|
284
|
+
type: 'string',
|
|
285
|
+
description: 'One glob filter for which files to search (e.g. "*.ts"). It is matched against each file\'s BASE NAME, so a glob carrying a directory ("src/*.ts") matches nothing — narrow with path instead — '
|
|
286
|
+
+ 'and there is no brace expansion, so "*.{ts,tsx}" matches nothing either. Not a list; negation is not supported.',
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
timeoutMs: TIMEOUT_MS,
|
|
290
|
+
output: {
|
|
291
|
+
schema: {
|
|
292
|
+
type: 'object',
|
|
293
|
+
additionalProperties: false,
|
|
294
|
+
properties: {
|
|
295
|
+
matches: {
|
|
296
|
+
type: 'array',
|
|
297
|
+
required: true,
|
|
298
|
+
items: {
|
|
299
|
+
type: 'object',
|
|
300
|
+
additionalProperties: false,
|
|
301
|
+
properties: {
|
|
302
|
+
path: { type: 'string', required: true },
|
|
303
|
+
lineNumber: { type: 'integer', required: true },
|
|
304
|
+
line: { type: 'string', required: true },
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
render: (_args, value) => renderGrep(value),
|
|
311
|
+
},
|
|
312
|
+
async execute(args, exec) {
|
|
313
|
+
if (args.pattern.length === 0) throw new Error('pattern must be a non-empty string')
|
|
314
|
+
if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given')
|
|
315
|
+
const { workdir, root } = await resolveRoot(ctx, exec, args)
|
|
316
|
+
const argv = grepArgv(
|
|
317
|
+
{ pattern: args.pattern, path: root, ...args.include !== undefined ? { include: args.include } : {} },
|
|
318
|
+
{ maxMatches: MAX_MATCHES },
|
|
319
|
+
)
|
|
320
|
+
const run = await runCommand(ctx, exec, 'grep', argv, workdir)
|
|
321
|
+
if (run.exitCode === 1) return { matches: [] }
|
|
322
|
+
if (run.exitCode !== 0) raiseExit('grep', run.exitCode, run.stderr)
|
|
323
|
+
const parsed = parseGrepOutput(run.stdout, RETAIN_ALL_MATCHES)
|
|
324
|
+
// grep prints `path:line:text` for every match, so output that parses to NOTHING is
|
|
325
|
+
// output the print reader could not read at all — a colon inside a path is the known
|
|
326
|
+
// cause. That must not answer as "No matches found", which is what a silent drop would
|
|
327
|
+
// look like: the two are distinguishable precisely because a genuine empty result
|
|
328
|
+
// prints not a single byte.
|
|
329
|
+
//
|
|
330
|
+
// That premise leans on `--binary-files=without-match` in `grepArgv` (`search.js`), and
|
|
331
|
+
// this coupling is the part no assertion can see. The flag's VALUE is the gap the tool
|
|
332
|
+
// description declares — a binary file is treated as non-matching, so its bytes never
|
|
333
|
+
// enter a line — and the measurement below is what makes this guard's premise provable:
|
|
334
|
+
// WITH the flag a binary file's match yields no line, no stdout byte and exit 1
|
|
335
|
+
// (measured, GNU grep 3.11), so a non-empty print here really is unreadable output.
|
|
336
|
+
// Measured without it: exit 0, an EMPTY stdout, and the `Binary file … matches` notice
|
|
337
|
+
// on STDERR — this guard reads stdout, so it would not fire; the call would answer
|
|
338
|
+
// "No matches found" and drop the notice, and the loud failure this guard exists for
|
|
339
|
+
// would need a grep that put that notice on stdout. The flag's presence is pinned
|
|
340
|
+
// byte-for-byte by the golden vector in `scripts/verify-search.mjs`; the reason it must
|
|
341
|
+
// stay is here. (Carried ruling 38, task 4; its wording states the result first under
|
|
342
|
+
// ruling 47's minor, alongside the declared gap.)
|
|
343
|
+
if (run.stdout.length > 0 && parsed.matches.length === 0) {
|
|
344
|
+
raise(new Error(`its output could not be read as match records (${run.stdout.length} bytes; a path containing a colon is the known cause) — narrow the path and retry`), 'grep')
|
|
345
|
+
}
|
|
346
|
+
return { matches: parsed.matches.map((match) => ({ path: match.path, lineNumber: match.lineNumber, line: match.text })) }
|
|
347
|
+
},
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The `glob` tool: files, not directories, whose paths match a glob pattern.
|
|
353
|
+
* @param {object} ctx - the realm's plugin context (the services `execute` reaches).
|
|
354
|
+
* @returns {object} the registry-ready tool definition.
|
|
355
|
+
*/
|
|
356
|
+
function globTool(ctx) {
|
|
357
|
+
return defineTool({
|
|
358
|
+
name: 'glob',
|
|
359
|
+
description: 'Find files (not directories) inside the WSL distribution, with the distribution\'s own find. '
|
|
360
|
+
+ 'Returns absolute paths exactly as find printed them, in modification-time order. '
|
|
361
|
+
+ '.gitignore is not read — only the six VCS directories are skipped — and dotted directories ARE searched. '
|
|
362
|
+
+ `Up to ${MAX_RESULTS} paths; a capped result says how many were withheld, and the complete list is not saved anywhere.`,
|
|
363
|
+
parameters: {
|
|
364
|
+
pattern: {
|
|
365
|
+
type: 'string',
|
|
366
|
+
required: true,
|
|
367
|
+
description: 'Glob pattern to match file paths against (e.g. "**/*.ts", "src/**/*.test.js"). Interpreted relative to the search root, and "*" crosses "/" as "*" and "**/" alike, '
|
|
368
|
+
+ 'so "src/*.ts" also matches files deeper than one level, while a pattern with no "/" matches at any depth and a leading "/" means the same as none. '
|
|
369
|
+
+ 'A pattern that matches nothing returns an empty list, not an error.',
|
|
370
|
+
},
|
|
371
|
+
path: {
|
|
372
|
+
type: 'string',
|
|
373
|
+
description: 'Directory to search in. Defaults to the session workspace; a relative path resolves against it.',
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
timeoutMs: TIMEOUT_MS,
|
|
377
|
+
output: {
|
|
378
|
+
schema: {
|
|
379
|
+
type: 'object',
|
|
380
|
+
additionalProperties: false,
|
|
381
|
+
properties: {
|
|
382
|
+
root: { type: 'string', required: true },
|
|
383
|
+
paths: { type: 'array', required: true, items: { type: 'string' } },
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
render: (_args, value) => renderGlob(value),
|
|
387
|
+
},
|
|
388
|
+
async execute(args, exec) {
|
|
389
|
+
if (args.pattern.trim().length === 0) throw new Error('pattern must be a non-empty string')
|
|
390
|
+
if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given')
|
|
391
|
+
const { workdir, root } = await resolveRoot(ctx, exec, args)
|
|
392
|
+
const argv = globArgv({ pattern: args.pattern, path: root }, { maxResults: MAX_RESULTS })
|
|
393
|
+
const run = await runCommand(ctx, exec, 'glob', argv, workdir)
|
|
394
|
+
if (run.exitCode !== 0) raiseExit('glob', run.exitCode, run.stderr)
|
|
395
|
+
const parsed = parseGlobOutput(run.stdout, RETAIN_ALL_PATHS)
|
|
396
|
+
// The `-printf` emits one `%T@ %p` record per hit, so a non-empty print that parsed to
|
|
397
|
+
// NO path is damaged output (the reader drops a record it cannot read, silently by
|
|
398
|
+
// design) — and silence here would be indistinguishable from "no files match", which
|
|
399
|
+
// find prints as nothing at all.
|
|
400
|
+
if (run.stdout.length > 0 && parsed.paths.length === 0) {
|
|
401
|
+
raise(new Error(`its output could not be read as path records (${run.stdout.length} bytes)`), 'glob')
|
|
402
|
+
}
|
|
403
|
+
return { root, paths: parsed.paths }
|
|
404
|
+
},
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The sentence each tool adds to the system prompt while that tool exists in the scope. */
|
|
409
|
+
const PROMPTS = {
|
|
410
|
+
grep: 'Use the grep tool — not shell grep — to search file contents in this WSL session; it is the distribution\'s own grep.',
|
|
411
|
+
glob: 'Use the glob tool — not shell find — to discover files by path pattern in this WSL session; it is the distribution\'s own find.',
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Register the ONE tool this row names, plus its scope-aware prompt guidance.
|
|
416
|
+
*
|
|
417
|
+
* The section is registered the way the host registers its own (`grep.ts:275-282`): its text
|
|
418
|
+
* is a function of the scope and is EMPTY while the tool is not resolvable there, so the
|
|
419
|
+
* guidance cannot outlive the tool it describes. The section name is `wsl:`-prefixed rather
|
|
420
|
+
* than the host's `tool:` — a composition that mounted both would otherwise have two
|
|
421
|
+
* sections claiming one name.
|
|
422
|
+
* @param {object} ctx - the realm's plugin context; registrations are effects scoped to it.
|
|
423
|
+
* @param {{ tool?: string } | undefined} config - the row's config: which tool this is.
|
|
424
|
+
*/
|
|
425
|
+
export function apply(ctx, config) {
|
|
426
|
+
const tool = config?.tool
|
|
427
|
+
if (tool !== 'grep' && tool !== 'glob') {
|
|
428
|
+
throw new Error(`wsl-search: this row must name the tool it registers as config.tool ("grep" or "glob"), got ${JSON.stringify(tool ?? null)}`)
|
|
429
|
+
}
|
|
430
|
+
ctx.systemPrompt.section({
|
|
431
|
+
name: `wsl:${tool}`,
|
|
432
|
+
order: ctx.systemPrompt.getSectionOrder(tool === 'grep' ? 'TOOL_GREP' : 'TOOL_GLOB'),
|
|
433
|
+
text: ({ scope }) => ctx.tools.get(tool, scope) === undefined ? '' : PROMPTS[tool],
|
|
434
|
+
})
|
|
435
|
+
ctx.tools.register(tool === 'grep' ? grepTool(ctx) : globTool(ctx))
|
|
436
|
+
}
|