dsh-moli 1.1.14 → 1.1.16

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.md CHANGED
@@ -242,8 +242,29 @@ Measured on a page whose `scroll` handler fills a slot: without `scroll` the dum
242
242
  `STILL-EMPTY`, with `scroll=1000` it says `LAZY-CONTENT-1000`. It also moves the screenshot, so a
243
243
  scrolled `dump=screenshot` shows that part of the page.
244
244
 
245
- It runs as part of the engine's own pre-capture evaluation and therefore composes with `eval`: the
246
- scroll happens first, and the expression still returns its own value.
245
+ `scroll` is a side effect, so it composes and stands alone:
246
+
247
+ ```
248
+ moli_fetch url=... dump=markdown scroll=600 # scroll, then dump the page
249
+ moli_fetch url=... eval="document.title" scroll=600 # scroll, then read a value
250
+ moli_fetch url=... scroll=600 # just scroll; you still get the page
251
+ ```
252
+
253
+ The last form is worth spelling out because it was broken. The scroll runs in the engine's own
254
+ pre-capture evaluation, and without `dump` the engine prints whatever that evaluation returns — so an
255
+ earlier version that returned the scroll position made this call answer `600` instead of the document,
256
+ while the identical call without `scroll` returned 1,989 characters of HTML. A scroll reports nothing
257
+ now; it only happens.
258
+
259
+ ### `eval` with `dump` — the expression value is dropped
260
+
261
+ The engine documents this and it is kept, because using an expression to change a page before
262
+ capturing it is a real pattern. What is not acceptable is that it used to look like a clean answer:
263
+ `eval='JSON.stringify({probe:1})'` with `dump=markdown` returned 900 characters of Markdown, reported
264
+ `ok: true`, and said nothing. The result now carries `evalDiscarded: true` and a note, and the rendered
265
+ output leads with the warning rather than burying it under the body.
266
+
267
+ If you want the expression's value, pass `eval` without `dump`.
247
268
 
248
269
  ### `failOnHttpError` — stop reading an error page as content
249
270
 
@@ -311,10 +332,13 @@ capabilities always visible to the model.
311
332
  2. **Binary output is copied byte for byte.** PowerShell's `>` operator re-encodes a native
312
333
  program's output as UTF-16, inserting a `00` after every byte, which corrupts a screenshot.
313
334
  Bytes here are written verbatim, so the PNG signature stays `89504e470d0a1a0a`.
