dsh-everything 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
3
3
  All notable changes to this project are documented in this file.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [1.3.1] - 2026-10-27
7
+
8
+ ### Fixed
9
+ - Multi-word queries (`*.docx dm:thisweek`) silently returned 0 results: the
10
+ whole query string was handed to es.exe as ONE argument, which Everything
11
+ then matched literally. Queries are now tokenized on whitespace (outside
12
+ double quotes) and each term is passed as its own argument, restoring AND
13
+ semantics. Quoted phrases without spaces keep their phrase meaning; quoted
14
+ phrases containing spaces degrade to ANDed words (es.exe cannot express
15
+ them positionally — use `regex=true` for exact multi-word matching).
16
+ - Non-ASCII file names came back as mojibake (`?????`): es.exe pipes results
17
+ in the console/ANSI codepage, not UTF-8. Results are now read from es.exe's
18
+ UTF-8 `-export-json` file instead, so Chinese/Japanese/emoji names survive
19
+ the round trip on every codepage (with a decoded-stdout fallback for es.exe
20
+ builds without `-export-json`).
21
+
6
22
  ## [1.3.0] - 2026-10-26
7
23
 
8
24
  ### Added
package/README.md CHANGED
@@ -37,16 +37,18 @@ Start-Process "C:\Program Files\Everything\Everything.exe"
37
37
  In a DSH session, ask the agent or call the plugin manager:
38
38
 
39
39
  ```
40
- plugin_manager install_bundle with target = this package directory (or the npm spec once published)
40
+ plugin_manager install_bundle with target = dsh-everything
41
41
  ```
42
42
 
43
43
  The plugin activates as `dsh-everything` and registers the `everything_search` tool for every agent in the profile.
44
44
 
