dsh-everything 1.3.1 → 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,41 @@
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
+
6
41
  ## [1.3.1] - 2026-10-27
7
42
 
8
43
  ### Fixed
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
 
@@ -59,13 +59,14 @@ Published on npm as [`dsh-everything`](https://www.npmjs.com/package/dsh-everyth
59
59
  | `foldersOnly` / `filesOnly` | boolean | Restrict result type. |
60
60
  | `dateCreated` | boolean | Also return each result's creation date. |
61
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. |
62
63
 
63
- 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.
64
65
 
65
66
  Typical queries: `report ext:docx` · `*.sln dm:thisweek` · `size:>500mb` · `notes !archive` · recently changed files with `sort: date-modified-descending` + `dm:thisweek`.
66
67
 
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.
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.
69
70
 
70
71
  > **Scope note:** searches names and paths only, never file contents — content search stays with `grep`.
71
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.
@@ -82,11 +83,20 @@ Edit the plugin's `config` in `cordis.patch.yml` (or your profile's patch layer)
82
83
  | `autoInstall` | `true` | Download & install Everything + ES CLI when missing (see above). |
83
84
  | `installDir` | `%LOCALAPPDATA%\dsh-everything` | Where the auto-installed ES CLI lives. |
84
85
  | `maxResults` | `100` | Default per-call result cap. |
