@fishdivinity/dsh-local-search 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh.md +36 -22
- package/README.md +35 -21
- package/cordis.patch.yml +2 -1
- package/lib/anytxt.js +49 -35
- package/lib/bootstrap.js +201 -0
- package/lib/client.js +1196 -290
- package/lib/config.js +193 -49
- package/lib/describe.js +157 -0
- package/lib/engines.js +6 -6
- package/lib/errors.js +2 -3
- package/lib/everything.js +70 -15
- package/lib/format.js +38 -33
- package/lib/index.js +75 -38
- package/lib/limits.js +13 -0
- package/lib/probe.js +34 -21
- package/lib/ref.js +5 -5
- package/lib/roots.js +34 -16
- package/lib/route.js +253 -70
- package/lib/schema.js +130 -0
- package/lib/tools.js +46 -36
- package/package.json +3 -2
package/README-zh.md
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
|
|
46
46
|
- Windows。
|
|
47
47
|
- DeepSeek Harness `>=0.2.1-0 <0.3.0`。
|
|
48
|
-
- Everything 正在运行,并安装 `es.exe`(voidtools 单独下载;插件会从 PATH/常见目录探测,也可配置 `esPath`)。
|
|
48
|
+
- Everything 正在运行,并安装 `es.exe`(voidtools 单独下载;插件会从 PATH/常见目录探测,也可配置 `files.everything.esPath`)。
|
|
49
49
|
- 内容检索需要 Anytxt Searcher 的 **GUI**(`ATGUI.exe`)在运行;JSON-RPC 服务在 GUI 进程里,只有 `ATService` 不够。
|
|
50
50
|
|
|
51
51
|
插件不打包 Everything、`es.exe` 或 Anytxt,需自行安装并遵守各自许可。
|
|
@@ -66,24 +66,34 @@ dsh plugin --profile desktop add github:fishdivinity/dsh-local-search
|
|
|
66
66
|
|
|
67
67
|
## 配置
|
|
68
68
|
|
|
69
|
-
侧边栏 **Plugins** → **Official plugins** 组里的「本地搜索」卡片 → 插件自己的页面。这张卡和 GitHub
|
|
69
|
+
侧边栏 **Plugins** → **Official plugins** 组里的「本地搜索」卡片 → 插件自己的页面。这张卡和 GitHub 插件那张是同一个形态。保存后对下一次工具调用生效;离开页面丢弃未保存改动。描述下面那个**重置**会先把会清什么摆出来,确认后才清掉引擎与上限、按一次新的探测填回去;检索范围不动,因为那是你自己敲进去的路径。
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
页面按能力组织:「文件名检索」与「文件内容检索」各以一行后端选择器开头,选中后展开该后端的字段;「检索范围」与「上限」与后端无关,收在页尾。分节、后端列表、字段类型与硬上限都由 Host 的 `GET /dsh-local-search/api/describe` 给出,页面只负责渲染,新增后端不用改页面逻辑。
|
|
72
|
+
|
|
73
|
+
**测试连接**就放在 Anytxt 地址字段下面,测的是**输入框里当前的地址和 `es.exe` 路径**(不用先保存),同时把 Everything 一起探一遍:路径打错就直接说找不到文件,不会拿已保存的那个报「正常」。结果与修法在一个弹窗里,弹窗还能再测一次。某个引擎不可用时会给出它的官方下载页;本插件不打包、不下载、也不代替你安装这两个程序。
|
|
74
|
+
|
|
75
|
+
**保存前会先校验。** 只要这次保存动了地址或 `es.exe` 路径,页面就先把它们探一遍:不通过就不写,弹窗问你要不要「仍然保存」——那是你明知的例外,而不是默认结果;路径上没有文件、目录不存在、地址被拒这类硬错没有强行写入的出口。路径那一项 Host 只查「文件在不在」,绝不启动请求里指定的文件。路径字段与 Anytxt 地址都在**失焦时**先校验一次:不存在、把目录填成文件、地址不是本机,都会在字段下面标红,改掉就收回。
|
|
76
|
+
|
|
77
|
+
路径字段保存时会改成标准写法:`d:/alpha` 存成 `D:\alpha`(盘符大写、反斜杠、去掉末尾分隔符),`allRoots` 按分号逐项改;路径存在时还会按磁盘上的真实大小写写回(`d:/alpha` → `D:\Alpha`)。名字里的空格与中文不动——`D:\Alpha\我的项目\drawio a` 这样带空格的目录名照写就行,宿主按值传给 `es.exe` 与文件接口,不经过 shell。
|
|
78
|
+
|
|
79
|
+
首用只补一次就停:`files.everything.esPath` 或 `content.anytxt.url` 还空着时,插件探一次、把能应答的写进去,并在隐藏键 `autoConfigured` 里记下这次已经做过决定。之后你清空的字段会保持空着,不会再被自动填回去。
|
|
72
80
|
|
|
73
81
|
全部可选;row config 是整块替换,不会深合并。只有 `timeoutMs` 留在 profile 的 `cordis.patch.yml`:每个工具在注册时就固定截止时间,因此页面上不提供这个字段,免得出现“改了没反应”。
|
|
74
82
|
|
|
75
|
-
|
|
|
83
|
+
| 键 | 默认 | 说明 |
|
|
76
84
|
|---|---|---|
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
79
|
-
| `
|
|
80
|
-
| `
|
|
81
|
-
| `
|
|
82
|
-
| `
|
|
83
|
-
| `
|
|
85
|
+
| `files.backend` | `everything` | `auto` 使用的文件名后端 |
|
|
86
|
+
| `files.everything.esPath` | 自动探测 | `es.exe` 绝对路径。顺序:配置值 → `ES_PATH` → 常见安装目录 → `where.exe` |
|
|
87
|
+
| `files.everything.esEncoding` | `gbk` | 解码 `es.exe` 输出的代码页;接受任意 `TextDecoder` 标签,页面上是 `gbk` / `utf-8` 二选一 |
|
|
88
|
+
| `content.backend` | `anytxt` | `auto` 使用的内容后端 |
|
|
89
|
+
| `content.anytxt.url` | `http://127.0.0.1:9920` | 本机 Anytxt JSON-RPC 地址(只接受 `127.0.0.1`、`localhost`、`[::1]`);默认还会尝试 `:9921` |
|
|
90
|
+
| `scopes.workspaceRoot` | 会话默认目录 | `scope=workspace` 的根 |
|
|
91
|
+
| `scopes.workRoot` | 空 | `scope=work` 的根;使用前必须配置 |
|
|
92
|
+
| `scopes.personalRoot` | 空 | `scope=personal` 的根;使用前必须配置 |
|
|
93
|
+
| `scopes.allRoots` | `C:\` + 存在时的 `D:\` | Anytxt `scope=all` 的根,分号分隔;Everything 的 `all` 搜它索引的所有卷 |
|
|
94
|
+
| `limits.maxResults` | `20` | 默认结果上限;硬上限 100 |
|
|
95
|
+
| `limits.readMaxChars` | `8000` | `local_search_read` 默认窗口;硬上限 20,000 字符 |
|
|
84
96
|
| `timeoutMs` | `30000` | 单次调用超时预算;仅 profile YAML |
|
|
85
|
-
| `readMaxChars` | `8000` | `local_search_read` 默认窗口;硬上限 20,000 字符 |
|
|
86
|
-
| `esEncoding` | `gbk` | `es.exe` 输出的代码页;页面上是 `gbk` / `utf-8` 的二选一控件 |
|
|
87
97
|
|
|
88
98
|
示例:
|
|
89
99
|
|
|
@@ -91,23 +101,27 @@ dsh plugin --profile desktop add github:fishdivinity/dsh-local-search
|
|
|
91
101
|
- id: fishdivinity-local-search
|
|
92
102
|
name: '@fishdivinity/dsh-local-search'
|
|
93
103
|
config:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
scopes:
|
|
105
|
+
workRoot: 'D:\Projects'
|
|
106
|
+
personalRoot: 'D:\Documents'
|
|
107
|
+
limits:
|
|
108
|
+
maxResults: 50
|
|
97
109
|
```
|
|
98
110
|
|
|
111
|
+
0.2 的 profile 不用手改:启动时那九个扁平键(`esPath`、`esEncoding`、`anytxtUrl`、`workspaceRoot`、`workRoot`、`personalRoot`、`allRoots`、`maxResults`、`readMaxChars`)会搬进上面的嵌套结构并清掉旧键。settings 写不进去时(profile 被 home patch 覆盖)旧键留在原地,读取仍然认它。
|
|
112
|
+
|
|
99
113
|
## Scope
|
|
100
114
|
|
|
101
115
|
- `workspace`(默认):会话工作目录。
|
|
102
|
-
- `work`:`workRoot`。
|
|
103
|
-
- `personal`:`personalRoot`。
|
|
104
|
-
- `all`:Anytxt 用 `allRoots`;Everything 搜所有已索引卷。
|
|
116
|
+
- `work`:`scopes.workRoot`。
|
|
117
|
+
- `personal`:`scopes.personalRoot`。
|
|
118
|
+
- `all`:Anytxt 用 `scopes.allRoots`;Everything 搜所有已索引卷。
|
|
105
119
|
|
|
106
120
|
只有用户明确说“全盘/跨盘”时才用 `all`;`path` 参数可覆盖 `scope`。
|
|
107
121
|
|
|
108
122
|
## 引擎选择
|
|
109
123
|
|
|
110
|
-
`engine` 默认 `auto
|
|
124
|
+
`engine` 默认 `auto`,跟随该能力配置的后端:`local_search_files` 用 `files.backend`,`local_search_content` / `fragments` / `read` 用 `content.backend`。
|
|
111
125
|
|
|
112
126
|
- `local_search_files`:`auto` / `everything`。
|
|
113
127
|
- `local_search_content` / `fragments` / `read`:`auto` / `anytxt`。
|
|
@@ -119,7 +133,7 @@ dsh plugin --profile desktop add github:fishdivinity/dsh-local-search
|
|
|
119
133
|
|
|
120
134
|
| code | 含义 |
|
|
121
135
|
|---|---|
|
|
122
|
-
| `EVERYTHING_ES_NOT_FOUND` | 找不到 `es.exe`;安装或配置 `esPath` |
|
|
136
|
+
| `EVERYTHING_ES_NOT_FOUND` | 找不到 `es.exe`;安装或配置 `files.everything.esPath` |
|
|
123
137
|
| `EVERYTHING_DOWN` | Everything 没运行/不可达 |
|
|
124
138
|
| `EVERYTHING_TIMEOUT` / `EVERYTHING_TOO_LARGE` | 查询太宽或太慢;缩小范围 |
|
|
125
139
|
| `ANYTXT_DOWN` | Anytxt GUI 没运行或 JSON-RPC 不可达 |
|
|
@@ -156,7 +170,7 @@ npm run check
|
|
|
156
170
|
npm run pack:check
|
|
157
171
|
```
|
|
158
172
|
|
|
159
|
-
引擎与配置模块(`lib/everything.js`、`lib/anytxt.js`、`lib/roots.js`、`lib/format.js`、`lib/config.js`)不依赖 DSH runtime,可在普通 Node 下测试。`lib/client.js` 是随包发布的浏览器半:直接按客户端模块系统的 lazy-CJS factory 格式手写成最终产物,没有构建步骤。架构与扩展见 [`docs/architecture.md`](docs/architecture.md)、[`docs/extending-engines.md`](docs/extending-engines.md)。
|
|
173
|
+
引擎与配置模块(`lib/everything.js`、`lib/anytxt.js`、`lib/roots.js`、`lib/format.js`、`lib/config.js`)不依赖 DSH runtime,可在普通 Node 下测试。`lib/client.js` 是随包发布的浏览器半:直接按客户端模块系统的 lazy-CJS factory 格式手写成最终产物,没有构建步骤。架构与扩展见 [`docs/architecture.md`](docs/architecture.md)、[`docs/extending-engines.md`](docs/extending-engines.md);改配置形状前先读 [`docs/config-migration.md`](docs/config-migration.md)。
|
|
160
174
|
|
|
161
175
|
## Changelog
|
|
162
176
|
|
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
|
|
47
47
|
- Windows.
|
|
48
48
|
- DeepSeek Harness `>=0.2.1-0 <0.3.0`.
|
|
49
|
-
- [Everything](https://www.voidtools.com/) running, plus `es.exe` (a separate download from voidtools; the plugin auto-detects PATH/common install folders, or set `esPath`).
|
|
49
|
+
- [Everything](https://www.voidtools.com/) running, plus `es.exe` (a separate download from voidtools; the plugin auto-detects PATH/common install folders, or set `files.everything.esPath`).
|
|
50
50
|
- [Anytxt Searcher](https://anytxt.net/) for content search: the **GUI** (`ATGUI.exe`) must be running because the JSON-RPC service lives inside it. The indexing service alone is not enough.
|
|
51
51
|
|
|
52
52
|
This plugin does **not** bundle Everything, `es.exe`, or Anytxt. Install and license them separately.
|
|
@@ -67,24 +67,34 @@ Restart the profile or open a new session. The five `local_search_*` tools appea
|
|
|
67
67
|
|
|
68
68
|
## Configuration
|
|
69
69
|
|
|
70
|
-
Sidebar **Plugins** → the **Local Search** card in the **Official plugins** group → the plugin's own page. That is the same card shape the GitHub plugin contributes. Saves apply to the next tool call; leaving the page drops unsaved edits, and a
|
|
70
|
+
Sidebar **Plugins** → the **Local Search** card in the **Official plugins** group → the plugin's own page. That is the same card shape the GitHub plugin contributes. Saves apply to the next tool call; leaving the page drops unsaved edits. **Reset**, under the description, first shows what it will clear and only writes once you confirm: the engine and limit settings go, a fresh probe refills them, and the search scopes are left alone because they are paths you typed.
|
|
71
71
|
|
|
72
|
-
**
|
|
72
|
+
The page is organised by capability: **File names** and **File contents** each open with a backend picker and show that backend's fields once it is selected; **Search scopes** and **Limits** are the same whichever backend runs, so they close the page. The Host's `GET /dsh-local-search/api/describe` supplies the sections, the registered backends, the field types and the hard caps, and the page only renders them, so a new backend does not change the page logic.
|
|
73
|
+
|
|
74
|
+
**Test connection** sits under the Anytxt endpoint field. It probes the endpoint as typed — nothing has to be saved first — together with Everything, and puts the result and its fix in a dialog that can probe again. The `es.exe` path in the field is checked too, so a typo there says the file is missing instead of reporting the saved binary as OK. A failing engine gets a link to its official download page; this plugin never bundles, downloads, or installs either product.
|
|
75
|
+
|
|
76
|
+
**A save checks before it writes.** When the endpoint or the `es.exe` path is part of the edit, the page probes them first: whatever the check refuses is not written, and a dialog asks whether to **Save anyway** — a deliberate exception rather than the default. A hard rejection (no file at that path, no such directory, a rejected address) gets no such way out. The Host only checks that the path exists; it never starts a file named by a request. Path fields and the endpoint are also checked when focus leaves them: a missing path, a directory where a file belongs, or a non-local address is flagged under the input and clears as soon as the value changes.
|
|
77
|
+
|
|
78
|
+
Path fields are rewritten to one standard spelling on save: `d:/alpha` is stored as `D:\alpha` (upper-case drive letter, backslashes, no trailing separator), and `allRoots` is rewritten entry by entry. When the path exists, the on-disk spelling wins (`d:/alpha` → `D:\Alpha`). Spaces and non-ASCII names are left alone — `D:\Alpha\我的项目\drawio a` is fine as typed, because the Host passes values to `es.exe` and the file APIs without going through a shell.
|
|
79
|
+
|
|
80
|
+
The first run fills what it can and then stops: while `files.everything.esPath` or `content.anytxt.url` is still unset, the plugin probes once, writes what answered, and records that pass in a hidden `autoConfigured` key. After that a field you clear stays clear instead of being filled again.
|
|
73
81
|
|
|
74
82
|
Every key is optional, and the row config replaces the whole config object rather than merging. Only `timeoutMs` stays in the profile's `cordis.patch.yml`: each tool fixes its deadline when it registers, so the page leaves it out rather than showing a field that would not take effect.
|
|
75
83
|
|
|
76
84
|
| Key | Default | Meaning |
|
|
77
85
|
|---|---|---|
|
|
78
|
-
| `
|
|
79
|
-
| `
|
|
80
|
-
| `
|
|
81
|
-
| `
|
|
82
|
-
| `
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
86
|
+
| `files.backend` | `everything` | Filename backend `auto` uses. |
|
|
87
|
+
| `files.everything.esPath` | auto-detect | Absolute path to `es.exe`. Resolution order: the configured value, `ES_PATH`, common install folders, `where.exe`. |
|
|
88
|
+
| `files.everything.esEncoding` | `gbk` | Code page used to decode `es.exe` output. Any `TextDecoder` label is accepted; the page offers `gbk` and `utf-8`. |
|
|
89
|
+
| `content.backend` | `anytxt` | Content backend `auto` uses. |
|
|
90
|
+
| `content.anytxt.url` | `http://127.0.0.1:9920` | Anytxt JSON-RPC endpoint on this machine (`127.0.0.1`, `localhost` or `[::1]` only). The default also tries `:9921`. |
|
|
91
|
+
| `scopes.workspaceRoot` | session default directory | Root for `scope=workspace`. |
|
|
92
|
+
| `scopes.workRoot` | empty | Root for `scope=work`; required before using that scope. |
|
|
93
|
+
| `scopes.personalRoot` | empty | Root for `scope=personal`; required before using that scope. |
|
|
94
|
+
| `scopes.allRoots` | `C:\` plus `D:\` when present | Semicolon-separated roots for Anytxt `scope=all`. Everything `scope=all` searches all volumes it indexes. |
|
|
95
|
+
| `limits.maxResults` | `20` | Default result cap; hard cap 100. |
|
|
96
|
+
| `limits.readMaxChars` | `8000` | Default `local_search_read` window; hard cap 20,000 characters. |
|
|
85
97
|
| `timeoutMs` | `30000` | Per-call timeout budget; profile YAML only. |
|
|
86
|
-
| `readMaxChars` | `8000` | Default `local_search_read` window; hard cap is 20,000 characters. |
|
|
87
|
-
| `esEncoding` | `gbk` | Code page used to decode `es.exe` output. The page offers `gbk` and `utf-8` as a two-choice control. |
|
|
88
98
|
|
|
89
99
|
Example row override:
|
|
90
100
|
|
|
@@ -92,23 +102,27 @@ Example row override:
|
|
|
92
102
|
- id: fishdivinity-local-search
|
|
93
103
|
name: '@fishdivinity/dsh-local-search'
|
|
94
104
|
config:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
scopes:
|
|
106
|
+
workRoot: 'D:\Projects'
|
|
107
|
+
personalRoot: 'D:\Documents'
|
|
108
|
+
limits:
|
|
109
|
+
maxResults: 50
|
|
98
110
|
```
|
|
99
111
|
|
|
112
|
+
A 0.2 profile needs no hand edit: on startup its nine flat keys (`esPath`, `esEncoding`, `anytxtUrl`, `workspaceRoot`, `workRoot`, `personalRoot`, `allRoots`, `maxResults`, `readMaxChars`) move into the structure above and are removed. When the settings layer refuses that write — a home patch overrides the profile — the old keys stay and are still read.
|
|
113
|
+
|
|
100
114
|
## Scopes
|
|
101
115
|
|
|
102
116
|
- `workspace` (default): the session's working directory.
|
|
103
|
-
- `work`: `workRoot`.
|
|
104
|
-
- `personal`: `personalRoot`.
|
|
105
|
-
- `all`: `allRoots` for Anytxt; all indexed volumes for Everything.
|
|
117
|
+
- `work`: `scopes.workRoot`.
|
|
118
|
+
- `personal`: `scopes.personalRoot`.
|
|
119
|
+
- `all`: `scopes.allRoots` for Anytxt; all indexed volumes for Everything.
|
|
106
120
|
|
|
107
121
|
Use `all` only when the user explicitly asks for a cross-drive/global search. The `path` argument overrides `scope`.
|
|
108
122
|
|
|
109
123
|
## Engine selection
|
|
110
124
|
|
|
111
|
-
`engine` defaults to `auto
|
|
125
|
+
`engine` defaults to `auto`, which follows the backend configured for that capability: `files.backend` for `local_search_files`, `content.backend` for `local_search_content`, `fragments`, and `read`.
|
|
112
126
|
|
|
113
127
|
- `local_search_files`: `auto` or `everything`.
|
|
114
128
|
- `local_search_content` / `fragments` / `read`: `auto` or `anytxt`.
|
|
@@ -122,7 +136,7 @@ Failures are typed `HarnessError`s with stable codes:
|
|
|
122
136
|
|
|
123
137
|
| Code | Meaning |
|
|
124
138
|
|---|---|
|
|
125
|
-
| `EVERYTHING_ES_NOT_FOUND` | `es.exe` is missing; install it or set `esPath`. |
|
|
139
|
+
| `EVERYTHING_ES_NOT_FOUND` | `es.exe` is missing; install it or set `files.everything.esPath`. |
|
|
126
140
|
| `EVERYTHING_DOWN` | Everything is not running / cannot be reached. |
|
|
127
141
|
| `EVERYTHING_TIMEOUT`, `EVERYTHING_TOO_LARGE` | Query too broad or slow; narrow it. |
|
|
128
142
|
| `ANYTXT_DOWN` | Anytxt GUI is not running or the JSON-RPC endpoint is unreachable. |
|
|
@@ -166,7 +180,7 @@ npm run check
|
|
|
166
180
|
npm run pack:check
|
|
167
181
|
```
|
|
168
182
|
|
|
169
|
-
The engine and config modules (`lib/everything.js`, `lib/anytxt.js`, `lib/roots.js`, `lib/format.js`, `lib/config.js`) are independent of the DSH runtime so their tests run under plain Node. `lib/client.js` ships as the browser half: it is written directly in the client module system's lazy-CJS factory format, so there is no build step. See [`docs/architecture.md`](docs/architecture.md), [`docs/extending-engines.md`](docs/extending-engines.md), and [`.github/RELEASING.md`](.github/RELEASING.md).
|
|
183
|
+
The engine and config modules (`lib/everything.js`, `lib/anytxt.js`, `lib/roots.js`, `lib/format.js`, `lib/config.js`) are independent of the DSH runtime so their tests run under plain Node. `lib/client.js` ships as the browser half: it is written directly in the client module system's lazy-CJS factory format, so there is no build step. See [`docs/architecture.md`](docs/architecture.md), [`docs/extending-engines.md`](docs/extending-engines.md), [`docs/config-migration.md`](docs/config-migration.md) for the rules a config-shape change has to follow, and [`.github/RELEASING.md`](.github/RELEASING.md).
|
|
170
184
|
|
|
171
185
|
## Changelog
|
|
172
186
|
|
package/cordis.patch.yml
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
# Every key has a schema default; override any of them here by replacing this
|
|
3
3
|
# row's config (the whole row is replaced, never deep-merged).
|
|
4
4
|
# Keep this file neutral (config: {}) in the package: machine-specific paths
|
|
5
|
-
# such as esPath / workRoot belong in the user's
|
|
5
|
+
# such as files.everything.esPath / scopes.workRoot belong in the user's
|
|
6
|
+
# profile patch, not here.
|
|
6
7
|
- insert:
|
|
7
8
|
- id: fishdivinity-local-search
|
|
8
9
|
name: '@fishdivinity/dsh-local-search'
|
package/lib/anytxt.js
CHANGED
|
@@ -6,15 +6,13 @@ const EXCLUDED = [/\\\$RECYCLE\.BIN\\/i, /\\System Volume Information\\/i]
|
|
|
6
6
|
const RPC_TIMEOUT_MS = 15000
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* 主地址,加上某些 Anytxt 构建会用的 9921 回退口。
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* @
|
|
16
|
-
* @returns the endpoints to try, in order
|
|
17
|
-
* @throws {EngineError} ANYTXT_ENDPOINT_REMOTE when the endpoint is not local
|
|
11
|
+
* loopback 规则就住在这里:地址是每次调用现读的,所以一份仍写着另一台机器的 profile 只会让
|
|
12
|
+
* 内容检索失败,并给出一个模型能解释的 code——而不是让激活失败、把文件名检索一起拖下水。
|
|
13
|
+
* @param url - 配置的地址
|
|
14
|
+
* @returns 要依次尝试的地址
|
|
15
|
+
* @throws {EngineError} 地址不是本机时抛 ANYTXT_ENDPOINT_REMOTE
|
|
18
16
|
*/
|
|
19
17
|
export function resolveAnytxtUrls(url = DEFAULT_URL) {
|
|
20
18
|
const base = String(url || DEFAULT_URL).replace(/\/+$/, '')
|
|
@@ -28,7 +26,7 @@ export function resolveAnytxtUrls(url = DEFAULT_URL) {
|
|
|
28
26
|
return urls
|
|
29
27
|
}
|
|
30
28
|
|
|
31
|
-
/**
|
|
29
|
+
/** 带引号的短语走精确模式(st=1),其余查询走高级模式。 */
|
|
32
30
|
export function quoteAware(query) {
|
|
33
31
|
const trimmed = String(query ?? '').trim()
|
|
34
32
|
if (/^".*"$/s.test(trimmed)) {
|
|
@@ -37,7 +35,7 @@ export function quoteAware(query) {
|
|
|
37
35
|
return { pattern: trimmed, st: '2' }
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
/**
|
|
38
|
+
/** 去掉 Anytxt 在片段里加的命中高亮标记。 */
|
|
41
39
|
export function stripHighlights(text) {
|
|
42
40
|
return String(text ?? '').replace(/\*<<\*|\*>>\*/g, '')
|
|
43
41
|
}
|
|
@@ -59,7 +57,7 @@ function parseFileTuple(tuple) {
|
|
|
59
57
|
return item
|
|
60
58
|
}
|
|
61
59
|
|
|
62
|
-
/**
|
|
60
|
+
/** 发一次 JSON-RPC POST 并返回 `result.data.output`。 */
|
|
63
61
|
export async function anytxtRpc(url, method, input, { timeoutMs = RPC_TIMEOUT_MS, signal, fetchImpl = fetch } = {}) {
|
|
64
62
|
const timeoutSignal = AbortSignal.timeout(timeoutMs)
|
|
65
63
|
const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal
|
|
@@ -70,9 +68,8 @@ export async function anytxtRpc(url, method, input, { timeoutMs = RPC_TIMEOUT_MS
|
|
|
70
68
|
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
71
69
|
body: JSON.stringify({ id: 1, jsonrpc: '2.0', method, params: { input } }),
|
|
72
70
|
signal: combined,
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// answer trusted as index data.
|
|
71
|
+
// 307/308 会把这个 body 重新 POST 到地址指向的任何地方,于是一个本机服务就能把请求弹到
|
|
72
|
+
// 别的机器上,而它的回答还会被当成索引数据信下来。
|
|
76
73
|
redirect: 'error',
|
|
77
74
|
})
|
|
78
75
|
} catch (error) {
|
|
@@ -122,7 +119,7 @@ export async function anytxtRpc(url, method, input, { timeoutMs = RPC_TIMEOUT_MS
|
|
|
122
119
|
return output
|
|
123
120
|
}
|
|
124
121
|
|
|
125
|
-
/**
|
|
122
|
+
/** 先试主地址,只在连接失败时才试 9921 回退口。 */
|
|
126
123
|
async function rpcFirst(urls, method, input, options) {
|
|
127
124
|
let lastError
|
|
128
125
|
for (const url of urls) {
|
|
@@ -137,7 +134,7 @@ async function rpcFirst(urls, method, input, options) {
|
|
|
137
134
|
throw lastError
|
|
138
135
|
}
|
|
139
136
|
|
|
140
|
-
/**
|
|
137
|
+
/** 通过 Anytxt 检索文档内容,每个根一对请求。 */
|
|
141
138
|
export async function searchContent({
|
|
142
139
|
query,
|
|
143
140
|
roots,
|
|
@@ -203,7 +200,7 @@ export async function searchContent({
|
|
|
203
200
|
return { items, total, truncated: total > items.length }
|
|
204
201
|
}
|
|
205
202
|
|
|
206
|
-
/**
|
|
203
|
+
/** 一个已索引文件里命中处附近的片段。 */
|
|
207
204
|
export async function getFragments({
|
|
208
205
|
ref,
|
|
209
206
|
query,
|
|
@@ -225,7 +222,7 @@ export async function getFragments({
|
|
|
225
222
|
return (output.text || []).map(stripHighlights).filter(Boolean)
|
|
226
223
|
}
|
|
227
224
|
|
|
228
|
-
/**
|
|
225
|
+
/** 一个已索引文件的完整抽取文本。 */
|
|
229
226
|
export async function getRawText({
|
|
230
227
|
ref,
|
|
231
228
|
url = DEFAULT_URL,
|
|
@@ -243,7 +240,12 @@ export async function getRawText({
|
|
|
243
240
|
return typeof output.text === 'string' ? output.text : ''
|
|
244
241
|
}
|
|
245
242
|
|
|
246
|
-
/**
|
|
243
|
+
/**
|
|
244
|
+
* 探测 Anytxt,不抛异常。
|
|
245
|
+
*
|
|
246
|
+
* 返回**实际应答**的那个地址,不是第一个尝试的地址:`resolveAnytxtUrls` 会带上 `:9921` 回退,
|
|
247
|
+
* 而自动配置要把「哪个端口能用」写回配置,报错端口等于把结论写反。
|
|
248
|
+
*/
|
|
247
249
|
export async function anytxtStatus({
|
|
248
250
|
url = DEFAULT_URL,
|
|
249
251
|
root = process.env.SystemDrive ? `${process.env.SystemDrive}\\` : 'C:\\',
|
|
@@ -252,21 +254,33 @@ export async function anytxtStatus({
|
|
|
252
254
|
fetchImpl = fetch,
|
|
253
255
|
} = {}) {
|
|
254
256
|
const urls = resolveAnytxtUrls(url)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
257
|
+
let lastError
|
|
258
|
+
for (const candidate of urls) {
|
|
259
|
+
try {
|
|
260
|
+
await anytxtRpc(
|
|
261
|
+
candidate,
|
|
262
|
+
'ATRpcServer.Searcher.V1.Search',
|
|
263
|
+
{
|
|
264
|
+
pattern: 'the',
|
|
265
|
+
filterDir: root,
|
|
266
|
+
filterExt: 'txt',
|
|
267
|
+
lastModifyBegin: 0,
|
|
268
|
+
lastModifyEnd: 2147483647,
|
|
269
|
+
},
|
|
270
|
+
{ timeoutMs, signal, fetchImpl },
|
|
271
|
+
)
|
|
272
|
+
return { ok: true, url: candidate }
|
|
273
|
+
} catch (error) {
|
|
274
|
+
lastError = error
|
|
275
|
+
// 与 rpcFirst 同一条规则:只有「连不上」才值得换下一个端口。
|
|
276
|
+
if (error.code === 'ANYTXT_DOWN' && candidate !== urls[urls.length - 1]) continue
|
|
277
|
+
break
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
ok: false,
|
|
282
|
+
code: lastError?.code ?? 'ANYTXT_DOWN',
|
|
283
|
+
message: lastError?.message,
|
|
284
|
+
url: urls[0],
|
|
271
285
|
}
|
|
272
286
|
}
|
package/lib/bootstrap.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 启动时的两次配置写入:把 0.2 的扁平键搬进嵌套结构,以及首用自动配置。
|
|
3
|
+
*
|
|
4
|
+
* 两者都走 `ctx.settings`:只有它能把值写进 profile 的 `cordis.patch.yml` 并通过 Loader
|
|
5
|
+
* 让 volatile 引用就地更新,不重载插件。写入范围都限定在「用户层还没有值的字段」,
|
|
6
|
+
* 所以幂等,也永远不会覆盖用户已经选过的值。
|
|
7
|
+
*
|
|
8
|
+
* 迁移失败不算致命:profile 被 home patch 覆盖时 settings 会拒绝写入,旧键留在原地,
|
|
9
|
+
* `readConfig` 的回退让它们继续生效。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { DEFAULTS, PATHS, RETIRED_PATHS, hasPath, pathValue, readConfig, setPath } from './config.js'
|
|
13
|
+
import { probeEngines } from './probe.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 在 settings 的描述里找本插件的命名空间。
|
|
17
|
+
* @param {Array<object>} descriptors - `settings.describe()` 的结果
|
|
18
|
+
* @param {string} ns - profile 行 id
|
|
19
|
+
* @returns {object|undefined} 对应的描述
|
|
20
|
+
*/
|
|
21
|
+
export function findDescriptor(descriptors, ns) {
|
|
22
|
+
return descriptors.find((descriptor) => descriptor.ns === ns)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 把退役路径搬进当前结构、再清掉旧键的操作列表。
|
|
27
|
+
*
|
|
28
|
+
* **只认用户层里真的存在的旧路径**。schema 给退役路径也声明了默认值,所以「配置里有值」不等于
|
|
29
|
+
* 「用户写过」——按配置判断会把 `esEncoding` / `anytxtUrl` / `maxResults` / `readMaxChars` 四个
|
|
30
|
+
* 默认值写进 profile,并让首用自动配置以为地址已经设过,从而丢掉 9921 那一次探测。
|
|
31
|
+
*
|
|
32
|
+
* 链式迁移在同一次调用里跑完:上一段的 `to` 是下一段的 `from`,所以「0.1 直装 1.0」不需要中间
|
|
33
|
+
* 版本存在过。链上的中间路径只搬不写(写进去也没人读);先写新位置再清旧键,settings 只应用了
|
|
34
|
+
* 一半时读侧兜底仍拿得到值。
|
|
35
|
+
* @param user - 设置表单的用户层
|
|
36
|
+
* @param retired - 退役路径表,测试可注入
|
|
37
|
+
* @returns {{ops: Array<object>, keys: string[]}} 操作列表与清掉的旧路径
|
|
38
|
+
*/
|
|
39
|
+
export function migrationOps(user = {}, retired = RETIRED_PATHS) {
|
|
40
|
+
const staged = {}
|
|
41
|
+
const carried = []
|
|
42
|
+
for (const { from, to } of retired) {
|
|
43
|
+
// 用户层里有这个旧路径就用它;否则接着链上一步放在 `staged` 里的值。
|
|
44
|
+
const value = hasPath(user, from) ? pathValue(user, from) : pathValue(staged, from)
|
|
45
|
+
if (value === undefined || value === null) continue
|
|
46
|
+
if (typeof value === 'string' && value.trim() === '') continue
|
|
47
|
+
setPath(staged, to, value)
|
|
48
|
+
carried.push(to.join('.'))
|
|
49
|
+
}
|
|
50
|
+
const currentPaths = new Set(Object.values(PATHS).map((path) => path.join('.')))
|
|
51
|
+
const ops = []
|
|
52
|
+
const keys = []
|
|
53
|
+
for (const pathKey of carried) {
|
|
54
|
+
if (!currentPaths.has(pathKey)) continue
|
|
55
|
+
const path = pathKey.split('.')
|
|
56
|
+
if (hasPath(user, path)) continue
|
|
57
|
+
const value = pathValue(staged, path)
|
|
58
|
+
if (value === undefined) continue
|
|
59
|
+
ops.push({ op: 'set', path, value })
|
|
60
|
+
}
|
|
61
|
+
for (const { from } of retired) {
|
|
62
|
+
// 只有用户层里真的存在、且不是空白的旧键才需要清;schema 默认值不算。
|
|
63
|
+
if (!hasPath(user, from)) continue
|
|
64
|
+
const value = pathValue(user, from)
|
|
65
|
+
if (value === undefined || value === null) continue
|
|
66
|
+
if (typeof value === 'string' && value.trim() === '') continue
|
|
67
|
+
keys.push(from.join('.'))
|
|
68
|
+
ops.push({ op: 'unset', path: from })
|
|
69
|
+
}
|
|
70
|
+
return { ops, keys }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 写一次设置,撞上并发修改就重读 revision 再试一次。
|
|
75
|
+
*
|
|
76
|
+
* 自动配置把「探测」夹在 `describe()` 与 `mutate()` 之间(一次 es.exe + 一次 HTTP),这段时间
|
|
77
|
+
* 里谁在页面上点了保存,我们的 revision 就过期(`SETTINGS_CONFLICT`)。重放安全——ops 只碰
|
|
78
|
+
* 用户层还没设过的字段。
|
|
79
|
+
*
|
|
80
|
+
* 命名空间消失时抛错而不是静默返回:那条消息是「写回为什么没发生」唯一的线索。
|
|
81
|
+
* @param {object} settings - Host 的 settings 服务
|
|
82
|
+
* @param {string} ns - profile 行 id
|
|
83
|
+
* @param {Array<object>} ops - 设置操作列表
|
|
84
|
+
*/
|
|
85
|
+
async function writeSettings(settings, ns, ops) {
|
|
86
|
+
const descriptor = findDescriptor(settings.describe(), ns)
|
|
87
|
+
if (descriptor === undefined) throw new Error(`settings namespace "${ns}" is not available`)
|
|
88
|
+
try {
|
|
89
|
+
await settings.mutate(ns, ops, descriptor.revision)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error?.code !== 'SETTINGS_CONFLICT') throw error
|
|
92
|
+
const retried = findDescriptor(settings.describe(), ns)
|
|
93
|
+
if (retried === undefined) throw error
|
|
94
|
+
await settings.mutate(ns, ops, retried.revision)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 是否还有值得探测的东西。
|
|
100
|
+
*
|
|
101
|
+
* 没有待填字段就完全不探测:一次探测要花一次 es.exe 启动和一次 HTTP 请求,配置齐了就不该
|
|
102
|
+
* 在每次启动时付这个成本。
|
|
103
|
+
* @param {object} values - 配置快照
|
|
104
|
+
* @param {object} user - 设置表单的用户层
|
|
105
|
+
* @returns {boolean} 需要探测时为 true
|
|
106
|
+
*/
|
|
107
|
+
export function autoconfigNeeds(values, user) {
|
|
108
|
+
const wantsEsPath = !hasPath(user, PATHS.esPath) && !values.files.everything.esPath
|
|
109
|
+
const wantsUrl = !hasPath(user, PATHS.anytxtUrl) && values.content.anytxt.url === DEFAULTS.content.anytxt.url
|
|
110
|
+
return wantsEsPath || wantsUrl
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 探测结果里可以写回去的字段。
|
|
115
|
+
* @param {{everything: object, anytxt: object}} probed - 探测结果
|
|
116
|
+
* @param {object} values - 配置快照
|
|
117
|
+
* @param {object} user - 设置表单的用户层
|
|
118
|
+
* @returns {Array<object>} 设置操作列表
|
|
119
|
+
*/
|
|
120
|
+
export function autoconfigOps(probed, values, user) {
|
|
121
|
+
const ops = []
|
|
122
|
+
const everythingPath = probed.everything?.ok ? probed.everything.path : undefined
|
|
123
|
+
if (typeof everythingPath === 'string' && everythingPath !== '') {
|
|
124
|
+
if (!hasPath(user, PATHS.esPath) && !values.files.everything.esPath) {
|
|
125
|
+
ops.push({ op: 'set', path: PATHS.esPath, value: everythingPath })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// 只在探测真的落到 9921 回退口时才写地址:默认值本身写回去没有信息量。
|
|
129
|
+
const anytxtUrl = probed.anytxt?.ok ? probed.anytxt.url : undefined
|
|
130
|
+
if (typeof anytxtUrl === 'string' && anytxtUrl !== '' && anytxtUrl !== DEFAULTS.content.anytxt.url) {
|
|
131
|
+
if (!hasPath(user, PATHS.anytxtUrl) && values.content.anytxt.url === DEFAULTS.content.anytxt.url) {
|
|
132
|
+
ops.push({ op: 'set', path: PATHS.anytxtUrl, value: anytxtUrl })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return ops
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 探测失败且值得告诉用户的引擎。取消与跳过是部署事实,不是故障。 */
|
|
139
|
+
function probeFailures(probed) {
|
|
140
|
+
const failures = []
|
|
141
|
+
for (const engine of ['everything', 'anytxt']) {
|
|
142
|
+
const status = probed[engine] ?? {}
|
|
143
|
+
if (status.ok) continue
|
|
144
|
+
const code = String(status.code ?? 'UNKNOWN')
|
|
145
|
+
if (code === 'SKIPPED' || code.endsWith('_ABORTED')) continue
|
|
146
|
+
failures.push({ engine, code })
|
|
147
|
+
}
|
|
148
|
+
return failures
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 执行一次启动配置:先迁移旧键,再按需探测并写回。
|
|
153
|
+
* @param {object} options
|
|
154
|
+
* @param {object} options.settings - Host 的 settings 服务
|
|
155
|
+
* @param {string} options.ns - profile 行 id(= 设置命名空间)
|
|
156
|
+
* @param {object} options.config - schema 输出,含活引用
|
|
157
|
+
* @param {object} [options.logger] - 有 `warn` 时迁移失败会记一条
|
|
158
|
+
* @param {Function} [options.probe] - 可注入的探测函数
|
|
159
|
+
* @param {object} [options.state] - 会被就地更新的描述载荷
|
|
160
|
+
* @returns {Promise<object>} 同一个 state
|
|
161
|
+
*/
|
|
162
|
+
export async function bootstrap({ settings, ns, config, logger, probe = probeEngines, state = {} }) {
|
|
163
|
+
const target = Object.assign({ ran: false, written: [], failures: [] }, state)
|
|
164
|
+
try {
|
|
165
|
+
const descriptor = findDescriptor(settings.describe(), ns)
|
|
166
|
+
if (descriptor === undefined) return target
|
|
167
|
+
const { ops, keys } = migrationOps(descriptor.user)
|
|
168
|
+
if (ops.length > 0) {
|
|
169
|
+
await writeSettings(settings, ns, ops)
|
|
170
|
+
target.written.push(...keys)
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
target.error = String(error?.message ?? error)
|
|
174
|
+
logger?.warn?.('dsh-local-search: could not migrate the flat config keys', error)
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const values = readConfig(config)
|
|
178
|
+
const descriptor = findDescriptor(settings.describe(), ns)
|
|
179
|
+
if (descriptor === undefined) return target
|
|
180
|
+
// 已经做过决定就再不碰:用户清空的字段必须能保持空着,否则「重置」形同虚设。
|
|
181
|
+
if (values.autoConfigured === true) return target
|
|
182
|
+
if (!autoconfigNeeds(values, descriptor.user)) {
|
|
183
|
+
// 配置本来就齐:只补标记,免得以后清空某个字段又被自动写回。
|
|
184
|
+
await writeSettings(settings, ns, [{ op: 'set', path: PATHS.autoConfigured, value: true }])
|
|
185
|
+
target.written.push(PATHS.autoConfigured.at(-1))
|
|
186
|
+
return target
|
|
187
|
+
}
|
|
188
|
+
target.ran = true
|
|
189
|
+
const probed = await probe(config, { detail: true })
|
|
190
|
+
target.failures = probeFailures(probed)
|
|
191
|
+
const ops = autoconfigOps(probed, values, descriptor.user)
|
|
192
|
+
// 标记与值同一次落盘:哪怕这次什么也没探测到,也只做这一次决定。
|
|
193
|
+
ops.push({ op: 'set', path: PATHS.autoConfigured, value: true })
|
|
194
|
+
await writeSettings(settings, ns, ops)
|
|
195
|
+
target.written.push(...ops.slice(0, -1).map((op) => op.path.at(-1)))
|
|
196
|
+
} catch (error) {
|
|
197
|
+
target.error = String(error?.message ?? error)
|
|
198
|
+
logger?.warn?.('dsh-local-search: first-run autoconfiguration failed', error)
|
|
199
|
+
}
|
|
200
|
+
return target
|
|
201
|
+
}
|