45
+ Published on npm as [`dsh-everything`](https://www.npmjs.com/package/dsh-everything); installing from the package directory works too.
46
+
45
47
  ## Tool: `everything_search`
46
48
 
47
49
  | Parameter | Type | Description |
48
50
  |---|---|---|
49
- | `query` *(required)* | string | Everything search expression: `report ext:docx`, `*.sln dm:thisweek`, `path:\projects\ ext:py`. Space = AND, `\|` = OR, `!` = NOT, quotes = exact phrase. |
51
+ | `query` *(required)* | string | Everything search expression: `report ext:docx`, `*.sln dm:thisweek`, `path:\projects\ ext:py`. Space = AND, `\|` = OR, `!` = NOT. Multi-word queries are split into es.exe terms automatically. |
50
52
  | `path` | string | Restrict to a directory and its subtree (absolute path). |
51
53
  | `maxResults` | integer | Result cap (default 100, ceiling 1000). |
52
54
  | `offset` | integer | Skip leading results (pagination). |
@@ -62,6 +64,9 @@ Returns `{ query, total, truncated, results: [{ path, size, dateModified, dateCr
62
64
 
63
65
  Typical queries: `report ext:docx` · `*.sln dm:thisweek` · `size:>500mb` · `notes !archive` · recently changed files with `sort: date-modified-descending` + `dm:thisweek`.
64
66
 
67
+ > **Phrase quirks:** a quoted phrase only matches the *name* part, so `"report.docx"` matches nothing — pass it unquoted. Phrases containing spaces cannot be expressed through the es.exe CLI (they degrade to ANDed words); use `regex=true` for exact multi-word matching.
68
+ > **Encoding:** results are read from es.exe's UTF-8 `-export-json` output, so non-ASCII file names (Chinese, Japanese, emoji, …) survive the round trip intact.
69
+
65
70
  > **Scope note:** searches names and paths only, never file contents — content search stays with `grep`.
66
71
  > **Coverage note:** results follow the local Everything index — NTFS/reFS drives and folders explicitly added to the index. FAT/exFAT removable drives and network shares are typically not covered.
67
72
 
package/README.zh-CN.md CHANGED
@@ -34,19 +34,19 @@ Start-Process "C:\Program Files\Everything\Everything.exe"
34
34
 
35
35
  ## 安装
36
36
 
37
- 在 DSH 会话中让插件管理器安装本包目录(或发布后的 npm 包名):
37
+ 在 DSH 会话中让插件管理器安装 npm 包名:
38
38
 
39
39
  ```
40
- plugin_manager install_bundle,target = 本包目录的绝对路径
40
+ plugin_manager install_bundle,target = dsh-everything
41
41
  ```
42
42
 
43
- 插件以 `dsh-everything` 激活,并为该 profile 下的所有 Agent 注册 `everything_search` 工具。
43
+ 插件以 `dsh-everything` 激活,并为该 profile 下的所有 Agent 注册 `everything_search` 工具。已发布到 npm([`dsh-everything`](https://www.npmjs.com/package/dsh-everything)),直接用本包目录安装也可以。
44
44
 
45
45
  ## 工具:everything_search
46
46
 
47
47
  | 参数 | 类型 | 说明 |
48
48
  |---|---|---|
49
- | `query` *(必填)* | string | Everything 搜索表达式:`report ext:docx`、`*.sln dm:thisweek`、`path:\projects\ ext:py`。空格 = 与,`\|` = 或,`!` = 非,双引号 = 精确短语。 |
49
+ | `query` *(必填)* | string | Everything 搜索表达式:`report ext:docx`、`*.sln dm:thisweek`、`path:\projects\ ext:py`。空格 = 与,`\|` = 或,`!` = 非。多词查询会自动拆分为 es.exe 的独立搜索词。 |
50
50
  | `path` | string | 限定在某目录及其子目录内(绝对路径)。 |
51
51
  | `maxResults` | integer | 返回条数上限(默认 100,封顶 1000)。 |
52
52
  | `offset` | integer | 跳过前 N 条(分页)。 |
@@ -62,6 +62,9 @@ plugin_manager install_bundle,target = 本包目录的绝对路径
62
62
 
63
63
  典型查询:`report ext:docx` · `*.sln dm:thisweek` · `size:>500mb` · `notes !archive` · 最近改动文件用 `sort: date-modified-descending` + `dm:thisweek`。
64
64
 
65
+ > **短语怪癖**:带引号的短语只匹配文件名的“名字部分”,所以 `"report.docx"` 永远搜不到——直接写不带引号的 `report.docx`。含空格的短语无法经 es.exe CLI 表达(会退化为逐词 AND);需要精确多词匹配请用 `regex=true`。
66
+ > **编码**:结果改从 es.exe 的 UTF-8 `-export-json` 输出读取,非 ASCII 文件名(中文、日文、emoji 等)完整回传、不再乱码。
67
+
65
68
  > **范围说明**:只搜文件名和路径,不搜文件内容——内容搜索请继续用 `grep`。
66
69
  > **覆盖说明**:结果取决于本机 Everything 索引——NTFS/reFS 磁盘与显式加入索引的目录;FAT/exFAT 可移动盘与网络共享通常不在覆盖范围。
67
70
 
package/index.js CHANGED
@@ -2,8 +2,9 @@
2
2
  * dsh-everything: model-facing `everything_search` tool backed by the local
3
3
  * Everything (voidtools) index through the official ES command line (es.exe).
4
4
  *
5
- * The tool spawns es.exe with a fixed argv vector (no shell layer), parses its
6
- * `-json` output, and projects results onto the built-in search-paths card.
5
+ * The tool spawns es.exe with a fixed argv vector (no shell layer), reads its
6
+ * results from a UTF-8 `-export-json` file (immune to console codepages), and
7
+ * projects results onto the built-in search-paths card.
7
8
  * No external npm dependencies. Windows only: Everything itself is a Windows
8
9
  * application, so on other platforms a stub tool is registered that fails with
9
10
  * a clear platform message instead of a confusing spawn error.
@@ -18,8 +19,9 @@
18
19
  * timeoutMs per-spawn timeout in milliseconds (default: 8000)
19
20
  */
20
21
  import { execFile, spawn } from 'node:child_process';
21
- import { existsSync } from 'node:fs';
22
- import { win32 as win32path } from 'node:path';
22
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import { join as joinPath, win32 as win32path } from 'node:path';
23
25
  import { ensureBackend, EverythingError } from './setup.js';
24
26
 
25
27
  const DEFAULT_MAX_RESULTS = 100;
@@ -64,7 +66,7 @@ function resolveEverythingPath(config, esPath) {
64
66
  return 'Everything.exe';
65
67
  }
66
68
 
67
- /** Run es.exe once and resolve with its raw stdout. Errors are classified into actionable codes. */
69
+ /** Run es.exe once and resolve with its raw stdout Buffer. Errors are classified into actionable codes. */
68
70
  function runEs(esPath, argv, timeoutMs, signal) {
69
71
  return new Promise((resolve, reject) => {
70
72
  let child;
@@ -72,7 +74,7 @@ function runEs(esPath, argv, timeoutMs, signal) {
72
74
  child = execFile(
73
75
  esPath,
74
76
  argv,
75
- { timeout: timeoutMs, maxBuffer: MAX_BUFFER, windowsHide: true, signal },
77
+ { timeout: timeoutMs, maxBuffer: MAX_BUFFER, windowsHide: true, signal, encoding: 'buffer' },
76
78
  (error, stdout, stderr) => {
77
79
  const stderrText = typeof stderr === 'string' ? stderr.trim() : '';
78
80
  if (error) {
@@ -104,7 +106,7 @@ function runEs(esPath, argv, timeoutMs, signal) {
104
106
  reject(new EverythingError('everything_search: Everything is not running.', 'ES_NOT_RUNNING'));
105
107
  return;
106
108
  }
107
- resolve(typeof stdout === 'string' ? stdout : '');
109
+ resolve(stdout); // Buffer (execFile encoding: 'buffer')
108
110
  }
109
111
  );
110
112
  } catch (error) {
@@ -146,6 +148,84 @@ async function autoStartEverything(everythingPath, esPath, signal) {
146
148
  throw new EverythingError('everything_search: Everything was started but did not answer within 15s; retry the search.', 'ES_NOT_RUNNING');
147
149
  }
148
150
 
151
+ /**
152
+ * Split an Everything search expression into es.exe search terms.
153
+ *
154
+ * es.exe takes each command-line argument as ONE search term; an argument
155
+ * that itself contains a space is matched literally and never matches, so
156
+ * `*.docx dm:thisweek` MUST be passed as two arguments. Quoted phrases
157
+ * without spaces keep their quotes (phrase semantics); a quoted phrase that
158
+ * contains a space cannot be expressed positionally, so it degrades to its
159
+ * component words ANDed together (use regex=true for exact multi-word
160
+ * matching).
161
+ */
162
+ function tokenizeQuery(query) {
163
+ const tokens = [];
164
+ let current = '';
165
+ let inQuotes = false;
166
+ const flush = () => {
167
+ if (current.length === 0) return;
168
+ if (/\s/.test(current)) {
169
+ // Only reachable for a quoted phrase with internal spaces: es.exe cannot
170
+ // take it as one term, so degrade to its component words (quotes stripped).
171
+ for (const word of current.split(/\s+/)) {
172
+ const bare = word.replace(/"/g, '');
173
+ if (bare.length > 0) tokens.push(bare);
174
+ }
175
+ } else if (current.replace(/"/g, '').length > 0) {
176
+ tokens.push(current);
177
+ }
178
+ current = '';
179
+ };
180
+ for (const ch of query) {
181
+ if (ch === '"') {
182
+ inQuotes = !inQuotes;
183
+ current += ch;
184
+ } else if (!inQuotes && /\s/.test(ch)) {
185
+ flush();
186
+ } else {
187
+ current += ch;
188
+ }
189
+ }
190
+ flush();
191
+ return tokens;
192
+ }
193
+
194
+ /** Decode es.exe stdout: it emits the console/ANSI codepage, not UTF-8. */
195
+ function decodeEsStdout(buf) {
196
+ if (!buf || buf.length === 0) return '';
197
+ const utf8 = buf.toString('utf8');
198
+ if (!utf8.includes('\uFFFD')) return utf8;
199
+ try {
200
+ return new TextDecoder('gbk').decode(buf);
201
+ } catch {
202
+ return utf8;
203
+ }
204
+ }
205
+
206
+ let exportFileCounter = 0;
207
+
208
+ /**
209
+ * Read the UTF-8 file written by `es.exe -export-json <file>`.
210
+ * The file occasionally exists but is still empty right after the process
211
+ * exits (flush race), so an empty read is retried briefly.
212
+ * Returns { exists, text } — `exists: false` lets the caller fall back to
213
+ * decoding stdout (e.g. an es.exe build that ignores -export-json).
214
+ */
215
+ function readExportText(filePath) {
216
+ for (let attempt = 0; attempt < 4; attempt++) {
217
+ try {
218
+ const text = readFileSync(filePath, 'utf8');
219
+ if (text.length > 0) return { exists: true, text: text.replace(/^\uFEFF/, '') };
220
+ } catch {
221
+ return { exists: false, text: '' };
222
+ }
223
+ // exists but empty: brief wait for the flush race
224
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
225
+ }
226
+ return { exists: true, text: '' };
227
+ }
228
+
149
229
  /**
150
230
  * Shared search flag vector (everything except output formatting and result
151
231
  * window). NOTE: `-regex` consumes the search text as its own value, so the
@@ -166,7 +246,13 @@ function buildSearchFlags(args, query) {
166
246
  argv.push('-sort', sort);
167
247
  }
168
248
  if (args.regex === true) argv.push('-regex', query);
169
- else argv.push(query);
249
+ else {
250
+ const terms = tokenizeQuery(query);
251
+ if (terms.length === 0) {
252
+ throw new EverythingError('everything_search: "query" must contain at least one search term (not only quotes or whitespace).', 'ES_INVALID_ARGS');
253
+ }
254
+ argv.push(...terms);
255
+ }
170
256
  return argv;
171
257
  }
172
258
 
@@ -286,7 +372,7 @@ const PARAMETERS_SCHEMA = {
286
372
  };
287
373
 
288
374
  const TOOL_DESCRIPTION =
289
- 'Locate files and folders across every indexed drive on this machine through the local Everything (voidtools) index — answers in milliseconds, far faster than glob or a recursive directory scan. Use this FIRST whenever you need to find a file or folder by name, extension, or path, especially outside the workspace. The query uses Everything search syntax, not a glob: plain words match names, `*`/`?` wildcards, `ext:js;ts` filters extensions, `dm:today` / `dm:thisweek` filter date modified, `size:>10mb` filters size, space means AND, `|` means OR, `!` means NOT, double quotes give an exact phrase (avoid combining quoted phrases with modifiers such as ext:). Combine sort="date-modified-descending" with dm:thisweek for recently changed files, or sort="size-descending" to find the biggest ones first. Restrict scope with the dedicated path parameter rather than a path: prefix inside query. Set regex=true for a full regular expression over the whole path, countOnly=true for a fast match count without paths, dateCreated=true to also return creation dates. Returns absolute paths with size and date modified. Names and paths only, never file contents — use grep for content. Coverage follows the local Everything index: NTFS/reFS drives and explicitly added folders; FAT/exFAT removable drives and network shares are typically not indexed.';
375
+ 'Locate files and folders across every indexed drive on this machine through the local Everything (voidtools) index — answers in milliseconds, far faster than glob or a recursive directory scan. Use this FIRST whenever you need to find a file or folder by name, extension, or path, especially outside the workspace. The query uses Everything search syntax, not a glob: plain words match names, `*`/`?` wildcards, `ext:js;ts` filters extensions, `dm:today` / `dm:thisweek` filter date modified, `size:>10mb` filters size, space means AND, `|` means OR, `!` means NOT. Two quoting quirks: do NOT quote a filename-with-extension like "report.docx" — quoted phrases only match the name part, so pass it unquoted; and quoted phrases must not contain spaces (use regex=true for exact multi-word matching). Combine sort="date-modified-descending" with dm:thisweek for recently changed files, or sort="size-descending" to find the biggest ones first. Restrict scope with the dedicated path parameter rather than a path: prefix inside query. Set regex=true for a full regular expression over the whole path, countOnly=true for a fast match count without paths, dateCreated=true to also return creation dates. Returns absolute paths with size and date modified; non-ASCII names come back intact. Names and paths only, never file contents — use grep for content. Coverage follows the local Everything index: NTFS/reFS drives and explicitly added folders; FAT/exFAT removable drives and network shares are typically not indexed.';
290
376
 
291
377
  const OUTPUT_SCHEMA = {
292
378
  type: 'object',
@@ -416,8 +502,34 @@ export function apply(ctx, config = {}) {
416
502
  if (input.dateCreated === true) searchArgv.push('-dc');
417
503
  if (offset > 0) searchArgv.push('-viewport-offset', String(offset));
418
504
  searchArgv.push(...flags);
419
- const stdout = await runEsWithRetry(searchArgv, signal);
420
- return { flags, results: parseResults(stdout) };
505
+
506
+ // Primary path: es.exe writes results to a UTF-8 file. Piping stdout
507
+ // instead emits the ANSI codepage and mangles non-ASCII names (GBK on a
508
+ // zh-CN machine, question marks elsewhere).
509
+ const exportPath = joinPath(tmpdir(), `dsh-everything-${process.pid}-${Date.now()}-${++exportFileCounter}.json`);
510
+ try {
511
+ let stdoutBuf;
512
+ try {
513
+ stdoutBuf = await runEsWithRetry([...searchArgv, '-export-json', exportPath], signal);
514
+ } catch (error) {
515
+ // es.exe builds that reject the option: retry once in stdout mode.
516
+ if (!(error instanceof EverythingError) || error.code !== 'ES_FAILED' || !/export|unknown|invalid|option/i.test(error.message ?? '')) throw error;
517
+ stdoutBuf = await runEsWithRetry(searchArgv, signal);
518
+ return { flags, results: parseResults(decodeEsStdout(stdoutBuf)) };
519
+ }
520
+ const exported = readExportText(exportPath);
521
+ if (!exported.exists) {
522
+ // No file written: an es.exe that ignores the option printed to stdout.
523
+ return { flags, results: parseResults(decodeEsStdout(stdoutBuf)) };
524
+ }
525
+ return { flags, results: parseResults(exported.text) };
526
+ } finally {
527
+ try {
528
+ unlinkSync(exportPath);
529
+ } catch {
530
+ /* best effort */
531
+ }
532
+ }
421
533
  }
422
534
 
423
535
  async function execute(args, exec) {
@@ -447,7 +559,7 @@ export function apply(ctx, config = {}) {
447
559
  if (results.length === limit) {
448
560
  try {
449
561
  const countOut = await runEs(esPath, ['-get-result-count', ...flags], timeoutMs, signal);
450
- const parsed = Number.parseInt(countOut.trim(), 10);
562
+ const parsed = Number.parseInt(String(countOut).trim(), 10);
451
563
  if (Number.isFinite(parsed) && parsed >= total) total = parsed;
452
564
  } catch {
453
565
  /* count is cosmetic; keep the page-based estimate */
@@ -482,10 +594,12 @@ export function apply(ctx, config = {}) {
482
594
  export {
483
595
  buildSearchFlags,
484
596
  clampInt,
597
+ decodeEsStdout,
485
598
  formatSize,
486
599
  parseResults,
487
600
  presentCall,
488
601
  presentResult,
489
602
  renderResults,
490
603
  resolveEsPath,
604
+ tokenizeQuery,
491
605
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-everything",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "DeepSeek Harness plugin: millisecond whole-disk file search for agents via the local Everything (voidtools) index",
5
5
  "keywords": ["dsh", "deepseek-harness", "everything", "voidtools", "file-search", "agent-tool"],
6
6
  "license": "MIT",