85
- | `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.
86
96
 
87
97
  ## Privacy
88
98
 
89
- 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.
90
100
 
91
101
  ## Development
92
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
 
@@ -57,13 +57,14 @@ plugin_manager install_bundle,target = dsh-everything
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
 
65
- > **短语怪癖**:带引号的短语只匹配文件名的“名字部分”,所以 `"report.docx"` 永远搜不到——直接写不带引号的 `report.docx`。含空格的短语无法经 es.exe CLI 表达(会退化为逐词 AND);需要精确多词匹配请用 `regex=true`。
66
- > **编码**:结果改从 es.exe 的 UTF-8 `-export-json` 输出读取,非 ASCII 文件名(中文、日文、emoji 等)完整回传、不再乱码。
66
+ > **短语怪癖**:带引号的短语只匹配文件名的“名字部分”,所以 `"report.docx"` 永远搜不到——直接写不带引号的 `report.docx`。**整条查询**就是一个含空格的引号短语(精确多词匹配)时,插件会自动转换成等价正则;与其他词混用的引号短语会退化为逐词 AND 并在 `notes` 里说明(这类场景请显式用 `regex=true`)。
67
+ > **编码**:HTTP 通道原生 UTF-8;es.exe 兜底通道从其 UTF-8 `-export-json` 输出读取——无论哪条通道,非 ASCII 文件名(中文、日文、emoji 等)都完整回传、不再乱码。
67
68
 
68
69
  > **范围说明**:只搜文件名和路径,不搜文件内容——内容搜索请继续用 `grep`。
69
70
  > **覆盖说明**:结果取决于本机 Everything 索引——NTFS/reFS 磁盘与显式加入索引的目录;FAT/exFAT 可移动盘与网络共享通常不在覆盖范围。
@@ -80,11 +81,20 @@ plugin_manager install_bundle,target = dsh-everything
80
81
  | `autoInstall` | `true` | 后端缺失时自动下载并安装 Everything + ES CLI(见上文)。 |
81
82
  | `installDir` | `%LOCALAPPDATA%\dsh-everything` | 自动安装的 ES CLI 存放目录。 |
82
83
  | `maxResults` | `100` | 单次返回条数默认上限。 |
83
- | `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` 指向不存在的路径会直接报错,而不是返回空结果。
84
94
 
85
95
  ## 隐私
86
96
 
87
- 搜索范围是本机**所有已索引磁盘**,结果路径会作为工具输出发给模型。在目录结构敏感的机器上请注意,可用 `path` 参数收窄范围。
97
+ 搜索范围是本机**所有已索引磁盘**,结果路径会作为工具输出发给模型。在目录结构敏感的机器上请注意,可用 `path` 参数收窄范围。HTTP 快速通道只监听 `127.0.0.1`——不向网络暴露任何东西。
88
98
 
89
99
  ## 开发
90
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,13 +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), 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.
8
- * No external npm dependencies. Windows only: Everything itself is a Windows
9
- * application, so on other platforms a stub tool is registered that fails with
10
- * 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.
11
17
  *
12
18
  * Config (set in cordis.patch.yml):
13
19
  * esPath absolute path to es.exe (default: well-known locations, then auto-install)
@@ -16,13 +22,18 @@
16
22
  * autoInstall download & install Everything + ES CLI when missing (default: true)
17
23
  * installDir where the auto-installed ES CLI lives (default: %LOCALAPPDATA%\dsh-everything)
18
24
  * maxResults default per-call result cap (default: 100, ceiling: 1000)
19
- * 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: '')
20
30
  */
21
31
  import { execFile, spawn } from 'node:child_process';
22
- import { existsSync, readFileSync, unlinkSync } from 'node:fs';
32
+ import { existsSync, readFileSync, statSync } from 'node:fs';
33
+ import { unlink } from 'node:fs/promises';
23
34
  import { tmpdir } from 'node:os';
24
35
  import { join as joinPath, win32 as win32path } from 'node:path';
25
- import { ensureBackend, EverythingError } from './setup.js';
36
+ import { ensureBackend, ensureHttpServer, probeHttpPort, EverythingError } from './setup.js';
26
37
 
27
38
  const DEFAULT_MAX_RESULTS = 100;
28
39
  const DEFAULT_TIMEOUT_MS = 8000;
@@ -30,6 +41,11 @@ const MAX_RESULTS_CEILING = 1000;
30
41
  const MAX_BUFFER = 32 * 1024 * 1024;
31
42
  const AUTOSTART_WAIT_MS = 15000;
32
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;
33
49
 
34
50
  /** Fallback candidates when the patch config does not pin `esPath`. */
35
51
  const ES_CANDIDATES = [
@@ -40,6 +56,9 @@ const ES_CANDIDATES = [
40
56
 
41
57
  const SORT_NAME_RE = /^[a-z][a-z0-9-]*(-(ascending|descending))?$/i;
42
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
+
43
62
  export { EverythingError };
44
63
 
45
64
  function clampInt(value, min, max, fallback) {
@@ -156,11 +175,14 @@ async function autoStartEverything(everythingPath, esPath, signal) {
156
175
  * `*.docx dm:thisweek` MUST be passed as two arguments. Quoted phrases
157
176
  * without spaces keep their quotes (phrase semantics); a quoted phrase that
158
177
  * 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).
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).
161
182
  */
162
183
  function tokenizeQuery(query) {
163
- const tokens = [];
184
+ const terms = [];
185
+ const degraded = [];
164
186
  let current = '';
165
187
  let inQuotes = false;
166
188
  const flush = () => {
@@ -168,12 +190,14 @@ function tokenizeQuery(query) {
168
190
  if (/\s/.test(current)) {
169
191
  // Only reachable for a quoted phrase with internal spaces: es.exe cannot
170
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);
171
195
  for (const word of current.split(/\s+/)) {
172
- const bare = word.replace(/"/g, '');
173
- if (bare.length > 0) tokens.push(bare);
196
+ const w = word.replace(/"/g, '');
197
+ if (w.length > 0) terms.push(w);
174
198
  }
175
199
  } else if (current.replace(/"/g, '').length > 0) {
176
- tokens.push(current);
200
+ terms.push(current);
177
201
  }
178
202
  current = '';
179
203
  };
@@ -188,7 +212,35 @@ function tokenizeQuery(query) {
188
212
  }
189
213
  }
190
214
  flush();
191
- return tokens;
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+');
192
244
  }
193
245
 
194
246
  /** Decode es.exe stdout: it emits the console/ANSI codepage, not UTF-8. */
@@ -208,11 +260,13 @@ let exportFileCounter = 0;
208
260
  /**
209
261
  * Read the UTF-8 file written by `es.exe -export-json <file>`.
210
262
  * 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).
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).
214
268
  */
215
- function readExportText(filePath) {
269
+ async function readExportText(filePath) {
216
270
  for (let attempt = 0; attempt < 4; attempt++) {
217
271
  try {
218
272
  const text = readFileSync(filePath, 'utf8');
@@ -221,7 +275,7 @@ function readExportText(filePath) {
221
275
  return { exists: false, text: '' };
222
276
  }
223
277
  // exists but empty: brief wait for the flush race
224
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
278
+ await sleep(100);
225
279
  }
226
280
  return { exists: true, text: '' };
227
281
  }
@@ -247,7 +301,7 @@ function buildSearchFlags(args, query) {
247
301
  }
248
302
  if (args.regex === true) argv.push('-regex', query);
249
303
  else {
250
- const terms = tokenizeQuery(query);
304
+ const { terms } = tokenizeQuery(query);
251
305
  if (terms.length === 0) {
252
306
  throw new EverythingError('everything_search: "query" must contain at least one search term (not only quotes or whitespace).', 'ES_INVALID_ARGS');
253
307
  }
@@ -278,6 +332,98 @@ function parseResults(stdout) {
278
332
  return results;
279
333
  }
280
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
+
281
427
  function formatSize(bytes) {
282
428
  if (bytes < 1024) return `${bytes} B`;
283
429
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -287,7 +433,7 @@ function formatSize(bytes) {
287
433
 
288
434
  function renderResults(value) {
289
435
  const lines = [];
290
- const { query, total, truncated, results, countOnly } = value;
436
+ const { query, total, truncated, results, countOnly, totalExact, notes } = value;
291
437
  if (countOnly) {
292
438
  lines.push(total === 0 ? `Everything "${query}": no matches.` : `Everything "${query}": ${total} match(es) (count only).`);
293
439
  return lines.join('\n');
@@ -296,7 +442,9 @@ function renderResults(value) {
296
442
  lines.push(`Everything "${query}": no matches.`);
297
443
  return lines.join('\n');
298
444
  }
299
- 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}`);
300
448
  for (const entry of results) {
301
449
  const meta = [entry.size !== undefined ? formatSize(entry.size) : null, entry.dateModified ?? null, entry.dateCreated ? `created ${entry.dateCreated}` : null]
302
450
  .filter(Boolean)
@@ -338,7 +486,7 @@ const PARAMETERS_SCHEMA = {
338
486
  },
339
487
  path: {
340
488
  type: 'string',
341
- 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).',
342
490
  },
