dsh-molbio-tools 0.24.0 → 0.25.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/charts.mjs CHANGED
@@ -60,6 +60,25 @@ import {
60
60
  textRun,
61
61
  } from './svgio.mjs';
62
62
 
63
+ // ── data-sized min/max ──────────────────────────────────────────────────────
64
+ //
65
+ // A table this module accepts can hold MAX_ROWS (200 000) values. Spreading an
66
+ // array that large into Math.min/Math.max overflows the engine's argument limit
67
+ // with a RangeError, so the data-sized extrema are computed with loops. Fixed-
68
+ // size arrays (bins, colour stops, density grids) still use the spread form.
69
+
70
+ function minData(values) {
71
+ let min = Infinity;
72
+ for (const value of values) if (value < min) min = value;
73
+ return min;
74
+ }
75
+
76
+ function maxData(values) {
77
+ let max = -Infinity;
78
+ for (const value of values) if (value > max) max = value;
79
+ return max;
80
+ }
81
+
63
82
  // ── canvas ──────────────────────────────────────────────────────────────────
64
83
 
65
84
  export const CHART_WIDTH = 720;
@@ -130,8 +149,8 @@ export function histogramBins(values, requestedBins) {
130
149
  const finite = values.filter((value) => Number.isFinite(value));
131
150
  if (finite.length === 0) throw new MolbioInputError('a histogram needs at least one finite value');
132
151
  const n = finite.length;
133
- const min = Math.min(...finite);
134
- const max = Math.max(...finite);
152
+ const min = minData(finite);
153
+ const max = maxData(finite);
135
154
  let binCount;
136
155
  let rule;
137
156
  if (requestedBins !== undefined) {
@@ -634,9 +653,9 @@ export function renderBoxPlot({ title, x_label, y_label, series }) {
634
653
  const stats = boxStats(validateFinite(entry.values, `series ${index + 1} values`));
635
654
  return { label, stats, color: entry.color ?? SERIES_COLORS[index % SERIES_COLORS.length] };
636
655
  });
637
- const lows = prepared.map((entry) => Math.min(entry.stats.whisker_low, ...entry.stats.outliers, entry.stats.min));
638
- const highs = prepared.map((entry) => Math.max(entry.stats.whisker_high, ...entry.stats.outliers, entry.stats.max));
639
- const [domainMin, domainMax] = padDomain(Math.min(...lows), Math.max(...highs));
656
+ const lows = prepared.map((entry) => Math.min(entry.stats.whisker_low, minData(entry.stats.outliers), entry.stats.min));
657
+ const highs = prepared.map((entry) => Math.max(entry.stats.whisker_high, maxData(entry.stats.outliers), entry.stats.max));
658
+ const [domainMin, domainMax] = padDomain(minData(lows), maxData(highs));
640
659
  const { parts, plot } = frame(title);
641
660
  const y = yAxis(parts, plot, domainMin, domainMax, y_label);
642
661
  const slot = plot.width / prepared.length;
@@ -695,8 +714,8 @@ export function renderLineChart({ title, x_label, y_label, series, markers = tru
695
714
  });
696
715
  const allX = prepared.flatMap((entry) => entry.xs);
697
716
  const allY = prepared.flatMap((entry) => entry.ys);
698
- const [xMin, xMax] = padDomain(Math.min(...allX), Math.max(...allX));
699
- const [yMin, yMax] = padDomain(Math.min(...allY), Math.max(...allY));
717
+ const [xMin, xMax] = padDomain(minData(allX), maxData(allX));
718
+ const [yMin, yMax] = padDomain(minData(allY), maxData(allY));
700
719
  const { parts, plot } = frame(title);
701
720
  const y = yAxis(parts, plot, yMin, yMax, y_label);
702
721
  const x = linearScale([xMin, xMax], [plot.x, plot.x + plot.width]);
@@ -719,11 +738,11 @@ export function renderLineChart({ title, x_label, y_label, series, markers = tru
719
738
  return {
720
739
  svg,
721
740
  stats: {
722
- series: prepared.map((entry) => ({ label: entry.label, points: entry.xs.length, x_min: Math.min(...entry.xs), x_max: Math.max(...entry.xs), y_min: Math.min(...entry.ys), y_max: Math.max(...entry.ys) })),
741
+ series: prepared.map((entry) => ({ label: entry.label, points: entry.xs.length, x_min: minData(entry.xs), x_max: maxData(entry.xs), y_min: minData(entry.ys), y_max: maxData(entry.ys) })),
723
742
  series_count: prepared.length,
724
743
  point_count: prepared.reduce((sum, entry) => sum + entry.xs.length, 0),
725
- x_range: [Math.min(...allX), Math.max(...allX)],
726
- y_range: [Math.min(...allY), Math.max(...allY)],
744
+ x_range: [minData(allX), maxData(allX)],
745
+ y_range: [minData(allY), maxData(allY)],
727
746
  },
728
747
  marks: prepared.reduce((sum, entry) => sum + entry.xs.length, 0),
729
748
  plot,
@@ -815,7 +834,7 @@ export function renderViolinPlot({ title, x_label, y_label, series, bandwidth, s
815
834
  });
816
835
  const lows = prepared.map((entry) => entry.stats.min);
817
836
  const highs = prepared.map((entry) => entry.stats.max);
818
- const [domainMin, domainMax] = padDomain(Math.min(...lows), Math.max(...highs));
837
+ const [domainMin, domainMax] = padDomain(minData(lows), maxData(highs));
819
838
  const { parts, plot } = frame(title);
820
839
  const y = yAxis(parts, plot, domainMin, domainMax, y_label);
821
840
  const slot = plot.width / prepared.length;
@@ -890,8 +909,8 @@ export function renderVolcanoPlot({ title, x_label, y_label, points, fold_change
890
909
  const significant = (point) => Math.abs(point.fold) >= fold_change_threshold && point.p <= p_threshold;
891
910
  const xs = prepared.map((point) => point.fold);
892
911
  const ys = prepared.map((point) => point.significance);
893
- const [xMin, xMax] = padDomain(Math.min(0, ...xs), Math.max(0, ...xs));
894
- const [yMin, yMax] = padDomain(0, Math.max(...ys, -Math.log10(p_threshold)));
912
+ const [xMin, xMax] = padDomain(Math.min(0, minData(xs)), Math.max(0, maxData(xs)));
913
+ const [yMin, yMax] = padDomain(0, Math.max(maxData(ys), -Math.log10(p_threshold)));
895
914
  const { parts, plot } = frame(title);
896
915
  const y = yAxis(parts, plot, yMin, yMax, y_label ?? '-log10 p');
897
916
  const x = linearScale([xMin, xMax], [plot.x, plot.x + plot.width]);
@@ -936,7 +955,7 @@ export function renderVolcanoPlot({ title, x_label, y_label, points, fold_change
936
955
  down_count: prepared.filter((point) => significant(point) && point.fold < 0).length,
937
956
  fold_change_threshold,
938
957
  p_threshold,
939
- max_significance: Math.max(...ys),
958
+ max_significance: maxData(ys),
940
959
  labelled: [...labelled],
941
960
  },
942
961
  marks: prepared.length,
@@ -1000,8 +1019,8 @@ export function renderHeatmap({ title, x_label, y_label, rows, columns, matrix,
1000
1019
  }
1001
1020
  const flat = values.flat().filter((value) => value !== null);
1002
1021
  if (flat.length === 0) throw new MolbioInputError('the matrix has no finite values to draw');
1003
- const min = Math.min(...flat);
1004
- const max = Math.max(...flat);
1022
+ const min = minData(flat);
1023
+ const max = maxData(flat);
1005
1024
  const ramp = color_scale ?? (min < 0 ? 'divergingRedBlue' : 'viridis');
1006
1025
  if (COLOR_RAMPS[ramp] === undefined) {
1007
1026
  throw new MolbioInputError(`unknown color_scale ${JSON.stringify(color_scale)}; use ${Object.keys(COLOR_RAMPS).join(', ')}`);
package/cordis.patch.yml CHANGED
@@ -21,7 +21,7 @@
21
21
  #
22
22
  # Keep the tool in the PRESET and only the anchor here. An earlier version of
23
23
  # this file inserted a host row for the tool itself (`name: dsh-molbio-tools`),
24
- # which both registered all 60 tools globally and collided with the preset's own
24
+ # which both registered every tool globally and collided with the preset's own
25
25
  # copy of that row:
26
26
  #
27
27
  # broken: "tool-molbio (...): never started"
@@ -10,14 +10,14 @@
10
10
 
11
11
  | 落点 | 内容 | 数据来源 |
12
12
  | --- | --- | --- |
13
- | **Molbio**(右栏 tab) | 列出会话工作区里的序列文件,选中即画:`.dna`/`.gb`/`.gbk` → 质粒图谱 + 特征表;`.fa`/`.fasta`(≥2 条)→ 序列标识图 | `workspaceFiles.readAll`(二进制,含 SnapGene) |
13
+ | **Molbio**(右栏 tab) | 列出会话工作区里的序列与结构文件,选中即画:`.dna`/`.gb`/`.gbk` → 质粒图谱 + 特征表;`.fa`/`.fasta`(≥2 条)→ 序列标识图;`.pdb`/`.ent`/`.cif`/`.mmcif`/`.sdf`/`.mol` → 可旋转的 3D ball-and-stick / space-filling 模型 | `workspaceFiles.readAll` / `readBytes`(二进制,含 SnapGene 与结构文件) |
14
14
  | **Papers**(右栏 tab) | 把 `molbio_paper_*` 工具维护的 `papers.json` 渲染成可搜索的阅读列表 + 详情面板(标题/作者/期刊/年份/PMID/URL/标签/笔记,标题链接到 PubMed 或原 URL) | `workspaceFiles.read`(文本) |
15
15
  | **图谱调用卡**(`tool.call.toolview`) | `molbio_plasmid_map` / `molbio_plasmid_map_file` 的调用卡:在对话里直接画出这次调用产出的图谱(摘要行 + 写入路径),失败时显示错误文本与 Inspect 入口 | 工具自己声明的 `output.presentationMeta`(见下) |
16
16
  | **图形调用卡**(`tool.call.toolview`) | `molbio_sequence_logo` / `molbio_grna_design` 的调用卡:logo 直接画图;gRNA 画 `map_path` 并列出前 5 条 guide(无 map 时仍有摘要与表格) | 同上,kind = `molbio-figure` |
17
17
  | **PubMed API key**(`plugins.detail.section`) | 插件 → 已安装插件 → `dsh-molbio-tools` 详情页里的一段表单:password 输入 + Save/Remove,状态只报「已配置 / 来源 / 未配置」,永不回显值 | `ctx.remote.credentials.describe` / `set` / `unset`(host 的凭据 seam) |
18
18
 
19
- **解析与渲染全部在浏览器里跑本仓库自己的模块**(`lib/genbank/snapgene/plasmid/msa/logo`),
20
- 不经过任何工具调用、不落盘 SVG、不弹系统查看器。
19
+ **解析与渲染全部在浏览器里跑本仓库自己的模块**(`lib/genbank/snapgene/plasmid/msa/logo`
20
+ 与 `structure-preview` / `structure-render`),不经过任何工具调用、不落盘 SVG、不弹系统查看器。
21
21
 
22
22
  ### 调用卡的数据通路(`presentationMeta`)
23
23
 
@@ -174,8 +174,9 @@ seed)、禁止动态 `import()`、禁止跨插件值导入。它自己只降
174
174
  生效、以及坏库时列表仍宣称"还没有文献"。插件详情页的 key 段也被真实驱动:
175
175
  `describe` → 输入 → Save(trim 后写入 `NCBI_API_KEY`)→ 状态刷新 + 清空输入 → Remove →
176
176
  provider 拒绝时消息原样显示。
177
- 4. **数据通路(质粒)**:真实 pUC118 `.dna` → 记录(名称/长度/拓扑/特征,AmpR 2102-2962
178
- 反链)→ SVG;GenBank 文本路径与 Node 工具解析结果**逐字段一致**;FASTA → 比对 → logo;
177
+ 4. **数据通路(质粒 / 比对 / 结构)**:真实 pUC118 `.dna` → 记录(名称/长度/拓扑/特征,
178
+ AmpR 2102-2962 反链)→ SVG;GenBank 文本路径与 Node 工具解析结果**逐字段一致**;
179
+ FASTA → 比对 → logo;PDB → 3D 模型(点选后 canvas 被绘制,拖拽触发重绘);
179
180
  扩展名分类、排序、base64 解码、非法输入的错误路径。
180
181
  5. **数据通路(文献库)**:`readWorkspaceText` 的三种结果(有文本 / 文件不存在 / 读取失败)
181
182
  分别对应"有库 / 空库 / 报错";`parseLibrary` 与工具侧契约一致(`{papers: [...]}`)且
@@ -243,7 +244,7 @@ dsh plugin --profile <profile> add dsh-molbio-tools # 或 npm 包
243
244
  `/plugins/dsh-molbio-tools/client.js?rev=<hash>` → 页面刷新时按需拉取、物化、执行
244
245
  `apply`。**不需要重建 Web 应用**(这正是客户端模块体系的设计目的)。
245
246
 
246
- 与 preset 渠道共存时:preset 提供 57 个工具,bundle 提供面板;同名工具由 preset 层
247
+ 与 preset 渠道共存时:preset 提供 64 个工具,bundle 提供面板;同名工具由 preset 层
247
248
  shadow(无冲突),面板的 `remote`/`slot` 服务不受影响。
248
249
 
249
250
  ## 7. 下一步
@@ -256,6 +257,11 @@ shadow(无冲突),面板的 `remote`/`slot` 服务不受影响。
256
257
  后续可考虑把同样的卡片给 `molbio_dotplot` 等新画图工具。**任何新卡片上线前先跑
257
258
  `test/client.mjs` 的抢座位顺序那一层**:0.7.1 的卡片因为裸 `register()` 抢座位而让 GUI
258
259
  起不来(0.7.2 修复,见 §4 第一条);`contract` 会从产物里的 key 表反推投影数。
260
+ - **结构预览**:✅ 已落地(0.25.0)。Molbio tab 直接读 PDB/mmCIF/SDF-MOL V2000,
261
+ 用 canvas 画可旋转的 3D **ball-and-stick / space-filling** 模型(软件光栅化 + z-buffer +
262
+ Phong 光照);明确的边界是 12,000 重原子、5 MB、无 V3000、无分子表面/静电势、
263
+ “preview, not for measurements”。没有走 `ctx.documentPreviews`:现有 tab 已有文件列表、
264
+ canvas 与错误态,新增 keyed 容器换不来新的用户能力。
259
265
  - **文献库写回**(可选):若要面板内编辑笔记/标签,需要先定义与 `molbio_paper_*` 工具
260
266
  一致的并发契约(乐观版本号或串行化队列),或让工具走同一条 RPC。
261
267
  - **上游提案(路径 B)**:preset 渠道挂 client 仍是真实生态需求,可以在社区反馈时
package/docs/history.md CHANGED
@@ -16,6 +16,8 @@
16
16
 
17
17
  | 包版本 | preset 版本 | 内容 | 工具数 |
18
18
  | --- | --- | --- | --- |
19
+ | 0.25.0 | — | **浏览器内真实 3D 结构预览**:Molbio 面板读 PDB/mmCIF/SDF-MOL V2000,软件光栅化 ball-and-stick / space-filling,拖拽旋转、滚轮缩放;工具数不变 | 64 |
20
+ | 0.24.1 | — | **输入边界与数据安全加固**:BibTeX 不再覆盖文献库、相对读取按会话工作区、批量/glob/并发声明修复、20 万行输入不再 `RangeError`;工具数不变 | 64 |
19
21
  | 0.24.0 | — | **数据卫生版**:新增 `molbio_contamination_screen`(内置载体/接头短签名,双链、0–2 错配)与 `molbio_fastq_trim`(质量/接头/引物/poly/N/长度,写 FASTQ/FASTA);修 FASTQ QC 接头表 Small RNA 5' 的 RNA→DNA 静默错 | 62 → 64 |
20
22
  | 0.23.0 | — | **2.0 遗留修复 + 2.9a 调用卡 + 两个候选池工具**:跨内含子引物报告真实反向分子(F 在 R 上游)、logo/gRNA 调用卡、`molbio_protein_charge`、`molbio_dotplot`;同版完成 **DSH 0.2.2-rc.1 适配**(删掉上游已移除的 `list-agents` 子路径行、subagent 模型选择改用新键、守卫补上 specifier 解析) | 60 → 62 |
21
23
  | 0.22.2 | — | **DSH 0.2.1-rc.5 适配**:基线 rc.3 → rc.5(上游 standard 逐字节未变);contract 声明守卫兼容桌面版裁掉 `lib/types` 的打包形态(+突变自检);engines.node 与 CI 降到 24 | 60 |
@@ -49,6 +51,58 @@
49
51
 
50
52
  ---
51
53
 
54
+ ## v0.25.0(2026-11-10,包 0.25.0)
55
+
56
+ **三条教训,关于“看得见”“同一份源码”和“产物真的能跑”。**
57
+
58
+ **(1) 2D PCA 不是文献里的结构图。** 第一版把 3D 坐标投到两个主轴,画一张静态 SVG;
59
+ 它保留了相对位置,却丢掉深度、遮挡和空间关系,蛋白质像线稿、核酸像折线。学术界常见的
60
+ 配图是立体的 ball-and-stick、space-filling 或 surface,而不是单张投影。0.25.0 在发布前
61
+ 改为**真实原子模型**:透视、z-buffer、Phong 光照、深度雾化;球和键按元素着色,支持
62
+ ball-and-stick 与 space-filling 两种表示。没有采用卡通 ribbon:它需要二级结构、朝向和
63
+ 带宽的额外几何,且不是这一版要解决的问题。
64
+
65
+ **(2) 软件光栅化让“浏览器里看到的”和“测试里验证的”是同一套像素。** 不引入 WebGL 或
66
+ Three.js;`structure-render.mjs` 返回 RGB 缓冲,面板只把它 `putImageData` 到 canvas。
67
+ Node 测试能直接检查中心原子、非背景像素、两种表示和 PNG 编码;否则 canvas 行为只能靠
68
+ 肉眼或第二个渲染器验证,二者必分叉。代价是 CPU 渲染:拖动时 1× 采样,松开后 2×,
69
+ 12,000 个重原子封顶。
70
+
71
+ **(3) 产物能生成,不等于产物能运行。** 加结构预览时发现 bundler 在生成 re-export 时
72
+ 丢掉了 imported name,所有 `browser-api.mjs` 再导出在产物里都是
73
+ `require(target).undefined`;格式/模块存在性检查全绿,但真实浏览器半拿到 undefined。
74
+ 现在保留 imported name,并在 `test/client.mjs` 加一条“产物不得含
75
+ `require(...).undefined`”的结构断言。这也是为什么“同一份源码”必须配合**执行**产物的
76
+ 守卫,而不只是文本对比。
77
+
78
+ **顺带记录**:只改浏览器半也会改变根包产物,所以根包 `0.24.1 → 0.25.0`、面板包
79
+ `0.1.10 → 0.1.11` 同时升版本。工具数保持 64,benchmark 保持 65 题。结构解析与渲染在
80
+ `structure-preview.mjs` / `structure-render.mjs`,面板用 canvas 承载,不再使用
81
+ `ctx.documentPreviews`(现有 tab 已有文件列表、错误态与画布)。
82
+
83
+ ## v0.24.1(2026-11-08,包 0.24.1)
84
+
85
+ **三条教训,都是“合法输入 + 默认路径”才会暴露的错。**
86
+
87
+ **(1) “自定义文件名”不能等于“库文件本身”。** `molbio_paper_export_bibtex` 的默认输出是
88
+ `path.replace(/\.json$/i, '.bib')`;当用户传 `file: refs.txt` 时这个替换是 no-op,
89
+ 于是导出的 BibTeX **直接覆盖文献库**。修法不是拒绝 `refs.txt`,而是先判断 `.json` 是否存在,
90
+ 不存在就追加 `.bib`。教训:**默认值必须是“另一个文件”的证明**,不能依赖字符串替换恰好命中。
91
+
92
+ **(2) 用户规模数组不能 spread 进 `Math.min`/`Math.max`。** 图表接受 200,000 行、
93
+ FASTQ QC 接受约 200,000 条 reads,而 `Math.min(...values)` 在这些规模上会以
94
+ `RangeError` 失败;错误发生在参数个数上限,不是算法。现在所有可能跟随输入增长的极值
95
+ 都用循环;固定的 64 项密码子表之类仍可 spread。教训:**先问数组的上界是谁定的**,再决定写法。
96
+
97
+ **(3) `safe` 声明要照着“默认写入”写,不是照着“参数是否为空”写。** 四个画图工具在
98
+ `output_path` 省略时仍会写默认 SVG,旧谓词却返回 `true`;并发调用会互相覆盖文件。
99
+ 同样的问题在 `molbio_fasta_fastq extract` 带 `output_path` 时也存在。教训:**并发安全
100
+ 是“这次调用碰不碰共享状态”,默认值也算碰。**
101
+
102
+ **顺带记录**:两个客户端产物因 `seqio.mjs` / `protein-structure.mjs` 改动而重建,
103
+ 面板包按仓库规则 `0.1.9 → 0.1.10`;工具数保持 64,preset 行清单与 DSH 基线不变。
104
+ `benchmark-changed` 的 POSIX 路径断言在本地沙箱因 Node 无法 spawn `git` 未执行,CI 会执行。
105
+
52
106
  ## v0.24.0(2026-11-05,包 0.24.0)
53
107
 
54
108
  **三条教训,都关于“表/边界看起来对、实际不对”。**
@@ -32,7 +32,7 @@
32
32
 
33
33
  - **包**:`dsh-molbio-tools`,零依赖 DSH 插件包,**64 个 `molbio_*` 工具**(其中
34
34
  `molbio_plot` 一个工具承载 8 种图表)。
35
- - **基线**:DSH **0.2.2-rc.1**;`package.json` 版本 0.24.0(DSH 桌面版内嵌 Node 24.21.0)。
35
+ - **基线**:DSH **0.2.2-rc.1**;`package.json` 版本 0.25.0(DSH 桌面版内嵌 Node 24.21.0)。
36
36
  - **两条分发渠道**:根包(工具 + 面板)与 `packages/molbio-panel`(只面板),
37
37
  **两个独立的 npm 条目、两条独立的版本线**,必须分别发布。
38
38
  - **工具只在专属模式里出现**(设计如此):bundle 的 `cordis.patch.yml` 只插一行**惰性宿主锚点**
package/docs/roadmap.md CHANGED
@@ -10,7 +10,7 @@ L = 需要新机制或外部资源)。
10
10
 
11
11
  ---
12
12
 
13
- ## 1. 当前状态(0.23.0)
13
+ ## 1. 当前状态(0.25.0)
14
14
 
15
15
  - 64 个 `molbio_*` 工具,零依赖,随 preset 分发(见 [README](../README.md))。
16
16
  - 离线测试金字塔 + 组合漂移守卫全绿;`benchmark/` 提供**模型使用**维度的评估
@@ -45,6 +45,15 @@ L = 需要新机制或外部资源)。
45
45
  的 key 解析顺序变为 **`api_key` 参数 → DSH 凭据库 → 环境变量**(环境变量会被 agent 自己的 shell
46
46
  子进程继承,凭据库不会);同时插件详情页出现 **PubMed API key** 表单,用户不必改 YAML。
47
47
  没有凭据库的组合行为与 0.22.0 相同。
48
+ - **0.25.0 交付 2.9c 真实 3D 结构预览(工具数不变)**:Molbio 面板新增 PDB/mmCIF/SDF-MOL
49
+ V2000 的 **ball-and-stick / space-filling** 软件光栅化(透视、z-buffer、Phong 光照、
50
+ 深度雾化、CPK 配色),支持拖拽旋转、滚轮缩放与表示切换;12,000 重原子封顶,
51
+ 明确拒绝 V3000,并标注"preview, not for measurements"。根包 `0.24.1 → 0.25.0`、
52
+ 面板包 `0.1.10 → 0.1.11`。同版修掉客户端 bundler 丢掉 re-export imported name 的缺陷。
53
+ - **0.24.1 是输入边界与数据安全加固版(工具数不变)**:修掉 BibTeX 导出覆盖文献库、
54
+ 相对路径按 provider cwd 解析、四个默认写 SVG 的工具被误标并发安全、批量 glob /
55
+ `max_files` / 坏路径、文献与协议库坏条目,以及 20 万行表格 / 13 万条 reads 触发的
56
+ `Math.min(...)` RangeError。面板包 `0.1.9 → 0.1.10`(客户端产物重建)。
48
57
  - **0.24.0 是数据卫生版**:新增 survey #11 载体/接头污染筛查与 #12 FASTQ 修剪/过滤;
49
58
  同时修掉 FASTQ QC 接头表里 Small RNA 5' 的 RNA→DNA 归一化静默错(旧表在 DNA reads 上
50
59
  只能命中反向互补)。工具 62 → 64,benchmark 63 → 65;客户端产物仅因
@@ -166,9 +175,10 @@ v16 的 CRISPR 已有订购 CSV 先例,可直接复用格式。
166
175
  (`molbio-figure` 投影 + `FIGURE_TOOL_KEYS`;logo 画图,gRNA 画图谱并列出前 5 条 guide)。
167
176
  `test/map-card.mjs` 现在同时跑两种卡片,`contract` 从产物里的两张 key 表反推投影数。
168
177
  - 文献库**写回**需先定并发契约(当前只读);
169
- - 结构文件的浏览器内预览(`.pdb/.cif/.sdf/.mol`):`ctx.documentPreviews.register` +
170
- keyed 座位是**容器**,不是现成的 3D 查看器——要么只做 2D 投影(Cα 轨迹/二级结构条带),
171
- 要么单独估工。
178
+ - ✅ **已做(0.25.0)**:结构文件的浏览器内预览(`.pdb/.ent/.cif/.mmcif/.sdf/.mol`)
179
+ 接在现有 Molbio tab 里,做**真实 3D 原子模型**(ball-and-stick / space-filling,
180
+ 透视 + z-buffer + Phong 光照);拖拽旋转、滚轮缩放。没有走 `ctx.documentPreviews`,
181
+ 也没有做卡通 ribbon、分子表面或静电势。边界与测试见 [history.md](history.md) 的 v0.25.0 段。
172
182
 
173
183
  ### 2.10 ✅ 图表八种全部落地(0.17.0 第一批 3 种,0.18.0 第二批 3 种 + 色带)
174
184
 
@@ -150,9 +150,9 @@ pnpm 的 hoisted linker 会在 `node_modules/` 留下一个指向仓库目录的
150
150
  ```
151
151
  node test/contract.mjs → 17 checks passed(host 契约、client 产物、tarball 覆盖、声明回退的突变自检)
152
152
  node test/preset-health.mjs → all 29 rows load on dsh 0.2.2-rc.1
153
- node test/smoke.mjs → registered 62 tools, all smoke tests passed
153
+ node test/smoke.mjs → registered 64 tools, all smoke tests passed
154
154
  node test/drift-probe.mjs → 16/16(15 个突变全部被抓到)
155
- node benchmark/run.mjs --offline → 63 tasks checked against the shipped tools
155
+ node benchmark/run.mjs --offline → 65 tasks checked against the shipped tools
156
156
  ```
157
157
 
158
158
  `test/release.mjs` 在完整权限的本地会话里整套通过(包括末尾的 npm 版本探测);
package/fastq-qc.mjs CHANGED
@@ -115,7 +115,10 @@ function entropyOf(counts) {
115
115
  * that tapers is not mistaken for a quality drop.
116
116
  */
117
117
  function perPosition(entries, maxBases) {
118
- const longest = Math.max(...entries.map((entry) => entry.sequence.length));
118
+ // A loop, not Math.max(...lengths): a 200k-read run is within the supported
119
+ // cap and would otherwise overflow the argument limit with a RangeError.
120
+ let longest = 0;
121
+ for (const entry of entries) if (entry.sequence.length > longest) longest = entry.sequence.length;
119
122
  const length = Math.min(longest, maxBases);
120
123
  const rows = [];
121
124
  for (let position = 0; position < length; position++) {
@@ -411,9 +414,24 @@ export function fastqQcReport(entries, options = {}) {
411
414
  }
412
415
 
413
416
  const perBase = perPosition(selected, settings.max_plot_bases);
414
- const summaryQualities = selected.flatMap((entry) => [...entry.quality].map(phredOf));
415
- const q20 = summaryQualities.filter((value) => value >= 20).length;
416
- const q30 = summaryQualities.filter((value) => value >= 30).length;
417
+ // One streaming pass over the qualities. The previous version materialised
418
+ // every Phred score of every sampled read in one array (200k reads x 150 bp
419
+ // is 30 million numbers) and then filtered it twice; the counters need none
420
+ // of that memory, and the statistics are unchanged.
421
+ let qualitySum = 0;
422
+ let qualityCount = 0;
423
+ let q20 = 0;
424
+ let q30 = 0;
425
+ for (const entry of selected) {
426
+ for (const character of entry.quality) {
427
+ const value = phredOf(character);
428
+ qualitySum += value;
429
+ qualityCount++;
430
+ if (value >= 20) q20++;
431
+ if (value >= 30) q30++;
432
+ }
433
+ }
434
+ if (qualityCount === 0) throw new MolbioInputError('the FASTQ reads contain no quality values');
417
435
  const gcCount = selected.reduce((sum, entry) => {
418
436
  let gc = 0;
419
437
  for (const base of entry.sequence) if (base === 'G' || base === 'C') gc++;
@@ -441,9 +459,9 @@ export function fastqQcReport(entries, options = {}) {
441
459
  gc_percent: Math.round(gcFraction * 10000) / 100,
442
460
  n_bases: nCount,
443
461
  n_percent: Math.round((nCount / allBases) * 10000) / 100,
444
- quality_mean: Math.round(mean(summaryQualities) * 100) / 100,
445
- quality_q20_percent: Math.round((q20 / summaryQualities.length) * 10000) / 100,
446
- quality_q30_percent: Math.round((q30 / summaryQualities.length) * 10000) / 100,
462
+ quality_mean: Math.round((qualitySum / qualityCount) * 100) / 100,
463
+ quality_q20_percent: Math.round((q20 / qualityCount) * 10000) / 100,
464
+ quality_q30_percent: Math.round((q30 / qualityCount) * 10000) / 100,
447
465
  quality_tail_below_30: tailPosition(perBase, 30),
448
466
  quality_tail_below_20: tailPosition(perBase, 20),
449
467
  per_base: perBase,
package/host.mjs CHANGED
@@ -27,8 +27,8 @@
27
27
  *
28
28
  * ── WHY NOT JUST ANCHOR ON index.mjs ───────────────────────────────────────
29
29
  *
30
- * `index.mjs` is the TOOL plugin: importing it must register 57 tools and a
31
- * prompt section. A host row naming it would put every tool back into every
30
+ * `index.mjs` is the TOOL plugin: importing it must register all 64 tools and
31
+ * a prompt section. A host row naming it would put every tool back into every
32
32
  * session, which is the thing the preset split exists to avoid. This anchor is
33
33
  * deliberately inert.
34
34
  *
package/index.mjs CHANGED
@@ -2211,7 +2211,14 @@ function fsService(ctx) {
2211
2211
 
2212
2212
  async function readFileBytes(ctx, exec, path) {
2213
2213
  const fs = fsService(ctx);
2214
- const target = await fs.resolve(path);
2214
+ // Resolve a model-supplied relative name against the SESSION workspace, not
2215
+ // the provider's process cwd. `ctx.fs.resolve(path)` alone uses the backend
2216
+ // default (process.cwd()), which is why tool-fs asks `workingDirectory` for
2217
+ // the agent's cwd; the sandbox policy carries the same root here. Absolute
2218
+ // paths are still accepted unchanged (workspaceFilePath leaves them alone).
2219
+ const sandboxPolicyService = ctx.get('sandboxPolicy');
2220
+ const policy = sandboxPolicyService?.resolve({ ...exec?.agent !== undefined ? { session: exec.agent.session } : {} });
2221
+ const target = await fs.resolve(workspaceFilePath(path, exec, policy?.workspaceRoot), { signal: exec?.signal });
2215
2222
  return await fs.readBytes(target, exec?.signal, MAX_DNA_BYTES);
2216
2223
  }
2217
2224
 
@@ -3547,6 +3554,8 @@ const proteinPropsTool = define({
3547
3554
  });
3548
3555
 
3549
3556
  const proteinChargeTool = (ctx) => define({
3557
+ // Always writes protein-charge.svg and may open a viewer.
3558
+ safe: false,
3550
3559
  name: 'molbio_protein_charge',
3551
3560
  description: 'Plot a protein\'s net charge across a pH range and report the isoelectric point on the same curve (Henderson-Hasselbalch summation over the terminal groups, K/R/H positive and D/E/C/Y negative). pka_set selects the pK table: bjellqvist (the set molbio_protein_props uses, so the pI matches) or emboss (the Epk.dat values). The SVG is written to the workspace and opened with the OS default viewer (auto_view, default true); the sampled curve and the charge at pH 7.4 are returned too. pI is an estimate and is only meaningful together with the named pK set.',
3552
3561
  parameters: {
@@ -3620,6 +3629,8 @@ const proteinChargeTool = (ctx) => define({
3620
3629
  });
3621
3630
 
3622
3631
  const dotplotTool = (ctx) => define({
3632
+ // Always writes dotplot.svg and may open a viewer.
3633
+ safe: false,
3623
3634
  name: 'molbio_dotplot',
3624
3635
  description: 'Compute and draw a self-comparison dotplot of a DNA sequence by exact word matching: a black cell at (i,j) means the k-mer at i equals the k-mer at j (direct repeat); a red cell means it equals the REVERSE COMPLEMENT of the k-mer at j (inverted repeat). word defaults to 8 (4-12); lower words find shorter repeats and produce more dots. The plot is binned to a fixed grid so a repetitive sequence cannot produce an enormous file; direct/inverted match counts and any scan/plot caps are reported. The SVG is written and opened with the OS default viewer (auto_view, default true). Exact k-mer view — not a windowed identity/scoring dotplot.',
3625
3636
  parameters: {
@@ -3803,7 +3814,9 @@ const WHEEL_RESIDUE_SCHEMA = {
3803
3814
  };
3804
3815
 
3805
3816
  const helicalWheelTool = (ctx) => define({
3806
- safe: (args) => args.output_path === undefined || args.output_path === '',
3817
+ // It always writes the SVG (output_path only chooses the name), so it is
3818
+ // never concurrency-safe; the old predicate marked the common case safe.
3819
+ safe: false,
3807
3820
  name: 'molbio_helical_wheel',
3808
3821
  description: 'Draw the helical wheel (Schiffer-Edmundson projection) of a peptide or protein stretch and analyse it: residues are placed 100° apart on the circle (3.6 residues per turn of an ideal alpha-helix), coloured by residue class (hydrophobic/polar/acidic/basic), with the Eisenberg hydrophobic moment μH of the stretch and the maximum μH over every window of moment_window residues (11 by default). An amphipathic helix shows one hydrophobic face and one polar face. The tool WRITES an SVG file and opens it with the OS default viewer (auto_view, default true); all values are sequence-propensity estimates from published scales, not a structure prediction.',
3809
3822
  parameters: {
@@ -3922,7 +3935,8 @@ const HYDROPATHY_PEAK_SCHEMA = {
3922
3935
  };
3923
3936
 
3924
3937
  const hydropathyTool = (ctx) => define({
3925
- safe: (args) => args.output_path === undefined || args.output_path === '',
3938
+ // Always writes the SVG; see the helical-wheel note.
3939
+ safe: false,
3926
3940
  name: 'molbio_hydropathy_plot',
3927
3941
  description: 'Plot the Kyte-Doolittle hydropathy profile of a protein: a sliding-window average (window, default 9; use 19-21 for transmembrane spans), the GRAVY value, and every peak whose window average reaches the threshold (default 1.6, the classic membrane-spanning cutoff). The tool WRITES an SVG file and opens it with the OS default viewer (auto_view, default true); the per-residue profile is also returned so it can feed further analysis. Values are the Kyte-Doolittle propensity scale, not a topology prediction.',
3928
3942
  parameters: {
@@ -5484,7 +5498,10 @@ function validateGrnaOptions(args) {
5484
5498
  }
5485
5499
 
5486
5500
  const fastaFastqTool = (ctx) => define({
5487
- safe: (args) => args.action !== 'convert' && args.action !== 'extract',
5501
+ // convert always writes; extract writes only when output_path is given, so
5502
+ // stats/qc (and extract without a path) are the pure cases.
5503
+ safe: (args) => !(args.action === 'convert'
5504
+ || (args.action === 'extract' && args.output_path !== undefined && args.output_path !== '')),
5488
5505
  name: 'molbio_fasta_fastq',
5489
5506
  description: 'Work with FASTA/FASTQ files in the workspace. action=stats: per-entry and overall statistics (length/GC). action=extract: pull entries by id (exact or substring) and optionally write them to a FASTA file. action=convert: FASTQ → FASTA (output_path required). action=qc: FASTQ quality statistics (mean/min/max Phred, low-quality fraction, per-position means).',
5490
5507
  parameters: {
@@ -5652,13 +5669,22 @@ async function batchFileList(ctx, exec, args, policy, root) {
5652
5669
  const files = [];
5653
5670
  let truncated = false;
5654
5671
  if (Array.isArray(args.paths) && args.paths.length > 0) {
5655
- for (const supplied of args.paths) {
5672
+ // The directory walk enforces max_files; the explicit list must too, or the
5673
+ // documented 1-500 bound disappears for the easier input form.
5674
+ const maxFiles = Math.min(args.max_files ?? 50, BATCH_LIMITS.maxFiles);
5675
+ for (const supplied of args.paths.slice(0, maxFiles)) {
5656
5676
  const absolute = workspaceFilePath(supplied, exec, root);
5657
- const target = await fs.resolve(absolute);
5658
5677
  const label = relative(root, absolute).split(sep).join('/');
5659
- files.push({ target, label });
5678
+ try {
5679
+ const target = await fs.resolve(absolute);
5680
+ files.push({ target, label });
5681
+ } catch (error) {
5682
+ // A bad explicit path is one error ROW, not a failed batch: the same
5683
+ // contract the directory walk provides for files it cannot parse.
5684
+ files.push({ label, resolveError: error instanceof Error ? error.message : String(error) });
5685
+ }
5660
5686
  }
5661
- return { fs, files, truncated, maxBytes };
5687
+ return { fs, files, truncated: args.paths.length > files.length, maxBytes };
5662
5688
  }
5663
5689
  if (typeof fs.listDir !== 'function') {
5664
5690
  throw new MolbioInputError('this harness fs service has no listDir; pass an explicit paths array instead of a directory');
@@ -5818,6 +5844,10 @@ const batchAnalyzeTool = (ctx) => define({
5818
5844
  const rows = [];
5819
5845
  for (const file of files) {
5820
5846
  const format = detectBatchFormat(file.label, '');
5847
+ if (file.resolveError !== undefined) {
5848
+ rows.push(batchErrorRow(file.label, format, file.resolveError));
5849
+ continue;
5850
+ }
5821
5851
  try {
5822
5852
  const info = file.size === undefined ? await fs.stat(file.target) : { size: file.size };
5823
5853
  if (info?.size !== undefined && info.size > maxBytes) {
@@ -7441,7 +7471,14 @@ const pubmedAbstractTool = (ctx) => define({
7441
7471
  }
7442
7472
  const apiKey = await pubmedKey(args, ctx);
7443
7473
  const url = pubmedFetchUrl(pmids, apiKey);
7444
- const result = await web.fetch({ url }, exec?.signal);
7474
+ let result;
7475
+ try {
7476
+ result = await web.fetch({ url }, exec?.signal);
7477
+ } catch (error) {
7478
+ // Same contract as eutilsRequest: a transport failure is a tool-input
7479
+ // error the caller can report, not a raw exception escaping the tool.
7480
+ throw new MolbioInputError(`PubMed efetch could not be reached: ${error instanceof Error ? error.message : String(error)}`);
7481
+ }
7445
7482
  const body = result?.body?.content;
7446
7483
  if (typeof body !== 'string' || body === '') {
7447
7484
  throw new MolbioInputError(`efetch returned no content (status ${result?.statusCode ?? 'unknown'})`);
@@ -7501,9 +7538,13 @@ const bibtexTool = (ctx) => define({
7501
7538
  const { fs, path, policy } = paperServiceDeps(ctx, exec, args);
7502
7539
  const library = await loadLibrary(fs, path);
7503
7540
  const bibtex = toBibtex(library.papers, args.tag_filter);
7541
+ // Never fall back to the LIBRARY path itself: a custom `file` without a
7542
+ // .json suffix used to make this replace a no-op, so the export overwrote
7543
+ // the library with BibTeX. Always derive a distinct .bib name.
7544
+ const defaultOutput = /\.json$/i.test(path) ? path.replace(/\.json$/i, '.bib') : `${path}.bib`;
7504
7545
  const output = args.output_path !== undefined && args.output_path !== ''
7505
7546
  ? workspaceFilePath(args.output_path, exec, policy?.workspaceRoot)
7506
- : path.replace(/\.json$/i, '.bib');
7547
+ : defaultOutput;
7507
7548
  await writeWorkspaceFile(fs, output, bibtex, policy);
7508
7549
  return { output_path: output, count: bibtex.trim() === '' ? 0 : library.papers.filter((paper) => args.tag_filter === undefined || args.tag_filter.length === 0 || (paper.tags !== undefined && args.tag_filter.some((tag) => paper.tags.includes(tag)))).length };
7509
7550
  },