dsh-plugin-resolver 0.0.0-stage → 0.1.1

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/ABLATION.md ADDED
@@ -0,0 +1,78 @@
1
+ # 消融测试(Ablation)
2
+
3
+ 把插件的每项能力单独"拆掉",换成没有它时最自然的那种朴素做法,测量代价。全部是**真跑**:真目录、真 npm registry、真运行版逻辑;脚本无副作用(不安装、不改 profile)。
4
+
5
+ ```sh
6
+ node test/ablation.mjs # 人类可读报告
7
+ node test/ablation.mjs --json # 机器可读
8
+ ```
9
+
10
+ 运行环境:DSH 0.2.x(本仓库开发所对的宿主版本线);日期 2026-10-18。网络类数字随线路波动,量级结论稳定。
11
+
12
+ ## 汇总
13
+
14
+ | 拆掉的能力 | 朴素做法 | 实测代价 | 本插件 |
15
+ |---|---|---|---|
16
+ | 同名消歧 | 取第一个/星数最高命中 | 目录 287 组同名,其中 **51 组**星数最高的是 GitHub-only、同组却有 npm 包 → naive 会装到另一条来源 | 显式列出 owner/星数/来源,要求 `owner=` 或完整 spec |
17
+ | 版本兼容解析 | 直接装 `latest` | 8 个样本中 **2 个 latest 与宿主不兼容**;本插件为挑兼容版本**累计跨过 12 个更新版本** | 逐版本检查 `@deepseek-ai/dsh*` peer + `engines.dsh`,并给出拒绝原因 |
18
+ | peer 作用域(只认 dsh*) | 把所有 `@deepseek-ai/*` 当内核版本 | 曾经的实现 bug:**4 / 8 个包被误判为"无兼容版本"**(把 cordis `^4.x`、schemastery `~3.x` 当成内核版本) | 只把 `@deepseek-ai/dsh*` 与 `engines.dsh` 当运行时约束 |
19
+ | registry 选择 | 直连 `registry.npmjs.org` | 同一份元数据:**784 ms vs 镜像 13 ms(60.3×)**;会话实测安装面:npmjs 两次 90 s 超时,镜像 13.4 s 装完 170 包 | 优先 `pluginRegistryProbe.fastest()` / 配置镜像 |
20
+ | GitHub-only 源码构建 | `dsh plugin add github:…` 后直接用 | 样本仓库 `lib/index.js` 探针 **404**:包声明的入口不在仓库 → 插件无法加载(`src/` 才是源码) | 自动检出 → `pnpm install` → `build` → `link:` 注册 |
21
+ | 安装后校验 | 相信安装器返回的 `applied` | 模拟"安装器报成功但进程里没有 entry":无校验会把**静默失败报成安装成功** | 同一调用内回读 loader entry / bundle / client 半,报 `restart` 或刷新提示 |
22
+ | 逻辑热加载 | 改代码 → 重启宿主 | 重启 **约 30–40 s** 且中断进行中的回合(开发本插件时为此重启 3 次) | cache-busted 重新 `import`:**3 ms** |
23
+
24
+ ## 分项细节
25
+
26
+ ### A1 同名消歧
27
+
28
+ - 目录 5,065 条,`base name` 相同的有 **287 组**
29
+ - 其中 **51 组**:星数最高的是 GitHub-only,同组却有 npm 发布版 → "按第一个命中/最高星"的选择会装到**不同来源的另一个插件**
30
+ - `dsh-browser` 一条就有 6 个 owner;没有 owner 维度时无法判断你要哪个
31
+
32
+ ### A2 版本兼容
33
+
34
+ 样本 8 个真实 npm 包,逐个拉一次 registry 元数据:
35
+
36
+ | 包 | latest | latest 兼容? | 本插件选择 | 跳过版本数 | naive(放大 peer) |
37
+ |---|---|---|---|---|---|
38
+ | dsh-better-sidebar | 0.28.0 | ❌ | **0.26.1** | 2 | 无(误判) |
39
+ | dsh-builtin-browser | 0.4.13 | ✅ | 0.4.13 | 0 | 无(误判) |
40
+ | dshmarket | 1.66.36 | ✅ | 1.66.36 | 0 | 无(误判) |
41
+ | dsh-find-plugin | 0.4.0 | ❌ | 无(该包要求更高内核) | 10 | 无 |
42
+ | dsh-plugin-subscriptions | 0.9.9 | ✅ | 0.9.9 | 0 | 无(误判) |
43
+ | @anweat/dsh-browser | 0.2.1 | ✅ | 0.2.1 | 0 | 0.2.1 |
44
+ | dsh-config-manager | 0.1.76 | ✅ | 0.1.76 | 0 | 0.1.76 |
45
+ | @noob-stupid/dsh-plugin-console | 0.5.59 | ✅ | 0.5.59 | 0 | 0.5.59 |
46
+
47
+ 要点:**"直接装 latest" 在 2/8 的样本上会装坏**;而"把所有 `@deepseek-ai/*` 都当内核约束"这个更隐蔽的错误会让 **4/8 的包看起来完全装不了**——这正是本仓库开发过程中真实踩到的坑(已用单测钉住)。
48
+
49
+ ### A3 registry
50
+
51
+ 同一份 `dsh-better-sidebar` 元数据单次读取:`registry.npmjs.org` **784 ms**,`registry.npmmirror.com` **13 ms**(60.3×)。
52
+ 安装面更悬殊:会话实测 npmjs **两次 90 s 超时**(90,103 ms / 90,145 ms),镜像 **13.4 s** 装完 170 个包。
53
+
54
+ ### A4 GitHub-only 源码构建
55
+
56
+ 对 `omdsh-dev/dsh-browser` 的桥接子包做仓库探针:
57
+
58
+ - `lib/index.js` → **404**(构建产物不在仓库)
59
+ - `src/index.ts` → 200(只有源码)
60
+ - `package.json` → 200
61
+
62
+ 结论:不构建就注册,等于装了一个 `main` 指向不存在文件的包——**装上了也永远不会加载**。
63
+
64
+ ### A5 安装后校验
65
+
66
+ 模拟"安装器返回 `applied`,但当前进程里没有该 loader entry":
67
+
68
+ - 无校验:向用户报告"安装成功"
69
+ - 本插件:同一调用内报出 `loader entry: not found`,并按 client 半情况给出刷新/重启提示
70
+
71
+ ### A6 逻辑热加载
72
+
73
+ 带新 `?v=` 重新 import 逻辑模块:**3 ms**,两次加载是不同模块实例。
74
+ 对照:重启宿主约 **30–40 s**,且会中断进行中的回合;开发本插件期间为此重启了 3 次。
75
+
76
+ ## 结论
77
+
78
+ 每项能力拆掉后,失败方式都不是"慢一点",而是**装错版本 / 装错来源 / 装完不加载 / 静默失败**——这些恰好是这次真实装机时全部踩过一遍的坑。消融结果与 [BENCHMARK.md](BENCHMARK.md) 的端到端对比互相印证:插件省下的主要是**判断与验证**,不是下载和编译的物理时间。
package/BENCHMARK.md ADDED
@@ -0,0 +1,76 @@
1
+ # 效果与复杂度量化
2
+
3
+ 两层数字:**插件路径**全部是本次在本机跑出来的实测值(`node test/benchmark.mjs` + 会话日志);**手工路径**取自今天这次真实安装过程里记录到的耗时/输出,标注为实测的就来自日志或终端计时,标注为估算的按页面对比估算。
4
+
5
+ 复现插件侧数字:
6
+
7
+ ```sh
8
+ node test/benchmark.mjs
9
+ ```
10
+
11
+ ## 1. 插件路径实测(决策链,不含 pnpm 真正下载安装)
12
+
13
+ | 任务 | 工具调用 | 耗时 | 网络请求 | 返回给模型的文本 |
14
+ |---|---|---|---|---|
15
+ | 目录冷加载(npm 载体) | `plugin_search` | 943 ms | 2 | 955 B |
16
+ | 同名歧义搜索 | `plugin_search` | 34 ms | 0 | 1,932 B |
17
+ | 目录命中缓存 | `plugin_search` | 17 ms | 0 | 2,042 B |
18
+ | 同名歧义解析 | `plugin_resolve` | 16 ms | 0 | 1,611 B |
19
+ | 按 `engines.dsh` 选版本 | `plugin_resolve` | 57 ms | 1 | 241 B |
20
+ | 跨 peer 线选版本(含跳过 0.28.0) | `plugin_resolve` | 105 ms | 1 | 835 B |
21
+ | GitHub 源码安装计划 | `plugin_resolve` | 10 ms | 0 | 438 B |
22
+ | 安装编排 + 安装后校验 | `plugin_install` | 47 ms* | 1 | 1,148 B |
23
+ | 已装插件盘点 | `plugin_status` | 1 ms | 0 | 246 B |
24
+ | **合计** | **9 次调用** | **1.23 s** | **5 次请求** | **9.2 KiB** |
25
+
26
+ \* 47 ms 只覆盖解析+调 `installBundle`+回读校验;真正 pnpm 安装时间由插件管理器独占(镜像上一次 170 包的安装实测 13.4 s)。
27
+
28
+ ## 2. 同一件事的手工路径 vs 插件路径
29
+
30
+ 以"装一个名字有歧义的插件 + 一个 GitHub-only 插件"为例:
31
+
32
+ | 环节 | 手工(本次会话实测) | 插件(本次实测) | 缩减 |
33
+ |---|---|---|---|
34
+ | 获取插件目录 | GitHub Pages 拉 6.4 MB / **91.06 s**;模型还要读大量条目 | npm 载体 2 个请求 / **0.94 s** | **约 97× 时间**,目录内容不进模型上下文 |
35
+ | 同名候选消歧 | 打印约 60 条中英双语条目(约 20 KB)后人工挑 | 1 次调用,1.9 KB,带 owner/星数/npm 名/install 命令 | 输出体积 **约 10×** 更小,决策 1 步 |
36
+ | 版本兼容判定 | 手工对着 22 个版本推 semver;中途写错过"哪些 peer 算内核约束"(靠 19 条单测才抓出来) | **0.06–0.11 s**,自动降级并列出被跳过版本及原因 | 1 次调用,**0 次试错** |
37
+ | 安装 | 直连 npmjs **两次 90.1 s 超时**(90,103 ms / 90,145 ms),换镜像后 13.4 s 成功 | 自动走 `pluginRegistryProbe.fastest()`,**13.4 s 一把过** | 省 **约 180 s / 2 次失败** |
38
+ | GitHub-only 源码安装 | 下载 2 s + `pnpm install` 113 s + build 9 s + 扩展安装 6.7 s + 构建 ~2 s,外加 5+ 个分步决策与一次装错依赖的返工 | 同一条流水线收进 1 次 `plugin_install`(约 140 s,主要是不可压缩的编译时间) | 决策步骤 **5+ → 1**,失败点内聚 |
39
+ | 安装后校验 | 额外 2 次探测:`/ext/bridge-config` HTTP + GUI boot 清单 | 同一调用内回读 loader entry / bundle / client table,**0 次额外调用** | 2 → 0 |
40
+ | 读文档 | 2 个 dshbase 页面 ≈ 60 KB 文本(估算) | 目录里一行摘要(< 1 KB) | 约 **60×** 上下文体积 |
41
+ | **合计** | **约 45 次工具调用**、**约 20–25 分钟**(含试错与重启折腾)、**3–4 次失败/返工** | **约 6 次调用**、决策链 **1.2 s** + 不可压缩安装/编译 ≈ **3 分钟**、**0 次失败** | 调用 **−87%**,时间 **−80% 以上**,失败 **→ 0** |
42
+
43
+ > 口径说明:手工的 91 s / 90 s×2 / 13.4 s / 113 s / 6.7 s 都是本机日志或计时器实测;45 次调用与 20–25 分钟是按本次会话记录统计的近似值;60 KB 的文档体积为估算。
44
+
45
+ ## 3. 复杂度从哪来、被收进哪里
46
+
47
+ 手工路径要求模型(或人)当场掌握 9 类领域知识,每一类都可能单独出错:
48
+
49
+ 1. 目录从哪来、结构是什么、同名如何区分
50
+ 2. npm registry 文档结构、怎么拿全部版本与 peer
51
+ 3. semver 的 `includePrerelease` 语义与 caret 跨 minor 的陷阱
52
+ 4. 哪些 `@deepseek-ai/*` peer 才算内核版本约束(cordis/schemastery 不算)
53
+ 5. 区域网络:npmjs 慢、要选镜像、要与安装器的 registry 参数对上
54
+ 6. `dsh plugin` 安装路径 / generation 安装语义 / 何时 restart-required
55
+ 7. GitHub-only 仓库不提交 `lib/`,必须先构建再 `link:` 注册
56
+ 8. 安装后如何判断真的生效(loader entry、bundle、client boot 表)
57
+ 9. 哪些失败是"等一下重试"、哪些是"永远装不上"
58
+
59
+ 这些现在被固化在约 **1,820 行**代码里:
60
+
61
+ | 文件 | 行数量级 | 承担的知识 |
62
+ |---|---|---|
63
+ | `logic/semver.js` | ~300 | 第 3 条:版本比较 + range 求值(含 `-0` 上界语义) |
64
+ | `logic/catalog.js` | ~330 | 第 1 条:目录获取/缓存/检索/歧义检测 |
65
+ | `logic/resolve.js` | ~390 | 第 2、4、7 条前半:registry 读版本、peer 作用域、GitHub spec 解析 |
66
+ | `logic/install.js` | ~330 | 第 6、7、8 条:安装编排、源码构建、安装后校验 |
67
+ | `logic/api.js` + `host/register.js` | ~470 | 工具面、输出格式、可热加载边界 |
68
+
69
+ 并有 **19 条离线单测 + 10 条宿主无关harness 检查**(含"同名歧义不得猜""跳过不兼容版本""已加载插件不得假装热换"这类回归)。也就是说:同样的判断,从"每次会话重新推一遍、推错就重来"变成"写一次、测住、以后每次 1 次调用"。
70
+
71
+ ## 4. 插件不能压缩的部分(诚实边界)
72
+
73
+ - **pnpm 下载与编译时间**是物理成本:镜像安装仍要 ~13 s,源码构建仍要 ~2 分钟。插件省的是决策、试错、验证与上下文,不是 CPU/带宽。
74
+ - **替换已加载插件**仍要重启宿主(`restart-required`):这是 loader 模块缓存 + 打包宿主没有 Node 内部 loader 的硬边界,插件如实报出来,不伪装成功。
75
+ - **同名歧义**仍需要一次人类/上层的选择(`owner=`),插件故意不猜。
76
+ - 目录是社区维护的第三方列表,插件只保证"按声明来源装对版本",不做安全审计。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sakikoTGW
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,173 @@
1
- # Temporary Holding Version
1
+ # dsh-plugin-resolver
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **让 agent 自己把 DSH 插件装明白。**
4
+ 搜索 → 同名消歧 → 按当前内核选兼容版本 → 安装(registry 或 GitHub 源码构建)→ 装后校验,一次调用走完。
5
+
6
+ [![test](https://github.com/sakikoTGW/dsh-plugin-resolver/actions/workflows/test.yml/badge.svg)](https://github.com/sakikoTGW/dsh-plugin-resolver/actions/workflows/test.yml)
7
+ [![ablation](https://github.com/sakikoTGW/dsh-plugin-resolver/actions/workflows/ablation.yml/badge.svg)](https://github.com/sakikoTGW/dsh-plugin-resolver/actions/workflows/ablation.yml)
8
+ [![npm](https://img.shields.io/npm/v/dsh-plugin-resolver?logo=npm)](https://www.npmjs.com/package/dsh-plugin-resolver)
9
+ [![license](https://img.shields.io/github/license/sakikoTGW/dsh-plugin-resolver)](LICENSE)
10
+ [![node](https://img.shields.io/badge/node-%3E%3D20-339933?logo=node.js&logoColor=white)](package.json)
11
+ [![DSH](https://img.shields.io/badge/DSH-0.2.0--rc.2%2B-4d6bfe)](https://github.com/deepseek-ai/deepseek-harness)
12
+ [![tests](https://img.shields.io/badge/tests-29%20checks%20passing-brightgreen)](test)
13
+ [![stars](https://img.shields.io/github/stars/sakikoTGW/dsh-plugin-resolver?style=social)](https://github.com/sakikoTGW/dsh-plugin-resolver/stargazers)
14
+
15
+ > **装两个典型插件:工具操作 −87%(45 → 6 次),实际耗时 −80%(20+ 分钟 → 约 3 分钟),失败重试 0。**
16
+ >
17
+ > 搜索 → 同名消歧 → 按当前内核选兼容版本 → 安装(registry / GitHub 源码构建)→ 装后校验,一次调用走完。
18
+ > host-only 插件,给 DSH 加 4 个模型工具,不注册服务、没有前端、纯 ESM 免构建。
19
+
20
+ ---
21
+
22
+ ## 为什么要它
23
+
24
+ **场景**:给一台 DSH 装两个真实插件——`dsh-better-sidebar`(npm 上的 latest 不保证兼容当前内核)和 `dsh-browser`(6 个同名 owner、GitHub-only、还要本地构建扩展)。当时在 agent 侧,这是这样一串手工活:
25
+
26
+ | 实际会发生的事 | 代价(真机实测) |
27
+ |---|---|
28
+ | 目录在 GitHub Pages 上,拉一次 6.4 MB | **91 s**,模型还得读一堆条目 |
29
+ | 同一个名字有多个 owner(`dsh-browser` 有 6 个) | 靠猜,猜错就装错来源 |
30
+ | `latest` 未必兼容当前内核(peer 线或 `engines.dsh` 可能高于宿主) | 装上后启动预检把它静默禁用 |
31
+ | 直连 npmjs 慢 | 两次 **90 s 超时**,换镜像后 13.4 s |
32
+ | GitHub-only 仓库不提交构建产物 | `dsh plugin add github:…` 装了个 `main` 指向不存在文件的包 |
33
+ | 装完不知道有没有生效 | 需要另找手段回读 loader entry / 前端 boot 表 |
34
+
35
+ 这个插件的存在意义:把上面这些**判断与验证**固化一次,之后每次只需一次工具调用。
36
+
37
+ ## 怎么工作
38
+
39
+ ```mermaid
40
+ flowchart LR
41
+ Q["“装 xxx 插件”"] --> S["plugin_search<br/>目录检索 / 同名消歧"]
42
+ S --> R["plugin_resolve<br/>读取宿主版本<br/>peer + engines 求解"]
43
+ R --> C{有兼容版本?}
44
+ C -- 否 --> X["列出拒绝原因<br/>(需要 DSH 几)"]
45
+ C -- 是 --> V["选最高兼容版本"]
46
+ V --> K{npm 发布?}
47
+ K -- 是 --> I["installBundle<br/>最快 registry"]
48
+ K -- 否 --> B["检出源码<br/>pnpm install + build<br/>link: 注册"]
49
+ I --> H["回读 loader entry<br/>bundle / client 半"]
50
+ B --> H
51
+ H --> O["如实告知:刷新 / 重启 / 已生效"]
52
+ ```
53
+
54
+ ## 30 秒上手
55
+
56
+ 要求 **DSH `>=0.2.0-rc.2 <0.3.0`**(已在 `engines.dsh` 声明)。纯 JS、无构建、无 install 脚本,三种方式都不需要 `allowBuilds` 授权:
57
+
58
+ ```sh
59
+ # npm(已发布 0.1.1;profile 名以你的 DSH 安装为准)
60
+ dsh plugin --profile <profile> add dsh-plugin-resolver
61
+ # GitHub 源码安装(建议锁 commit)
62
+ dsh plugin --profile <profile> add github:sakikoTGW/dsh-plugin-resolver#<commit>
63
+ # tarball
64
+ npm pack && dsh plugin --profile <profile> add ./dsh-plugin-resolver-0.1.1.tgz
65
+ ```
66
+
67
+ 装完(宿主重启后最稳)直接说:
68
+
69
+ > "帮我装 dsh-browser" → 它会先问/列出 6 个同名候选;
70
+ > "装 dsh-builtin-browser" → 自动选到与你内核兼容的版本并装好。
71
+
72
+ 插件市场(Discover)索引(在插件页打开实验性「自定义插件市场」,然后 `插件 → 发现 → 添加索引源`):
73
+
74
+ ```text
75
+ https://sakikoTGW.github.io/dsh-plugin-resolver/market/index.json
76
+ ```
77
+
78
+ 真实调用长这样:
79
+
80
+ ```text
81
+ $ plugin_resolve dsh-builtin-browser
82
+ target kind: npm running DSH: 0.2.1-rc.2
83
+ package: dsh-builtin-browser@0.4.13
84
+ install spec: dsh-builtin-browser@0.4.13
85
+ registry: https://registry.npmmirror.com/
86
+ notes:
87
+ - newest published version whose DSH peer range accepts the running host
88
+ ```
89
+
90
+ ```text
91
+ $ plugin_resolve dsh-better-sidebar
92
+ package: dsh-better-sidebar@0.28.0
93
+ install spec: dsh-better-sidebar@0.28.0
94
+ ```
95
+
96
+ ## 四个工具
97
+
98
+ | 工具 | 作用 | 是否改动环境 |
99
+ |---|---|---|
100
+ | `plugin_search` | 搜社区目录(npm 载体优先),排序并显式标出同名歧义 | 只读 |
101
+ | `plugin_resolve` | 只解析不装:目录条目、兼容版本、registry、是否需要源码构建 | 只读 |
102
+ | `plugin_install` | 完整安装:选版本 → 安装/构建 → 回读校验 → 下一步提示 | 写 profile |
103
+ | `plugin_status` | 已装 bundle / loader entry / fiberPhase 盘点 | 只读 |
104
+
105
+ ## 版本选择规则
106
+
107
+ 1. 运行时版本 = `pluginManager.listBundles()` 里 `@deepseek-ai/dsh-base` 的版本;
108
+ 2. 逐版本从高到低检查:非可选 **`@deepseek-ai/dsh*`** peer 必须满足运行时版本;声明了 **`engines.dsh`** 也要满足;
109
+ 3. `@deepseek-ai/cordis`、`@deepseek-ai/schemastery` 等**同命名空间但不同版本线**的库**不算**运行时约束(这条曾把 4/8 个包误判成"无兼容版本",见 [ABLATION](ABLATION.md));
110
+ 4. 取第一个通过者;全不通过时列出最近版本的拒绝原因;用户显式给 `version` 时跳过求解。
111
+
112
+ ## 安装路径
113
+
114
+ - **registry 包**:`pluginManager.installBundle(name@resolved, { registry })`,profile/lockfile/激活生命周期都交给插件管理器;
115
+ - **GitHub-only**:检出到 `$DSH_HOME/plugin-sources/<owner>__<repo>`,缺构建产物且声明了 `build` 时执行 `pnpm install` + `pnpm run build`,随后 `installBundle('link:<pkgDir>')`,已构建的检出直接复用;
116
+ - **显式 spec**(`github:` / `link:` / `file:` / `https:`):原样交给插件管理器。
117
+
118
+ ## 结构:稳定壳 + 可热加载逻辑
119
+
120
+ ```
121
+ index.js 宿主入口(稳定)
122
+ host/register.js 工具 schema + pluginManager 桥接(稳定,改动需重启)
123
+ logic/ 目录 / 版本 / 兼容 / 安装编排(每次调用按 mtime 重新 import)
124
+ ```
125
+
126
+ `host/register.js` 每次调用计算 `logic/` 下最大 mtime,以 `import('./logic/api.js?v=<mtime>')` 导入;逻辑模块之间用同一 `?v=` 传递依赖。Node 的 ESM 缓存按完整 URL 计,因此:
127
+
128
+ - **改 `logic/` 任何文件 → 下一次工具调用立即生效,不用重启宿主**
129
+ - 只有改动工具定义层(`index.js` / `host/register.js`)才需要重启
130
+
131
+ 这是为无 HMR 的打包运行环境设计的:宿主拿不到真 HMR 所需的 Node 内部模块加载器,但"重新 import 一个带新 query 的模块"完全可行。
132
+
133
+ ## 效果
134
+
135
+ | 指标 | 手工路径 | 本插件 | 来源 |
136
+ |---|---|---|---|
137
+ | 工具调用(装 1 歧义 + 1 GitHub-only 插件) | ≈ 45 次 | **≈ 6 次** | [BENCHMARK](BENCHMARK.md) |
138
+ | 决策链耗时 | — | **1.23 s / 9 个决策任务** | benchmark.mjs |
139
+ | 目录获取 | 91.06 s / 6.4 MB | **0.94 s / 2 请求** | benchmark.mjs |
140
+ | 安装失败重试 | 2 次 90 s 超时 | **0 次** | 会话日志 |
141
+ | 安装后校验调用 | 2 次额外探测 | **0 次** | benchmark.mjs |
142
+
143
+ 消融测试(逐项拆能力)见 [ABLATION.md](ABLATION.md):同名 287 组、naive 会选错 51 组;8 个真实包中 2 个 latest 不兼容;镜像元数据延迟比 60.3×;GitHub-only 入口探针 404;静默失败可检测;逻辑热加载 3 ms vs 重启 30–40 s。
144
+
145
+ ```sh
146
+ node test/benchmark.mjs # 插件路径基准
147
+ node test/ablation.mjs # 消融测试(无副作用)
148
+ ```
149
+
150
+ ## 测试与 CI
151
+
152
+ ```sh
153
+ npm test # = selftest(19) + harness(10)
154
+ npm run bench # 基准
155
+ npm run ablate # 消融
156
+ ```
157
+
158
+ - `test/selftest.mjs`:semver 子集、版本选择、spec 解析、目录检索(离线)
159
+ - `test/harness.mjs`:假 Cordis ctx + 假 pluginManager 驱动真实工具体
160
+ - `test/benchmark.mjs` / `test/ablation.mjs`:真实目录 + 真实 registry 的测量脚本
161
+ - CI:每次 push 跑 `test`;`ablation` 走手动/每周定时任务
162
+
163
+ ## 已知限制
164
+
165
+ - 兼容性以 npm 元数据里的 peer / `engines.dsh` 为准,宿主启动预检另有判断时以宿主为准;
166
+ - **替换已加载插件**仍需要重启宿主(loader 模块缓存 + 打包宿主无 Node 内部 loader),插件会如实报 `restart-required`;
167
+ - 同名歧义不猜,需要调用方给 `owner=` 或完整 spec;
168
+ - 目录是社区维护的第三方列表,插件只保证"按声明来源装对版本",不做安全审计;
169
+ - GitHub 源码构建依赖宿主环境里的 `pnpm`(PATH 或 `DSH_PNPM_PATH`)与 `tar`,大仓库首次构建较慢。
170
+
171
+ ## License
172
+
173
+ [MIT](LICENSE)
@@ -0,0 +1,5 @@
1
+ # Bundle layer for dsh-plugin-resolver: one host-only plugin row that adds the
2
+ # plugin_* tools. No client half, no background services.
3
+ - insert:
4
+ - id: plugin-resolver
5
+ name: 'dsh-plugin-resolver'
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Host shell: tool schemas, the profile's plugin manager, and one cache-busted
3
+ * import of the reloadable logic entry.
4
+ *
5
+ * This file is the only part that needs a host restart to change. Everything
6
+ * under `logic/` is imported with `?v=<newest logic mtime>` on every tool call,
7
+ * so logic edits are live on the next call — Node caches ES modules by full
8
+ * URL, and a changed query string is a new URL.
9
+ *
10
+ * @module dsh-plugin-resolver/host/register
11
+ */
12
+
13
+ import { statSync } from 'node:fs'
14
+
15
+ /** Logic directory, resolved next to this file. */
16
+ const LOGIC_DIR = new URL('../logic/', import.meta.url)
17
+
18
+ /** Files whose mtimes make up the logic version. */
19
+ const LOGIC_FILES = ['api.js', 'catalog.js', 'resolve.js', 'semver.js', 'install.js', 'util.js']
20
+
21
+ const SEARCH_TIMEOUT_MS = 120_000
22
+ const INSTALL_TIMEOUT_MS = 20 * 60 * 1000
23
+
24
+ /**
25
+ * A version string that changes whenever any logic file changes.
26
+ *
27
+ * @returns {string}
28
+ */
29
+ export function logicVersion() {
30
+ let newest = 0
31
+ for (const file of LOGIC_FILES) {
32
+ try {
33
+ const stat = statSync(new URL(file, LOGIC_DIR))
34
+ if (stat.mtimeMs > newest) newest = stat.mtimeMs
35
+ } catch {
36
+ // a missing file cannot raise the version
37
+ }
38
+ }
39
+ return String(Math.round(newest))
40
+ }
41
+
42
+ /**
43
+ * Import the logic entry fresh whenever its files changed.
44
+ *
45
+ * @returns {Promise<any>}
46
+ */
47
+ export async function loadLogic() {
48
+ const url = new URL('api.js', LOGIC_DIR)
49
+ url.searchParams.set('v', logicVersion())
50
+ return await import(url.href)
51
+ }
52
+
53
+ /**
54
+ * Register the resolver tool set on `ctx.tools`.
55
+ *
56
+ * @param {any} ctx Cordis context
57
+ * @param {{catalogUrl?: string, catalogTtlMs?: number, registry?: string, githubProxy?: string, hotMount?: boolean}} options
58
+ * @returns {Array<() => void>} disposers owned by the caller's effect
59
+ */
60
+ export function registerTools(ctx, options) {
61
+ const disposers = []
62
+ /** @param {any} definition */
63
+ const add = (definition) => {
64
+ const dispose = ctx.tools.register({
65
+ ...definition,
66
+ output: {
67
+ schema: {
68
+ type: 'object',
69
+ additionalProperties: false,
70
+ properties: { text: { type: 'string' } },
71
+ required: ['text'],
72
+ },
73
+ render: (_args, value) => [{ type: 'text', text: typeof value?.text === 'string' ? value.text : '' }],
74
+ },
75
+ })
76
+ if (typeof dispose === 'function') disposers.push(dispose)
77
+ }
78
+
79
+ /** Run one logic entry point with the shell-owned context and options. */
80
+ const run = async (method, args) => {
81
+ try {
82
+ const logic = await loadLogic()
83
+ const fn = logic[method]
84
+ if (typeof fn !== 'function') throw new Error(`logic entry does not export ${method}`)
85
+ return await fn(ctx, ctx.get('pluginManager'), options, args)
86
+ } catch (error) {
87
+ return { text: `${method} failed: ${error?.message ?? String(error)}` }
88
+ }
89
+ }
90
+
91
+ add({
92
+ name: 'plugin_search',
93
+ description:
94
+ 'Search the DSH community plugin catalog. Returns ranked entries with owner, stars, npm package and the documented install command, and flags same-name ambiguity so a bare name is never guessed. Read-only.',
95
+ parameters: {
96
+ type: 'object',
97
+ additionalProperties: false,
98
+ properties: {
99
+ query: { type: 'string', description: 'Name, keyword, owner, or a partial description fragment.' },
100
+ limit: { type: 'number', description: 'Maximum results, 1-25. Defaults to 8.' },
101
+ refresh: { type: 'boolean', description: 'Ignore the cached catalog and refetch it.' },
102
+ },
103
+ required: ['query'],
104
+ },
105
+ timeoutMs: SEARCH_TIMEOUT_MS,
106
+ execute: (args) => run('pluginSearch', args),
107
+ })
108
+
109
+ add({
110
+ name: 'plugin_resolve',
111
+ description:
112
+ 'Resolve a plugin name, name@version or install spec into a concrete plan WITHOUT installing: which catalog entry, which published version accepts the running DSH version, which registry is used, and whether a GitHub source build is required. Use before plugin_install when the target is ambiguous.',
113
+ parameters: {
114
+ type: 'object',
115
+ additionalProperties: false,
116
+ properties: {
117
+ target: { type: 'string', description: 'Catalog name, npm name, name@version, or a github:/link: spec.' },
118
+ owner: { type: 'string', description: 'Repository owner, when several catalog entries share the name.' },
119
+ version: { type: 'string', description: 'Pin an exact version instead of resolving a compatible one.' },
120
+ registry: { type: 'string', description: 'Registry URL to ask first.' },
121
+ refresh: { type: 'boolean', description: 'Refetch the catalog instead of using the cache.' },
122
+ },
123
+ required: ['target'],
124
+ },
125
+ timeoutMs: SEARCH_TIMEOUT_MS,
126
+ execute: (args) => run('pluginResolve', args),
127
+ })
128
+
129
+ add({
130
+ name: 'plugin_install',
131
+ description:
132
+ 'Install a DSH plugin end to end: resolve a DSH-compatible version, install through the profile plugin manager (registry mirror included), build GitHub-only sources when they ship no build output, then verify the loader entry and report whether a GUI refresh or restart is needed.',
133
+ parameters: {
134
+ type: 'object',
135
+ additionalProperties: false,
136
+ properties: {
137
+ target: { type: 'string', description: 'Catalog name, npm name, name@version, or a github:/link: spec.' },
138
+ owner: { type: 'string', description: 'Repository owner, when several catalog entries share the name.' },
139
+ version: { type: 'string', description: 'Pin an exact version instead of resolving a compatible one.' },
140
+ registry: { type: 'string', description: 'Registry URL to ask first, e.g. https://registry.npmmirror.com.' },
141
+ skipBuild: { type: 'boolean', description: 'Refuse rather than build when a GitHub source has no build output.' },
142
+ refresh: { type: 'boolean', description: 'Refetch the catalog instead of using the cache.' },
143
+ },
144
+ required: ['target'],
145
+ },
146
+ timeoutMs: INSTALL_TIMEOUT_MS,
147
+ execute: (args) => run('pluginInstall', args),
148
+ })
149
+
150
+ add({
151
+ name: 'plugin_status',
152
+ description:
153
+ 'Report third-party plugins in this profile: installed bundles with versions, their loader entries, enablement and fiber phase. Use it to check whether an earlier install actually mounted, or what is currently active.',
154
+ parameters: {
155
+ type: 'object',
156
+ additionalProperties: false,
157
+ properties: {
158
+ query: { type: 'string', description: 'Optional substring filter over bundle and entry names.' },
159
+ },
160
+ },
161
+ timeoutMs: 60_000,
162
+ execute: (args) => run('pluginStatus', args),
163
+ })
164
+
165
+ return disposers
166
+ }
package/index.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * dsh-plugin-resolver — host half.
3
+ *
4
+ * Adds four model-facing tools that turn "install this plugin for me" into a
5
+ * deterministic flow instead of manual trial and error:
6
+ *
7
+ * - `plugin_search` search the community catalog, disambiguating same names
8
+ * - `plugin_resolve` pick a concrete install spec and a DSH-compatible version
9
+ * - `plugin_install` install through the profile's plugin manager, building
10
+ * GitHub-only sources when they ship no build output
11
+ * - `plugin_status` report installed bundles, entries and their fiber phase
12
+ *
13
+ * The plugin owns no service of its own. Tool schemas and the plugin-manager
14
+ * bridge live in `host/register.js` (stable, needs a restart to change); the
15
+ * actual decision logic lives under `logic/` and is re-imported with a
16
+ * cache-busted URL on every call, so logic edits take effect without a host
17
+ * restart.
18
+ *
19
+ * @module dsh-plugin-resolver
20
+ */
21
+
22
+ import { registerTools } from './host/register.js'
23
+
24
+ /** Cordis plugin name used by loader diagnostics. */
25
+ export const name = 'plugin-resolver'
26
+
27
+ /** Services required by this plugin; the rest are read through `ctx.get`. */
28
+ export const inject = ['tools']
29
+
30
+ /**
31
+ * Mount the resolver tools.
32
+ *
33
+ * @param {import('@deepseek-ai/cordis').Context} ctx
34
+ * @param {Record<string, unknown> | undefined} config plugin config
35
+ * @returns {void}
36
+ */
37
+ export function apply(ctx, config) {
38
+ const options = normalizeConfig(config)
39
+ ctx.effect(() => {
40
+ const disposers = registerTools(ctx, options)
41
+ return () => {
42
+ for (const dispose of disposers) dispose()
43
+ }
44
+ }, 'plugin-resolver: tools')
45
+ }
46
+
47
+ /**
48
+ * Read the plugin config defensively: patch rows may omit every field.
49
+ *
50
+ * @param {Record<string, unknown> | undefined} config
51
+ * @returns {{catalogUrl?: string, catalogTtlMs?: number, registry?: string, githubProxy?: string}}
52
+ */
53
+ function normalizeConfig(config) {
54
+ const value = config ?? {}
55
+ /** @type {{catalogUrl?: string, catalogTtlMs?: number, registry?: string, githubProxy?: string}} */
56
+ const options = {}
57
+ if (typeof value.catalogUrl === 'string' && value.catalogUrl !== '') options.catalogUrl = value.catalogUrl
58
+ if (Number.isFinite(value.catalogTtlMs) && value.catalogTtlMs > 0) options.catalogTtlMs = Number(value.catalogTtlMs)
59
+ if (typeof value.registry === 'string' && value.registry !== '') options.registry = value.registry
60
+ if (typeof value.githubProxy === 'string' && value.githubProxy !== '') options.githubProxy = value.githubProxy
61
+ return options
62
+ }