@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 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 插件那张是同一个形态。保存后对下一次工具调用生效;离开页面丢弃未保存改动,字段上的「重置」把它交回 profile 层。
69
+ 侧边栏 **Plugins** → **Official plugins** 组里的「本地搜索」卡片 → 插件自己的页面。这张卡和 GitHub 插件那张是同一个形态。保存后对下一次工具调用生效;离开页面丢弃未保存改动。描述下面那个**重置**会先把会清什么摆出来,确认后才清掉引擎与上限、按一次新的探测填回去;检索范围不动,因为那是你自己敲进去的路径。
70
70
 
71
- **测试连接**就放在 Anytxt 地址输入框下面,测的是**输入框里当前的地址**(不用先保存),同时把 Everything 一起探一遍,一行给结果。某个引擎不可用时会给出它的官方下载页;本插件不打包、不下载、也不代替你安装这两个程序。
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
- | `esPath` | 自动探测 | `es.exe` 绝对路径。顺序:`esPath` → `ES_PATH` → 常见安装目录 → `where.exe` |
78
- | `anytxtUrl` | `http://127.0.0.1:9920` | 本机 Anytxt JSON-RPC 地址(只接受 `127.0.0.1`、`localhost`、`[::1]`);默认还会尝试 `:9921` |
79
- | `workspaceRoot` | 会话默认目录 | `scope=workspace` 的根 |
80
- | `workRoot` | 空 | `scope=work` 的根;使用前必须配置 |
81
- | `personalRoot` | 空 | `scope=personal` 的根;使用前必须配置 |
82
- | `allRoots` | `C:\` + 存在时的 `D:\` | Anytxt `scope=all` 的根,分号分隔;Everything 的 `all` 搜它索引的所有卷 |
83
- | `maxResults` | `20` | 默认结果上限;硬上限 100 |
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
- workRoot: 'D:\Projects'
95
- personalRoot: 'D:\Documents'
96
- allRoots: 'C:\;D:\'
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 field's reset returns it to the profile layer.
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
- **Test connection**, directly under the Anytxt endpoint, probes that address as typed — nothing has to be saved first — together with Everything, and answers in one line. A failing engine gets a link to its official download page; this plugin never bundles, downloads, or installs either product.
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
- | `esPath` | auto-detect | Absolute path to `es.exe`. Resolution order: `esPath`, `ES_PATH`, common install folders, `where.exe`. |
79
- | `anytxtUrl` | `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`. |
80
- | `workspaceRoot` | session default directory | Root for `scope=workspace`. |
81
- | `workRoot` | empty | Root for `scope=work`; required before using that scope. |
82
- | `personalRoot` | empty | Root for `scope=personal`; required before using that scope. |
83
- | `allRoots` | `C:\` plus `D:\` when present | Semicolon-separated roots for Anytxt `scope=all`. Everything `scope=all` searches all volumes it indexes. |
84
- | `maxResults` | `20` | Default result cap. Hard cap is 100. |
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
- workRoot: 'D:\Projects'
96
- personalRoot: 'D:\Documents'
97
- allRoots: 'C:\;D:\'
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 profile patch, not here.
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
- * Primary URL plus the 9921 fallback used by some Anytxt builds.
9
+ * 主地址,加上某些 Anytxt 构建会用的 9921 回退口。
10
10
  *
11
- * This is where the loopback rule lives: the endpoint is read per call, so a
12
- * profile that still names another machine fails only the content search, with
13
- * a code the model can explain — instead of failing activation and taking
14
- * file-name search down with it.
15
- * @param url - configured endpoint
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
- /** Quoted phrases use exact mode (st=1); other queries use advanced mode. */
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
- /** Remove Anytxt's match highlight markers from snippets. */
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
- /** POST one JSON-RPC request and return `result.data.output`. */
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
- // A 307/308 would re-POST this body wherever the endpoint points, so a
74
- // loopback service could bounce the request off-machine and have its
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
- /** Try the primary URL, then the 9921 fallback only on connection failures. */
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
- /** Search document contents through Anytxt, one request pair per root. */
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
- /** Snippets around a match in one indexed file. */
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
- /** Full extracted text for one indexed file. */
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
- /** Probe Anytxt without throwing. */
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
- try {
256
- await rpcFirst(
257
- urls,
258
- 'ATRpcServer.Searcher.V1.Search',
259
- {
260
- pattern: 'the',
261
- filterDir: root,
262
- filterExt: 'txt',
263
- lastModifyBegin: 0,
264
- lastModifyEnd: 2147483647,
265
- },
266
- { timeoutMs, signal, fetchImpl },
267
- )
268
- return { ok: true, url: urls[0] }
269
- } catch (error) {
270
- return { ok: false, code: error.code ?? 'ANYTXT_DOWN', message: error.message, url: urls[0] }
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
  }
@@ -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
+ }