dsh-molbio-tools 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,78 @@
10
10
  改为按**包名**引用后模块缓存问题随之消失——没有拷贝,也就没有需要保持同步的版本目录。
11
11
  下文历史条目里出现的 `vN` 目录保持原样,作为当时的记录。
12
12
 
13
+ ## [0.22.0] — 2026-10-25(DSH 0.2.1-rc.3 适配;PubMed 官方 API + NCBI API key)
14
+
15
+ **工具数不变(60)。新增一条通路:`molbio_pubmed_search` / `molbio_pubmed_abstract` 可以带着
16
+ NCBI API key 走 PubMed 官方 E-utilities。** 没有 key 时两条工具的行为与 0.21.1 相同——检索仍走
17
+ harness 的 web 检索,摘要仍走 `ctx.web.fetch`。
18
+
19
+ ### 1. 为什么要有官方通路
20
+
21
+ 原来的检索完全依赖 harness 的 web 检索服务:它返回的是**搜索引擎索引到的页面**,不是 PubMed 的
22
+ 题录。三个具体代价:命中的 PMID 要从 URL 里猜、拿不到期刊/年份/作者/DOI 这些结构化字段、也
23
+ 无法用 NCBI 的 API key 提升速率上限(无 key 3 次/秒,有 key 10 次/秒)。官方 API 是两步:
24
+ `esearch`(term → PMID 列表)→ `esummary`(PMID → 题录),摘要另走 `efetch`。
25
+
26
+ ### 2. 通路选择与 key 的来源
27
+
28
+ | 条件 | 检索通路 | 结果字段 |
29
+ | --- | --- | --- |
30
+ | 有 key(参数或环境变量) | 官方 E-utilities:esearch + esummary | PMID、标题、期刊、年份、作者、DOI,另含 `total_matches` |
31
+ | 无 key | 原来的 harness web 检索(行为不变) | 标题、URL、snippet、从 URL 提取的 PMID |
32
+
33
+ key 的优先级是 **`api_key` 参数 > `NCBI_API_KEY` > `MOLBIO_NCBI_API_KEY`**;空白值视为未提供
34
+ (不会发出 `api_key=` 这种被 NCBI 以 400 拒绝的空参数)。一次带 key 的检索是 **2 个请求**,
35
+ `molbio_pubmed_abstract` 是 1 个 efetch 请求。
36
+
37
+ > **内联传 key 会出现在对话记录和工具调用卡片里**,这是参数通路的固有代价;长期使用应设环境
38
+ > 变量。工具的输出、渲染和错误信息都不回显 key。
39
+
40
+ ### 3. 实现
41
+
42
+ - `papers.mjs` 增加纯函数:`pubmedApiKey`(参数/环境变量优先级)、`pubmedSearchUrl` /
43
+ `pubmedSummaryUrl` / `pubmedFetchUrl`(三个 URL 构造器,统一带 `tool=dsh-molbio-tools`)、
44
+ `parseEsearch`(ids + count)、`parseEsummaryRecords`(按 `uids` 顺序归一化题录,
45
+ DOI 从 `articleids` 里取 `idtype: doi`)、`esummarySource`(转成工具返回的 source 形状)。
46
+ - 传输仍走 `ctx.web.fetch`:官方 API 不是一条绕开沙箱/同意机制的新网络通路。没有 `web.fetch`
47
+ 而用户又给了 key 时,工具明确报「需要 ctx.web.fetch」,而不是悄悄退回 web 检索。
48
+ - 错误处理:NCBI 的 `{"error":"API key invalid"}` 会变成 `PubMed E-utilities error: API key
49
+ invalid`,**不会被当成「没有结果」**——「搜不到」和「key 错了」是两件事。空结果返回
50
+ `sources: []` 与 `total_matches: 0`。
51
+ - 输出 schema 扩展:`backend`(`pubmed-eutils` / `web-search`)、`total_matches`,以及 source 上的
52
+ `doi` / `authors` / `journal` / `year`;全部为可选字段,旧调用的输出形状不变。
53
+
54
+ ### 4. rc.3 适配:这次上游什么都没动,但仍要走完流程
55
+
56
+ - `@deepseek-ai/dsh-web-app@0.2.1-rc.3` 的 `standard.patch.yml` 与 rc.2 **逐字节一致**(`git diff
57
+ --no-index` 验证),所以 preset 行清单、`cordis.patch.yml`、`preset-health` 的期望都没有改。
58
+ - 在 rc.3 上全套守卫全绿:`preset-health`(30 行)、`drift-probe`(14 条突变)、`contract`(16 项,
59
+ 含客户端入口与 `presentationMeta` 的行为断言)、`benchmark-profile`(12 行 / 60 工具)。
60
+ - 声明:`engines.dsh` 仍为 `>=0.2.1-rc.2 <0.3.0-0`(rc.2 仍可用);`dshReleases` 把 rc.3 改成
61
+ `compatible`;CI 的 test/release 两条工作流钉到 `0.2.1-rc.3`;面板包 `0.1.4 → 0.1.5`(清单的
62
+ 兼容声明变了,客户端产物字节未变,因此只 bump 面板版本线)。
63
+ - 证据用 rc.3 的 CLI 重跑:`docs/store-evidence.md`(0.22.0 tarball 安装 → 启动 → 卸载)。
64
+
65
+ ### 5. benchmark 的处理(以及为什么没有重跑模型)
66
+
67
+ - 默认通路(无 key)的输出与 0.21.1 相同,`bench --offline` 的 61 题期望值逐条照跑。
68
+ - `literature-search` 仍是 `NETWORK_TASKS` 里唯一的一条:它断言的是模型的最终回答(PMID、标题、
69
+ 摘要措辞),工具结果本来就是**当天的 live PubMed 数据**,没有可以钉住的值。
70
+ - **带 key 的新通路由 `test/smoke.mjs` 覆盖,而不是 benchmark**:benchmark 不能要求真实 key、
71
+ 也不该把 live 记录冻结成期望值。smoke 用 mock 的 esearch/esummary/efetch 断言了四件事——
72
+ key 同时出现在两个请求里、题录字段解析正确、环境变量能回退、无效 key 会报错;另有一条
73
+ 「无 key 时不碰 E-utilities 传输」的对照。
74
+
75
+ ### 6. 版本与验证
76
+
77
+ - 根包 `0.21.1 → 0.22.0`;面板 `0.1.4 → 0.1.5`。
78
+ - `npm test`(DSH 0.2.1-rc.3)全绿:smoke、画面四套、客户端四套、contract 16、drift-probe 14 条
79
+ 突变、preset-health 30 行、release 13 项、benchmark 四道守卫 + `bench --offline` 61 题。
80
+ - `node build/preset-patch.mjs --check` 与 `node build/client-bundle.mjs --check` 均报告与源一致
81
+ (rc.3 适配没有动任何生成物)。
82
+ - 一次性 Profile:tarball **115 个文件 / 约 1.0 MB**;安装 571 ms;启动 `HTTP 200`、38180 字节,
83
+ `__DSH_BOOT__` 含 `dsh-molbio-tools/client.js&rev=a25c3919676b`;卸载 421 ms,`molbio` 引用 0、
84
+ lockfile 与 `node_modules` 无残留。
13
85
  ## [0.21.1] — 2026-10-25(DSH STORE 提交前置:一个 patch、逐版本兼容声明、一个 tarball 一个清单)
14
86
 
15
87
  **工具数不变(60),插件运行时行为零改动。** 这一版不加任何计算能力,只把"这个包以什么形状提交"
package/README.md CHANGED
@@ -224,8 +224,8 @@ dsh plugin --profile <profile> add dsh-molbio-panel
224
224
 
225
225
  | 工具 | 用途 |
226
226
  | --- | --- |