343
491
  maxResults: {
344
492
  type: 'integer',
@@ -366,13 +514,18 @@ const PARAMETERS_SCHEMA = {
366
514
  type: 'boolean',
367
515
  description: 'Return only the total number of matches, without listing paths (fastest way to check existence or estimate volume).',
368
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
+ },
369
522
  },
370
523
  required: ['query'],
371
524
  additionalProperties: false,
372
525
  };
373
526
 
374
527
  const TOOL_DESCRIPTION =
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.';
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.';
376
529
 
377
530
  const OUTPUT_SCHEMA = {
378
531
  type: 'object',
@@ -381,8 +534,11 @@ const OUTPUT_SCHEMA = {
381
534
  properties: {
382
535
  query: { type: 'string' },
383
536
  total: { type: 'integer' },
537
+ totalExact: { type: 'boolean', description: 'false means total is a page-based lower bound ("≥N").' },
384
538
  truncated: { type: 'boolean' },
385
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.' },
386
542
  results: {
387
543
  type: 'array',
388
544
  items: {
@@ -424,11 +580,20 @@ export function apply(ctx, config = {}) {
424
580
  const autoInstall = config.autoInstall !== false;
425
581
  const maxResults = clampInt(config.maxResults, 1, MAX_RESULTS_CEILING, DEFAULT_MAX_RESULTS);
426
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 : '';
427
587
 
428
588
  // Mutable: auto-install may re-resolve both paths after provisioning.
429
589
  let esPath = resolveEsPath(config);
430
590
  let everythingPath = resolveEverythingPath(config, esPath ?? 'es.exe');
431
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
+
432
597
  /**
433
598
  * One shared provisioning run. Rejects clear the cache so the next call can
434
599
  * retry (e.g. after the user approves a UAC prompt on a second attempt).
@@ -451,10 +616,27 @@ export function apply(ctx, config = {}) {
451
616
  }
452
617
  },
453
618
  })
454
- .then((result) => {
619
+ .then(async (result) => {
455
620
  esPath = result.esPath;
456
621
  everythingPath = result.everythingPath;
457
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
+ }
458
640
  return result;
459
641
  })
460
642
  .catch((error) => {
@@ -517,21 +699,111 @@ export function apply(ctx, config = {}) {
517
699
  stdoutBuf = await runEsWithRetry(searchArgv, signal);
518
700
  return { flags, results: parseResults(decodeEsStdout(stdoutBuf)) };
519
701
  }
520
- const exported = readExportText(exportPath);
521
- if (!exported.exists) {
522
- // No file written: an es.exe that ignores the option printed to stdout.
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.
523
706
  return { flags, results: parseResults(decodeEsStdout(stdoutBuf)) };
524
707
  }
525
708
  return { flags, results: parseResults(exported.text) };
526
709
  } finally {
527
710
  try {
528
- unlinkSync(exportPath);
711
+ await unlink(exportPath);
529
712
  } catch {
530
713
  /* best effort */
531
714
  }
532
715
  }
533
716
  }
534
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
+ }
805
+ }
806
+
535
807
  async function execute(args, exec) {
536
808
  const input = typeof args === 'object' && args !== null ? args : {};
537
809
  const signal = exec?.signal;
@@ -539,34 +811,96 @@ export function apply(ctx, config = {}) {
539
811
  if (query.length === 0) throw new EverythingError('everything_search: "query" must be a non-empty Everything search expression.', 'ES_INVALID_ARGS');
540
812
  const limit = clampInt(input.maxResults, 1, MAX_RESULTS_CEILING, maxResults);
541
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
+ }
542
866
 
543
867
  // Count-only mode: one IPC round-trip, no paths listed.
544
868
  if (input.countOnly === true) {
545
- const flags = buildSearchFlags(input, query);
869
+ const flags = buildSearchFlags(flagsInput, effectiveQuery);
546
870
  const countOut = await runEsWithRetry(['-get-result-count', ...flags], signal);
547
871
  const total = Number.parseInt(String(countOut).trim(), 10);
548
872
  if (!Number.isFinite(total) || total < 0) {
549
873
  throw new EverythingError(`everything_search: could not parse the result count ("${countOut}").`, 'ES_PARSE_FAILED');
550
874
  }
551
- 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 } : {}) };
552
876
  }
