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 +35 -0
- package/README.md +17 -7
- package/README.zh-CN.md +17 -7
- package/cordis.patch.yml +4 -0
- package/index.js +378 -39
- package/package.json +1 -1
- package/setup.js +153 -2
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
|
|
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.
|
|
68
|
-
> **Encoding:**
|
|
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-
|
|
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
|
|
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
|
|
66
|
-
>
|
|
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` |
|
|
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
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
|
|
3
|
+
* Everything (voidtools) index.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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-
|
|
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,
|
|
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 (
|
|
160
|
-
*
|
|
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
|
|
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
|
|
173
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
213
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
882
|
+
// page is full and the caller has not opted out (exactTotal=false).
|
|
558
883
|
let total = offset + results.length;
|
|
559
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
+
"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)
|
|
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
|
+
}
|