dsh-everything 1.3.0 → 1.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/CHANGELOG.md CHANGED
@@ -3,6 +3,57 @@
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.4.0] - 2026-10-27
7
+
8
+ ### Added
9
+ - **HTTP fast channel**: searches are now served by Everything's built-in
10
+ HTTP JSON server on `127.0.0.1` when it is available — no per-call
11
+ `es.exe` spawn, native UTF-8, and exact totals for free. The plugin
12
+ auto-enables the server itself (Everything ships it disabled AND
13
+ policy-locked: `allow_http_server=0` in the ini next to
14
+ `Everything.exe`; the GUI cannot unlock it) by patching both
15
+ `Everything.ini` files, binding loopback only, and restarting the GUI
16
+ once per session (`httpAutoEnable`, default on; `httpPort` default 5151,
17
+ `0` disables; optional Basic auth via `httpUsername`/`httpPassword`).
18
+ The `es.exe` CLI remains the always-available fallback and covers
19
+ `dateCreated`, the extra sort keys, and `regex` + `path`/type-filter
20
+ combinations the HTTP API cannot express.
21
+ - `exactTotal` (default `true`): set `false` to skip the extra
22
+ match-count round-trip on full pages; the total then reports as a
23
+ page-based lower bound (`≥N`, `totalExact: false`).
24
+ - Results now carry `channel` (`"http"`/`"es"`), `totalExact`, and `notes`
25
+ (non-fatal query-interpretation remarks) so behavior is observable.
26
+ - A whole-query quoted phrase containing spaces (an exact multi-word
27
+ match) is automatically converted to an equivalent regex — previously
28
+ it degraded to ANDed words silently.
29
+
30
+ ### Fixed
31
+ - `readExportText` no longer blocks the event loop while waiting out the
32
+ es.exe export-file flush race (async retry instead of `Atomics.wait`),
33
+ and a genuinely empty/missing export file now falls back to decoded
34
+ stdout instead of reporting a fake empty result page.
35
+ - A `path` that does not exist (or points at a file) is rejected up front
36
+ with `ES_INVALID_ARGS` instead of silently returning "no matches"
37
+ (UNC paths are checked after a zero-result page).
38
+ - `truncated` is `true` whenever a full page is returned with an
39
+ inexact total, not only when the exact count exceeds the page.
40
+
41
+ ## [1.3.1] - 2026-10-27
42
+
43
+ ### Fixed
44
+ - Multi-word queries (`*.docx dm:thisweek`) silently returned 0 results: the
45
+ whole query string was handed to es.exe as ONE argument, which Everything
46
+ then matched literally. Queries are now tokenized on whitespace (outside
47
+ double quotes) and each term is passed as its own argument, restoring AND
48
+ semantics. Quoted phrases without spaces keep their phrase meaning; quoted
49
+ phrases containing spaces degrade to ANDed words (es.exe cannot express
50
+ them positionally — use `regex=true` for exact multi-word matching).
51
+ - Non-ASCII file names came back as mojibake (`?????`): es.exe pipes results
52
+ in the console/ANSI codepage, not UTF-8. Results are now read from es.exe's
53
+ UTF-8 `-export-json` file instead, so Chinese/Japanese/emoji names survive
54
+ the round trip on every codepage (with a decoded-stdout fallback for es.exe
55
+ builds without `-export-json`).
56
+
6
57
  ## [1.3.0] - 2026-10-26
7
58
 
8
59
  ### Added
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  [中文文档](README.zh-CN.md)
4
4
 
5
- A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) plugin that gives the agent an **`everything_search`** tool: whole-disk file and folder search in **milliseconds**, powered by the local [Everything](https://www.voidtools.com/) (voidtools) index through its official command line `es.exe`.
5
+ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) plugin that gives the agent an **`everything_search`** tool: whole-disk file and folder search in **milliseconds**, powered by the local [Everything](https://www.voidtools.com/) (voidtools) index.
6
6
 
7
- Instead of recursively scanning directories (glob) when the model needs to locate a file, the agent asks Everything's pre-built index — instant even on drives with millions of files.
7
+ Instead of recursively scanning directories (glob) when the model needs to locate a file, the agent asks Everything's pre-built index — instant even on drives with millions of files. Two channels are used: Everything's built-in HTTP JSON server (loopback only, auto-enabled — no process spawn per call, native UTF-8, exact totals) with the official command line `es.exe` as an always-available fallback for everything the HTTP server cannot express.
8
8
 
9
9
  ## Requirements
10
10
 
@@ -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). |
@@ -57,11 +59,15 @@ The plugin activates as `dsh-everything` and registers the `everything_search` t
57
59
  | `foldersOnly` / `filesOnly` | boolean | Restrict result type. |
58
60
  | `dateCreated` | boolean | Also return each result's creation date. |
59
61
  | `countOnly` | boolean | Return only the total match count — the fastest way to check existence or estimate volume. |
62
+ | `exactTotal` | boolean | Default `true`. `false` skips the extra match-count round-trip on full pages and reports a `≥N` total instead. |
60
63
 
61
- Returns `{ query, total, truncated, results: [{ path, size, dateModified, dateCreated? }] }` and renders in the chat as a search-paths card.
64
+ Returns `{ query, total, totalExact, truncated, results: [{ path, size, dateModified, dateCreated? }], channel, notes }` (`channel` is `"http"` or `"es"`; `notes` carries query-interpretation remarks) and renders in the chat as a search-paths card.
62
65
 
63
66
  Typical queries: `report ext:docx` · `*.sln dm:thisweek` · `size:>500mb` · `notes !archive` · recently changed files with `sort: date-modified-descending` + `dm:thisweek`.
64
67
 
68
+ > **Phrase quirks:** a quoted phrase only matches the *name* part, so `"report.docx"` matches nothing — pass it unquoted. A quoted phrase **containing spaces that is the whole query** (an exact multi-word match) is automatically converted to an equivalent regex; one embedded alongside other terms degrades to ANDed words and is reported in `notes` (use `regex=true` for those).
69
+ > **Encoding:** the HTTP channel is UTF-8 natively; the es.exe fallback reads its UTF-8 `-export-json` output — non-ASCII file names (Chinese, Japanese, emoji, …) survive the round trip intact either way.
70
+
65
71
  > **Scope note:** searches names and paths only, never file contents — content search stays with `grep`.
66
72
  > **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
73
 
@@ -77,11 +83,20 @@ Edit the plugin's `config` in `cordis.patch.yml` (or your profile's patch layer)
77
83
  | `autoInstall` | `true` | Download & install Everything + ES CLI when missing (see above). |
78
84
  | `installDir` | `%LOCALAPPDATA%\dsh-everything` | Where the auto-installed ES CLI lives. |
79
85
  | `maxResults` | `100` | Default per-call result cap. |
80
- | `timeoutMs` | `8000` | Per-spawn timeout (ms). |
86
+ | `timeoutMs` | `8000` | Per-call timeout (ms). |
87
+ | `httpPort` | `5151` | Everything HTTP server port for the fast channel; `0` disables the HTTP channel entirely. |
88
+ | `httpAutoEnable` | `true` | When the HTTP server is down, once per session: patch Everything's own config (unlock + enable + loopback bind) and restart its GUI. Set `false` to never touch Everything's config. |
89
+ | `httpUsername` / `httpPassword` | `''` | Optional Basic-auth credentials for the HTTP server. |
90
+
91
+ ### Fast path: the built-in HTTP server
92
+
93
+ Everything 1.4 ships with an HTTP JSON server, but it is **disabled and policy-locked by default** — installed builds write `allow_http_server=0` next to `Everything.exe`, and the GUI cannot unlock it. With `httpAutoEnable: true` the plugin flips that switch itself (one-time, once per session): it patches both `Everything.ini` files, binds the server to **127.0.0.1 only**, restarts the Everything GUI, and routes searches through it. The background index service keeps running untouched.
94
+
95
+ The HTTP channel serves plain searches, `path`/`foldersOnly`/`filesOnly` scope, `countOnly`, pagination, and the `name`/`path`/`size`/`date-modified` sort keys. Everything else — `dateCreated`, other sort keys, and `regex` combined with a `path` or type filter — automatically uses the es.exe channel instead. If the HTTP server cannot be started (port busy, Everything not installed yet), the plugin silently stays on es.exe and retries later; a `path` that does not exist is reported as an error rather than an empty result.
81
96
 