553
877
 
554
- 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);
555
880
 
556
881
  // The exact total costs a second IPC round-trip; only fetch it when the
557
- // page is full and truncation is actually possible.
882
+ // page is full and the caller has not opted out (exactTotal=false).
558
883
  let total = offset + results.length;
559
- if (results.length === limit) {
884
+ let totalExact = results.length < limit;
885
+ if (results.length === limit && input.exactTotal !== false) {
560
886
  try {
561
887
  const countOut = await runEs(esPath, ['-get-result-count', ...flags], timeoutMs, signal);
562
888
  const parsed = Number.parseInt(String(countOut).trim(), 10);
563
- if (Number.isFinite(parsed) && parsed >= total) total = parsed;
889
+ if (Number.isFinite(parsed) && parsed >= total) {
890
+ total = parsed;
891
+ totalExact = true;
892
+ }
564
893
  } catch {
565
894
  /* count is cosmetic; keep the page-based estimate */
566
895
  }
567
896
  }
568
897
 
569
- 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 } : {}) };
570
904
  }
571
905
 
572
906
  ctx.tools.register({
@@ -595,8 +929,13 @@ export {
595
929
  buildSearchFlags,
596
930
  clampInt,
597
931
  decodeEsStdout,
932
+ fileTimeToLocalIso,
598
933
  formatSize,
934
+ httpSearchParams,
935
+ httpSortParams,
599
936
  parseResults,
937
+ phraseToRegex,
938
+ prepareQuery,
600
939
  presentCall,
601
940
  presentResult,
602
941
  renderResults,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-everything",
3
- "version": "1.3.1",
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
+ }