227
- | `molbio_pubmed_search` | 通过 harness 的 web 检索服务搜文献,自动提取 PMID |
228
- | `molbio_pubmed_abstract` | 按 PMID 拉取摘要(依赖部署提供 web fetch 能力) |
227
+ | `molbio_pubmed_search` | 搜 PubMed 文献。提供 NCBI API key(`api_key` 参数,或环境变量 `NCBI_API_KEY` / `MOLBIO_NCBI_API_KEY`)时走**官方 E-utilities**(esearch + esummary),返回 PMID/标题/期刊/年份/作者/DOI;无 key 时回退到 harness 的 web 检索 |
228
+ | `molbio_pubmed_abstract` | 按 PMID 拉取摘要(efetch;同样支持 NCBI API key,依赖部署提供 web fetch 能力) |
229
229
  | `molbio_paper_add` / `molbio_paper_list` / `molbio_paper_update` / `molbio_paper_remove` | 阅读库(工作区 `papers.json`):加/列/改/删,按 PMID→URL→标题+年份去重 |
230
230
  | `molbio_paper_export_bibtex` | `papers.json` → BibTeX `.bib`(可按 tag 过滤) |
231
231
  | `molbio_protocol_add` / `molbio_protocol_list` / `molbio_protocol_update` | 协议库(`protocols.json`):步骤列表、自由参数、来源文献 |
@@ -556,6 +556,11 @@ mutations remain deferred")。所以插件无法在工作区里合法地落一
556
556
  ### 文献与记录
557
557
 
558
558
  - 检索与摘要走 harness 的 **web** 服务(部署不提供时工具会明确报错,而不是假装搜到)。
559
+ - 提供 NCBI API key 时,`molbio_pubmed_search` 用官方 E-utilities:esearch 拿 PMID、esummary
560
+ 拿题录(标题/期刊/年份/作者/DOI),`molbio_pubmed_abstract` 用 efetch 拿摘要。key 的来源优先级是
561
+ `api_key` 参数 > `NCBI_API_KEY` > `MOLBIO_NCBI_API_KEY`;**长期使用请用环境变量**,内联传参会让 key
562
+ 出现在对话记录与工具卡片里。NCBI 的速率上限是**无 key 3 次/秒、有 key 10 次/秒**,一次带 key 的
563
+ 检索是 2 个请求(esearch + esummary)。没有 key 时检索仍走原来的 web 检索通路,行为不变。
559
564
  - 阅读库/协议库/实验日志是工作区里的 `papers.json` / `protocols.json` / `experiments.json`,
560
565
  写入遵循会话的**沙箱政策**,与其它文件工具同权;面板(Papers tab)直接读 `papers.json`。
561
566
  - `molbio_paper_export_bibtex` 可选按 tag 过滤导出。
@@ -649,6 +654,12 @@ node <包目录>\preset\install.mjs --profile web --check # 只报告,不组
649
654
  工具会返回 `notes` 说明放宽哪个约束(Tm 窗口、GC 窗口、扩增子长度、探针长度…)。通常是把
650
655
  `amplicon_min/max` 或 `tm_min/max` 放宽一点即可。
651
656
 
657
+ **PubMed 的 API key 怎么配?**
658
+ 在启动 DSH 的环境里设 `NCBI_API_KEY`(或 `MOLBIO_NCBI_API_KEY`),插件会自动把它带进 E-utilities 请求;
659
+ 也可以在单次调用里传 `api_key`。有 key 时 `molbio_pubmed_search` 走官方 API(PubMed 自己的题录,
660
+ 含 DOI),没有 key 时仍走 harness 的 web 检索。注意:**内联传的 key 会出现在对话记录和工具调用卡片里**,
661
+ 长期用请设环境变量。
662
+
652
663
  **"帮我把这段序列分析一下" 它却问我要参数?**
653
664
  把目标说具体即可:要引物还是探针、产物多长、Tm 多少、哪个区域。工具默认值都是常规实验条件
654
665
  (例如引物 18–28 nt / Tm 55–65 °C / GC 40–60%),不特别说明时按默认来。
@@ -13,7 +13,11 @@
13
13
  "whatever PubMed answers today and there is no recorded value to pin. Its",
14
14
  "assertions are therefore all `where: \"answer\"` and structural — a PMID",
15
15
  "appears, a title appears — and `benchmark/verifications.mjs` deliberately",
16
- "declares no offline invocation for it. See NETWORK_TASKS there."
16
+ "declares no offline invocation for it. See NETWORK_TASKS there.",
17
+ "",
18
+ "The 0.22.0 NCBI-API-key path is NOT exercised here: a benchmark cannot",
19
+ "require a real key or pin live PubMed records. test/smoke.mjs covers it with",
20
+ "mocked esearch/esummary/efetch responses (including the invalid-key error)."
17
21
  ],
