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/CHANGELOG.md +109 -0
- package/README.md +3 -1
- package/batch.mjs +5 -1
- package/benchmark/changed.mjs +4 -3
- package/benchmark/profile.mjs +1 -1
- package/build/browser-api.mjs +16 -6
- package/build/client-bundle-core.mjs +4 -1
- package/build/client-bundle.mjs +1 -1
- package/build/client-entry.mjs +141 -4
- package/build/panel-core.mjs +52 -4
- package/build/preset-patch.mjs +1 -1
- package/build/release-preflight.mjs +1 -1
- package/charts.mjs +35 -16
- package/cordis.patch.yml +1 -1
- package/docs/client-panel.md +12 -6
- package/docs/history.md +54 -0
- package/docs/maintainer.md +1 -1
- package/docs/roadmap.md +14 -4
- package/docs/store-evidence.md +2 -2
- package/fastq-qc.mjs +25 -7
- package/host.mjs +2 -2
- package/index.mjs +51 -10
- package/lib/client.js +1124 -81
- package/package.json +5 -3
- package/papers.mjs +10 -0
- package/plot.mjs +24 -8
- package/preset/install.mjs +8 -0
- package/protein-structure.mjs +10 -2
- package/records.mjs +17 -2
- package/seqio.mjs +39 -14
- package/structure-preview.mjs +395 -0
- package/structure-render.mjs +402 -0
- package/test/charts.mjs +11 -0
- package/test/client.mjs +9 -0
- package/test/panel-render.mjs +73 -4
- package/test/smoke.mjs +90 -0
- package/test/structure.mjs +199 -0
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 =
|
|
134
|
-
const max =
|
|
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,
|
|
638
|
-
const highs = prepared.map((entry) => Math.max(entry.stats.whisker_high,
|
|
639
|
-
const [domainMin, domainMax] = padDomain(
|
|
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(
|
|
699
|
-
const [yMin, yMax] = padDomain(
|
|
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:
|
|
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: [
|
|
726
|
-
y_range: [
|
|
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(
|
|
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,
|
|
894
|
-
const [yMin, yMax] = padDomain(0, Math.max(
|
|
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:
|
|
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 =
|
|
1004
|
-
const max =
|
|
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
|
|
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"
|
package/docs/client-panel.md
CHANGED
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
|
|
11
11
|
| 落点 | 内容 | 数据来源 |
|
|
12
12
|
| --- | --- | --- |
|
|
13
|
-
| **Molbio**(右栏 tab) |
|
|
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
|
-
|
|
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.
|
|
178
|
-
反链)→ SVG;GenBank 文本路径与 Node 工具解析结果**逐字段一致**;
|
|
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 提供
|
|
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
|
**三条教训,都关于“表/边界看起来对、实际不对”。**
|
package/docs/maintainer.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
-
|
|
170
|
-
|
|
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
|
|
package/docs/store-evidence.md
CHANGED
|
@@ -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
|
|
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 →
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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(
|
|
445
|
-
quality_q20_percent: Math.round((q20 /
|
|
446
|
-
quality_q30_percent: Math.round((q30 /
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
},
|