82
97
  ## Privacy
83
98
 
84
- The search scope is **every indexed drive** on the machine, and result paths are sent to the model as tool output. Keep that in mind on machines with sensitive directory layouts; use `path` to scope searches.
99
+ The search scope is **every indexed drive** on the machine, and result paths are sent to the model as tool output. Keep that in mind on machines with sensitive directory layouts; use `path` to scope searches. The HTTP fast path listens on `127.0.0.1` only — nothing is exposed to the network.
85
100
 
86
101
  ## Development
87
102
 
package/README.zh-CN.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  [English](README.md)
4
4
 
5
- 一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)插件,为模型注册 **`everything_search`** 工具:借助本机 [Everything](https://www.voidtools.com/)(voidtools)索引,通过官方命令行 `es.exe` 实现**毫秒级**全盘文件/文件夹搜索。
5
+ 一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)插件,为模型注册 **`everything_search`** 工具:借助本机 [Everything](https://www.voidtools.com/)(voidtools)索引,实现**毫秒级**全盘文件/文件夹搜索。
6
6
 
7
- 当模型需要找文件时,不再递归遍历目录(glob),而是直接查询 Everything 预先建好的索引——即使磁盘上有数百万文件也是秒回。
7
+ 当模型需要找文件时,不再递归遍历目录(glob),而是直接查询 Everything 预先建好的索引——即使磁盘上有数百万文件也是秒回。内部走两条通道:优先用 Everything 内置的 HTTP JSON 服务(仅绑定 127.0.0.1,插件会自动启用——每次调用无需再拉起进程、原生 UTF-8、精确总数免费自带),官方命令行 `es.exe` 作为兜底,覆盖 HTTP 服务表达不了的一切查询。
8
8
 
9
9
  ## 环境要求
10
10
 
@@ -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 条(分页)。 |
@@ -57,11 +57,15 @@ plugin_manager install_bundle,target = 本包目录的绝对路径
57
57
  | `foldersOnly` / `filesOnly` | boolean | 只看文件夹 / 只看文件。 |
58
58
  | `dateCreated` | boolean | 一并返回每个结果的创建时间。 |
59
59
  | `countOnly` | boolean | 只返回匹配总数——判断存在性、估量级的最快方式。 |
60
+ | `exactTotal` | boolean | 默认 `true`。设 `false` 时整页结果不再额外做一次计数往返,总数显示为 `≥N` 下界。 |
60
61
 
61
- 返回 `{ query, total, truncated, results: [{ path, size, dateModified, dateCreated? }] }`,在会话里以搜索路径卡片展示。
62
+ 返回 `{ query, total, totalExact, truncated, results: [{ path, size, dateModified, dateCreated? }], channel, notes }`(`channel` 为 `"http"` 或 `"es"`;`notes` 携带查询解释说明),在会话里以搜索路径卡片展示。
62
63
 
63
64
  典型查询:`report ext:docx` · `*.sln dm:thisweek` · `size:>500mb` · `notes !archive` · 最近改动文件用 `sort: date-modified-descending` + `dm:thisweek`。
64
65
 
66
+ > **短语怪癖**:带引号的短语只匹配文件名的“名字部分”,所以 `"report.docx"` 永远搜不到——直接写不带引号的 `report.docx`。**整条查询**就是一个含空格的引号短语(精确多词匹配)时,插件会自动转换成等价正则;与其他词混用的引号短语会退化为逐词 AND 并在 `notes` 里说明(这类场景请显式用 `regex=true`)。
67
+ > **编码**:HTTP 通道原生 UTF-8;es.exe 兜底通道从其 UTF-8 `-export-json` 输出读取——无论哪条通道,非 ASCII 文件名(中文、日文、emoji 等)都完整回传、不再乱码。
68
+
65
69
  > **范围说明**:只搜文件名和路径,不搜文件内容——内容搜索请继续用 `grep`。
66
70
  > **覆盖说明**:结果取决于本机 Everything 索引——NTFS/reFS 磁盘与显式加入索引的目录;FAT/exFAT 可移动盘与网络共享通常不在覆盖范围。
67
71
 
@@ -77,11 +81,20 @@ plugin_manager install_bundle,target = 本包目录的绝对路径
77
81
  | `autoInstall` | `true` | 后端缺失时自动下载并安装 Everything + ES CLI(见上文)。 |
78
82
  | `installDir` | `%LOCALAPPDATA%\dsh-everything` | 自动安装的 ES CLI 存放目录。 |
79
83
  | `maxResults` | `100` | 单次返回条数默认上限。 |
80
- | `timeoutMs` | `8000` | 单次 spawn 超时(毫秒)。 |
84
+ | `timeoutMs` | `8000` | 单次调用超时(毫秒)。 |
85
+ | `httpPort` | `5151` | 快速通道的 Everything HTTP 服务端口;设 `0` 完全关闭 HTTP 通道。 |
86
+ | `httpAutoEnable` | `true` | HTTP 服务未开启时,每会话一次:自动改写 Everything 自身配置(解锁+启用+仅回环绑定)并重启其 GUI。设 `false` 则永不触碰 Everything 的配置。 |
87
+ | `httpUsername` / `httpPassword` | `''` | HTTP 服务的可选 Basic 认证凭据。 |
88
+
89
+ ### 快速通道:内置 HTTP 服务
90
+
91
+ Everything 1.4 自带 HTTP JSON 服务,但**默认是关闭且被策略锁死的**——安装版会在 `Everything.exe` 旁写入 `allow_http_server=0`,GUI 里也解不了锁。`httpAutoEnable: true`(默认)时插件会亲自翻开这个开关(每会话一次):同时补丁两份 `Everything.ini`、把服务**只绑定到 127.0.0.1**、重启 Everything GUI,之后搜索都走 HTTP。后台索引服务全程不受影响。
92
+
93
+ HTTP 通道覆盖:普通搜索、`path`/`foldersOnly`/`filesOnly` 范围限定、`countOnly`、分页,以及 `name`/`path`/`size`/`date-modified` 排序键。其余——`dateCreated`、其他排序键、`regex` 搭配 `path` 或类型过滤——自动改走 es.exe 通道。HTTP 服务起不来(端口被占、Everything 未安装)时插件静默留在 es.exe 通道、稍后重试;`path` 指向不存在的路径会直接报错,而不是返回空结果。
81
94
 
82
95
  ## 隐私
83
96
 
84
- 搜索范围是本机**所有已索引磁盘**,结果路径会作为工具输出发给模型。在目录结构敏感的机器上请注意,可用 `path` 参数收窄范围。
97
+ 搜索范围是本机**所有已索引磁盘**,结果路径会作为工具输出发给模型。在目录结构敏感的机器上请注意,可用 `path` 参数收窄范围。HTTP 快速通道只监听 `127.0.0.1`——不向网络暴露任何东西。
85
98
 
86
99
  ## 开发
87
100
 
package/cordis.patch.yml CHANGED
@@ -6,3 +6,7 @@
6
6
  autoInstall: true
7
7
  maxResults: 100
8
8
  timeoutMs: 8000
9
+ httpPort: 5151
10
+ httpAutoEnable: true
11
+ httpUsername: ''
12
+ httpPassword: ''
package/index.js CHANGED
@@ -1,12 +1,19 @@
1
1
  /**
2
2
  * dsh-everything: model-facing `everything_search` tool backed by the local
3
- * Everything (voidtools) index through the official ES command line (es.exe).
3
+ * Everything (voidtools) index.
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.
7
- * No external npm dependencies. Windows only: Everything itself is a Windows
8
- * application, so on other platforms a stub tool is registered that fails with
9
- * a clear platform message instead of a confusing spawn error.
5
+ * Two channels, tried in order:
6
+ * 1. Everything's built-in HTTP JSON server (127.0.0.1 only) — no process
7
+ * spawn per call, native UTF-8, exact totals for free. Auto-enabled from
8
+ * the plugin when httpAutoEnable is on (one Everything GUI restart).
9
+ * 2. The official ES command line (es.exe) — always available as fallback
10
+ * (and used for dateCreated, unsupported sort keys, and regex+scope
11
+ * combos). Results are read from a UTF-8 `-export-json` file, immune to
12
+ * console codepages.
13
+ * Results project onto the built-in search-paths card. No external npm
14
+ * dependencies. Windows only: Everything itself is a Windows application, so
15
+ * on other platforms a stub tool is registered that fails with a clear
16
+ * platform message instead of a confusing spawn error.
10
17
  *
11
18
  * Config (set in cordis.patch.yml):
12
19
  * esPath absolute path to es.exe (default: well-known locations, then auto-install)
@@ -15,12 +22,18 @@
15
22
  * autoInstall download & install Everything + ES CLI when missing (default: true)
16
23
  * installDir where the auto-installed ES CLI lives (default: %LOCALAPPDATA%\dsh-everything)
17
24
  * maxResults default per-call result cap (default: 100, ceiling: 1000)
18
- * timeoutMs per-spawn timeout in milliseconds (default: 8000)
25
+ * timeoutMs per-call timeout in milliseconds (default: 8000)
26
+ * httpPort Everything HTTP server port; 0 disables the HTTP channel (default: 5151)
27
+ * httpAutoEnable flip Everything's own config to start its HTTP server (default: true)
28
+ * httpUsername optional Basic-auth user for the Everything HTTP server (default: '')
29
+ * httpPassword optional Basic-auth password (default: '')
19
30
  */
20
31
  import { execFile, spawn } from 'node:child_process';
21
- import { existsSync } from 'node:fs';
22
- import { win32 as win32path } from 'node:path';
23
- import { ensureBackend, EverythingError } from './setup.js';
32
+ import { existsSync, readFileSync, statSync } from 'node:fs';
33
+ import { unlink } from 'node:fs/promises';
34
+ import { tmpdir } from 'node:os';
35
+ import { join as joinPath, win32 as win32path } from 'node:path';
36
+ import { ensureBackend, ensureHttpServer, probeHttpPort, EverythingError } from './setup.js';
24
37
 
25
38
  const DEFAULT_MAX_RESULTS = 100;
26
39
  const DEFAULT_TIMEOUT_MS = 8000;
@@ -28,6 +41,11 @@ const MAX_RESULTS_CEILING = 1000;
28
41
  const MAX_BUFFER = 32 * 1024 * 1024;
29
42
  const AUTOSTART_WAIT_MS = 15000;
30
43
  const SETUP_BUDGET_MS = 240000;
44
+ const DEFAULT_HTTP_PORT = 5151;
45
+ /** After an HTTP failure, stay on the es.exe channel for this long before probing again. */
46
+ const HTTP_RETRY_DOWN_MS = 30000;
47
+ /** Per-request cap for HTTP calls (hot latency is ~2ms; 5s covers worst cases). */
48
+ const HTTP_REQUEST_CAP_MS = 5000;
31
49
 
32
50
  /** Fallback candidates when the patch config does not pin `esPath`. */
33
51
  const ES_CANDIDATES = [
@@ -38,6 +56,9 @@ const ES_CANDIDATES = [
38
56
 
39
57
  const SORT_NAME_RE = /^[a-z][a-z0-9-]*(-(ascending|descending))?$/i;
40
58
 
59
+ /** Sort keys the Everything HTTP server understands (es.exe accepts more). */
60
+ const HTTP_SORT_KEYS = { name: 'name', path: 'path', size: 'size', 'date-modified': 'date_modified' };
61
+
41
62
  export { EverythingError };
42
63
 
43
64
  function clampInt(value, min, max, fallback) {
@@ -64,7 +85,7 @@ function resolveEverythingPath(config, esPath) {
64
85
  return 'Everything.exe';
65
86
  }
66
87
 
67
- /** Run es.exe once and resolve with its raw stdout. Errors are classified into actionable codes. */
88
+ /** Run es.exe once and resolve with its raw stdout Buffer. Errors are classified into actionable codes. */
68
89
  function runEs(esPath, argv, timeoutMs, signal) {
69
90
  return new Promise((resolve, reject) => {
70
91
  let child;
@@ -72,7 +93,7 @@ function runEs(esPath, argv, timeoutMs, signal) {
72
93
  child = execFile(
73
94
  esPath,
74
95
  argv,
75
- { timeout: timeoutMs, maxBuffer: MAX_BUFFER, windowsHide: true, signal },
96
+ { timeout: timeoutMs, maxBuffer: MAX_BUFFER, windowsHide: true, signal, encoding: 'buffer' },
76
97
  (error, stdout, stderr) => {
77
98
  const stderrText = typeof stderr === 'string' ? stderr.trim() : '';
78
99
  if (error) {
@@ -104,7 +125,7 @@ function runEs(esPath, argv, timeoutMs, signal) {
104
125
  reject(new EverythingError('everything_search: Everything is not running.', 'ES_NOT_RUNNING'));
105
126
  return;
106
127
  }
107
- resolve(typeof stdout === 'string' ? stdout : '');
128
+ resolve(stdout); // Buffer (execFile encoding: 'buffer')
108
129
  }
109
130
  );
110
131
  } catch (error) {
@@ -146,6 +167,119 @@ async function autoStartEverything(everythingPath, esPath, signal) {
146
167
  throw new EverythingError('everything_search: Everything was started but did not answer within 15s; retry the search.', 'ES_NOT_RUNNING');
147
168
  }
148
169
 
170
+ /**
171
+ * Split an Everything search expression into es.exe search terms.
172
+ *
173
+ * es.exe takes each command-line argument as ONE search term; an argument
174
+ * that itself contains a space is matched literally and never matches, so
175
+ * `*.docx dm:thisweek` MUST be passed as two arguments. Quoted phrases
176
+ * without spaces keep their quotes (phrase semantics); a quoted phrase that
177
+ * contains a space cannot be expressed positionally, so it degrades to its
178
+ * component words ANDed together (report it so callers can switch to regex).
179
+ *
180
+ * Returns { terms, degraded } — `degraded` lists the quoted phrases that had
181
+ * to be split (only used for the user-facing note).
182
+ */
183
+ function tokenizeQuery(query) {
184
+ const terms = [];
185
+ const degraded = [];
186
+ let current = '';
187
+ let inQuotes = false;
188
+ const flush = () => {
189
+ if (current.length === 0) return;
190
+ if (/\s/.test(current)) {
191
+ // Only reachable for a quoted phrase with internal spaces: es.exe cannot
192
+ // take it as one term, so degrade to its component words (quotes stripped).
193
+ const bare = current.replace(/"/g, '').trim();
194
+ if (bare.length > 0) degraded.push(bare);
195
+ for (const word of current.split(/\s+/)) {
196
+ const w = word.replace(/"/g, '');
197
+ if (w.length > 0) terms.push(w);
198
+ }
199
+ } else if (current.replace(/"/g, '').length > 0) {
200
+ terms.push(current);
201
+ }
202
+ current = '';
203
+ };
204
+ for (const ch of query) {
205
+ if (ch === '"') {
206
+ inQuotes = !inQuotes;
207
+ current += ch;
208
+ } else if (!inQuotes && /\s/.test(ch)) {
209
+ flush();
210
+ } else {
211
+ current += ch;
212
+ }
213
+ }
214
+ flush();
215
+ return { terms, degraded };
216
+ }
217
+
218
+ /**
219
+ * Pre-process a raw query. A quoted phrase WITH spaces that is the whole
220
+ * query (the only exact-phrase intent es.exe cannot express) is converted to
221
+ * an equivalent regex (`word\s+word`), because both channels accept regex.
222
+ */
223
+ function prepareQuery(query) {
224
+ const trimmed = query.trim();
225
+ const m = /^"([^"]*\s[^"]*)"$/.exec(trimmed);
226
+ if (m) {
227
+ return {
228
+ query: phraseToRegex(m[1]),
229
+ regex: true,
230
+ note: `exact phrase "${m[1]}" was converted to a regular expression (quoted phrases with spaces are not expressible otherwise)`,
231
+ };
232
+ }
233
+ return { query: trimmed, regex: false, note: null };
234
+ }
235
+
236
+ /** Escape each word and join with `\s+` — a regex equivalent of a phrase. */
237
+ function phraseToRegex(phrase) {
238
+ const escapeRe = /[.*+?^${}()|[\]\\]/g;
239
+ return phrase
240
+ .split(/\s+/)
241
+ .filter((w) => w.length > 0)
242
+ .map((w) => w.replace(escapeRe, '\\$&'))
243
+ .join('\\s+');
244
+ }
245
+
246
+ /** Decode es.exe stdout: it emits the console/ANSI codepage, not UTF-8. */
247
+ function decodeEsStdout(buf) {
248
+ if (!buf || buf.length === 0) return '';
249
+ const utf8 = buf.toString('utf8');
250
+ if (!utf8.includes('\uFFFD')) return utf8;
251
+ try {
252
+ return new TextDecoder('gbk').decode(buf);
253
+ } catch {
254
+ return utf8;
255
+ }
256
+ }
257
+
258
+ let exportFileCounter = 0;
259
+
260
+ /**
261
+ * Read the UTF-8 file written by `es.exe -export-json <file>`.
262
+ * The file occasionally exists but is still empty right after the process
263
+ * exits (flush race), so an empty read is retried briefly (async, never
264
+ * blocking the event loop).
265
+ * Returns { exists, text } — `exists: false` (or empty text) lets the caller
266
+ * fall back to decoding stdout (e.g. an es.exe build that ignores
267
+ * -export-json).
268
+ */
269
+ async function readExportText(filePath) {
270
+ for (let attempt = 0; attempt < 4; attempt++) {
271
+ try {
272
+ const text = readFileSync(filePath, 'utf8');
273
+ if (text.length > 0) return { exists: true, text: text.replace(/^\uFEFF/, '') };
274
+ } catch {
275
+ return { exists: false, text: '' };
276
+ }
277
+ // exists but empty: brief wait for the flush race
278
+ await sleep(100);
279
+ }
280
+ return { exists: true, text: '' };
281
+ }
282
+
149
283
  /**
150
284
  * Shared search flag vector (everything except output formatting and result
151
285
  * window). NOTE: `-regex` consumes the search text as its own value, so the
@@ -166,7 +300,13 @@ function buildSearchFlags(args, query) {
166
300
  argv.push('-sort', sort);
167
301
  }
168
302
  if (args.regex === true) argv.push('-regex', query);
169
- else argv.push(query);
303
+ else {
304
+ const { terms } = tokenizeQuery(query);
305
+ if (terms.length === 0) {
306
+ throw new EverythingError('everything_search: "query" must contain at least one search term (not only quotes or whitespace).', 'ES_INVALID_ARGS');
307
+ }
308
+ argv.push(...terms);
309
+ }
170
310
  return argv;
171
311
  }
172
312
 
@@ -192,6 +332,98 @@ function parseResults(stdout) {
192
332
  return results;
193
333
  }
194
334
 
335
+ // ---------------------------------------------------------------------------
336
+ // HTTP channel (Everything's built-in JSON server on 127.0.0.1)
337
+ // ---------------------------------------------------------------------------
338
+
339
+ /** Map our sort names onto HTTP params. null = this sort must use es.exe. */
340
+ function httpSortParams(sort) {
341
+ if (typeof sort !== 'string' || sort.trim().length === 0) return {};
342
+ const s = sort.trim();
343
+ const key = s.replace(/-(ascending|descending)$/i, '');
344
+ const httpKey = HTTP_SORT_KEYS[key];
345
+ if (!httpKey) return null;
346
+ return { sort: httpKey, ascending: /-descending$/i.test(s) ? 0 : 1 };
347
+ }
348
+
349
+ /**
350
+ * URL parameters for the Everything HTTP server (pure, exported for tests).
351
+ * Scope (`path:`) and type (`folder:`/`file:`) filters are folded into the
352
+ * search text — the HTTP API has no dedicated parameters for them. NOTE: in
353
+ * regex mode Everything does not interpret those prefixes, so the caller
354
+ * routes regex+scope combos to es.exe instead.
355
+ */
356
+ function httpSearchParams(input, query, { limit, offset }) {
357
+ let search = query;
358
+ if (input.foldersOnly === true) search = `folder:${search}`;
359
+ else if (input.filesOnly === true) search = `file:${search}`;
360
+ if (typeof input.path === 'string' && input.path.trim().length > 0) search += ` path:"${input.path.trim()}"`;
361
+ const params = {
362
+ search,
363
+ json: '1',
364
+ count: String(input.countOnly === true ? 1 : limit),
365
+ offset: String(offset),
366
+ // JSON mode defaults count to 4294967295 — always pass it explicitly. The
367
+ // *_column switches make results carry the fields we project.
368
+ path_column: '1',
369
+ size_column: '1',
370
+ date_modified_column: '1',
371
+ };
372
+ if (input.regex === true) params.regex = '1';
373
+ if (input.matchCase === true) params.case = '1';
374
+ if (input.wholeWord === true) params.wholeword = '1';
375
+ const sort = httpSortParams(input.sort);
376
+ if (sort && sort.sort) {
377
+ params.sort = sort.sort;
378
+ params.ascending = String(sort.ascending);
379
+ }
380
+ return params;
381
+ }
382
+
383
+ /**
384
+ * Convert a Windows FILETIME string (> 2^53, needs BigInt) into the same
385
+ * local "YYYY-MM-DDTHH:MM:SS" rendering es.exe -date-format 1 produces.
386
+ * Returns undefined on garbage input.
387
+ */
388
+ function fileTimeToLocalIso(fileTime) {
389
+ try {
390
+ const n = typeof fileTime === 'bigint' ? fileTime : BigInt(fileTime);
391
+ const ms = Number((n - 116444736000000000n) / 10000n);
392
+ const d = new Date(ms);
393
+ if (Number.isNaN(d.getTime())) return undefined;
394
+ const p = (x) => String(x).padStart(2, '0');
395
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
396
+ } catch {
397
+ return undefined;
398
+ }
399
+ }
400
+
401
+ /** fetch with a timeout, honoring the caller's abort signal. */
402
+ async function fetchHttp(url, { headers, signal, timeoutMs }) {
403
+ const ctrl = new AbortController();
404
+ const onAbort = () => ctrl.abort();
405
+ let timer = null;
406
+ try {
407
+ if (signal) {
408
+ if (signal.aborted) throw new EverythingError('everything_search: the call was aborted (tool timeout or caller cancellation).', 'ES_ABORTED');
409
+ signal.addEventListener('abort', onAbort, { once: true });
410
+ }
411
+ timer = setTimeout(() => ctrl.abort(new Error('timeout')), timeoutMs);
412
+ return await fetch(url, { headers, signal: ctrl.signal });
413
+ } catch (error) {
414
+ if (signal?.aborted) throw new EverythingError('everything_search: the call was aborted (tool timeout or caller cancellation).', 'ES_ABORTED');
415
+ const cause = error?.cause?.code ?? error?.message ?? String(error);
416
+ throw new EverythingError(`everything_search: the Everything HTTP server on 127.0.0.1 is unreachable (${cause}).`, 'ES_HTTP_UNREACHABLE');
417
+ } finally {
418
+ if (timer) clearTimeout(timer);
419
+ if (signal) signal.removeEventListener('abort', onAbort);
420
+ }
421
+ }
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // Rendering / presentation
425
+ // ---------------------------------------------------------------------------
426
+
195
427
  function formatSize(bytes) {
196
428
  if (bytes < 1024) return `${bytes} B`;
197
429
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -201,7 +433,7 @@ function formatSize(bytes) {
201
433
 
202
434
  function renderResults(value) {
203
435
  const lines = [];
204
- const { query, total, truncated, results, countOnly } = value;
436
+ const { query, total, truncated, results, countOnly, totalExact, notes } = value;
205
437
  if (countOnly) {
206
438
  lines.push(total === 0 ? `Everything "${query}": no matches.` : `Everything "${query}": ${total} match(es) (count only).`);
207
439
  return lines.join('\n');
@@ -210,7 +442,9 @@ function renderResults(value) {
210
442
  lines.push(`Everything "${query}": no matches.`);
211
443
  return lines.join('\n');
212
444
  }
213
- lines.push(`Everything "${query}": ${results.length} of ${total} result(s)${truncated ? ' (truncated, refine the query, raise maxResults, or page with offset)' : ''}:`);
445
+ const totalText = totalExact === false ? `≥${total}` : String(total);
446
+ lines.push(`Everything "${query}": ${results.length} of ${totalText} result(s)${truncated ? ' (truncated, refine the query, raise maxResults, or page with offset)' : ''}:`);
447
+ for (const note of Array.isArray(notes) ? notes : []) lines.push(` note: ${note}`);
214
448
  for (const entry of results) {
215
449
  const meta = [entry.size !== undefined ? formatSize(entry.size) : null, entry.dateModified ?? null, entry.dateCreated ? `created ${entry.dateCreated}` : null]
216
450
  .filter(Boolean)
@@ -252,7 +486,7 @@ const PARAMETERS_SCHEMA = {
252
486
  },
253
487
  path: {
254
488
  type: 'string',
255
- description: 'Restrict the search to this directory and everything below it (absolute path).',
489
+ description: 'Restrict the search to this directory and everything below it (absolute path; must exist).',
256
490
  },
257
491
  maxResults: {
258
492
  type: 'integer',
@@ -280,13 +514,18 @@ const PARAMETERS_SCHEMA = {
280
514
  type: 'boolean',
281
515
  description: 'Return only the total number of matches, without listing paths (fastest way to check existence or estimate volume).',
282
516
  },
517
+ exactTotal: {
518
+ type: 'boolean',
519
+ description:
520
+ 'When a full page is returned, fetch the exact total match count (default true). Set false to skip that extra round-trip and get a fast "≥N" total.',
521
+ },
283
522
  },
284
523
  required: ['query'],
285
524
  additionalProperties: false,
286
525
  };
287
526
 
288
527
  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.';
528
+ '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; a whole-query quoted phrase with spaces (exact multi-word match) is auto-converted to a regex. 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 regular expression over the whole path, countOnly=true for a fast match count without paths, dateCreated=true to also return creation dates, exactTotal=false to trade the exact total for speed on full pages. 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
529
 
291
530
  const OUTPUT_SCHEMA = {
292
531
  type: 'object',
@@ -295,8 +534,11 @@ const OUTPUT_SCHEMA = {
295
534
  properties: {
296
535
  query: { type: 'string' },
297
536
  total: { type: 'integer' },
537
+ totalExact: { type: 'boolean', description: 'false means total is a page-based lower bound ("≥N").' },
298
538
  truncated: { type: 'boolean' },
299
539
  countOnly: { type: 'boolean' },
540
+ channel: { type: 'string', description: 'Which Everything channel served the call: "http" or "es".' },
541
+ notes: { type: 'array', items: { type: 'string' }, description: 'Non-fatal remarks about how the query was interpreted.' },
300
542
  results: {
301
543
  type: 'array',
302
544
  items: {
@@ -338,11 +580,20 @@ export function apply(ctx, config = {}) {
338
580
  const autoInstall = config.autoInstall !== false;
339
581
  const maxResults = clampInt(config.maxResults, 1, MAX_RESULTS_CEILING, DEFAULT_MAX_RESULTS);
340
582
  const timeoutMs = clampInt(config.timeoutMs, 1000, 60000, DEFAULT_TIMEOUT_MS);
583
+ const httpPort = clampInt(config.httpPort, 0, 65535, DEFAULT_HTTP_PORT);
584
+ const httpAutoEnable = config.httpAutoEnable !== false;
585
+ const httpUsername = typeof config.httpUsername === 'string' ? config.httpUsername.trim() : '';
586
+ const httpPassword = typeof config.httpPassword === 'string' ? config.httpPassword : '';
341
587
 
342
588
  // Mutable: auto-install may re-resolve both paths after provisioning.
343
589
  let esPath = resolveEsPath(config);
344
590
  let everythingPath = resolveEverythingPath(config, esPath ?? 'es.exe');
345
591
 
592
+ // HTTP channel state: cached readiness, a 30s breaker after failures, and
593
+ // a one-shot flag so an auth failure is logged once, not per call.
594
+ const httpState = { ready: false, downUntil: 0, authWarned: false };
595
+ let httpSetupDone = false;
596
+
346
597
  /**
347
598
  * One shared provisioning run. Rejects clear the cache so the next call can
348
599
  * retry (e.g. after the user approves a UAC prompt on a second attempt).
@@ -365,10 +616,27 @@ export function apply(ctx, config = {}) {
365
616
  }
366
617
  },
367
618
  })
368
- .then((result) => {
619
+ .then(async (result) => {
369
620
  esPath = result.esPath;
370
621
  everythingPath = result.everythingPath;
371
622
  if (result.installed.length > 0) ctx.logger?.info?.(`everything_search: auto-installed ${result.installed.join(', ')} under ${result.installDir}`);
623
+ // One-time attempt to switch Everything's own config on so its
624
+ // HTTP JSON server starts (the fast channel). Best effort only.
625
+ if (httpPort > 0 && httpAutoEnable && !httpSetupDone) {
626
+ httpSetupDone = true;
627
+ try {
628
+ const r = await ensureHttpServer({ port: httpPort, everythingPath, log: ctx.logger, username: httpUsername, password: httpPassword });
629
+ if (r.enabled || r.alreadyRunning) {
630
+ httpState.ready = true;
631
+ httpState.downUntil = 0; // clear the breaker so the next call uses HTTP
632
+ }
633
+ if (r.enabled) {
634
+ ctx.logger?.info?.(`everything_search: HTTP channel enabled (127.0.0.1:${httpPort}); searches will skip per-call es.exe launches.`);
635
+ }
636
+ } catch (error) {
637
+ ctx.logger?.warn?.(`everything_search: HTTP channel setup failed (${error?.message ?? error}); staying on the es.exe channel.`);
638
+ }
639
+ }
372
640
  return result;
373
641
  })
374
642
  .catch((error) => {
@@ -416,8 +684,124 @@ export function apply(ctx, config = {}) {
416
684
  if (input.dateCreated === true) searchArgv.push('-dc');
417
685
  if (offset > 0) searchArgv.push('-viewport-offset', String(offset));
418
686
  searchArgv.push(...flags);
419
- const stdout = await runEsWithRetry(searchArgv, signal);
420
- return { flags, results: parseResults(stdout) };
687
+
688
+ // Primary path: es.exe writes results to a UTF-8 file. Piping stdout
689
+ // instead emits the ANSI codepage and mangles non-ASCII names (GBK on a
690
+ // zh-CN machine, question marks elsewhere).
691
+ const exportPath = joinPath(tmpdir(), `dsh-everything-${process.pid}-${Date.now()}-${++exportFileCounter}.json`);
692
+ try {
693
+ let stdoutBuf;
694
+ try {
695
+ stdoutBuf = await runEsWithRetry([...searchArgv, '-export-json', exportPath], signal);
696
+ } catch (error) {
697
+ // es.exe builds that reject the option: retry once in stdout mode.
698
+ if (!(error instanceof EverythingError) || error.code !== 'ES_FAILED' || !/export|unknown|invalid|option/i.test(error.message ?? '')) throw error;
699
+ stdoutBuf = await runEsWithRetry(searchArgv, signal);
700
+ return { flags, results: parseResults(decodeEsStdout(stdoutBuf)) };
701
+ }
702
+ const exported = await readExportText(exportPath);
703
+ if (!exported.exists || exported.text.length === 0) {
704
+ // No file written (or an es.exe that ignores the option): fall back
705
+ // to decoding stdout instead of reporting a fake empty result.
706
+ return { flags, results: parseResults(decodeEsStdout(stdoutBuf)) };
707
+ }
708
+ return { flags, results: parseResults(exported.text) };
709
+ } finally {
710
+ try {
711
+ await unlink(exportPath);
712
+ } catch {
713
+ /* best effort */
714
+ }
715
+ }
716
+ }
717
+
718
+ /** Is the HTTP channel up? Probes lazily; 30s breaker after failures. */
719
+ async function httpChannelReady() {
720
+ if (httpPort <= 0) return false;
721
+ if (httpState.ready) return true;
722
+ if (Date.now() < httpState.downUntil) return false;
723
+ try {
724
+ await probeHttpPort(httpPort, { timeoutMs: 1500, username: httpUsername, password: httpPassword });
725
+ httpState.ready = true;
726
+ return true;
727
+ } catch {
728
+ httpState.downUntil = Date.now() + HTTP_RETRY_DOWN_MS;
729
+ // The server is down: one shot per session at switching Everything's
730
+ // own config on (config patch + GUI restart, never blocks this call).
731
+ if (httpAutoEnable && !httpSetupDone && (esPath || existsSync(everythingPath))) {
732
+ void ensureReady().catch(() => {
733
+ /* logged inside ensureReady */
734
+ });
735
+ }
736
+ return false;
737
+ }
738
+ }
739
+
740
+ /** One HTTP search (or count). Throws ES_HTTP_* to let the caller fall back to es.exe. */
741
+ async function httpSearch(input, query, limit, offset, signal) {
742
+ const params = httpSearchParams(input, query, { limit, offset });
743
+ const url = `http://127.0.0.1:${httpPort}/?${new URLSearchParams(params).toString()}`;
744
+ const headers = httpUsername ? { authorization: `Basic ${Buffer.from(`${httpUsername}:${httpPassword}`).toString('base64')}` } : undefined;
745
+ const res = await fetchHttp(url, { headers, signal, timeoutMs: Math.min(timeoutMs, HTTP_REQUEST_CAP_MS) });
746
+ if (res.status === 401 || res.status === 403) {
747
+ throw new EverythingError(`everything_search: the Everything HTTP server rejected the credentials (HTTP ${res.status}); check httpUsername/httpPassword in the plugin config.`, 'ES_HTTP_AUTH');
748
+ }
749
+ if (!res.ok) {
750
+ throw new EverythingError(`everything_search: the Everything HTTP server returned HTTP ${res.status}.`, 'ES_HTTP_UNREACHABLE');
751
+ }
752
+ let json;
753
+ try {
754
+ json = await res.json();
755
+ } catch {
756
+ throw new EverythingError('everything_search: the Everything HTTP server returned a malformed response.', 'ES_HTTP_UNREACHABLE');
757
+ }
758
+ if (typeof json?.totalResults !== 'number' || !Array.isArray(json.results)) {
759
+ throw new EverythingError('everything_search: unexpected JSON from the Everything HTTP server (is another service on that port?).', 'ES_HTTP_UNREACHABLE');
760
+ }
761
+ const total = json.totalResults;
762
+ if (input.countOnly === true) return { total, results: [], truncated: false, totalExact: true, countOnly: true };
763
+ const results = [];
764
+ for (const item of json.results) {
765
+ if (typeof item !== 'object' || item === null) continue;
766
+ const dir = typeof item.path === 'string' ? item.path : '';
767
+ const name = typeof item.name === 'string' ? item.name : '';
768
+ if (name.length === 0 && dir.length === 0) continue;
769
+ const entry = { path: dir ? (dir.endsWith('\\') ? dir + name : `${dir}\\${name}`) : name };
770
+ if (typeof item.size === 'string' && item.size.length > 0) {
771
+ const n = Number(item.size);
772
+ if (Number.isFinite(n)) entry.size = n;
773
+ }
774
+ if (typeof item.date_modified === 'string' && item.date_modified.length > 0) {
775
+ const iso = fileTimeToLocalIso(item.date_modified);
776
+ if (iso !== undefined) entry.dateModified = iso;
777
+ }
778
+ results.push(entry);
779
+ }
780
+ return { total, results, truncated: total > offset + results.length, totalExact: true };
781
+ }
782
+
783
+ /** Reject a local-drive path that does not exist (or is a file) up front. */
784
+ function validatePathExists(pathArg) {
785
+ if (/^[a-z]:[\\/]/i.test(pathArg)) {
786
+ if (!existsSync(pathArg)) {
787
+ throw new EverythingError(`everything_search: path does not exist: ${pathArg}`, 'ES_INVALID_ARGS');
788
+ }
789
+ try {
790
+ if (statSync(pathArg).isFile()) {
791
+ throw new EverythingError(`everything_search: path must be a directory, not a file: ${pathArg} (drop the path parameter, or pass its parent directory).`, 'ES_INVALID_ARGS');
792
+ }
793
+ } catch (error) {
794
+ if (error instanceof EverythingError) throw error;
795
+ /* statSync raced — treat as existing */
796
+ }
797
+ }
798
+ }
799
+
800
+ /** UNC paths cannot be stat-checked cheaply up front: check after a miss. */
801
+ function noteUncMissing(pathArg, results) {
802
+ if (results.length === 0 && typeof pathArg === 'string' && pathArg.startsWith('\\\\') && !existsSync(pathArg)) {
803
+ throw new EverythingError(`everything_search: path does not exist: ${pathArg}`, 'ES_INVALID_ARGS');
804
+ }
421
805
  }
422
806
 
423
807
  async function execute(args, exec) {
@@ -427,34 +811,96 @@ export function apply(ctx, config = {}) {
427
811
  if (query.length === 0) throw new EverythingError('everything_search: "query" must be a non-empty Everything search expression.', 'ES_INVALID_ARGS');
428
812
  const limit = clampInt(input.maxResults, 1, MAX_RESULTS_CEILING, maxResults);
429
813
  const offset = clampInt(input.offset, 0, Number.MAX_SAFE_INTEGER, 0);
814
+ if (typeof input.sort === 'string' && input.sort.trim().length > 0 && !SORT_NAME_RE.test(input.sort.trim())) {
815
+ throw new EverythingError(`everything_search: invalid sort "${input.sort.trim()}"; use e.g. "date-modified-descending", "size", "name-ascending".`, 'ES_INVALID_ARGS');
816
+ }
817
+
818
+ // Exact-phrase upgrade (a whole-query quoted phrase with spaces becomes
819
+ // an equivalent regex — the only way to express it on either channel).
820
+ const prep = prepareQuery(query);
821
+ const effectiveQuery = prep.query;
822
+ const regexMode = input.regex === true || prep.regex;
823
+ const notes = prep.note ? [prep.note] : [];
824
+
825
+ const pathArg = typeof input.path === 'string' ? input.path.trim() : '';
826
+ if (pathArg.length > 0) validatePathExists(pathArg);
827
+ const flagsInput = { ...input, regex: regexMode };
828
+
829
+ // Channel choice. The HTTP server has no date_created column, supports
830
+ // only a subset of sort keys, and its regex mode does not interpret
831
+ // path:/folder:/file: prefixes — those combinations go to es.exe.
832
+ const httpSortOk = httpSortParams(input.sort) !== null;
833
+ const httpEligible =
834
+ httpSortOk &&
835
+ (input.countOnly === true || input.dateCreated !== true) &&
836
+ !(regexMode && (pathArg.length > 0 || input.foldersOnly === true || input.filesOnly === true));
837
+
838
+ if (httpEligible && (await httpChannelReady())) {
839
+ try {
840
+ const res = await httpSearch(flagsInput, effectiveQuery, limit, offset, signal);
841
+ noteUncMissing(pathArg, res.results);
842
+ return { query, ...res, channel: 'http', ...(notes.length > 0 ? { notes } : {}) };
843
+ } catch (error) {
844
+ if (error instanceof EverythingError && error.code === 'ES_ABORTED') throw error;
845
+ if (error instanceof EverythingError && (error.code === 'ES_HTTP_UNREACHABLE' || error.code === 'ES_HTTP_AUTH')) {
846
+ // Circuit-break the HTTP channel for 30s and fall back to es.exe.
847
+ httpState.ready = false;
848
+ httpState.downUntil = Date.now() + HTTP_RETRY_DOWN_MS;
849
+ if (error.code === 'ES_HTTP_AUTH' && !httpState.authWarned) {
850
+ httpState.authWarned = true;
851
+ ctx.logger?.warn?.(error.message);
852
+ }
853
+ } else {
854
+ throw error;
855
+ }
856
+ }
857
+ }
858
+
859
+ // es.exe channel. Report phrases that had to be degraded to ANDed words.
860
+ if (!regexMode) {
861
+ const { degraded } = tokenizeQuery(effectiveQuery);
862
+ for (const phrase of degraded) {
863
+ notes.push(`quoted phrase "${phrase}" contains spaces; matched as ANDed words instead (use regex=true, or a whole-query quoted phrase, for exact multi-word matching)`);
864
+ }
865
+ }
430
866
 
431
867
  // Count-only mode: one IPC round-trip, no paths listed.
432
868
  if (input.countOnly === true) {
433
- const flags = buildSearchFlags(input, query);
869
+ const flags = buildSearchFlags(flagsInput, effectiveQuery);
434
870
  const countOut = await runEsWithRetry(['-get-result-count', ...flags], signal);
435
871
  const total = Number.parseInt(String(countOut).trim(), 10);
436
872
  if (!Number.isFinite(total) || total < 0) {
437
873
  throw new EverythingError(`everything_search: could not parse the result count ("${countOut}").`, 'ES_PARSE_FAILED');
438
874
  }
439
- return { query, total, truncated: false, results: [], countOnly: true };
875
+ return { query, total, truncated: false, results: [], countOnly: true, totalExact: true, channel: 'es', ...(notes.length > 0 ? { notes } : {}) };
440
876
  }
441
877
 
442
- const { flags, results } = await searchOnce(input, query, limit, offset, signal);
878
+ const { flags, results } = await searchOnce(flagsInput, effectiveQuery, limit, offset, signal);
879
+ noteUncMissing(pathArg, results);
443
880
 
444
881
  // The exact total costs a second IPC round-trip; only fetch it when the
445
- // page is full and truncation is actually possible.
882
+ // page is full and the caller has not opted out (exactTotal=false).
446
883
  let total = offset + results.length;
447
- if (results.length === limit) {
884
+ let totalExact = results.length < limit;
885
+ if (results.length === limit && input.exactTotal !== false) {
448
886
  try {
449
887
  const countOut = await runEs(esPath, ['-get-result-count', ...flags], timeoutMs, signal);
450
- const parsed = Number.parseInt(countOut.trim(), 10);
451
- if (Number.isFinite(parsed) && parsed >= total) total = parsed;
888
+ const parsed = Number.parseInt(String(countOut).trim(), 10);
889
+ if (Number.isFinite(parsed) && parsed >= total) {
890
+ total = parsed;
891
+ totalExact = true;
892
+ }
452
893
  } catch {
453
894
  /* count is cosmetic; keep the page-based estimate */
454
895
  }
455
896
  }
456
897
 
457
- return { query, total, truncated: total > offset + results.length, results };
898
+ // With an exact total, "truncated" means more results exist past this
899
+ // page; with a page-based estimate (exactTotal=false) a full page always
900
+ // reports as truncated because more results may exist.
901
+ const truncated = totalExact ? total > offset + results.length : results.length === limit;
902
+
903
+ return { query, total, truncated, totalExact, results, channel: 'es', ...(notes.length > 0 ? { notes } : {}) };
458
904
  }
459
905
 
460
906
  ctx.tools.register({
@@ -482,10 +928,17 @@ export function apply(ctx, config = {}) {
482
928
  export {
483
929
  buildSearchFlags,
484
930
  clampInt,
931
+ decodeEsStdout,
932
+ fileTimeToLocalIso,
485
933
  formatSize,
934
+ httpSearchParams,
935
+ httpSortParams,
486
936
  parseResults,
937
+ phraseToRegex,
938
+ prepareQuery,
487
939
  presentCall,
488
940
  presentResult,
489
941
  renderResults,
490
942
  resolveEsPath,
943
+ tokenizeQuery,
491
944
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-everything",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
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",
package/setup.js CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { execFile, spawn } from 'node:child_process';
25
25
  import { createHash } from 'node:crypto';
26
- import { createWriteStream, existsSync, mkdirSync } from 'node:fs';
26
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
27
27
  import { readFile } from 'node:fs/promises';
28
28
  import { win32 as win32path } from 'node:path';
29
29
 
@@ -412,13 +412,30 @@ export function findEverythingInstall() {
412
412
  return null;
413
413
  }
414
414
 
415
+ /**
416
+ * Fresh installs ship with the HTTP server feature locked off
417
+ * (allow_http_server=0 in the ini next to Everything.exe). Open it once at
418
+ * install time so the fast channel can come up later.
419
+ */
420
+ function allowHttpInLocalIni(everythingPath, log) {
421
+ try {
422
+ const localIni = win32path.join(win32path.dirname(everythingPath), 'Everything.ini');
423
+ patchIniValue(localIni, 'allow_http_server', 1, { create: true });
424
+ } catch (error) {
425
+ log?.warn?.(`everything_search: could not unlock the HTTP server in the Everything.ini next to ${everythingPath} (${error?.message ?? error}).`);
426
+ }
427
+ }
428
+
415
429
  async function installEverything(installDir, log, proxy) {
416
430
  // Strategy 1: winget (present on most Windows 10/11 desktops).
417
431
  try {
418
432
  log?.info?.('everything_search: installing Everything via winget (a UAC prompt may appear)…');
419
433
  await run('winget', buildWingetInstallArgs(), WINGET_TIMEOUT_MS);
420
434
  const found = findEverythingInstall();
421
- if (found) return { everythingPath: found, via: 'winget' };
435
+ if (found) {
436
+ allowHttpInLocalIni(found, log);
437
+ return { everythingPath: found, via: 'winget' };
438
+ }
422
439
  } catch (error) {
423
440
  log?.warn?.(`everything_search: winget install failed (${error?.message ?? error}); falling back to the official setup.`);
424
441
  }
@@ -438,6 +455,7 @@ async function installEverything(installDir, log, proxy) {
438
455
  'ES_SETUP_FAILED'
439
456
  );
440
457
  }
458
+ allowHttpInLocalIni(found, log);
441
459
  return { everythingPath: found, via: 'setup' };
442
460
  }
443
461
 
@@ -520,3 +538,136 @@ export async function ensureBackend({ config = {}, log = null, proxy, probe = nu
520
538
  export function launchDetached(binaryPath, argv) {
521
539
  spawn(binaryPath, argv, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
522
540
  }
541
+
542
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
543
+
544
+ /**
545
+ * Replace (or append) one `key=value` line in an Everything .ini file.
546
+ * `onlyIfEmpty` keeps an existing non-empty value (used for bindings so a
547
+ * user's explicit choice is never overridden). Returns true when the file
548
+ * was written.
549
+ */
550
+ export function patchIniValue(filePath, key, value, { create = false, onlyIfEmpty = false } = {}) {
551
+ let text = '';
552
+ if (existsSync(filePath)) text = readFileSync(filePath, 'utf8');
553
+ else if (!create) return false;
554
+ const line = `${key}=${value}`;
555
+ const re = new RegExp(`^${key}=.*$`, 'm');
556
+ if (re.test(text)) {
557
+ const current = text.match(re)[0];
558
+ if (onlyIfEmpty && current !== `${key}=`) return false;
559
+ writeFileSync(filePath, text.replace(re, line), 'utf8');
560
+ return true;
561
+ }
562
+ const out = text.length === 0 ? `[Everything]\n${line}\n` : `${text.endsWith('\n') ? text : `${text}\n`}${line}\n`;
563
+ writeFileSync(filePath, out, 'utf8');
564
+ return true;
565
+ }
566
+
567
+ /**
568
+ * Probe the Everything HTTP JSON server on 127.0.0.1:<port>. Validates the
569
+ * `totalResults` field so a foreign service on the same port is detected,
570
+ * not silently used. Resolves true, or throws ES_HTTP_UNREACHABLE.
571
+ */
572
+ export async function probeHttpPort(port, { timeoutMs = 1500, username = '', password = '' } = {}) {
573
+ const url = `http://127.0.0.1:${port}/?${new URLSearchParams({ json: '1', search: '_dsh_probe_', count: '1' }).toString()}`;
574
+ const headers = username ? { authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` } : undefined;
575
+ let res;
576
+ try {
577
+ res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
578
+ } catch (error) {
579
+ throw new EverythingError(`everything_search setup: no Everything HTTP server on 127.0.0.1:${port} (${error?.cause?.code ?? error?.message ?? error}).`, 'ES_HTTP_UNREACHABLE');
580
+ }
581
+ if (!res.ok) {
582
+ throw new EverythingError(`everything_search setup: HTTP probe on 127.0.0.1:${port} returned ${res.status}.`, 'ES_HTTP_UNREACHABLE');
583
+ }
584
+ let json;
585
+ try {
586
+ json = await res.json();
587
+ } catch {
588
+ throw new EverythingError(`everything_search setup: the service on 127.0.0.1:${port} is not the Everything HTTP server.`, 'ES_HTTP_UNREACHABLE');
589
+ }
590
+ if (typeof json?.totalResults !== 'number') {
591
+ throw new EverythingError(`everything_search setup: the service on 127.0.0.1:${port} is not the Everything HTTP server.`, 'ES_HTTP_UNREACHABLE');
592
+ }
593
+ return true;
594
+ }
595
+
596
+ /** Kill the Everything GUI (session > 0); the background service is left alone. */
597
+ async function stopEverythingGui() {
598
+ const script = 'Get-Process Everything -ErrorAction SilentlyContinue | Where-Object { $_.SessionId -ne 0 } | Stop-Process -Force';
599
+ try {
600
+ await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], 20000);
601
+ await sleep(500);
602
+ } catch {
603
+ /* best effort — the config patch still applies at next Everything start */
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Turn on Everything's built-in HTTP JSON server.
609
+ *
610
+ * Everything 1.4 ships it disabled AND locked: the per-machine policy switch
611
+ * `allow_http_server` in the Everything.ini next to Everything.exe defaults
612
+ * to 0 (installed builds), and the per-user %APPDATA% ini only honors
613
+ * http_server_* when the local lock is open. The server cannot be enabled
614
+ * through the GUI on locked installs, so we patch both files directly and
615
+ * restart the GUI (the background index service keeps running untouched).
616
+ *
617
+ * Idempotent: returns {enabled: false, alreadyRunning: true} when the server
618
+ * already answers. All failures are returned, never thrown — the caller
619
+ * stays on the es.exe channel.
620
+ */
621
+ export async function ensureHttpServer({ port = 5151, everythingPath, log = null, username = '', password = '' } = {}) {
622
+ try {
623
+ await probeHttpPort(port, { username, password });
624
+ return { enabled: false, alreadyRunning: true };
625
+ } catch {
626
+ /* not up yet — continue below */
627
+ }
628
+
629
+ if (!everythingPath || !existsSync(everythingPath)) {
630
+ return { enabled: false, reason: 'Everything.exe path unknown' };
631
+ }
632
+
633
+ log?.info?.(`everything_search: enabling the Everything HTTP server on 127.0.0.1:${port} (the Everything GUI restarts once)…`);
634
+ await stopEverythingGui();
635
+
636
+ // 1. Unlock the feature in the ini next to Everything.exe (machine policy).
637
+ const localIni = win32path.join(win32path.dirname(everythingPath), 'Everything.ini');
638
+ try {
639
+ patchIniValue(localIni, 'allow_http_server', 1, { create: true });
640
+ } catch (error) {
641
+ log?.warn?.(`everything_search: could not patch ${localIni} (${error?.message ?? error}).`);
642
+ }
643
+
644
+ // 2. Enable + configure it in the per-user ini. Bind loopback only when the
645
+ // user has not chosen something else.
646
+ const appDataDir = process.env.APPDATA ?? win32path.join(process.env.USERPROFILE ?? '.', 'AppData', 'Roaming');
647
+ const appDataIni = win32path.join(appDataDir, 'Everything', 'Everything.ini');
648
+ try {
649
+ mkdirSync(win32path.dirname(appDataIni), { recursive: true });
650
+ patchIniValue(appDataIni, 'http_server_enabled', 1, { create: true });
651
+ patchIniValue(appDataIni, 'http_server_port', port, { create: true });
652
+ patchIniValue(appDataIni, 'http_server_username', username, { create: true });
653
+ patchIniValue(appDataIni, 'http_server_password', password, { create: true });
654
+ patchIniValue(appDataIni, 'http_server_bindings', '127.0.0.1', { create: true, onlyIfEmpty: true });
655
+ } catch (error) {
656
+ log?.warn?.(`everything_search: could not patch ${appDataIni} (${error?.message ?? error}).`);
657
+ }
658
+
659
+ // 3. Restart the GUI detached and wait for the server to answer.
660
+ launchDetached(everythingPath, ['-startup']);
661
+ const deadline = Date.now() + 15000;
662
+ while (Date.now() < deadline) {
663
+ await sleep(250);
664
+ try {
665
+ await probeHttpPort(port, { username, password });
666
+ return { enabled: true };
667
+ } catch {
668
+ /* retry until the deadline */
669
+ }
670
+ }
671
+ log?.warn?.(`everything_search: the Everything HTTP server did not come up on 127.0.0.1:${port} within 15s; staying on the es.exe channel.`);
672
+ return { enabled: false, reason: 'timeout' };
673
+ }