18
22
  "tasks": [
19
23
  {
@@ -402,5 +402,10 @@ export function invocationsFor(task) {
402
402
  * answer (structural facts: a PMID appears, a title appears), which is why this
403
403
  * module returns no invocations for them and why the offline gate tolerates that
404
404
  * specific case while failing every other task that forgets to declare one.
405
+ *
406
+ * The NCBI-API-key path added in 0.22.0 is covered by `test/smoke.mjs` instead:
407
+ * a keyed call is two E-utilities requests (esearch + esummary) whose responses
408
+ * the mock returns, including the invalid-key error. A benchmark must not need a
409
+ * real NCBI key or pin live PubMed data, so this task stays network-only.
405
410
  */
406
411
  export const NETWORK_TASKS = new Set(['literature-search']);
@@ -201,7 +201,7 @@ export function tagVersion(tag) {
201
201
  * `rc.10 < rc.2`, so a release of `rc.2` after `rc.10` would look like a
202
202
  * DOWNGRADE and the gate would refuse a legitimate publish (or, in the other
203
203
  * direction, wave through a stale one). This repository does cut prereleases
204
- * (the harness baseline itself is `0.2.1-rc.2`), so the rule is implemented
204
+ * (the harness baseline itself is `0.2.1-rc.3`), so the rule is implemented
205
205
  * rather than approximated.
206
206
  *
207
207
  * Semver's rule: split on dots; numeric identifiers compare numerically and rank
package/docs/history.md CHANGED
@@ -16,6 +16,7 @@
16
16
 
17
17
  | 包版本 | preset 版本 | 内容 | 工具数 |
18
18
  | --- | --- | --- | --- |
19
+ | 0.22.0 | — | **DSH 0.2.1-rc.3 适配 + PubMed 官方 API**:`molbio_pubmed_search`/`_abstract` 支持 NCBI API key(E-utilities),无 key 行为不变 | 60 |
19
20
  | 0.21.1 | — | **DSH STORE 提交前置**:单文件 bundle patch、逐版本兼容声明;根 tarball 只带一个插件清单(工具行为零变化) | 60 |
20
21
  | 0.21.0 | — | **DSH 0.2.1-rc.2 适配**:preset 行清单逐行重抄;contract 两条断言重锚到行为;两条 suspended 记录用真实模型重录 | 60 |
21
22
  | 0.20.1 | — | 插件专属图标(透明底双螺旋 SVG),运行时零改动 | 60 |
@@ -44,6 +45,30 @@
44
45
 
45
46
  ---
46
47
 
48
+ ## v0.22.0(2026-10-25,包 0.22.0)
49
+
50
+ 一半是适配(rc.3),一半是新能力(PubMed 官方 API + key)。三条教训。
51
+
52
+ **(1) 「上游没变」不是可以跳过流程的理由,而是一个需要被证明的结论。**
53
+ rc.3 的 `standard.patch.yml` 与 rc.2 逐字节相同,所以 preset 行、生成物、`preset-health` 的期望
54
+ 一行都没动——但这是先 diff 过、再把 `preset-health` / `drift-probe` / `contract` /
55
+ `benchmark-profile` 全套在 rc.3 上跑绿之后才敢下的结论。上一次(0.21.0)同一个文件在补丁级版本里
56
+ 改了 12 处结构;把「这次应该没事」当选版本的理由,就是在赌下一次。
57
+
58
+ **(2) 官方 API 不是「另一条网络通路」,它仍然是一次 `ctx.web.fetch`。**
59
+ PubMed E-utilities 的加入没有引入 `fetch` 依赖,也没有绕开沙箱/同意机制:工具只是把 URL 构造成
60
+ esearch/esummary/efetch,再交给同一个 `ctx.web` 缝。两个细节必须做对:**空结果与鉴权失败是两件
61
+ 事**(`{"error":"API key invalid"}` 不能渲染成「没有搜到」);**key 的优先级要可预测**
62
+ (参数 > `NCBI_API_KEY` > `MOLBIO_NCBI_API_KEY`,空白值视为缺席)。另外,内联传 key 会让它出现在
63
+ 对话记录与工具卡片里——这是参数通路的固有代价,所以文档把「长期使用请设环境变量」写在工具描述里,
64
+ 而不是只写在 README。
65
+
66
+ **(3) 需要真实凭证/实时数据的路径,归 smoke,不归 benchmark。**
67
+ benchmark 的 `literature-search` 一直是 `NETWORK_TASKS`:它只断言最终回答的结构,因为工具返回的
68
+ 是当天的 PubMed 数据,没有可冻结的值。带 key 的新通路同样不能被 benchmark 钉住(既不能要求一个
69
+ 真 key,也不该把 live 记录写进 fixture),于是用 mock 的 esearch/esummary/efetch 在 `test/smoke.mjs`
70
+ 里断言「key 真的进了两个请求」「字段解析正确」「环境变量能回退」「坏 key 会报错」,并保留一条
71
+ 「无 key 时不碰 E-utilities」的对照——benchmark 负责模型会不会用,smoke 负责这条分支本身对不对。
47
72
  ## v0.21.1(2026-10-25,包 0.21.1)
48
73
 
49
74
  上架前置版:一个工具都没改,只改「提交的形状」。三条教训都关于**契约不止一份**。
@@ -32,7 +32,7 @@
32
32
 
33
33
  - **包**:`dsh-molbio-tools`,零依赖 DSH 插件包,**60 个 `molbio_*` 工具**(其中
34
34
  `molbio_plot` 一个工具承载 8 种图表)。
35
- - **基线**:DSH **0.2.1-rc.2**;`package.json` 版本 0.21.1。
35
+ - **基线**:DSH **0.2.1-rc.3**;`package.json` 版本 0.22.0。
36
36
  - **两条分发渠道**:根包(工具 + 面板)与 `packages/molbio-panel`(只面板),
37
37
  **两个独立的 npm 条目、两条独立的版本线**,必须分别发布。
38
38
  - **工具只在专属模式里出现**(设计如此):bundle 的 `cordis.patch.yml` 只插一行**惰性宿主锚点**
package/docs/roadmap.md CHANGED
@@ -10,12 +10,12 @@ L = 需要新机制或外部资源)。
10
10
 
11
11
  ---
12
12
 
13
- ## 1. 当前状态(0.21.1)
13
+ ## 1. 当前状态(0.22.0)
14
14
 
15
15
  - 60 个 `molbio_*` 工具,零依赖,随 preset 分发(见 [README](../README.md))。
16
16
  - 离线测试金字塔 + 组合漂移守卫全绿;`benchmark/` 提供**模型使用**维度的评估
17
17
  (见 [benchmark/README.md](../benchmark/README.md))。
18
- - 基线:DSH **0.2.1-rc.2**。
18
+ - 基线:DSH **0.2.1-rc.3**。
19
19
  - **CI 已在位**(`.github/workflows/test.yml`,0.18.2 起):push / PR / 手动都跑全套;harness 装
20
20
  **钉死版本**,并用 `npm prefix -g` 显式导出 `DSH_HARNESS_ROOT`。0.18.4 起**同时跑
21
21
  `ubuntu-latest` 与 `windows-latest`**——3.6 已修,套件不再依赖宿主分隔符。
@@ -37,6 +37,10 @@ L = 需要新机制或外部资源)。
37
37
  `dshReleases`、根 tarball 去掉 `packages/`(一个包只带一个插件清单)。剩下的两条预检规则
38
38
  不是改包能解决的,证据与请求见 [store-preset-exemption.md](store-preset-exemption.md)。
39
39
  下一版若要动工具,按本文件第 2 节挑候选;若只等商城结论,则不必发版。
40
+ - **0.22.0 是 rc.3 适配 + 文献检索升级**:DSH 基线 0.2.1-rc.2 → 0.2.1-rc.3(上游
41
+ `standard` 行清单**逐字节未变**,`preset-health` / `drift-probe` / `contract` 在 rc.3 上
42
+ 全绿,因此这一半只改声明与证据);`molbio_pubmed_search` / `molbio_pubmed_abstract` 增加
43
+ NCBI API key 通路,走官方 E-utilities(esearch + esummary),无 key 时保持原有 web 检索。
40
44
 
41
45
  ---
42
46
 
@@ -18,7 +18,7 @@
18
18
  "dshReleases": {
19
19
  "0.2.1-rc.1": "unknown",
20
20
  "0.2.1-rc.2": "compatible",
21
- "0.2.1-rc.3": "unknown"
21
+ "0.2.1-rc.3": "compatible"
22
22
  }
23
23
  }
24
24
  }
@@ -29,19 +29,19 @@
29
29
  `@deepseek-ai/dsh-app-boot` 的 `unmetRequirements`):带 prerelease 选项时 `<0.3.0`
30
30
  会放行 `0.3.0-alpha.1`,而 `-0` 不会。
31
31
 
32
- ### 为什么 rc.2 是 compatible、rc.1 / rc.3 是 unknown
32
+ ### 为什么 rc.2 / rc.3 是 compatible、rc.1 是 unknown
33
33
 
34
34
  | 版本 | 记录 | 依据 |
35
35
  | --- | --- | --- |
36
36
  | 0.2.1-rc.1 | unknown | 早于本包基线(0.20.1 钉 `0.2.0-rc.2`),未在此版本上运行过;同时也不在 `engines.dsh` 范围内 |
37
- | 0.2.1-rc.2 | compatible | CI 基线固定该版本;本文件第 3 节的一次性 Profile 流程;`contract` / `preset-health` / `smoke` 均通过 |
38
- | 0.2.1-rc.3 | unknown | 未获取到该版本,未验证。`engines.dsh` 的补丁级范围包含它,但**是否授权安装以商城的 `dshReleases` 记录为准**——范围不等于验收 |
37
+ | 0.2.1-rc.2 | compatible | 0.21.0 / 0.21.1 的发布基线;本文件第 3 节的一次性 Profile 流程在 rc.2 上跑过;`contract` / `preset-health` / `smoke` 均通过 |
38
+ | 0.2.1-rc.3 | compatible | 0.22.0 的发布基线(CI 钉该版本);同一套一次性 Profile 流程在 rc.3 上重跑通过(安装 → 启动 HTTP 200 → 卸载无残留);`contract` / `preset-health` / `drift-probe` / `benchmark-profile` / `smoke` 在 rc.3 上全绿。上游 `standard` 行清单与 rc.2 **逐字节一致** |
39
39
 
40
40
  ## 2. 环境
41
41
 
42
42
  | 项 | 值 |
43
43
  | --- | --- |
44
- | DSH | `0.2.1-rc.2` |
44
+ | DSH | `0.2.1-rc.3` |
45
45
  | Node.js | `v26.12.1` |
46
46
  | pnpm | `11.22.0` |
47
47
  | 平台 | Windows(win32 x64) |
@@ -52,9 +52,9 @@
52
52
 