314
- 3. **No external dependencies.** A profile plugin is loaded from its real path on disk (a `link:`
335
+ 3. **One optional external dependency.** A profile plugin is loaded from its real path on disk (a `link:`
315
336
  dependency stays a symlink target), so a bare specifier such as `@deepseek-ai/schemastery`
316
- resolves against the plugin directory instead of the profile's `node_modules` and the plugin
317
- fails to load. This plugin imports only `node:` builtins and its own files.
337
+ resolves against the plugin directory instead of the profile's `node_modules`. The import is
338
+ therefore guarded: without schemastery (a `link:` install) the settings form does not appear and
339
+ the plugin degrades to plain patch-layer configuration, while `moli_fetch` and `moli_probe` keep
340
+ registering. With it (a normal npm install, which brings the dependency along) the settings page
341
+ is available.
318
342
 
319
343
  ## Platform support
320
344
 
@@ -358,6 +382,8 @@ exercised the proxy download route as well as the engine paths.
358
382
  ## Configuration
359
383
 
360
384
  Override any field under the plugin's `config` entry in the profile patch layer. All are optional.
385
+ The same fields are also editable from the **Moli 安装与抓取** settings section on the plugin's page in
386
+ the Plugins manager (a `plugins.bundle.config` form registered by the browser half).
361
387
 
362
388
  ```yaml
363
389
  - insert:
@@ -379,6 +405,22 @@ Without `exePath`, the plugin probes `exePath` → `MOLI_BIN` → `%LOCALAPPDATA
379
405
  `%USERPROFILE%\.local\bin\moli.exe`, then scans inside `%LOCALAPPDATA%\Moli` for a `moli.exe`, so an
380
406
  installation under a versioned subdirectory is still found.
381
407
 
408
+ ### Engine lifecycle from the settings page
409
+
410
+ The settings section also carries an **engine** row that detects the installed engine and lets you
411
+ install or uninstall it without touching the profile:
412
+
413
+ - **state** — `engineState` is `installed` (with `engineVersion`), `missing`, or a short error text.
414
+ - **install** — the form writes `engineAction: "install"`; the host downloads and runs the official
415
+ installer asynchronously, then reports the outcome back through `engineState` / `engineVersion`.
416
+ - **uninstall** — `engineAction: "uninstall"` removes the engine. On Windows only the installer-owned
417
+ `%LOCALAPPDATA%\Moli` tree is deleted; without one, nothing is touched. On POSIX the located binary
418
+ is removed and the now-empty install directories are pruned.
419
+
420
+ These three fields (`engineAction`, `engineState`, `engineVersion`) are part of the live Config schema;
421
+ `engineAction` is a `none`/`install`/`uninstall` union. They are meant to be driven by the settings
422
+ form, so prefer editing the four fields above for routine configuration.
423
+
382
424
  ## Known limitations
383
425
 
384
426
  - **macOS is untested, and Linux was tested by the contributor rather than the maintainer.** The
@@ -416,8 +458,14 @@ installation under a versioned subdirectory is still found.
416
458
 
417
459
  ## Verification
418
460
 
419
- Four suites live in [`verify/`](verify/), and all are runnable against a working tree:
461
+ Eight suites live in [`verify/`](verify/), and all are runnable against a working tree:
420
462
 
463
+ - `config-verify.mjs` checks the settings surface and engine lifecycle added by the plugin's browser
464
+ half: the volatile `Config` schema (every declared field, `engineAction` as a `none`/`install`/
465
+ `uninstall` union), `apply` reading both a plain config and a volatile `.get()` reference, the
466
+ `settings/document-updated` wiring, `uninstallMoli` (platform-aware: POSIX removes the located
467
+ binary, Windows removes only the installer-owned `%LOCALAPPDATA%\Moli` tree), and the client.js
468
+ shape (`plugins.bundle.config` registration, zh/en dictionaries, raw `true`/`false` select labels).
421
469
  - `schema-verify.mjs` checks the tool definitions with the registry's own validator, including
422
470
  controls that assert the two invalid schema shapes still fail.
423
471
  - `install-verify.mjs` drives the install pipeline against a loopback HTTP server: route ordering,
@@ -445,6 +493,8 @@ Four suites live in [`verify/`](verify/), and all are runnable against a working
445
493
  reaches the engine is indistinguishable from a feature that does not work. It then fetches a 404 and
446
494
  checks that `failOnHttpError` reports it while keeping the body, that a healthy page is unaffected,
447
495
  and that a screenshot of an error page is still written as a valid PNG rather than decoded as text.
496
+ It also covers the two silent-answer cases: `eval` with `dump` is flagged `evalDiscarded` and the
497
+ warning renders first, and `scroll` alone returns the document rather than the scroll offset.
448
498
  The end-to-end half needs an HTTP error from a real server: loopback is unreachable from inside this
449
499
  sandbox — `curl.exe` times out on `127.0.0.1` while the Node host reaches it — so it uses a path
450
500
  under `example.com` and skips when the network is away.
@@ -459,6 +509,12 @@ through a proxy, that proxy too. Useful where a network is available, but a suit
459
509
  because a website changed is worse than no suite, so it stays a local check. If you run it and see
460
510
  failures in sections 4–6 while sections 1–3 pass, suspect the network before the code.
461
511
 
512
+ ### Prerequisites for the suites
513
+
514
+ The plugin has one optional runtime dependency — [`@deepseek-ai/schemastery`](https://www.npmjs.com/package/@deepseek-ai/schemastery) — used by the live `Config` schema that registers the settings namespace (optional because the import is guarded, so a `link:` install without it still loads and registers the tools). Suites that import the plugin (`config-verify`, `schema-verify`, `empty-verify`, `report-verify`, `http-verify`) need it resolvable from the working tree, so run `npm install` first.
515
+
516
+ `schema-verify` additionally imports the registry's own validator out of the npm-global `@deepseek-ai/dsh` install (`@deepseek-ai/dsh-tools`); `report-verify` and the engine suites need a real Moli engine, and `empty-verify` needs a C compiler to build its stub engines. On Windows, `@deepseek-ai/schemastery` can be resolved through a directory junction into the fork's `node_modules` from the npm-global DSH tree, the same way the suites resolve `dsh-tools`.
517
+
462
518
  Beyond those, `lib/moli.js` and the tool layer were exercised end to end against Moli 1.1.16 on
463
519
  Windows: registration, probe, Markdown and JSON dumps, `eval` (including a tight-timeout regression
464
520
  guard for the `-t` argument position), PNG and PDF artifacts with valid signatures, truncation, every
package/README.zh.md CHANGED
@@ -227,8 +227,26 @@ moli_fetch url=https://example.com/feed dump=markdown scroll=-400 # 往回滚
227
227
  `scroll=1000` 时是 `LAZY-CONTENT-1000`。它同样会改变截图,所以带滚动的 `dump=screenshot` 拍到的是页面的
228
228
  那一段。
229
229
 
230
- 它作为引擎自身"抓取前求值"的一部分执行,因此与 `eval` 可组合:**先滚动,表达式再读取页面,并且仍然返回
231
- 它自己的值**。
230
+ `scroll` 是副作用,因此既可组合也可单独使用:
231
+
232
+ ```
233
+ moli_fetch url=... dump=markdown scroll=600 # 先滚动,再 dump 页面
234
+ moli_fetch url=... eval="document.title" scroll=600 # 先滚动,再取一个值
235
+ moli_fetch url=... scroll=600 # 只滚动,你仍然拿到页面
236
+ ```
237
+
238
+ **最后一种写法值得单独说明,因为它曾经是坏的。** 滚动在引擎自身的"抓取前求值"里执行,而在没有 `dump` 时,
239
+ 引擎打印的就是这个求值的返回值——所以早期版本返回滚动位置,导致这一调用回的是 `600` 而不是页面,而**同样的
240
+ 调用不加 `scroll` 会返回 1989 个字符的 HTML**。现在滚动不报告任何东西,它只是发生。
241
+
242
+ ### `eval` 与 `dump` 同用 —— 表达式值会被丢弃
243
+
244
+ 这是引擎写明并有意的行为,予以保留——**用表达式在抓取前改变页面**是真实用法。不能接受的是它**看起来像一个
245
+ 干净答案**:`eval='JSON.stringify({probe:1})'` 配 `dump=markdown` 会返回 900 个字符的 Markdown、报
246
+ `ok: true`、什么都不说。现在结果会带 `evalDiscarded: true` 和说明,而且渲染输出**把警告放在最前面**,不会
247
+ 被正文埋掉。
248
+
249
+ 要拿表达式的值,就只传 `eval`、不要传 `dump`。
232
250
 
233
251
  ### `failOnHttpError` —— 别再把错误页当正文读
234
252
 
@@ -289,9 +307,10 @@ Moli 仓库自带官方 agent skill:[`moli-webfetch`](https://github.com/lexmo
289
307
  重定向到临时文件再读回。
290
308
  2. **二进制按原始字节落盘。** PowerShell 的 `>` 会把原生程序输出按 UTF-16 重编码(每字节后插
291
309
  `00`),截图因此损坏。这里直接写字节,PNG 签名保持 `89504e470d0a1a0a`。
292
- 3. **零外部依赖。** profile 插件按磁盘真实路径加载(`link:` 依赖仍是符号链接目标),任何裸包名
293
- 导入(例如 `@deepseek-ai/schemastery`)都会解析到插件目录而非 profile 的 `node_modules`,导致
294
- 插件加载失败。本插件只导入 `node:` 内置模块和自己的文件。
310
+ 3. **一个可选的外部依赖。** profile 插件按磁盘真实路径加载(`link:` 依赖仍是符号链接目标),任何裸包名
311
+ 导入(例如 `@deepseek-ai/schemastery`)都会解析到插件目录而非 profile 的 `node_modules`。因此该导入
312
+ 是**受保护的**:没有 schemastery(`link:` 安装)时设置界面不出现、插件退化为纯补丁层配置,而
313
+ `moli_fetch` 与 `moli_probe` 照常注册;有它(普通 npm 安装会自动带上)时设置页面可用。
295
314
 
296
315
  ## 平台支持
297
316
 
@@ -328,7 +347,7 @@ cookie 驱动的查询参数。那次实测同时覆盖了代理下载路由与
328
347
 
329
348
  ## 配置
330
349
 
331
- 在 profile 的补丁层里覆盖 `config`,全部可选:
350
+ 在 profile 的补丁层里覆盖 `config`,全部可选。也可以在图 **Moli 设置** 界面里编辑(见下方「设置页入口」)。
332
351
 
333
352
  ```yaml
334
353
  - insert:
@@ -349,6 +368,32 @@ profile。无论生效的是哪个代理,loopback 地址一律绕过它。
349
368
  → `%USERPROFILE%\.local\bin\moli.exe`,随后还会在 `%LOCALAPPDATA%\Moli` 内递归查找 `moli.exe`,
350
369
  所以装在带版本号的子目录里也能找到。
351
370
 
371
+ ### 设置页入口
372
+
373
+ 插件自带一个**设置界面**,挂在插件管理页的对应插件条目上(`plugins.bundle.config` 槽位,由浏览器端
374
+ `client.js` 注册)。两种方式进入:
375
+
376
+ 1. **插件管理页**:打开 DSH Web 界面的插件管理页,找到 **dsh-moli** 条目,其配置区即是本设置界面;
377
+ 引擎状态、安装/卸载按钮、以及下方所有配置字段都在这里编辑。
378
+ 2. **直接改 profile**:按上面的 YAML 改 `cordis.patch.yml` 同样生效,两边是同一份 `config`,保存即同步。
379
+
380
+ 设置界面跟随 DSH 当前语言(内置 `zh` / `en`,英语兜底),布尔字段(如 `insecureTls`)以下拉选择
381
+ **原始值**(`true` / `false`)显示,不做本地化翻译。
382
+
383
+ ### 引擎生命周期(设置页的「引擎」行)
384
+
385
+ 设置界面顶部有一条**引擎**状态行,自动检测已安装的引擎,并可一键安装/卸载,无需动 profile:
386
+
387
+ - **状态** — `engineState` 为 `installed`(同时给出 `engineVersion`)、`missing`,或一段简短错误文本。
388
+ - **安装** — 点「安装引擎」后表单写入 `engineAction: "install"`,主机异步下载并运行官方安装器,
389
+ 完成后通过 `engineState` / `engineVersion` 回写结果。
390
+ - **卸载** — 「卸载引擎」写入 `engineAction: "uninstall"`。Windows 上只删除安装器所属的
391
+ `%LOCALAPPDATA%\Moli` 目录树;没有该目录树时**不触碰任何文件**。POSIX 上删除定位到的二进制,
392
+ 并清理随之变空的安装目录。
393
+
394
+ `engineAction` / `engineState` / `engineVersion` 三个字段属于动态 Config schema(`engineAction` 是
395
+ `none` / `install` / `uninstall` 三选一),设计上由设置界面驱动;日常改配置请优先用上面的四个字段。
396
+
352
397
  ## 已知限制
353
398
 
354
399
  - **macOS 未经验证,Linux 是由贡献者而非维护者验证的。** 维护者是一名**没有 macOS / Linux 电脑**的
@@ -376,8 +421,13 @@ profile。无论生效的是哪个代理,loopback 地址一律绕过它。
376
421
 
377
422
  ## 验证情况
378
423
 
379
- [`verify/`](verify/) 下有四个可直接对工作区运行的测试:
424
+ [`verify/`](verify/) 下有八个可直接对工作区运行的测试:
380
425
 
426
+ - `config-verify.mjs` 检查设置面与引擎生命周期:动态 `Config` schema(每个已声明字段、`engineAction`
427
+ 为 `none`/`install`/`uninstall` 三选一)、`apply` 同时接受普通配置与动态 `.get()` 引用、
428
+ `settings/document-updated` 接线、`uninstallMoli`(平台感知:POSIX 删除定位到的二进制,Windows
429
+ 只删除安装器所属的 `%LOCALAPPDATA%\Moli` 目录树)、以及 client.js 的形状(`plugins.bundle.config`
430
+ 注册、zh/en 词典、`true`/`false` 下拉显示原始值)。
381
431
  - `schema-verify.mjs` 用注册表自己的校验器检查工具定义,并带有断言"两种非法 schema 形状仍会被拒"的对照组。
382
432
  - `install-verify.mjs` 用本地回环 HTTP 服务器驱动安装链路:路由顺序、被提供字节的 SHA256、校验不匹配时
383
433
  拒绝执行、匹配时真正执行、以及全部路径失败的诊断。
@@ -396,13 +446,26 @@ profile。无论生效的是哪个代理,loopback 地址一律绕过它。
396
446
  断言的是**输出**的性质,而不是内部调用。
397
447
  - `http-verify.mjs` 直接断言"参数 → 引擎选项"的映射,因为**参数没送到引擎和功能不生效,从外面看是一样的**。
398
448
  随后抓一个 404,验证 `failOnHttpError` 会报告失败同时保留响应体、健康页面不受影响、以及错误页的截图仍然被
399
- 写为合法 PNG 而不是被当文本解码。端到端部分需要一个真实服务器的 HTTP 错误:**本沙箱内无法访问 loopback**
400
- ——`curl.exe` 连 `127.0.0.1` 会超时,而 Node 宿主进程可以——所以它用 `example.com` 下的一个路径,网络不可用
401
- 时跳过。
402
-
403
- `.github/workflows/verify.yml` 在 Linux、Windows、macOS 上跑 `schema-verify`、`empty-verify` 与
404
- `scroll-verify`(无需引擎、无需网络),在 Linux 与 Windows 上尝试依赖引擎的几套;若 runner 上没有引擎,
405
- 则报告为跳过而不是失败。
449
+ 写为合法 PNG 而不是被当文本解码。它还覆盖那两个"静默给错答案"的情形:`eval` 配 `dump` 会带
450
+ `evalDiscarded` 标记且警告渲染在最前,`scroll` 单独用返回的是文档而不是滚动偏移。端到端部分需要一个真实
451
+ 服务器的 HTTP 错误:**本沙箱内无法访问 loopback**——`curl.exe` 连 `127.0.0.1` 会超时,而 Node 宿主进程
452
+ 可以——所以它用 `example.com` 下的一个路径,网络不可用时跳过。
453
+
454
+ ### 测试前置条件
455
+
456
+ 插件有一个可选的运行期依赖——[`@deepseek-ai/schemastery`](https://www.npmjs.com/package/@deepseek-ai/schemastery),
457
+ 由动态 `Config` schema(注册设置命名空间的那份)使用。凡是 import 插件本体的套件(`config-verify`、
458
+ `schema-verify`、`empty-verify`、`report-verify`、`http-verify`)都需要它能从工作区解析,所以先跑
459
+ `npm install`。
460
+
461
+ `schema-verify` 额外需要从 npm 全局安装的 `@deepseek-ai/dsh` 里 import 注册表自己的校验器
462
+ (`@deepseek-ai/dsh-tools`);`report-verify` 与引擎类套件需要真实 Moli 引擎,`empty-verify` 需要
463
+ C 编译器来构建它的桩引擎。在 Windows 上,`@deepseek-ai/schemastery` 可以通过目录 junction 指到
464
+ npm 全局 DSH 树来解析(与套件解析 `dsh-tools` 的方式相同)。
465
+
466
+ `.github/workflows/verify.yml` 在 Linux、Windows、macOS 上跑 `schema-verify`、`empty-verify`、
467
+ `scroll-verify` 与 `http-verify`(无需引擎、无需网络),在 Linux 与 Windows 上尝试依赖引擎的几套;
468
+ 若 runner 上没有引擎,则报告为跳过而不是失败。
406
469
 
407
470
  `report-verify.mjs` **故意不放进 CI**:其中三项会抓 `example.com`,还有一项抓一个具体的论坛帖子,因此它
408
471
  需要能上外网——如果这台机器的流量走代理,还需要那个代理可用。有网络时它是有用的,但**一个因为网站改版就变
package/client.js ADDED
@@ -0,0 +1,347 @@
1
+ /**
2
+ * dsh-moli — browser half.
3
+ *
4
+ * Settings section (Moli install & fetch): the plugin's configuration —
5
+ * engine lifecycle (install / uninstall), forward proxy, per-call timeout,
6
+ * inline truncation budget, the TLS no-verify switch, and the engine path —
7
+ * edited on the plugin's page in the Plugins manager.
8
+ *
9
+ * Hand-written ModuleLoader bundle — no build step required.
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: "dsh-moli",
13
+ factory: (require) => {
14
+ var module = { exports: {} };
15
+ var exports = module.exports;
16
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
17
+ var react = require("react");
18
+ var h = react.createElement;
19
+
20
+ // ── CSS (theme tokens, same vocabulary as dsh-soul-md) ────────────────
21
+ var CSS = ".__moli_root{max-width:640px;display:flex;flex-direction:column;gap:10px}" +
22
+ ".__moli_field{display:flex;flex-direction:column;gap:4px}" +
23
+ ".__moli_label{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:6px}" +
24
+ ".__moli_hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}" +
25
+ ".__moli_input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 10px;font-size:13px;box-sizing:border-box;width:100%}" +
26
+ ".__moli_actions{display:flex;gap:8px;align-items:center;margin-top:4px}" +
27
+ ".__moli_btn{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 14px;font:inherit;font-size:13px;cursor:pointer}" +
28
+ ".__moli_btn:hover:not(:disabled){border-color:var(--dsw-alias-state-business-primary)}" +
29
+ ".__moli_btn:disabled{opacity:.5;cursor:default}" +
30
+ ".__moli_btnPrimary{border-color:var(--dsw-alias-state-business-primary, #3964fe);background:var(--dsw-alias-state-business-primary, #3964fe);color:#fff}" +
31
+ ".__moli_btnDanger{border-color:var(--dsw-alias-state-error-primary, #f85149);color:var(--dsw-alias-state-error-primary, #f85149)}" +
32
+ ".__moli_status{font-size:12px;color:var(--dsw-alias-label-tertiary)}" +
33
+ ".__moli_error{font-size:12px;color:var(--dsw-alias-state-error-primary)}" +
34
+ ".__moli_unavailable{font-size:13px;color:var(--dsw-alias-label-tertiary)}" +
35
+ ".__moli_group{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:8px}" +
36
+ ".__moli_engineRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}" +
37
+ ".__moli_engineState{font-size:13px;color:var(--dsw-alias-label-primary);font-weight:600}" +
38
+ ".__moli_engineDetail{font-size:11px;color:var(--dsw-alias-label-tertiary)}";
39
+ if (typeof document !== "undefined") {
40
+ var style = document.createElement("style");
41
+ style.textContent = CSS;
42
+ style.setAttribute("data-dsh-moli", "");
43
+ if (!document.head.querySelector("style[data-dsh-moli]")) document.head.appendChild(style);
44
+ }
45
+
46
+ // ── locale ────────────────────────────────────────────────────────────
47
+ var zh = {
48
+ nav: "Moli 设置",
49
+ sectionTitle: "Moli 安装与抓取",
50
+ unavailable: "配置不可用(可能当前 profile 未启用本插件)。",
51
+ loading: "正在读取配置…",
52
+ saved: "已保存。",
53
+ save: "保存",
54
+ error: "保存失败",
55
+ notApplied: "写入被宿主拒绝,表单已按宿主实际状态刷新。",
56
+ engineSection: "引擎",
57
+ engineInstalled: "已安装",
58
+ engineMissing: "未安装",
59
+ engineUnknown: "检测中…",
60
+ engineWorking: "正在处理…",
61
+ engineActionInstall: "安装引擎",
62
+ engineActionUninstall: "卸载引擎",
63
+ engineVersion: "版本 {version}",
64
+ engineInstallHint: "未检测到 Moli 引擎。点击「安装引擎」下载官方安装器并运行(需联网,可能耗时数分钟)。",
65
+ engineUninstallHint: "点击「卸载引擎」将删除本机安装的 Moli 二进制。",
66
+ fieldProxy: "转发代理",
67
+ hintProxy: '每次调用的代理覆盖项;留空表示直连。例如 "http://127.0.0.1:7890"。回环地址总是绕过代理。',
68
+ fieldDefaultTimeoutMs: "默认超时(毫秒)",
69
+ hintDefaultTimeoutMs: "单次调用默认超时,默认 30000(30 秒)。",
70
+ fieldMaxChars: "内联文本截断(字符)",
71
+ hintMaxChars: "内联文本最多返回的字符数,默认 200000;超出时保留头尾并注明省略量,传 writeTo 可保留全文。",
72
+ fieldInsecureTls: "跳过 TLS 验证(insecureTls)",
73
+ hintInsecureTls: '本网络证书吊销应答不可达时,GitHub 等 HTTPS 主机可能报错;开启后透传 moli 的 no-verify 标志。默认开启。',
74
+ fieldExePath: "引擎路径(exePath)",
75
+ hintExePath: "可选。指定 moli 可执行文件的绝对路径;留空则自动在常见安装位置查找。",
76
+ placeholderProxy: "http://127.0.0.1:7890",
77
+ placeholderExePath: "/home/user/.local/bin/moli",
78
+ };
79
+ var en = {
80
+ nav: "Moli settings",
81
+ sectionTitle: "Moli install & fetch",
82
+ unavailable: "Configuration unavailable (this profile may not enable the plugin).",
83
+ loading: "Loading configuration…",
84
+ saved: "Saved.",
85
+ save: "Save",
86
+ error: "Save failed",
87
+ notApplied: "The write was refused by the host; the form was reloaded to what the host actually holds.",
88
+ engineSection: "Engine",
89
+ engineInstalled: "Installed",
90
+ engineMissing: "Not installed",
91
+ engineUnknown: "Detecting…",
92
+ engineWorking: "Working…",
93
+ engineActionInstall: "Install engine",
94
+ engineActionUninstall: "Uninstall engine",
95
+ engineVersion: "version {version}",
96
+ engineInstallHint: "No Moli engine found. Click “Install engine” to download and run the official installer (needs network; may take a few minutes).",
97
+ engineUninstallHint: "Click “Uninstall engine” to remove the locally installed Moli binary.",
98
+ fieldProxy: "Forward proxy",
99
+ hintProxy: 'Per-call proxy override; leave empty for direct. E.g. "http://127.0.0.1:7890". Loopback URLs always bypass it.',
100
+ fieldDefaultTimeoutMs: "Default timeout (ms)",
101
+ hintDefaultTimeoutMs: "Per-call default timeout; 30000 (30 s) by default.",
102
+ fieldMaxChars: "Inline truncation budget (chars)",
103
+ hintMaxChars: "Max characters returned inline; 200000 by default. Beyond it the head and tail are kept with an omitted-count note; pass writeTo to keep everything.",
104
+ fieldInsecureTls: "Skip TLS verification (insecureTls)",
105
+ hintInsecureTls: "When this network's CRL/OCSP responder is unreachable, HTTPS hosts like GitHub fail; enabling passes moli's no-verify flag. On by default.",
106
+ fieldExePath: "Engine path (exePath)",
107
+ hintExePath: "Optional. Absolute path to the moli executable; leave empty to search the usual install locations.",
108
+ placeholderProxy: "http://127.0.0.1:7890",
109
+ placeholderExePath: "/home/user/.local/bin/moli",
110
+ };
111
+
112
+ // ── write helper (same contract as dsh-soul-md) ───────────────────────
113
+ function settingsOpsApplied(snapshot, ops) {
114
+ var v = snapshot && snapshot.status === "ready" ? snapshot.value : void 0;
115
+ if (v === void 0) return false;
116
+ return ops.every(function (op) {
117
+ if (op.op === "unset") return v[op.path[0]] === void 0;
118
+ return v[op.path[0]] === op.value;
119
+ });
120
+ }
121
+ function commitSettingsOps(scope, ops) {
122
+ if (ops.length === 0) return Promise.resolve(true);
123
+ var run;
124
+ if (typeof scope.mutate === "function") {
125
+ run = scope.mutate(ops, scope.getSnapshot().revision);
126
+ } else {
127
+ run = ops.reduce(function (chain, op) {
128
+ return chain.then(function () {
129
+ return op.op === "unset" ? scope.unset(op.path[0]) : scope.set(op.path[0], op.value);
130
+ });
131
+ }, Promise.resolve());
132
+ }
133
+ return Promise.resolve(run).then(function () {
134
+ return settingsOpsApplied(scope.getSnapshot(), ops);
135
+ });
136
+ }
137
+
138
+ // ── field descriptors ─────────────────────────────────────────────────
139
+ // `select` fields store booleans (single option pair); the draft holds the
140
+ // string option and the ops map it back to a boolean.
141
+ var FIELDS = [
142
+ { key: "proxy", label: "fieldProxy", type: "text", hint: "hintProxy", placeholder: "placeholderProxy" },
143
+ { key: "defaultTimeoutMs", label: "fieldDefaultTimeoutMs", type: "number", min: 1000, hint: "hintDefaultTimeoutMs" },
144
+ { key: "maxChars", label: "fieldMaxChars", type: "number", min: 100, hint: "hintMaxChars" },
145
+ { key: "insecureTls", label: "fieldInsecureTls", type: "select", options: [["true", "true"], ["false", "false"]], hint: "hintInsecureTls" },
146
+ { key: "exePath", label: "fieldExePath", type: "text", hint: "hintExePath", placeholder: "placeholderExePath" },
147
+ ];
148
+ function toDraft(value) {
149
+ var out = {};
150
+ for (var i = 0; i < FIELDS.length; i++) {
151
+ var f = FIELDS[i];
152
+ var v = value && value[f.key];
153
+ if (f.type === "number") out[f.key] = typeof v === "number" ? String(v) : "";
154
+ else if (f.type === "select") out[f.key] = v ? "true" : "false";
155
+ else out[f.key] = typeof v === "string" ? v : "";
156
+ }
157
+ return out;
158
+ }
159
+ function draftOps(draft) {
160
+ var ops = [];
161
+ for (var i = 0; i < FIELDS.length; i++) {
162
+ var f = FIELDS[i];
163
+ if (f.type === "number") {
164
+ var n = Number(draft[f.key]);
165
+ if (draft[f.key] !== "" && Number.isFinite(n)) ops.push({ op: "set", path: [f.key], value: n });
166
+ } else if (f.type === "select") {
167
+ ops.push({ op: "set", path: [f.key], value: draft[f.key] === "true" });
168
+ } else {
169
+ ops.push({ op: "set", path: [f.key], value: typeof draft[f.key] === "string" ? draft[f.key] : "" });
170
+ }
171
+ }
172
+ return ops;
173
+ }
174
+
175
+ // ── settings section ──────────────────────────────────────────────────
176
+ function MoliConfigSection(props) {
177
+ useLocale(props.locale);
178
+ var t = props.t;
179
+ var scope = props.scope;
180
+ var [snapshot, setSnapshot] = react.useState(function () { return scope.getSnapshot(); });
181
+ var ready = snapshot.status === "ready" && snapshot.value !== void 0;
182
+ var [draft, setDraft] = react.useState(null);
183
+ var [busy, setBusy] = react.useState(false);
184
+ var [notice, setNotice] = react.useState(null);
185
+ var [error, setError] = react.useState(null);
186
+ var writePending = react.useRef(false);
187
+
188
+ react.useEffect(function () {
189
+ var alive = true;
190
+ var sync = function () { if (alive) setSnapshot(scope.getSnapshot()); };
191
+ var un = typeof scope.subscribe === "function" ? scope.subscribe(sync) : null;
192
+ // Bound once per plugin, shared across mounts: unmount only unsubscribes,
193
+ // never disposes, so a later remount does not find a dead write queue.
194
+ return function () { alive = false; if (un) un(); };
195
+ }, [scope]);
196
+
197
+ if (snapshot.status === "unavailable") {
198
+ return h("p", { className: "__moli_unavailable" }, t("unavailable"));
199
+ }
200
+ if (!ready) return h("p", { className: "__moli_status" }, t("loading"));
201
+
202
+ var value = snapshot.value;
203
+ var currentDraft = draft === null ? toDraft(value) : draft;
204
+ var valueKey = JSON.stringify(toDraft(value));
205
+ var draftKey = JSON.stringify(currentDraft);
206
+ var dirty = valueKey !== draftKey;
207
+ var saving = busy;
208
+
209
+ function setField(f, v) { setNotice(null); setError(null); setDraft(function (p) { return Object.assign({}, p, { [f.key]: v }); }); }
210
+
211
+ function onSave() {
212
+ if (writePending.current) return;
213
+ writePending.current = true;
214
+ setBusy(true); setNotice(null); setError(null);
215
+ commitSettingsOps(scope, draftOps(currentDraft)).then(function (ok) {
216
+ writePending.current = false;
217
+ setBusy(false);
218
+ setSnapshot(scope.getSnapshot());
219
+ if (!ok) { setError(t("notApplied")); return; }
220
+ setNotice(t("saved"));
221
+ setDraft(null);
222
+ }).catch(function (e) {
223
+ writePending.current = false;
224
+ setBusy(false);
225
+ setSnapshot(scope.getSnapshot());
226
+ setError(String(e && e.message || e));
227
+ });
228
+ }
229
+
230
+ // Engine lifecycle: the Host reacts to `engineAction` and reports back
231
+ // through `engineState` / `engineVersion`. While the action is pending the
232
+ // form shows "working…"; scope updates (including the reset to `none`)
233
+ // drive the display back to the resulting state.
234
+ var engineState = typeof value.engineState === "string" ? value.engineState : "unknown";
235
+ var engineVersion = typeof value.engineVersion === "string" ? value.engineVersion : "";
236
+ var engineAction = typeof value.engineAction === "string" ? value.engineAction : "none";
237
+ var engineWorking = engineAction !== "none";
238
+ var engineLabel = engineWorking ? t("engineWorking")
239
+ : engineState === "installed" ? t("engineInstalled")
240
+ : engineState === "missing" ? t("engineMissing")
241
+ : t("engineUnknown");
242
+
243
+ function onEngineAction(action) {
244
+ if (engineWorking) return;
245
+ if (action === "uninstall" && !window.confirm(t("engineUninstallHint"))) return;
246
+ setNotice(null); setError(null);
247
+ commitSettingsOps(scope, [{ op: "set", path: ["engineAction"], value: action }]).then(function (ok) {
248
+ setSnapshot(scope.getSnapshot());
249
+ if (!ok) setError(t("notApplied"));
250
+ }).catch(function (e) {
251
+ setSnapshot(scope.getSnapshot());
252
+ setError(String(e && e.message || e));
253
+ });
254
+ }
255
+
256
+ return h("div", { className: "__moli_root" },
257
+ // Engine lifecycle group.
258
+ h("div", { className: "__moli_group" },
259
+ h("div", { className: "__moli_label" }, t("engineSection")),
260
+ h("div", { className: "__moli_engineRow" },
261
+ h("span", { className: "__moli_engineState" }, engineLabel),
262
+ engineVersion && engineState === "installed"
263
+ ? h("span", { className: "__moli_engineDetail" }, t("engineVersion").replace("{version}", engineVersion))
264
+ : null,
265
+ ),
266
+ engineState !== "installed" && !engineWorking
267
+ ? h("span", { className: "__moli_engineDetail" }, t("engineInstallHint"))
268
+ : engineState === "installed" && !engineWorking
269
+ ? h("span", { className: "__moli_engineDetail" }, t("engineUninstallHint"))
270
+ : null,
271
+ h("div", { className: "__moli_actions" },
272
+ engineState === "installed"
273
+ ? h("button", { className: "__moli_btn __moli_btnDanger", disabled: engineWorking, onClick: function () { onEngineAction("uninstall"); } }, t("engineActionUninstall"))
274
+ : h("button", { className: "__moli_btn __moli_btnPrimary", disabled: engineWorking, onClick: function () { onEngineAction("install"); } }, t("engineActionInstall")),
275
+ ),
276
+ ),
277
+ // Configuration fields group.
278
+ h("div", { className: "__moli_group" },
279
+ FIELDS.map(function (f) {
280
+ if (f.type === "select") {
281
+ return h("label", { key: f.key, className: "__moli_field" },
282
+ h("span", { className: "__moli_label" }, t(f.label)),
283
+ h("select", {
284
+ className: "__moli_input",
285
+ value: currentDraft[f.key],
286
+ onChange: function (e) { setField(f, e.target.value); },
287
+ }, f.options.map(function (o) {
288
+ // Option labels are the raw stored values ("true"/"false"),
289
+ // never translated; only the field label and hint are locale-bound.
290
+ return h("option", { key: o[0], value: o[0] }, o[1]);
291
+ })),
292
+ f.hint ? h("span", { className: "__moli_hint" }, t(f.hint)) : null,
293
+ );
294
+ }
295
+ return h("label", { key: f.key, className: "__moli_field" },
296
+ h("span", { className: "__moli_label" }, t(f.label)),
297
+ h("input", {
298
+ className: "__moli_input",
299
+ type: f.type === "number" ? "number" : "text",
300
+ min: f.min,
301
+ value: currentDraft[f.key],
302
+ placeholder: f.placeholder ? t(f.placeholder) : void 0,
303
+ onChange: function (e) { setField(f, e.target.value); },
304
+ }),
305
+ f.hint ? h("span", { className: "__moli_hint" }, t(f.hint)) : null,
306
+ );
307
+ }),
308
+ h("div", { className: "__moli_actions" },
309
+ h("button", { className: "__moli_btn __moli_btnPrimary", disabled: saving || !dirty, onClick: onSave }, t("save")),
310
+ notice ? h("span", { className: "__moli_status" }, notice) : null,
311
+ error ? h("span", { className: "__moli_error" }, error) : null,
312
+ ),
313
+ ),
314
+ );
315
+ }
316
+
317
+ var NS = "dsh-moli";
318
+
319
+ // Keep the section re-rendering when the active locale changes.
320
+ function useLocale(locale) {
321
+ var refresh = react.useState(0)[1];
322
+ react.useEffect(function () {
323
+ if (!locale || typeof locale.subscribe !== "function") return;
324
+ return locale.subscribe(function () { refresh(function (revision) { return revision + 1; }); });
325
+ }, [locale]);
326
+ }
327
+
328
+ function apply(ctx) {
329
+ var t = ctx.locale.bind(NS);
330
+ ctx.effect(function () { return ctx.locale.register(NS, { zh: zh, en: en }); }, "dsh-moli: dictionaries");
331
+ var scope = ctx.configForms.get("dsh-moli");
332
+ ctx.slots.inject("plugins.bundle.config", function () {
333
+ return ctx.slots.register({
334
+ name: "plugins.bundle.config",
335
+ key: "dsh-moli",
336
+ locale: NS
337
+ }, function (props) {
338
+ return h(MoliConfigSection, Object.assign({}, props, { scope: scope, t: t, locale: ctx.locale }));
339
+ });
340
+ });
341
+ }
342
+
343
+ exports.apply = apply;
344
+ exports.inject = ["slots", "locale", "configForms"];
345
+ return module.exports;
346
+ }
347
+ });
package/index.js CHANGED
@@ -1,19 +1,27 @@
1
1
  /** Host half of dsh-moli: expose the local `moli` headless browser as callable tools. */
2
- import { MANUAL_INSTALL, installMoli } from './lib/install.js';
3
- import { findMoli, moliCandidates, probe, runFetch } from './lib/moli.js';
2
+ import { MANUAL_INSTALL, installMoli, uninstallMoli } from './lib/install.js';
3
+ import { findMoli, moliCandidates, moliVersion, probe, runFetch } from './lib/moli.js';
4
4
 
5
5
  export const name = 'dsh-moli';
6
6
  export const inject = ['tools'];
7
7
 
8
8
  /**
9
- * This plugin deliberately imports nothing but `node:` builtins and its own
10
- * files. A profile plugin is loaded from its real path on disk (a `link:`
11
- * dependency stays a symlink target), so a bare specifier such as
12
- * `@deepseek-ai/schemastery` would resolve against the plugin directory
13
- * instead of the profile's `node_modules` and fail to load. The registry
14
- * validates parameters and results itself, so `defineTool` and a Schema-based
15
- * config are not needed; configuration is read defensively from the patch layer.
9
+ * `@deepseek-ai/schemastery` powers the live Config schema and therefore the
10
+ * settings namespace. A profile plugin is loaded from its real path on disk (a
11
+ * `link:` dependency stays a symlink target), so a bare specifier must resolve
12
+ * against the plugin's own `node_modules` chain — which does not exist when the
13
+ * plugin is installed as a `link:`/junction (the README's first install route).
14
+ * The import is therefore optional: without schemastery the settings form does
15
+ * not appear and the plugin degrades to plain patch-layer configuration, while
16
+ * `moli_fetch` / `moli_probe` keep registering — those are the plugin's reason
17
+ * to exist, and losing them would be worse than losing the form.
16
18
  */
19
+ let z;
20
+ try {
21
+ z = (await import('@deepseek-ai/schemastery')).default;
22
+ } catch {
23
+ z = undefined;
24
+ }
17
25
 
18
26
  /** Deployment configuration defaults; override via the plugin's `config` entry. */
19
27
  const DEFAULTS = {
@@ -27,6 +35,35 @@ const DEFAULTS = {
27
35
  insecureTls: true,
28
36
  };
29
37
 
38
+ /**
39
+ * Live Config schema. Declaring it (and exporting it) makes DSH register the
40
+ * `dsh-moli` settings namespace, which the browser half's `plugins.bundle.config`
41
+ * form edits. `.volatile()` marks the whole object live so the namespace appears
42
+ * in the settings describe mirror and the form's writes are applied via the
43
+ * ConfigEditor / volatile HMR path.
44
+ *
45
+ * `undefined` when `@deepseek-ai/schemastery` cannot be resolved (a `link:`
46
+ * install) — then no settings namespace is registered and the engine lifecycle
47
+ * is skipped, but the tools still register.
48
+ */
49
+ export const Config = z
50
+ ? z.object({
51
+ defaultTimeoutMs: z.number().default(DEFAULTS.defaultTimeoutMs),
52
+ maxChars: z.number().default(DEFAULTS.maxChars),
53
+ insecureTls: z.boolean().default(DEFAULTS.insecureTls),
54
+ proxy: z.string().default(''),
55
+ exePath: z.string().default(''),
56
+ // Engine lifecycle, driven by the settings form. The form writes an action
57
+ // (`install` / `uninstall`) and the Host executes it asynchronously, then
58
+ // reports the outcome back through `engineState` / `engineVersion` and resets
59
+ // `engineAction` to `none`. `engineState` is `installed`, `missing`, or a
60
+ // short error text.
61
+ engineAction: z.union([z.const('none'), z.const('install'), z.const('uninstall')]).default('none'),
62
+ engineState: z.string().default('unknown'),
63
+ engineVersion: z.string().default(''),
64
+ }).volatile()
65
+ : undefined;
66
+
30
67
  /**
31
68
  * Parameter schema of `moli_fetch`.
32
69
  *
@@ -137,6 +174,7 @@ const FETCH_OUTPUT_SCHEMA = {
137
174
  empty: { type: 'boolean' },
138
175
  omitted: { type: 'integer' },
139
176
  evalFailed: { type: 'boolean' },
177
+ evalDiscarded: { type: 'boolean' },
140
178
  truncated: { type: 'boolean' },
141
179
  error: { type: 'string' },
142
180
  hint: { type: 'string' },
@@ -206,6 +244,12 @@ function renderFetch(_args, value) {
206
244
  }];
207
245
  }
208
246
  const parts = [];
247
+ // A discarded `eval` value is the one case where the output looks like a clean
248
+ // answer to a question that was never answered, so the warning goes first rather
249
+ // than after the body, where a 900-character page would bury it.
250
+ if (value.evalDiscarded === true) {
251
+ parts.push('Note: the `eval` expression was dropped because `dump` was given too — the engine discards the expression value in that combination. What follows is the page, not the expression.');
252
+ }
209
253
  if (typeof value.note === 'string' && value.note !== '') parts.push(value.note);
210
254
  parts.push(String(value.content ?? ''));
211
255
  return [{ type: 'text', text: parts.join('\n\n') }];
@@ -265,7 +309,10 @@ function renderProbe(_args, value) {
265
309
  * @param config - deployment configuration for paths, timeout, and output size.
266
310
  */
267
311
  export function apply(ctx, config) {
268
- const options = config ?? {};
312
+ // The Config schema is declared volatile, so DSH hands it as a live reference
313
+ // whose `.get()` reads the current values; a deployment that mounts an older
314
+ // loader without volatile wiring passes a plain object instead. Read either.
315
+ const options = typeof config?.get === 'function' ? config.get() : (config ?? {});
269
316
  // Mutable: a successful install resolves a path this process can use at once,
270
317
  // without waiting for a restart to re-run discovery.
271
318
  let cachedExe = findMoli(options.exePath);
@@ -384,5 +431,167 @@ export function apply(ctx, config) {
384
431
  },
385
432
  });
386
433
 
434
+ // ── engine lifecycle via the settings form ─────────────────────────────
435
+ // The browser half's `plugins.bundle.config` form drives install/uninstall
436
+ // through the `engineAction` field and reads the outcome from
437
+ // `engineState`/`engineVersion`. State is written back only when it changes,
438
+ // and a finished action always resets `engineAction` to `none`, so the
439
+ // resulting `settings/document-updated` re-entry lands in the plain sync
440
+ // branch instead of looping the action.
441
+ //
442
+ // Everything here depends on the settings namespace, which exists only when
443
+ // schemastery resolved (see the `Config` export): without it there is no form
444
+ // to drive the lifecycle, so the whole block is skipped and the plugin is
445
+ // pure tool registration.
446
+ if (Config) {
447
+ const NS = 'dsh-moli';
448
+ let engineBusy = false;
449
+ const currentConfig = () => (typeof config.get === 'function' ? config.get() : (config ?? {}));
450
+ // Write settings outside the current HMR transaction. `settings.update`
451
+ // routes through dsh-hmr's runExclusive, which rejects when it is called
452
+ // from inside an active transaction — the settings/document-updated event
453
+ // handler runs in one, so a direct update throws
454
+ // "HMR transactions cannot be nested" and the write-back is silently lost
455
+ // (the form then stays on "working…" forever). setImmediate defers the
456
+ // update until the event transaction has exited.
457
+ const writeSettings = (settings, patch) =>
458
+ new Promise((resolve, reject) => {
459
+ setImmediate(() => {
460
+ settings.update(NS, patch).then(resolve, reject);
461
+ });
462
+ });
463
+ const syncEngineState = async (settings, current) => {
464
+ const exe = findMoli(current.exePath);
465
+ const state = exe === undefined ? 'missing' : 'installed';
466
+ const version = exe === undefined ? '' : (moliVersion(exe) ?? '');
467
+ if (current.engineState === state && current.engineVersion === version) return;
468
+ try {
469
+ await writeSettings(settings, { engineState: state, engineVersion: version });
470
+ } catch (error) {
471
+ console.error(`[dsh-moli] engine-state sync failed: ${String(error?.message ?? error)}`);
472
+ ctx.logger?.warn?.('dsh-moli: engine-state sync failed', String(error?.message ?? error));
473
+ }
474
+ };
475
+ const runEngineAction = async (settings, current, action) => {
476
+ if (engineBusy) return;
477
+ engineBusy = true;
478
+ try {
479
+ if (action === 'install') {
480
+ const outcome = await installMoli({
481
+ exePath: current.exePath,
482
+ timeoutMs: 180000,
483
+ proxy: current.proxy,
484
+ locate: findMoli,
485
+ candidates: moliCandidates(current.exePath),
486
+ });
487
+ cachedExe = outcome.ok === true ? (outcome.exe ?? cachedExe) : cachedExe;
488
+ await writeSettings(settings, {
489
+ engineAction: 'none',
490
+ engineState: outcome.ok === true ? 'installed' : `install failed: ${String(outcome.error ?? 'unknown error').slice(0, 200)}`,
491
+ engineVersion: outcome.ok === true ? String(outcome.version ?? '') : '',
492
+ });
493
+ } else if (action === 'uninstall') {
494
+ const removed = uninstallMoli(current.exePath);
495
+ cachedExe = undefined;
496
+ await writeSettings(settings, {
497
+ engineAction: 'none',
498
+ engineState: removed === undefined ? 'uninstall failed: nothing to remove' : 'missing',
499
+ engineVersion: '',
500
+ });
501
+ }
502
+ } catch (error) {
503
+ // console (not ctx.logger) so the failure reaches the service journal
504
+ console.error(`[dsh-moli] engine action failed: ${String(error?.message ?? error)}`);
505
+ ctx.logger?.error?.('dsh-moli: engine action failed', String(error?.message ?? error));
506
+ try {
507
+ await writeSettings(settings, {
508
+ engineAction: 'none',
509
+ engineState: `action failed: ${String(error?.message ?? error).slice(0, 200)}`,
510
+ });
511
+ } catch (error2) {
512
+ console.error(`[dsh-moli] engine action failure report could not be written back: ${String(error2?.message ?? error2)}`);
513
+ ctx.logger?.error?.('dsh-moli: engine action failure report could not be written back', String(error2?.message ?? error2));
514
+ }
515
+ } finally {
516
+ engineBusy = false;
517
+ }
518
+ };
519
+ // Dispatch an engineAction if one is pending, otherwise sync state. Shared by
520
+ // the settings update handler and the boot-time initial sync, so an action
521
+ // whose `settings/document-updated` event was lost (a crash between the
522
+ // form's write and the handler) is still picked up on the next boot instead
523
+ // of leaving the form stuck on "processing".
524
+ // The event fires inside a dsh-hmr transaction. That transaction propagates
525
+ // through AsyncLocalStorage into anything the handler spawns — including
526
+ // setTimeout/setImmediate callbacks — so a deferred settings.update still
527
+ // nests and is rejected ("HMR transactions cannot be nested"). dsh-hmr
528
+ // itself escapes its own transaction with `executing.exit(...)` (watchConfig
529
+ // does this); running the dispatch through the same exit clears the ALS
530
+ // store so the write-back opens its own outermost transaction. The poller
531
+ // below stays as a defensive fallback (exit missing, or an event lost
532
+ // between the form's write and the handler).
533
+ const dispatchOutsideTransaction = (settings) => {
534
+ const hmr = ctx.get?.('hmr');
535
+ const run = () => {
536
+ const current = currentConfig();
537
+ const action = current.engineAction;
538
+ if (action === 'install' || action === 'uninstall') void runEngineAction(settings, current, action);
539
+ else void syncEngineState(settings, current);
540
+ };
541
+ if (hmr && typeof hmr.executing?.exit === 'function') {
542
+ hmr.executing.exit(run);
543
+ } else {
544
+ run();
545
+ }
546
+ };
547
+ // Actions are driven by an independent poller as a fallback, and by the
548
+ // transaction-escaped dispatch on the event path. A setInterval callback
549
+ // runs in the timer phase, outside any transaction, so the action and its
550
+ // write-back are safe there too.
551
+ let pollTimer = null;
552
+ let pendingCheck = false;
553
+ const pollCheck = () => {
554
+ const settings = ctx.get('settings');
555
+ if (!settings) return;
556
+ const current = currentConfig();
557
+ const action = current.engineAction;
558
+ if (action === 'install' || action === 'uninstall') {
559
+ void runEngineAction(settings, current, action);
560
+ } else {
561
+ void syncEngineState(settings, current);
562
+ }
563
+ };
564
+ ctx.on?.('settings/document-updated', (id) => {
565
+ if (id !== NS) return;
566
+ const settings = ctx.get('settings');
567
+ if (!settings) return;
568
+ pendingCheck = true;
569
+ dispatchOutsideTransaction(settings);
570
+ });
571
+ pollTimer = setInterval(() => {
572
+ if (pendingCheck) {
573
+ pendingCheck = false;
574
+ pollCheck();
575
+ }
576
+ }, 500);
577
+ pollTimer.unref?.();
578
+ // Initial state once settings is live. `ctx.get('settings')` may be null in a
579
+ // boot-time effect (the service mounts later), so retry briefly; the poller
580
+ // also picks up any pending engineAction written before the service settled.
581
+ let syncAttempts = 0;
582
+ const tryInitialSync = () => {
583
+ const settings = ctx.get('settings');
584
+ if (!settings) return false;
585
+ pollCheck();
586
+ return true;
587
+ };
588
+ if (!tryInitialSync()) {
589
+ const timer = setInterval(() => {
590
+ if (tryInitialSync() || ++syncAttempts >= 40) clearInterval(timer);
591
+ }, 250);
592
+ timer.unref?.();
593
+ }
594
+ }
595
+
387
596
  ctx.logger?.info?.(`dsh-moli: registered moli_fetch and moli_probe (${cachedExe ?? 'moli missing'})`);
388
597
  }
package/lib/install.js CHANGED
@@ -36,10 +36,10 @@
36
36
  */
37
37
  import { spawn, spawnSync } from 'node:child_process';
38
38
  import { createHash } from 'node:crypto';
39
- import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, rmSync, statSync } from 'node:fs';
39
+ import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
40
40
  import { createConnection } from 'node:net';
41
41
  import { tmpdir } from 'node:os';
42
- import { join } from 'node:path';
42
+ import { dirname, join } from 'node:path';
43
43
  import { findMoli } from './moli.js';
44
44
 
45
45
  /** Official installer for the latest release. */
@@ -673,7 +673,7 @@ export async function installMoli(options = {}) {
673
673
  return {
674
674
  ok: false,
675
675
  error: `installer checksum mismatch: expected ${expected}, got ${download.sha256}`,
676
- sha256: download.sha256,
676
+ installerSha256: download.sha256,
677
677
  expectedSha256: expected,
678
678
  method: download.method,
679
679
  note: 'Refusing to execute the installer.',
@@ -759,3 +759,37 @@ export async function installMoli(options = {}) {
759
759
  rmSync(workDir, { recursive: true, force: true });
760
760
  }
761
761
  }
762
+
763
+ /**
764
+ * Remove an installed Moli engine. Windows deletes the installer-owned
765
+ * `%LOCALAPPDATA%\Moli` tree; POSIX removes the located binary and prunes the
766
+ * install directories it sits in when they end up empty.
767
+ *
768
+ * @param override - explicit path from configuration, when set.
769
+ * @returns the removed path, or undefined when nothing was installed.
770
+ */
771
+ export function uninstallMoli(override) {
772
+ const exe = findMoli(override);
773
+ if (exe === undefined) return undefined;
774
+ if (process.platform === 'win32') {
775
+ const root = process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Moli') : undefined;
776
+ if (root !== undefined && existsSync(root)) {
777
+ rmSync(root, { recursive: true, force: true });
778
+ return root;
779
+ }
780
+ // No installer-owned tree: nothing to remove. Returning here keeps the
781
+ // uninstall from falling through to the POSIX branch and deleting whatever
782
+ // findMoli located — which could be a user-provided path or a test fixture.
783
+ return undefined;
784
+ }
785
+ rmSync(exe, { force: true });
786
+ const dir = dirname(exe);
787
+ for (const parent of [dir, dirname(dir)]) {
788
+ try {
789
+ if (existsSync(parent) && readdirSync(parent).length === 0) rmSync(parent, { force: true });
790
+ } catch {
791
+ // A non-empty or protected parent is fine; the binary is already gone.
792
+ }
793
+ }
794
+ return exe;
795
+ }
package/lib/moli.js CHANGED
@@ -56,6 +56,14 @@ const SCROLL_TOKENS = {
56
56
  * content that "below the fold" already contains; it is about triggering the
57
57
  * handler, the intersection observer, or the lazy image that only runs on a scroll.
58
58
  *
59
+ * **The expression must not return the scroll position.** Without `--dump` the
60
+ * engine prints whatever `--eval` evaluates to, so returning `String(window.scrollY)`
61
+ * here replaced the page with the number `600`: `moli_fetch url=... scroll=600`
62
+ * answered `600`, while the identical call without `scroll` returned the document's
63
+ * HTML. The scroll is a side effect; the value it wants to report is recorded on
64
+ * `window` instead, where a later read can pick it up without changing what the
65
+ * engine prints.
66
+ *
59
67
  * @param scroll - pixels down (negative scrolls up), or `top` / `bottom`.
60
68
  * @returns a JavaScript expression, or undefined when there is nothing to do.
61
69
  */
@@ -74,26 +82,55 @@ export function scrollScript(scroll) {
74
82
  return undefined;
75
83
  }
76
84
  // Dispatch a scroll event as well as moving: `window.scrollTo` updates the
77
- // position, but a page listening for the event is the reason this exists.
78
- return `(() => { window.scrollTo(0, ${target}); window.dispatchEvent(new Event('scroll')); return String(window.scrollY); })()`;
85
+ // position, but a page listening for the event is the reason this exists. The
86
+ // height is captured on both sides of the move so a caller can tell whether the
87
+ // page grew -- which is the one signal that says lazy loading actually happened.
88
+ return `(() => {
89
+ const before = document.body === null ? 0 : document.body.scrollHeight;
90
+ window.scrollTo(0, ${target});
91
+ window.dispatchEvent(new Event('scroll'));
92
+ window.__dshMoliScroll = {
93
+ requested: ${target},
94
+ y: window.scrollY,
95
+ heightBefore: before,
96
+ heightAfter: document.body === null ? 0 : document.body.scrollHeight,
97
+ };
98
+ })()`;
79
99
  }
80
100
 
81
101
  /**
82
102
  * Combine the caller's `eval` with a scroll, if one was asked for.
83
103
  *
84
- * The two have to share one expression because the engine accepts a single
85
- * `--eval`. The scroll goes first so the page is in its final state before the
86
- * caller's expression reads it, and the caller's value still becomes the result.
104
+ * The two share one expression because the engine accepts a single `--eval`.
105
+ *
106
+ * What gets returned depends on whether anything else will produce the output,
107
+ * and getting this wrong breaks the feature in one direction or the other:
87
108
  *
88
- * @returns the expression to pass, or undefined when neither was given.
109
+ * - **A `dump` was requested.** The dump produces the output, so the expression is
110
+ * there only to scroll first and its value is discarded by the engine anyway.
111
+ * It must still be passed, or the scroll never runs.
112
+ * - **No `dump`, and the caller gave an expression.** The expression value *is* the
113
+ * output, and the scroll runs as part of it.
114
+ * - **No `dump`, no caller expression.** The engine prints whatever `--eval`
115
+ * evaluates to, so passing the scroll expression here would replace the page with
116
+ * the scroll's return value. `undefined` is returned instead, leaving the engine's
117
+ * default output -- the serialized document -- intact. This is the case that
118
+ * shipped broken: `moli_fetch url=... scroll=600` answered `600`.
119
+ *
120
+ * @param evalExpression - the caller's expression, if any.
121
+ * @param scroll - the caller's `scroll` value, if any.
122
+ * @param hasDump - whether the caller also asked for a dump.
123
+ * @returns the expression to pass, or undefined to let the engine decide.
89
124
  */
90
- export function combineEval(evalExpression, scroll) {
125
+ export function combineEval(evalExpression, scroll, hasDump = false) {
91
126
  const move = scrollScript(scroll);
92
- if (move === undefined) return evalExpression;
93
- if (evalExpression === undefined || evalExpression === '') return move;
94
- // The caller's expression is kept last and wrapped so its value is the one the
95
- // engine reports. Losing it would contradict the documented `eval` behaviour.
96
- return `${move}; (() => (${evalExpression}))()`;
127
+ const caller = evalExpression === undefined || evalExpression === '' ? undefined : evalExpression;
128
+ if (move === undefined) return caller;
129
+ // The caller's expression goes last and is wrapped so its value is the one the
130
+ // engine reports; losing it would contradict the documented `eval` behaviour.
131
+ if (caller !== undefined) return `${move}; (() => (${caller}))()`;
132
+ // No caller expression: keep the scroll only when a dump will produce the output.
133
+ return hasDump ? move : undefined;
97
134
  }
98
135
 
99
136
  /**
@@ -334,7 +371,11 @@ export function buildArgs(args) {
334
371
  if (args.scrollbars === true) argv.push('--scrollbars');
335
372
  // `scroll` rides along in the same expression: the engine takes a single `--eval`,
336
373
  // and the scroll has to happen before the page is captured.
337
- const evaluation = combineEval(args.eval, args.scroll);
374
+ // `scroll` rides along in the same expression: the engine takes a single `--eval`,
375
+ // and the scroll has to happen before the page is captured. `hasDump` matters --
376
+ // see combineEval -- because with a dump the expression exists only for its side
377
+ // effect, and without one it would become the output.
378
+ const evaluation = combineEval(args.eval, args.scroll, args.dump !== undefined);
338
379
  if (evaluation !== undefined) argv.push('--eval', evaluation);
339
380
  if (args.wait !== undefined && args.wait !== '') argv.push('--wait', args.wait);
340
381
  if (args.waitSelector !== undefined && args.waitSelector !== '') argv.push('--wait-selector', args.waitSelector);
@@ -561,6 +602,16 @@ export function runFetch(options) {
561
602
  }
562
603
 
563
604
  const text = outcome.stdout.toString('utf8');
605
+ // `eval` together with `dump` is the engine's documented behaviour -- it discards
606
+ // the expression's value and dumps the page state the expression produced -- but
607
+ // it reads as a successful answer to a question that was never answered. The
608
+ // caller asked for `{probe:1}` and got 900 characters of Markdown. The discard is
609
+ // kept, because mutating a page before capturing it is a real use, so the result
610
+ // has to say what happened instead of looking clean.
611
+ const evalDiscarded = args.eval !== undefined && args.eval !== '' && args.dump !== undefined;
612
+ const evalNote = evalDiscarded
613
+ ? 'The `eval` expression produced no value here: `dump` was given too, and the engine discards the expression result in that combination. The output above is the page, not the expression. Run again with `eval` alone for the value, or keep both deliberately when the expression only changes the page before it is captured.'
614
+ : undefined;
564
615
  // A JSON dump is the only format that reports the HTTP status, so an error
565
616
  // page is otherwise indistinguishable from content. Surface it rather than
566
617
  // letting the model read a 404 body as if the fetch succeeded.
@@ -591,7 +642,10 @@ export function runFetch(options) {
591
642
  path: args.writeTo,
592
643
  chars: text.length,
593
644
  bytes,
594
- note: 'Full output saved to file; read it for the complete text.',
645
+ note: evalNote === undefined
646
+ ? 'Full output saved to file; read it for the complete text.'
647
+ : `Full output saved to file; read it for the complete text. ${evalNote}`,
648
+ ...(evalDiscarded ? { evalDiscarded: true } : {}),
595
649
  };
596
650
  }
597
651
 
@@ -617,6 +671,12 @@ export function runFetch(options) {
617
671
  content = text.slice(0, headLen) + mark + text.slice(text.length - tailLen);
618
672
  note = `Output truncated: showing the first ${String(headLen)} and the last ${String(tailLen)} of ${String(text.length)} characters, with ${String(omitted)} omitted in the middle. Pass writeTo to keep all ${String(text.length)}.`;
619
673
  }
674
+ // `eval` together with `dump` is the engine's documented behaviour -- it discards
675
+ // the expression's value and dumps the page state the expression produced -- but
676
+ // it reads as a successful answer to a question that was never answered. The
677
+ // caller asked for `{probe:1}` and got 900 characters of Markdown. The value is
678
+ // still discarded (that combination is genuinely useful for mutating a page before
679
+ // capturing it), so the result has to say so rather than look clean.
620
680
  return {
621
681
  ok: true,
622
682
  url: args.url,
@@ -626,7 +686,11 @@ export function runFetch(options) {
626
686
  chars: text.length,
627
687
  bytes,
628
688
  truncated,
629
- ...(truncated ? { omitted, note } : {}),
689
+ ...(truncated ? { omitted } : {}),
690
+ ...(evalDiscarded ? { evalDiscarded: true } : {}),
691
+ ...(note === undefined && evalNote === undefined
692
+ ? {}
693
+ : { note: [note, evalNote].filter(part => part !== undefined).join(' ') }),
630
694
  };
631
695
  }
632
696
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-moli",
3
- "version": "1.1.14",
3
+ "version": "1.1.16",
4
4
  "type": "module",
5
5
  "description": "DeepSeek Harness tools for the local Moli headless browser: rendered fetches, markdown, semantic trees, screenshots, and PDFs.",
6
6
  "license": "MIT",
@@ -32,14 +32,19 @@
32
32
  "registry": "https://registry.npmjs.org",
33
33
  "access": "public"
34
34
  },
35
+ "dependencies": {
36
+ "@deepseek-ai/schemastery": "^3.18.4"
37
+ },
35
38
  "exports": {
36
39
  ".": "./index.js",
40
+ "./client": "./client.js",
37
41
  "./cordis.patch.yml": "./cordis.patch.yml",
38
42
  "./package.json": "./package.json"
39
43
  },
40
44
  "files": [
41
45
  "index.js",
42
46
  "lib",
47
+ "client.js",
43
48
  "cordis.patch.yml",
44
49
  "README.md",
45
50
  "README.zh.md",
@@ -48,6 +53,9 @@
48
53
  "dsh": {
49
54
  "bundle": {
50
55
  "patch": "./cordis.patch.yml"
56
+ },
57
+ "client": {
58
+ "platform": "web"
51
59
  }
52
60
  }
53
61
  }