53
53
  ```bash
54
54
  export DSH_HOME=<workspace>/.tmp/dsh-store-check
55
- npm pack --pack-destination "$DSH_HOME" # 得到 dsh-molbio-tools-0.21.1.tgz
55
+ npm pack --pack-destination "$DSH_HOME" # 得到 dsh-molbio-tools-0.22.0.tgz
56
56
  dsh --profile molbio-tgz-check --from-default-profile web --dump-config
57
- dsh plugin --profile molbio-tgz-check add "$DSH_HOME/dsh-molbio-tools-0.21.1.tgz"
57
+ dsh plugin --profile molbio-tgz-check add "$DSH_HOME/dsh-molbio-tools-0.22.0.tgz"
58
58
  dsh --profile molbio-tgz-check --dump-config # 安装后的组合树
59
59
  dsh --profile molbio-tgz-check --no-open --port 0 # 启动,打印 URL
60
60
  curl "http://127.0.0.1:<port>/?token=<token>" # 读回页面
@@ -65,20 +65,20 @@ dsh --profile molbio-tgz-check --dump-config # 卸载后的组合树
65
65
  安装用 **tarball** 而不是目录,因为 tarball 就是发布物本身:它能同时证明 `files`
66
66
  白名单带全了运行文件(`host.mjs`、`lib/client.js`、单个 `cordis.patch.yml` 的两个 insert、preset)。
67
67
 
68
- tarball 是 **115 个文件 / 约 999 kB**(`npm pack --dry-run` 实测约 999.3 kB,解包约 3.2 MB;再改文档会让
69
- 字节数在 1 kB 内浮动,文件数不变),且 `package/packages/**` 为 **0**:根包不再内嵌
68
+ tarball 是 **115 个文件 / 约 1.0 MB**(`npm pack` 实测 1,004,017 B,解包约 3.2 MB;再改文档会让字节数
69
+ 小幅浮动,文件数不变),且 `package/packages/**` 为 **0**:根包不再内嵌
70
70
  `packages/molbio-panel`,一个 tarball 里只有一个插件清单(面板包按自己的地址单独发布)。
71
71
  面板本身没有因此消失——根包的 `lib/client.js` 就带着它,第 3.2 节的回读可以作证。
72
72
 
73
- 下面这次是在 **0.21.1 的 tarball** 上重跑的(2026-10-25),文件数与发布物一致;
74
- 0.21.0 时代的记录只差版本号与文件数,流程未变。
73
+ 下面这次是在 **0.22.0 的 tarball** 上、用 **dsh 0.2.1-rc.3** 的 CLI 重跑的(2026-10-25),
74
+ 文件数与发布物一致;更早的记录只差版本号与文件数,流程未变。
75
75
 
76
76
  ### 3.1 安装
77
77
 
78
78
  ```
79
- + dsh-molbio-tools file:D:/.../dsh-molbio-tools-0.21.1.tgz
79
+ + dsh-molbio-tools file:D:/.../dsh-molbio-tools-0.22.0.tgz
80
80
  Packages: +1
81
- Done in 631ms
81
+ Done in 571ms
82
82
  ```
83
83
 
84
84
  安装后的 `--dump-config` 出现 bundle 层,且 `host.mjs` 解析到**已安装包内**:
@@ -98,19 +98,19 @@ Done in 631ms
98
98
  ### 3.2 启动
99
99
 
100
100
  ```
101
- dsh web: http://127.0.0.1:61834/?token=...
101
+ dsh web: http://127.0.0.1:50071/?token=...
102
102
  ```
103
103
 
104
- 读回结果:`HTTP 200`、37721 字节;页面里的 `window.__DSH_BOOT__` 图包含
105
- `dsh-molbio-tools/client.js&rev=e4e837d570c2`,即浏览器半被宿主 Loader 发现并进了客户端图——
104
+ 读回结果:`HTTP 200`、38180 字节;页面里的 `window.__DSH_BOOT__` 图包含
105
+ `dsh-molbio-tools/client.js&rev=a25c3919676b`,即浏览器半被宿主 Loader 发现并进了客户端图——
106
106
  这是"启动成功"的可观察证据,而不只是进程没退出。(先用 token 换 cookie,再带 cookie 读 `/`,
107
107
  否则第一跳是 303。)
108
108
 
109
109
  ### 3.3 卸载
110
110
 
111
111
  ```
112
- - dsh-molbio-tools file:D:/.../dsh-molbio-tools-0.21.1.tgz
113
- Done in 452ms
112
+ - dsh-molbio-tools file:D:/.../dsh-molbio-tools-0.22.0.tgz
113
+ Done in 421ms
114
114
  ```
115
115
 
116
116
  卸载后四项同时回到初始状态:
@@ -133,7 +133,7 @@ pnpm 的 hoisted linker 会在 `node_modules/` 留下一个指向仓库目录的
133
133
 
134
134
  ```
135
135
  node test/contract.mjs → 16 checks passed(host 契约、client 产物、tarball 覆盖)
136
- node test/preset-health.mjs → all 30 rows load on dsh 0.2.1-rc.2
136
+ node test/preset-health.mjs → all 30 rows load on dsh 0.2.1-rc.3
137
137
  node test/smoke.mjs → registered 60 tools, all smoke tests passed
138
138
  ```
139
139
 
@@ -43,6 +43,10 @@ or a fixed-source manual review.
43
43
  }
44
44
  ```
45
45
 
46
+ > 这段是**提交时那个固定 commit** 的原文(所以 rc.3 当时是 `unknown`)。0.22.0 起 rc.3 已在其上
47
+ > 跑过同一套一次性 Profile 与守卫,`dshReleases` 已改为 `compatible`,见
48
+ > [store-evidence.md](store-evidence.md) 第 1–2 节。
49
+
46
50
  同一个 patch 文件里的两个 insert(顺序即应用顺序),分工固定:
47
51
 
48
52
  ```yaml
package/docs/workflow.md CHANGED
@@ -456,7 +456,7 @@ git status --short # lib/client.js 与 packages/molbio-panel/lib/clien
456
456
  ### DSH 升级后:preset 组合的维护(必做)
457
457
 
458
458
  `preset/molbio-lab/agent.cordis.yml` 是官方 `standard` 预设的**行清单**副本 + 末尾一行
459
- `tool-molbio`(当前基线:**dsh 0.2.1-rc.2**)。它不会自动跟随 DSH 升级,因此每次升级
459
+ `tool-molbio`(当前基线:**dsh 0.2.1-rc.3**)。它不会自动跟随 DSH 升级,因此每次升级
460
460
  DSH 后:
461
461
 
462
462
  1. 取新版的 shipped `standard`:
package/index.mjs CHANGED
@@ -104,9 +104,16 @@ import {
104
104
  } from './methylation.mjs';
105
105
  import {
106
106
  addPapers,
107
+ esummarySource,
107
108
  libraryPath,
108
109
  loadLibrary,
110
+ parseEsearch,
111
+ parseEsummaryRecords,
109
112
  pmidFromUrl,
113
+ pubmedApiKey,
114
+ pubmedFetchUrl,
115
+ pubmedSearchUrl,
116
+ pubmedSummaryUrl,
110
117
  removePaper,
111
118
  saveLibrary,
112
119
  updatePaper,
@@ -6888,14 +6895,34 @@ const phylogeneticTreeTool = (ctx) => define({
6888
6895
  },
6889
6896
  });
6890
6897
 
6898
+ /** Fetch one E-utilities URL through the harness web service and parse JSON. */
6899
+ async function eutilsRequest(web, url, signal, label) {
6900
+ let result;
6901
+ try {
6902
+ result = await web.fetch({ url }, signal);
6903
+ } catch (error) {
6904
+ throw new MolbioInputError(`${label} could not be reached: ${error instanceof Error ? error.message : String(error)}`);
6905
+ }
6906
+ const body = result?.body?.content;
6907
+ if (typeof body !== 'string' || body === '') {
6908
+ throw new MolbioInputError(`${label} returned no content (status ${result?.statusCode ?? 'unknown'})`);
6909
+ }
6910
+ try {
6911
+ return JSON.parse(body);
6912
+ } catch {
6913
+ throw new MolbioInputError(`${label} returned a non-JSON response (status ${result?.statusCode ?? 'unknown'})`);
6914
+ }
6915
+ }
6916
+
6891
6917
  const pubmedAbstractTool = (ctx) => define({
6892
6918
  name: 'molbio_pubmed_abstract',
6893
- description: 'Fetch PubMed abstracts for PMIDs via NCBI E-utilities (efetch). Requires the deployment to provide the web fetch capability; otherwise it reports that clearly. Use it to pull abstracts for papers found by molbio_pubmed_search before adding them to the library.',
6919
+ description: 'Fetch PubMed abstracts for PMIDs via NCBI E-utilities (efetch). Requires the deployment to provide the web fetch capability; otherwise it reports that clearly. Pass api_key (or set NCBI_API_KEY / MOLBIO_NCBI_API_KEY) to attribute the request to your NCBI key and raise the rate limit. Use it to pull abstracts for papers found by molbio_pubmed_search before adding them to the library.',
6894
6920
  parameters: {
6895
6921
  type: 'object',
6896
6922
  required: ['pmids'],
6897
6923
  properties: {
6898
6924
  pmids: { type: 'array', items: { type: 'string' }, description: 'PubMed IDs, e.g. ["37607951"].' },
6925
+ api_key: { type: 'string', description: 'Optional NCBI API key for E-utilities. Prefer setting NCBI_API_KEY in the environment: an inline key appears in the conversation transcript.' },
6899
6926
  },
6900
6927
  },
6901
6928
  outputSchema: {
@@ -6939,7 +6966,8 @@ const pubmedAbstractTool = (ctx) => define({
6939
6966
  if (web === undefined || typeof web.fetch !== 'function') {
6940
6967
  throw new MolbioInputError('this deployment provides no web fetch capability, so PubMed abstracts cannot be retrieved; molbio_pubmed_search still works for titles and snippets');
6941
6968
  }
6942
- const url = `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=pubmed&id=${pmids.join(',')}&retmode=xml`;
6969
+ const apiKey = pubmedApiKey(args);
6970
+ const url = pubmedFetchUrl(pmids, apiKey);
6943
6971
  const result = await web.fetch({ url }, exec?.signal);
6944
6972
  const body = result?.body?.content;
6945
6973
  if (typeof body !== 'string' || body === '') {
@@ -7290,21 +7318,24 @@ function paperServiceDeps(ctx, exec, args) {
7290
7318
 
7291
7319
  const pubmedSearchTool = (ctx) => define({
7292
7320
  name: 'molbio_pubmed_search',
7293
- description: 'Search the web for literature (PubMed results preferred). Uses the harness web-search service; returns up to max_results sources with titles, snippets, URLs and extracted PMIDs. Add the ones you keep to the reading library with molbio_paper_add.',
7321
+ description: 'Search PubMed for literature. With an NCBI API key (the api_key argument, or NCBI_API_KEY / MOLBIO_NCBI_API_KEY in the environment) it queries the official E-utilities API and returns PubMed records with PMIDs, titles, journal/year, authors and DOI; without a key it falls back to the harness web-search service. Add the papers you keep to the reading library with molbio_paper_add.',
7294
7322
  parameters: {
7295
7323
  type: 'object',
7296
7324
  required: ['query'],
7297
7325
  properties: {
7298
7326
  query: requiredString('Search query, e.g. "CRISPR base editing review 2024" or "site:pubmed.ncbi.nlm.nih.gov KRAS G12D".'),
7299
7327
  max_results: { type: 'integer', description: 'Maximum results (1-20, default 8).' },
7328
+ api_key: { type: 'string', description: 'Optional NCBI API key for the official PubMed E-utilities API. Prefer setting NCBI_API_KEY in the environment: an inline key appears in the conversation transcript.' },
7300
7329
  },
7301
7330
  },
7302
7331
  outputSchema: {
7303
7332
  type: 'object',
7304
7333
  additionalProperties: false,
7305
- required: ['query', 'sources'],
7334
+ required: ['query', 'backend', 'sources'],
7306
7335
  properties: {
7307
7336
  query: { type: 'string' },
7337
+ backend: { type: 'string', enum: ['pubmed-eutils', 'web-search'] },
7338
+ total_matches: { type: 'integer' },
7308
7339
  answer: { type: 'string' },
7309
7340
  sources: {
7310
7341
  type: 'array',
@@ -7317,14 +7348,22 @@ const pubmedSearchTool = (ctx) => define({
7317
7348
  url: { type: 'string' },
7318
7349
  snippet: { type: 'string' },
7319
7350
  pmid: { type: 'string' },
7351
+ doi: { type: 'string' },
7352
+ authors: { type: 'string' },
7353
+ journal: { type: 'string' },
7354
+ year: { type: 'string' },
7320
7355
  },
7321
7356
  },
7322
7357
  },
7323
7358
  },
7324
7359
  },
7325
7360
  render(value) {
7326
- if (value.sources.length === 0) return `no results for "${value.query}"`;
7327
- const lines = [`results for "${value.query}":`];
7361
+ const via = value.backend === 'pubmed-eutils' ? ' via PubMed E-utilities' : '';
7362
+ if (value.sources.length === 0) return `no results for "${value.query}"${via}`;
7363
+ const matches = value.total_matches === undefined
7364
+ ? ''
7365
+ : ` — ${value.total_matches} matching record${value.total_matches === 1 ? '' : 's'}`;
7366
+ const lines = [`results for "${value.query}"${via}${matches}:`];
7328
7367
  if (value.answer !== undefined) lines.push(value.answer);
7329
7368
  for (const source of value.sources) {
7330
7369
  const pmid = source.pmid !== undefined ? ` [PMID ${source.pmid}]` : '';
@@ -7334,12 +7373,26 @@ const pubmedSearchTool = (ctx) => define({
7334
7373
  return lines.join('\n');
7335
7374
  },
7336
7375
  async execute(args, exec) {
7337
- const web = ctx.get('web');
7338
- if (web === undefined) throw new MolbioInputError('the web search service is not available in this composition; molbio_pubmed_search needs ctx.web');
7339
7376
  const maxResults = args.max_results ?? 8;
7340
7377
  if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 20) {
7341
7378
  throw new MolbioInputError('max_results must be an integer between 1 and 20');
7342
7379
  }
7380
+ const web = ctx.get('web');
7381
+ const apiKey = pubmedApiKey(args);
7382
+ if (apiKey !== undefined) {
7383
+ if (web === undefined || typeof web.fetch !== 'function') {
7384
+ throw new MolbioInputError('an NCBI API key was provided, but this deployment provides no web fetch capability; the official PubMed API needs ctx.web.fetch');
7385
+ }
7386
+ const search = await eutilsRequest(web, pubmedSearchUrl(args.query, maxResults, apiKey), exec?.signal, 'PubMed esearch');
7387
+ const { ids, count } = parseEsearch(search);
7388
+ if (ids.length === 0) {
7389
+ return { query: args.query, backend: 'pubmed-eutils', total_matches: count, sources: [] };
7390
+ }
7391
+ const summary = await eutilsRequest(web, pubmedSummaryUrl(ids, apiKey), exec?.signal, 'PubMed esummary');
7392
+ const sources = parseEsummaryRecords(summary).map(esummarySource);
7393
+ return { query: args.query, backend: 'pubmed-eutils', total_matches: count, sources };
7394
+ }
7395
+ if (web === undefined) throw new MolbioInputError('the web search service is not available in this composition; molbio_pubmed_search needs ctx.web (or an NCBI API key plus ctx.web.fetch)');
7343
7396
  const result = await web.search({ query: args.query, maxResults }, exec?.signal);
7344
7397
  const sources = result.sources.map((source) => {
7345
7398
  const pmid = pmidFromUrl(source.url ?? '');
@@ -7352,6 +7405,7 @@ const pubmedSearchTool = (ctx) => define({
7352
7405
  });
7353
7406
  return {
7354
7407
  query: args.query,
7408
+ backend: 'web-search',
7355
7409
  ...result.content !== undefined ? { answer: result.content } : {},
7356
7410
  sources,
7357
7411
  };
@@ -7520,9 +7574,9 @@ const PROMPT_SECTION = `Molecular-biology tools (dsh-molbio-tools) are available
7520
7574
  - Sequences & files: molbio_align (local alignment with a readable match line), molbio_msa_align (progressive multiple sequence alignment — pass an array of sequences or a workspace FASTA), molbio_conservation (consensus + per-column identity/conservation + variable positions; accepts aligned rows or raw sequences it aligns first), molbio_sequence_logo (draws the same alignment as a sequence logo SVG — pass the molbio_msa_align output rows as the alignment argument; letters are sized by per-position information content), molbio_fasta_fastq (FASTA/FASTQ stats/extract/convert/QC on workspace files), molbio_batch_analyze (run one summary/ORF/enzyme analysis over every matching FASTA/FASTQ/GenBank/.dna file in a directory and write one CSV/TSV table), molbio_extract_region (pull a CDS/promoter/region from a plasmid file by feature label or coordinates).
7521
7575
  - CRISPR: molbio_grna_design (family-aware guide RNAs over a sequence or plasmid file: cas_family spcas9/sacas9/cas12a/cas13a/cas13b/cas13d selects the PAM side, crRNA length, seed and target type — Cas12a uses a 5' TTTV PAM and 23 nt crRNA; Cas13 targets RNA and reports a PFS preference rather than a PAM. Both strands are scanned for DNA families, the sense strand by default for Cas13; guides are filtered on GC/poly-T/self-complementarity and ranked, with a mismatch-tolerant off-target search over the same sequence — reaching a whole genome simply means passing it as the target). Pass the top candidates on for ordering with save_path (CSV) or map_path (SVG map); the ranking is a heuristic and says so, so present it as candidate ranking, never as a validated efficiency prediction.
7522
7576
  - Map extras: pass gc_skew: true for the GC skew ring, show_unique_cutters: true to mark single-cutting enzymes on the map.
7523
- - Literature & records: molbio_pubmed_abstract (fetch abstracts by PMID — works only when the deployment provides web fetch), molbio_paper_export_bibtex (papers.json → .bib), molbio_protocol_add / molbio_protocol_list / molbio_protocol_update (protocols.json), molbio_experiment_log / molbio_experiment_list (experiments.json).
7577
+ - Literature & records: molbio_pubmed_abstract (fetch abstracts by PMID — needs web fetch; pass api_key or set NCBI_API_KEY to use your NCBI key), molbio_paper_export_bibtex (papers.json → .bib), molbio_protocol_add / molbio_protocol_list / molbio_protocol_update (protocols.json), molbio_experiment_log / molbio_experiment_list (experiments.json).
7524
7578
  - Bench analysis (v19): molbio_fastq_qc (read-level QC for a FASTQ file: per-base quality with quartiles, per-read quality, base content, GC distribution, length, exact duplication, over-represented sequences, adapter screen, plus a report SVG — use it for "is this run good"), molbio_codon_usage (CAI/RSCU/Nc/GC3, rare-codon list and CDS CpG against E. coli/yeast/human — use it for "will this gene express"; it explains, molbio_codon_optimize rewrites), molbio_phylogenetic_tree (distance method: p-distance/JC/K2P/Tamura-Nei, UPGMA or neighbour-joining, bootstrap support, consensus, Newick file and an SVG tree — it is NOT a maximum-likelihood or Bayesian analysis, and bootstrap percentages are not p-values), molbio_pcr_simulate (where a primer pair amplifies: product sizes and coordinates on both strands, mismatch detail, the 3'-end exact-match requirement, circular templates, screening other templates, and an expected gel — use it for "what band will I see"), molbio_gc_composition (sliding-window GC, CpG islands under the Gardiner-Garden or Takai criteria, GC/AT skew with the cumulative-skew origin/terminus indication, dinucleotide obs/exp, entropy, complexity, N50/L50, plus a figure — use it for "is this a promoter or an origin").
7525
- - Literature: molbio_pubmed_search (web search), and the reading library molbio_paper_add / molbio_paper_list / molbio_paper_update / molbio_paper_remove (papers.json in the workspace).
7579
+ - Literature: molbio_pubmed_search — with an NCBI API key (api_key argument, or NCBI_API_KEY / MOLBIO_NCBI_API_KEY in the environment) it uses the official PubMed E-utilities API and returns PMIDs, titles, journal/year, authors and DOI; without a key it falls back to the harness web search. The reading library is molbio_paper_add / molbio_paper_list / molbio_paper_update / molbio_paper_remove (papers.json in the workspace).
7526
7580
 
7527
7581
  Cloning workflow rules (follow them to avoid slow re-derivation):
7528
7582
  - To clone a sequence into a plasmid, call molbio_clone_simulate FIRST — pass the BARE insert with add_flanks: true and the tool adds the enzyme recognition sites itself. The tool also validates single cutting, insert orientation, and internal sites itself: never pre-verify enzymes, cutting counts, or flank construction by thinking. Its output already contains final_sequence AND the remapped feature coordinates (features field). NEVER recompute feature coordinates or insert positions by reasoning — always take them from the tool output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-molbio-tools",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "description": "Zero-dependency molecular-biology plugin bundle for the DeepSeek Harness: sequence analysis, primer design (incl. cross-intron qPCR primers, TaqMan hydrolysis/MGB probes with ordering CSV and multiplex compatibility), in-silico PCR, cloning simulation, SnapGene .dna parsing and plasmid maps, Sanger verification, protein tools (incl. helical wheel and hydropathy plots), methylation-aware restriction analysis with double-digest buffer checks, qPCR analysis and plotting, multiple sequence alignment that conserves every input residue, conservation analysis, sequence logos, codon-usage analysis, phylogenetic trees with bootstrap support, Newick I/O and long-name label wrapping, CpG-island/GC-skew composition analysis, read-level FASTQ QC, batch file analysis with CSV export, silent restriction-site mutagenesis, family-aware CRISPR gRNA design (SpCas9/SaCas9/Cas12a/Cas13) with off-target search, an opt-in hand-off that shows a generated plot to the model as an attached image, plus a browser-side right-sidebar panel (plasmid maps and sequence logos drawn in the Web GUI) and a literature/protocol/experiment record system.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
@@ -96,7 +96,7 @@
96
96
  "dshReleases": {
97
97
  "0.2.1-rc.1": "unknown",
98
98
  "0.2.1-rc.2": "compatible",
99
- "0.2.1-rc.3": "unknown"
99
+ "0.2.1-rc.3": "compatible"
100
100
  }
101
101
  },
102
102
  "bundle": {
package/papers.mjs CHANGED
@@ -132,6 +132,157 @@ export function pmidFromUrl(url) {
132
132
  return match?.[1];
133
133
  }
134
134
 
135
+ // ── PubMed E-utilities (official API) ───────────────────────────────────────
136
+ //
137
+ // `molbio_pubmed_search` used to depend entirely on the harness web-search
138
+ // service. The official API is a different thing: it answers with PubMed's own
139
+ // records (PMIDs, indexed titles, journal/year/DOI) instead of whatever a
140
+ // general search engine indexed, and an NCBI API key raises the rate limit from
141
+ // 3 to 10 requests per second. The helpers below build the request URLs and
142
+ // normalize the responses; the tool keeps the transport (ctx.web.fetch) so the
143
+ // existing sandbox/consent rules still apply.
144
+
145
+ /** The E-utilities endpoint prefix. */
146
+ export const EUTILS_BASE = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
147
+
148
+ /**
149
+ * The NCBI API key for this call: an explicit tool argument first, then the two
150
+ * environment variables a deployment can set once. A blank value counts as
151
+ * absent, so an empty `api_key` falls back instead of sending `api_key=` (NCBI
152
+ * rejects the empty form with a 400 that blames the query).
153
+ */
154
+ export function pubmedApiKey(args, env = process.env) {
155
+ for (const candidate of [args?.api_key, env?.NCBI_API_KEY, env?.MOLBIO_NCBI_API_KEY]) {
156
+ if (typeof candidate === 'string' && candidate.trim() !== '') return candidate.trim();
157
+ }
158
+ return undefined;
159
+ }
160
+
161
+ function eutilsUrl(endpoint, params) {
162
+ const pairs = Object.entries(params)
163
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
164
+ .map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`);
165
+ return `${EUTILS_BASE}/${endpoint}?${pairs.join('&')}`;
166
+ }
167
+
168
+ /** esearch: the PMIDs matching a query, relevance-ranked. */
169
+ export function pubmedSearchUrl(query, maxResults, apiKey) {
170
+ return eutilsUrl('esearch.fcgi', {
171
+ db: 'pubmed',
172
+ term: query,
173
+ retmax: maxResults,
174
+ retmode: 'json',
175
+ sort: 'relevance',
176
+ tool: 'dsh-molbio-tools',
177
+ api_key: apiKey,
178
+ });
179
+ }
180
+
181
+ /** esummary: title/authors/journal/year/DOI for the PMIDs esearch returned. */
182
+ export function pubmedSummaryUrl(pmids, apiKey) {
183
+ return eutilsUrl('esummary.fcgi', {
184
+ db: 'pubmed',
185
+ id: pmids.join(','),
186
+ retmode: 'json',
187
+ tool: 'dsh-molbio-tools',
188
+ api_key: apiKey,
189
+ });
190
+ }
191
+
192
+ /** efetch: the full XML records (abstracts live here, not in esummary). */
193
+ export function pubmedFetchUrl(pmids, apiKey) {
194
+ return eutilsUrl('efetch.fcgi', {
195
+ db: 'pubmed',
196
+ id: pmids.join(','),
197
+ retmode: 'xml',
198
+ tool: 'dsh-molbio-tools',
199
+ api_key: apiKey,
200
+ });
201
+ }
202
+
203
+ /** The PMIDs and total hit count in an esearch JSON response. */
204
+ export function parseEsearch(payload) {
205
+ if (typeof payload?.error === 'string' && payload.error !== '') {
206
+ throw new MolbioInputError(`PubMed E-utilities error: ${payload.error}`);
207
+ }
208
+ const result = payload?.esearchresult;
209
+ if (result === undefined || result === null || typeof result !== 'object') {
210
+ throw new MolbioInputError('PubMed esearch returned no esearchresult — is the response really E-utilities JSON?');
211
+ }
212
+ if (typeof result.ERROR === 'string' && result.ERROR !== '') {
213
+ throw new MolbioInputError(`PubMed esearch failed: ${result.ERROR}`);
214
+ }
215
+ const ids = Array.isArray(result.idlist)
216
+ ? result.idlist.map((id) => String(id).trim()).filter((id) => /^\d+$/.test(id))
217
+ : [];
218
+ const count = Number.parseInt(String(result.count ?? ''), 10);
219
+ return { ids, count: Number.isFinite(count) ? count : ids.length };
220
+ }
221
+
222
+ /**
223
+ * Normalize the records in an esummary JSON response.
224
+ *
225
+ * The response puts a `uids` array beside one object per PMID; walking `uids`
226
+ * (rather than Object.keys) keeps the esearch order, which is relevance order.
227
+ */
228
+ export function parseEsummaryRecords(payload) {
229
+ if (typeof payload?.error === 'string' && payload.error !== '') {
230
+ throw new MolbioInputError(`PubMed E-utilities error: ${payload.error}`);
231
+ }
232
+ const result = payload?.result;
233
+ if (result === undefined || result === null || typeof result !== 'object') {
234
+ throw new MolbioInputError('PubMed esummary returned no result — is the response really E-utilities JSON?');
235
+ }
236
+ if (typeof result.error === 'string' && result.error !== '') {
237
+ throw new MolbioInputError(`PubMed esummary failed: ${result.error}`);
238
+ }
239
+ const uids = Array.isArray(result.uids) ? result.uids.map(String) : [];
240
+ const records = [];
241
+ for (const uid of uids) {
242
+ const row = result[uid];
243
+ if (row === undefined || row === null || typeof row !== 'object') continue;
244
+ const title = typeof row.title === 'string' ? row.title.trim() : '';
245
+ const authors = Array.isArray(row.authors)
246
+ ? row.authors
247
+ .map((author) => (typeof author?.name === 'string' ? author.name.trim() : ''))
248
+ .filter((name) => name !== '')
249
+ : [];
250
+ const articleIds = Array.isArray(row.articleids) ? row.articleids : [];
251
+ const doi = articleIds.find((id) => id?.idtype === 'doi' && typeof id.value === 'string')?.value ?? '';
252
+ const pubdate = typeof row.pubdate === 'string' ? row.pubdate : '';
253
+ const year = /\b(?:1[89]|20)\d{2}\b/.exec(pubdate)?.[0] ?? '';
254
+ const journal = typeof row.fulljournalname === 'string' && row.fulljournalname !== ''
255
+ ? row.fulljournalname
256
+ : (typeof row.source === 'string' ? row.source : '');
257
+ records.push({
258
+ pmid: String(row.uid ?? uid),
259
+ title,
260
+ authors,
261
+ journal,
262
+ pubdate,
263
+ year,
264
+ doi,
265
+ });
266
+ }
267
+ return records;
268
+ }
269
+
270
+ /** One esummary record as a search source (the shape molbio_pubmed_search returns). */
271
+ export function esummarySource(record) {
272
+ const source = {
273
+ title: record.title === '' ? `PMID ${record.pmid}` : record.title,
274
+ url: `https://pubmed.ncbi.nlm.nih.gov/${record.pmid}/`,
275
+ pmid: record.pmid,
276
+ };
277
+ const authorText = record.authors.length > 2 ? `${record.authors[0]} et al.` : record.authors.join(', ');
278
+ const snippet = [record.journal, record.year, authorText].filter((part) => part !== '').join(' · ');
279
+ if (snippet !== '') source.snippet = snippet;
280
+ if (record.authors.length > 0) source.authors = record.authors.join(', ');
281
+ if (record.journal !== '') source.journal = record.journal;
282
+ if (record.year !== '') source.year = record.year;
283
+ if (record.doi !== '') source.doi = record.doi;
284
+ return source;
285
+ }
135
286
  /** Escape a string for BibTeX. */
136
287
  function bibtexEscape(text) {
137
288
  return String(text)
@@ -48,14 +48,15 @@
48
48
  # list carries no comments of its own, so none appear below; provenance and the
49
49
  # upgrade rules live in this header.
50
50
  #
51
- # Baseline: the `standard` preset of dsh 0.2.1-rc.2 (harness root
51
+ # Baseline: the `standard` preset of dsh 0.2.1-rc.3 (harness root
52
52
  # `%APPDATA%\npm\node_modules\@deepseek-ai\dsh`, file
53
53
  # `node_modules/@deepseek-ai/dsh-web-app/presets/standard.patch.yml`, whose
54
54
  # rows live under the `preset-standard` row's `config.plugins:`).
55
55
  #
56
- # 0.2.1-rc.2 is the first bump since 0.1.7-alpha.1 that changed the row list: it
57
- # added `time-context`, `tool-schedule` and `tool-plugin-market`; dropped the two
58
- # disabled optional subagent providers (`tool-subagent-codex`,
56
+ # 0.2.1-rc.3 ships the SAME row list as 0.2.1-rc.2 (verified by diffing the
57
+ # file), so no row changed in this bump. The last upstream row change was
58
+ # 0.2.1-rc.2: it added `time-context`, `tool-schedule` and `tool-plugin-market`;
59
+ # dropped the two disabled optional subagent providers (`tool-subagent-codex`,
59
60
  # `tool-subagent-claude-code`) and `tool-ralph`; dropped the persona `suffix` and
60
61
  # both `backgroundMode: continuable` settings; and reworded the plan-mode
61
62
  # section. Absorb such a bump verbatim — see workflow.md §4.
package/test/smoke.mjs CHANGED
@@ -99,6 +99,9 @@ const memFs = {
99
99
  };
100
100
 
101
101
  const mockWeb = {
102
+ // Every fetch URL the tools request, so the tests can prove the NCBI key
103
+ // actually travels in the E-utilities query instead of merely being accepted.
104
+ calls: [],
102
105
  async search({ query }) {
103
106
  return {
104
107
  content: `answer for ${query}`,
@@ -109,9 +112,45 @@ const mockWeb = {
109
112
  truncated: false,
110
113
  };
111
114
  },
112
- async fetch() {
115
+ async fetch({ url } = {}) {
116
+ const target = String(url ?? '');
117
+ this.calls.push(target);
118
+ const json = (payload, statusCode = 200) => ({
119
+ url: target,
120
+ statusCode,
121
+ body: { kind: 'text', content: JSON.stringify(payload) },
122
+ truncated: false,
123
+ });
124
+ if (target.includes('/esearch.fcgi')) {
125
+ if (target.includes('api_key=bad-key')) return json({ error: 'API key invalid' }, 400);
126
+ return json({ esearchresult: { count: '2', retmax: '2', idlist: ['39609418', '12345678'] } });
127
+ }
128
+ if (target.includes('/esummary.fcgi')) {
129
+ return json({
130
+ result: {
131
+ uids: ['39609418', '12345678'],
132
+ '39609418': {
133
+ uid: '39609418',
134
+ title: 'SPLICER: a highly efficient base editing toolbox',
135
+ pubdate: '2024 Nov',
136
+ source: 'Nat Biotechnol',
137
+ fulljournalname: 'Nature biotechnology',
138
+ authors: [{ name: 'Li X' }, { name: 'Wang Y' }, { name: 'Zhang Z' }],
139
+ articleids: [{ idtype: 'doi', value: '10.1038/s41587-024-02460-4' }],
140
+ },
141
+ '12345678': {
142
+ uid: '12345678',
143
+ title: 'An older paper',
144
+ pubdate: '2019',
145
+ source: 'Cell',
146
+ authors: [{ name: 'Doe J' }],
147
+ articleids: [],
148
+ },
149
+ },
150
+ });
151
+ }
113
152
  return {
114
- url: 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi',
153
+ url: target,
115
154
  statusCode: 200,
116
155
  body: {
117
156
  kind: 'text',
@@ -1891,15 +1930,83 @@ function splicedToGenomic(splicedPos, exons) {
1891
1930
 
1892
1931
  // ── pubmed search (mocked web service) ──────────────────────────────────────
1893
1932
 
1933
+ // The keys below are cleared for the whole section: a developer machine with
1934
+ // NCBI_API_KEY exported must not silently change what these tests exercise.
1935
+ const savedNcbiEnv = {
1936
+ NCBI_API_KEY: process.env.NCBI_API_KEY,
1937
+ MOLBIO_NCBI_API_KEY: process.env.MOLBIO_NCBI_API_KEY,
1938
+ };
1939
+ delete process.env.NCBI_API_KEY;
1940
+ delete process.env.MOLBIO_NCBI_API_KEY;
1941
+
1894
1942
  {
1943
+ // no key and no environment: the pre-existing web-search path
1944
+ mockWeb.calls.length = 0;
1895
1945
  const out = await run('molbio_pubmed_search', { query: 'CRISPR' });
1896
1946
  assert.equal(out.query, 'CRISPR');
1947
+ assert.equal(out.backend, 'web-search');
1897
1948
  assert.equal(out.answer, 'answer for CRISPR');
1898
1949
  assert.equal(out.sources.length, 2);
1899
1950
  assert.equal(out.sources[0].pmid, '12345678');
1900
1951
  assert.equal(out.sources[1].pmid, undefined);
1952
+ assert.equal(mockWeb.calls.length, 0, 'the web-search path must not touch the E-utilities transport');
1953
+ }
1954
+
1955
+ {
1956
+ // official PubMed API: an explicit key switches the tool to E-utilities, and
1957
+ // the key travels in the request (esearch AND esummary) rather than merely
1958
+ // being accepted by the argument schema.
1959
+ mockWeb.calls.length = 0;
1960
+ const out = await run('molbio_pubmed_search', { query: 'CRISPR base editing', api_key: 'test-key-123' });
1961
+ assert.equal(out.backend, 'pubmed-eutils');
1962
+ assert.equal(out.total_matches, 2);
1963
+ assert.equal(out.sources.length, 2);
1964
+ assert.equal(out.sources[0].pmid, '39609418');
1965
+ assert.equal(out.sources[0].url, 'https://pubmed.ncbi.nlm.nih.gov/39609418/');
1966
+ assert.match(out.sources[0].title, /SPLICER/);
1967
+ assert.equal(out.sources[0].journal, 'Nature biotechnology');
1968
+ assert.equal(out.sources[0].year, '2024');
1969
+ assert.equal(out.sources[0].doi, '10.1038/s41587-024-02460-4');
1970
+ assert.match(out.sources[0].snippet, /Nature biotechnology · 2024 · Li X et al\./);
1971
+ assert.equal(out.sources[1].authors, 'Doe J');
1972
+ assert.ok(mockWeb.calls.every((url) => url.includes('api_key=test-key-123')));
1973
+ assert.ok(mockWeb.calls.some((url) => url.includes('/esearch.fcgi')));
1974
+ assert.ok(mockWeb.calls.some((url) => url.includes('/esummary.fcgi')));
1975
+ }
1976
+
1977
+ {
1978
+ // the environment fallback is what makes "set the key once" possible
1979
+ const saved = process.env.NCBI_API_KEY;
1980
+ process.env.NCBI_API_KEY = 'env-key-456';
1981
+ try {
1982
+ mockWeb.calls.length = 0;
1983
+ const out = await run('molbio_pubmed_search', { query: 'base editing' });
1984
+ assert.equal(out.backend, 'pubmed-eutils');
1985
+ assert.ok(mockWeb.calls.length === 2 && mockWeb.calls.every((url) => url.includes('api_key=env-key-456')));
1986
+ } finally {
1987
+ delete process.env.NCBI_API_KEY;
1988
+ if (saved !== undefined) process.env.NCBI_API_KEY = saved;
1989
+ }
1990
+ }
1991
+
1992
+ {
1993
+ // an invalid key is reported as such, not presented as "no results"
1994
+ await assert.rejects(() => run('molbio_pubmed_search', { query: 'x', api_key: 'bad-key' }), /API key invalid/);
1901
1995
  }
1902
1996
 
1997
+ {
1998
+ // abstracts use the same key and the efetch endpoint
1999
+ mockWeb.calls.length = 0;
2000
+ const out = await run('molbio_pubmed_abstract', { pmids: ['12345678'], api_key: 'test-key-123' });
2001
+ assert.equal(out.abstracts[0].error, '');
2002
+ assert.equal(out.abstracts[0].title, 'Test Article Title');
2003
+ assert.ok(mockWeb.calls.length === 1 && mockWeb.calls[0].includes('/efetch.fcgi') && mockWeb.calls[0].includes('api_key=test-key-123'));
2004
+ }
2005
+
2006
+ // Restore whatever the developer machine had exported.
2007
+ if (savedNcbiEnv.NCBI_API_KEY !== undefined) process.env.NCBI_API_KEY = savedNcbiEnv.NCBI_API_KEY;
2008
+ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_KEY = savedNcbiEnv.MOLBIO_NCBI_API_KEY;
2009
+
1903
2010
  // ── paper library (mocked fs service) ───────────────────────────────────────
1904
2011
 
1905
2012
  {