dsh-molbio-tools 0.22.2 → 0.24.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +51 -19
  3. package/benchmark/README.md +7 -7
  4. package/benchmark/fixtures.mjs +32 -4
  5. package/benchmark/run.mjs +1 -0
  6. package/benchmark/sequences.mjs +1 -0
  7. package/benchmark/tasks/02-primers.json +3 -3
  8. package/benchmark/tasks/06-analysis.json +111 -1
  9. package/benchmark/tasks/09-formats.json +44 -1
  10. package/benchmark/verifications.mjs +13 -0
  11. package/build/client-entry.mjs +80 -1
  12. package/build/panel-core.mjs +85 -0
  13. package/build/release-preflight.mjs +1 -1
  14. package/contamination.mjs +229 -0
  15. package/cordis.patch.yml +9 -5
  16. package/design.mjs +111 -78
  17. package/docs/capability-gap-survey.md +5 -0
  18. package/docs/client-panel.md +9 -7
  19. package/docs/history.md +62 -0
  20. package/docs/maintainer.md +8 -5
  21. package/docs/roadmap.md +33 -15
  22. package/docs/rules.md +5 -2
  23. package/docs/store-evidence.md +30 -23
  24. package/docs/store-preset-exemption-issue.md +1 -1
  25. package/docs/store-preset-exemption.md +1 -1
  26. package/docs/v24-plan.md +178 -0
  27. package/docs/workflow.md +11 -10
  28. package/dotplot.mjs +257 -0
  29. package/fastq-qc.mjs +6 -9
  30. package/fastq-trim.mjs +238 -0
  31. package/index.mjs +470 -24
  32. package/lib/client.js +327 -93
  33. package/package.json +8 -3
  34. package/preset/molbio-lab/agent.cordis.yml +19 -13
  35. package/preset/molbio-lab/preset.yml +1 -1
  36. package/protein-charge.mjs +158 -0
  37. package/protein.mjs +36 -14
  38. package/seqio.mjs +10 -0
  39. package/test/client.mjs +12 -5
  40. package/test/contract.mjs +44 -12
  41. package/test/drift-probe.mjs +15 -1
  42. package/test/map-card.mjs +139 -1
  43. package/test/preset-health.mjs +39 -0
  44. package/test/smoke.mjs +288 -11
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-molbio-tools",
3
- "version": "0.22.2",
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.",
3
+ "version": "0.24.0",
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, hydropathy and charge-pH 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, k-mer dotplots, read-level FASTQ QC, vector/adapter contamination screening, FASTQ trimming/filtering, 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",
7
7
  "engines": {
@@ -59,6 +59,10 @@
59
59
  "sanger.mjs",
60
60
  "protein.mjs",
61
61
  "protein-structure.mjs",
62
+ "protein-charge.mjs",
63
+ "dotplot.mjs",
64
+ "contamination.mjs",
65
+ "fastq-trim.mjs",
62
66
  "taqman.mjs",
63
67
  "multiplex.mjs",
64
68
  "methylation.mjs",
@@ -98,7 +102,8 @@
98
102
  "0.2.1-rc.2": "compatible",
99
103
  "0.2.1-rc.3": "compatible",
100
104
  "0.2.1-rc.4": "unknown",
101
- "0.2.1-rc.5": "compatible"
105
+ "0.2.1-rc.5": "compatible",
106
+ "0.2.2-rc.1": "compatible"
102
107
  }
103
108
  },
104
109
  "bundle": {
@@ -17,7 +17,7 @@
17
17
  #
18
18
  # Edit THIS file, then run `node build/preset-patch.mjs`.
19
19
  #
20
- # NOTE: the mode is the ONLY place these 60 tools load. The same bundle patch's
20
+ # NOTE: the mode is the ONLY place these 64 tools load. The same bundle patch's
21
21
  # other insert (`molbio-client` → `./host.mjs`) deliberately registers nothing —
22
22
  # registering the tool at the host level as well would put it in every session
23
23
  # and is not what this package is for.
@@ -48,18 +48,20 @@
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.3 (harness root
51
+ # Baseline: the `standard` preset of dsh 0.2.2-rc.1 (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.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`,
60
- # `tool-subagent-claude-code`) and `tool-ralph`; dropped the persona `suffix` and
61
- # both `backgroundMode: continuable` settings; and reworded the plan-mode
62
- # section. Absorb such a bump verbatim — see workflow.md §4.
56
+ # 0.2.2-rc.1 is the FIRST row change since 0.2.1-rc.2 dropped the optional
57
+ # subagent providers and `tool-ralph`. It folded `list_agents` into
58
+ # `@deepseek-ai/dsh-tool-subagent-control` (the `./list-agents` subpath export is
59
+ # gone, so the `tool-subagent-list-agents` row was dropped here), renamed the
60
+ # `tool-subagent` model-selection settings to `modelSelection` +
61
+ # `modelDiscovery`, and made `applyPatch: false` explicit on `tool-fs`. A row
62
+ # that names a deleted package subpath still PASSES a directory-existence check:
63
+ # `test/preset-health.mjs` now resolves the specifier the way the Loader does.
64
+ # Absorb such a bump verbatim — see workflow.md §4.
63
65
  #
64
66
  # Keeping the text verbatim is not cosmetic: a preset mounts DSH's OWN
65
67
  # packages, so upstream drift here kills the whole preset at mount (the failure
@@ -100,6 +102,11 @@
100
102
  disabled: !!js process.platform !== 'win32'
101
103
  - id: tool-fs
102
104
  name: '@deepseek-ai/dsh-tool-fs'
105
+ config:
106
+ # Set false to drop the apply_patch tool beside edit. (0.2.2-rc.1 makes this
107
+ # explicit in the shipped standard preset; the package default is false, and
108
+ # the row is kept verbatim so the two stay comparable.)
109
+ applyPatch: false
103
110
  - id: tool-fs-search
104
111
  name: '@deepseek-ai/dsh-tool-fs-search'
105
112
  config:
@@ -162,14 +169,13 @@
162
169
  config:
163
170
  - id: tool-subagent-control
164
171
  name: '@deepseek-ai/dsh-tool-subagent-control'
165
- - id: tool-subagent-list-agents
166
- name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
167
172
  - id: tool-subagent
168
173
  name: '@deepseek-ai/dsh-tool-subagent'
169
174
  config:
170
175
  provider: spawn
171
176
  toolName: subagent
172
- modelSelectionSettings: true
177
+ modelSelection: true
178
+ modelDiscovery: true
173
179
  - id: tool-subagent-fork
174
180
  name: '@deepseek-ai/dsh-tool-subagent'
175
181
  config:
@@ -201,7 +207,7 @@
201
207
  name: '@deepseek-ai/dsh-plugin-market/tools'
202
208
  disabled: true
203
209
 
204
- # dsh-molbio-tools: molecular-biology toolset (60 tools across sequence
210
+ # dsh-molbio-tools: molecular-biology toolset (64 tools across sequence
205
211
  # analysis, primers (incl. TaqMan probes and multiplex compatibility),
206
212
  # in-silico PCR, cloning (incl. Golden Gate), enzyme catalog, methylation-aware
207
213
  # and double digests, plasmids, Sanger verification, proteins (incl. helical
@@ -1,2 +1,2 @@
1
1
  name: Molecular Biology Lab
2
- description: 分子生物学专属模式:标准编码能力 + 60 个 molbio_* 工具(序列分析、引物设计(含跨内含子 qPCR、盐/浓度旋钮与目标位置偏好)、TaqMan 水解探针(含 MGB 与订购 CSV)与多重 PCR 互扰/退火温度/浓度建议、克隆模拟与选酶(含 Golden Gate)、酶目录查询、甲基化敏感位点与双酶切 buffer 兼容检查、多序列比对(每个输入残基都会进比对结果)、保守性分析与比对后处理(共识/缺口修剪/同一性矩阵/逐列覆盖度,含 sequence logo 图)、CRISPR gRNA 设计(SpCas9/SaCas9/Cas12a/Cas13,家族特定 PAM/PFS/种子)与脱靶搜索、SnapGene/GenBank 解析与质粒图谱、Sanger 测序验证、蛋白性质/密码子优化/螺旋轮投影与疏水性图、qPCR 与绘图、虚拟凝胶、实验台分析(FASTQ 质控、密码子使用、系统发生树(长叶名自动折行)、in-silico PCR、CpG/GC 组成、批量文件分析/CSV)、静默酶切位点突变设计——生成的 SVG 图片自动用系统默认应用打开,文献/协议/实验记录)。
2
+ description: 分子生物学专属模式:标准编码能力 + 64 个 molbio_* 工具(序列分析、引物设计(含跨内含子 qPCR、盐/浓度旋钮与目标位置偏好)、TaqMan 水解探针(含 MGB 与订购 CSV)与多重 PCR 互扰/退火温度/浓度建议、克隆模拟与选酶(含 Golden Gate)、酶目录查询、甲基化敏感位点与双酶切 buffer 兼容检查、多序列比对(每个输入残基都会进比对结果)、保守性分析与比对后处理(共识/缺口修剪/同一性矩阵/逐列覆盖度,含 sequence logo 图)、CRISPR gRNA 设计(SpCas9/SaCas9/Cas12a/Cas13,家族特定 PAM/PFS/种子)与脱靶搜索、SnapGene/GenBank 解析与质粒图谱、Sanger 测序验证、蛋白性质/电荷-pH 曲线/密码子优化/螺旋轮投影与疏水性图、qPCR 与绘图、虚拟凝胶、实验台分析(FASTQ 质控、密码子使用、系统发生树(长叶名自动折行)、in-silico PCR、CpG/GC 组成、序列 dotplot、载体/接头污染筛查、FASTQ 修剪/过滤、批量文件分析/CSV)、静默酶切位点突变设计——生成的 SVG 图片自动用系统默认应用打开,文献/协议/实验记录)。
@@ -0,0 +1,158 @@
1
+ /**
2
+ * dsh-molbio-tools/protein-charge.mjs
3
+ *
4
+ * Net-charge-vs-pH titration for a protein, with the pI found on the same
5
+ * curve. Pure calculation plus an SVG renderer: no external data, no services.
6
+ *
7
+ * The pK tables live in `protein.mjs` (PK_SETS) so `molbio_protein_props` and
8
+ * this tool can never disagree about the default set. `emboss` is offered as an
9
+ * explicitly named alternative; every result carries the set it used, because a
10
+ * pI is only meaningful together with its pK values.
11
+ */
12
+ import { MolbioInputError } from './lib.mjs';
13
+ import { PK_SETS, countAminoAcids, isoelectricPoint, netCharge, normalizeProtein } from './protein.mjs';
14
+ import { axis, linearScale, niceTicks, panel, rect, series, svgDocument, textRun } from './svgio.mjs';
15
+
16
+ /** Accepted ranges; the schema checks types and the module checks values. */
17
+ export const CHARGE_LIMITS = {
18
+ maxLength: 20_000,
19
+ minPh: 0,
20
+ maxPh: 14,
21
+ minSteps: 2,
22
+ maxSteps: 141,
23
+ };
24
+
25
+ const round = (value, digits = 4) => Math.round(value * 10 ** digits) / 10 ** digits;
26
+
27
+ /**
28
+ * Compute the titration curve and the pI for one protein.
29
+ *
30
+ * @param {string} raw protein sequence (one-letter codes).
31
+ * @param {{pkaSet?: string, phMin?: number, phMax?: number, steps?: number}} [options]
32
+ * @returns {{length: number, pka_set: string, pka_label: string, ph_min: number,
33
+ * ph_max: number, steps: number, pI: number, charge_at_7_4: number,
34
+ * charge_min: number, charge_max: number, points: Array<{ph: number, charge: number}>}}
35
+ */
36
+ export function chargeCurve(raw, options = {}) {
37
+ const seq = normalizeProtein(raw);
38
+ if (seq.length > CHARGE_LIMITS.maxLength) {
39
+ throw new MolbioInputError('the protein is ' + seq.length + ' aa; the titration limit is ' + CHARGE_LIMITS.maxLength);
40
+ }
41
+ const pkaSet = options.pkaSet ?? 'bjellqvist';
42
+ const pk = PK_SETS[pkaSet];
43
+ if (pk === undefined) {
44
+ throw new MolbioInputError('unknown pka_set ' + JSON.stringify(pkaSet) + '; expected one of ' + Object.keys(PK_SETS).join(', '));
45
+ }
46
+ const phMin = options.phMin ?? 0;
47
+ const phMax = options.phMax ?? 14;
48
+ const steps = options.steps ?? 29;
49
+ if (!(typeof phMin === 'number' && Number.isFinite(phMin) && phMin >= CHARGE_LIMITS.minPh && phMin <= CHARGE_LIMITS.maxPh)) {
50
+ throw new MolbioInputError('ph_min must be a number between ' + CHARGE_LIMITS.minPh + ' and ' + CHARGE_LIMITS.maxPh);
51
+ }
52
+ if (!(typeof phMax === 'number' && Number.isFinite(phMax) && phMax >= CHARGE_LIMITS.minPh && phMax <= CHARGE_LIMITS.maxPh)) {
53
+ throw new MolbioInputError('ph_max must be a number between ' + CHARGE_LIMITS.minPh + ' and ' + CHARGE_LIMITS.maxPh);
54
+ }
55
+ if (!(phMax > phMin)) throw new MolbioInputError('ph_max must be greater than ph_min');
56
+ if (!Number.isInteger(steps) || steps < CHARGE_LIMITS.minSteps || steps > CHARGE_LIMITS.maxSteps) {
57
+ throw new MolbioInputError('steps must be an integer between ' + CHARGE_LIMITS.minSteps + ' and ' + CHARGE_LIMITS.maxSteps);
58
+ }
59
+ const counts = countAminoAcids(seq);
60
+ const points = [];
61
+ for (let index = 0; index < steps; index++) {
62
+ const ph = phMin + ((phMax - phMin) * index) / (steps - 1);
63
+ points.push({ ph: round(ph, 3), charge: round(netCharge(seq, counts, ph, pkaSet), 4) });
64
+ }
65
+ const charges = points.map((point) => point.charge);
66
+ return {
67
+ length: seq.length,
68
+ pka_set: pkaSet,
69
+ pka_label: pk.label,
70
+ ph_min: phMin,
71
+ ph_max: phMax,
72
+ steps,
73
+ pI: isoelectricPoint(seq, counts, pkaSet),
74
+ charge_at_7_4: round(netCharge(seq, counts, 7.4, pkaSet), 4),
75
+ charge_min: Math.min(...charges),
76
+ charge_max: Math.max(...charges),
77
+ points,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Render the titration curve as an SVG document: net charge against pH, with
83
+ * the zero line, the pI marker and the charged ends labelled.
84
+ *
85
+ * @param {ReturnType<typeof chargeCurve>} curve
86
+ * @param {{title?: string}} [options]
87
+ * @returns {string} SVG text.
88
+ */
89
+ export function renderChargeCurve(curve, options = {}) {
90
+ const width = 720;
91
+ const height = 420;
92
+ const frame = panel({
93
+ x: 16,
94
+ y: 16,
95
+ width: width - 32,
96
+ height: height - 32,
97
+ title: options.title ?? 'Net charge vs pH',
98
+ subtitle: curve.length + ' aa · ' + curve.pka_label + ' pK set · pI ' + curve.pI,
99
+ });
100
+ const plot = frame.plot;
101
+ const yMin = Math.min(0, curve.charge_min);
102
+ const yMax = Math.max(0, curve.charge_max);
103
+ const pad = Math.max(0.5, (yMax - yMin) * 0.08);
104
+ const domain = [yMin - pad, yMax + pad];
105
+ const x = linearScale([curve.ph_min, curve.ph_max], [plot.x, plot.x + plot.width]);
106
+ const y = linearScale(domain, [plot.y + plot.height, plot.y]);
107
+ const parts = [frame.markup];
108
+ // Zero-charge line: the sign of the charge is the point of the plot.
109
+ if (domain[0] < 0 && domain[1] > 0) {
110
+ const zero = y.of(0);
111
+ parts.push('<line x1="' + plot.x + '" y1="' + zero + '" x2="' + (plot.x + plot.width) + '" y2="' + zero + '" stroke="#8c959f" stroke-width="1" stroke-dasharray="4 3"/>');
112
+ }
113
+ parts.push(axis({
114
+ from: plot.x,
115
+ to: plot.x + plot.width,
116
+ at: plot.y + plot.height,
117
+ ticks: niceTicks([curve.ph_min, curve.ph_max], 7).map((tick) => x.of(tick)),
118
+ orientation: 'x',
119
+ label: 'pH',
120
+ tickLabel: (pixel) => String(Math.round(x.invert(pixel) * 10) / 10),
121
+ title: 'pH',
122
+ }));
123
+ parts.push(axis({
124
+ from: plot.y,
125
+ to: plot.y + plot.height,
126
+ at: plot.x,
127
+ ticks: niceTicks(domain, 6).map((tick) => y.of(tick)),
128
+ orientation: 'y',
129
+ label: 'net charge',
130
+ tickLabel: (pixel) => String(Math.round(y.invert(pixel) * 10) / 10),
131
+ title: 'net charge (e)',
132
+ }));
133
+ parts.push(series({
134
+ values: curve.points.map((point) => point.charge),
135
+ xOf: (index) => x.of(curve.points[index].ph),
136
+ yOf: (charge) => y.of(charge),
137
+ stroke: '#0969da',
138
+ width: 2,
139
+ }));
140
+ if (curve.pI >= curve.ph_min && curve.pI <= curve.ph_max) {
141
+ const marker = x.of(curve.pI);
142
+ parts.push('<line x1="' + marker + '" y1="' + plot.y + '" x2="' + marker + '" y2="' + (plot.y + plot.height) + '" stroke="#bf3989" stroke-width="1" stroke-dasharray="2 3"/>');
143
+ parts.push(textRun({ x: marker + 4, y: plot.y + 12, text: 'pI ' + curve.pI, size: 9, fill: '#bf3989' }));
144
+ }
145
+ // Label the two ends of the curve with the charges they represent.
146
+ const first = curve.points[0];
147
+ const last = curve.points[curve.points.length - 1];
148
+ parts.push(textRun({ x: x.of(first.ph) + 4, y: y.of(first.charge) - 6, text: 'pH ' + first.ph + ': ' + first.charge, size: 9, fill: '#57606a' }));
149
+ parts.push(textRun({ x: x.of(last.ph) - 4, y: y.of(last.charge) + 14, text: 'pH ' + last.ph + ': ' + last.charge, size: 9, fill: '#57606a', anchor: 'end' }));
150
+ parts.push(rect({ x: plot.x, y: plot.y, width: plot.width, height: plot.height, fill: 'none', stroke: '#d0d7de' }));
151
+ return svgDocument({
152
+ width,
153
+ height,
154
+ title: options.title ?? 'Net charge vs pH',
155
+ description: 'Titration curve for a ' + curve.length + ' aa protein using the ' + curve.pka_label + ' pK set; pI ' + curve.pI + '.',
156
+ body: parts.join('\n'),
157
+ });
158
+ }
package/protein.mjs CHANGED
@@ -32,48 +32,70 @@ const RESIDUE_MASS = {
32
32
  };
33
33
  const WATER = 18.015;
34
34
 
35
- // Bjellqvist 1993 pK values (EMBOSS-style)
36
- const PK = {
37
- Nterm: 8.6, Cterm: 3.6, C: 9.0, D: 4.05, E: 4.45, H: 6.04,
38
- K: 10.8, R: 12.0, Y: 10.3,
35
+ /**
36
+ * Selectable pK sets for the Henderson-Hasselbalch charge model. `bjellqvist`
37
+ * is the set `proteinProperties` has always used for its pI; `emboss` is the
38
+ * Epk.dat table EMBOSS ships with `iep`. They are NOT interchangeable — the
39
+ * reported set name travels with every charge/pI value.
40
+ */
41
+ export const PK_SETS = {
42
+ bjellqvist: {
43
+ label: 'Bjellqvist 1993',
44
+ Nterm: 8.6, Cterm: 3.6, C: 9.0, D: 4.05, E: 4.45, H: 6.04,
45
+ K: 10.8, R: 12.0, Y: 10.3,
46
+ },
47
+ emboss: {
48
+ label: 'EMBOSS Epk.dat',
49
+ Nterm: 8.6, Cterm: 3.6, C: 8.5, D: 3.9, E: 4.1, H: 6.5,
50
+ K: 10.8, R: 12.5, Y: 10.1,
51
+ },
39
52
  };
40
53
 
54
+ const PK = PK_SETS.bjellqvist;
55
+
41
56
  const HYDROPATHY = {
42
57
  A: 1.8, R: -4.5, N: -3.5, D: -3.5, C: 2.5, Q: -3.5, E: -3.5,
43
58
  G: -0.4, H: -3.2, I: 4.5, L: 3.8, K: -3.9, M: 1.9, F: 2.8,
44
59
  P: -1.6, S: -0.8, T: -0.7, W: -0.9, Y: -1.3, V: 4.2,
45
60
  };
46
61
 
47
- function countAminoAcids(seq) {
62
+ export function countAminoAcids(seq) {
48
63
  const counts = {};
49
64
  for (const aa of seq) counts[aa] = (counts[aa] ?? 0) + 1;
50
65
  return counts;
51
66
  }
52
67
 
53
- function chargeAt(seq, counts, pH) {
68
+ /**
69
+ * Net charge at a given pH by Henderson-Hasselbalch summation. The terminal
70
+ * groups count once each; K/R/H are positive, D/E/C/Y negative. `pkaSet` is a
71
+ * key of PK_SETS (or a set object), so callers can name the table they used.
72
+ */
73
+ export function netCharge(seq, counts, pH, pkaSet = 'bjellqvist') {
74
+ const pk = typeof pkaSet === 'string' ? PK_SETS[pkaSet] : pkaSet;
75
+ if (pk === undefined) throw new MolbioInputError('unknown pK set ' + JSON.stringify(pkaSet) + '; expected one of ' + Object.keys(PK_SETS).join(', '));
54
76
  let charge = 0;
55
77
  const pos = (pKa) => 1 / (1 + Math.pow(10, pH - pKa));
56
78
  const neg = (pKa) => 1 / (1 + Math.pow(10, pKa - pH));
57
- charge += pos(PK.Nterm);
58
- for (const [aa, pKa] of [['K', PK.K], ['R', PK.R], ['H', PK.H]]) {
79
+ charge += pos(pk.Nterm);
80
+ for (const [aa, pKa] of [['K', pk.K], ['R', pk.R], ['H', pk.H]]) {
59
81
  if (counts[aa] !== undefined) charge += counts[aa] * pos(pKa);
60
82
  }
61
- charge -= neg(PK.Cterm);
62
- for (const [aa, pKa] of [['D', PK.D], ['E', PK.E], ['C', PK.C], ['Y', PK.Y]]) {
83
+ charge -= neg(pk.Cterm);
84
+ for (const [aa, pKa] of [['D', pk.D], ['E', pk.E], ['C', pk.C], ['Y', pk.Y]]) {
63
85
  if (counts[aa] !== undefined) charge -= counts[aa] * neg(pKa);
64
86
  }
65
87
  return charge;
66
88
  }
67
89
 
68
90
  /** Isoelectric point by bisection over the net-charge function. */
69
- export function isoelectricPoint(seq, counts) {
91
+ export function isoelectricPoint(seq, counts, pkaSet = 'bjellqvist') {
70
92
  let lo = 0;
71
93
  let hi = 14;
72
- let chargeLo = chargeAt(seq, counts, lo);
73
- let chargeHi = chargeAt(seq, counts, hi);
94
+ let chargeLo = netCharge(seq, counts, lo, pkaSet);
95
+ let chargeHi = netCharge(seq, counts, hi, pkaSet);
74
96
  for (let i = 0; i < 60; i++) {
75
97
  const mid = (lo + hi) / 2;
76
- const chargeMid = chargeAt(seq, counts, mid);
98
+ const chargeMid = netCharge(seq, counts, mid, pkaSet);
77
99
  if (Math.abs(chargeMid) < 1e-4) return Math.round(mid * 100) / 100;
78
100
  if (Math.sign(chargeMid) === Math.sign(chargeLo)) {
79
101
  lo = mid;
package/seqio.mjs CHANGED
@@ -84,6 +84,16 @@ export function toFasta(entries) {
84
84
  return entries.map((entry) => `>${entry.id}${entry.description !== '' ? ' ' + entry.description : ''}\n${entry.sequence}`).join('\n') + '\n';
85
85
  }
86
86
 
87
+ /** Render entries as four-line FASTQ text (quality is preserved verbatim). */
88
+ export function toFastq(entries) {
89
+ return entries.map((entry) => {
90
+ if (typeof entry.quality !== 'string' || entry.quality.length !== entry.sequence.length) {
91
+ throw new MolbioInputError(`entry ${entry.id}: sequence and quality lengths differ`);
92
+ }
93
+ return `@${entry.id}${entry.description !== undefined && entry.description !== '' ? ' ' + entry.description : ''}\n${entry.sequence}\n+\n${entry.quality}`;
94
+ }).join('\n') + '\n';
95
+ }
96
+
87
97
  /** Summary statistics over parsed entries. */
88
98
  export function entryStats(entries, qualityMode = false) {
89
99
  const lengths = entries.map((entry) => entry.sequence.length);
package/test/client.mjs CHANGED
@@ -144,6 +144,8 @@ assert.deepEqual(effects, [
144
144
  'molbio panel: papers tab title',
145
145
  'molbio panel: molbio_plasmid_map card',
146
146
  'molbio panel: molbio_plasmid_map_file card',
147
+ 'molbio panel: molbio_sequence_logo card',
148
+ 'molbio panel: molbio_grna_design card',
147
149
  'molbio panel: NCBI key section gate',
148
150
  'molbio panel: NCBI key section',
149
151
  ]);
@@ -186,9 +188,9 @@ assert.deepEqual(
186
188
  assert.equal(slots.registrations.length, 4, 'both tab bodies and titles land as soon as their seat exists');
187
189
  slots.declare('tool.call.toolview');
188
190
 
189
- // Body + title register in the keyed seats under each type's id, and the two
190
- // map tools claim their own `tool.call.toolview` key (an unclaimed key falls
191
- // back to the generic tool row, so claiming ours is additive).
191
+ // Body + title register in the keyed seats under each type's id; two map tools
192
+ // and two figure tools claim their own `tool.call.toolview` keys (an unclaimed
193
+ // key falls back to the generic tool row, so claiming ours is additive).
192
194
  assert.deepEqual(
193
195
  slots.registrations.map((entry) => [entry.registration.name, entry.registration.key]),
194
196
  [
@@ -198,6 +200,8 @@ assert.deepEqual(
198
200
  ['sidebar.right.pane.tab.title', 'dsh-molbio-tools/papers'],
199
201
  ['tool.call.toolview', 'molbio_plasmid_map'],
200
202
  ['tool.call.toolview', 'molbio_plasmid_map_file'],
203
+ ['tool.call.toolview', 'molbio_sequence_logo'],
204
+ ['tool.call.toolview', 'molbio_grna_design'],
201
205
  ],
202
206
  );
203
207
  const claim = (name, key) => slots.registrations.find(
@@ -206,6 +210,9 @@ const claim = (name, key) => slots.registrations.find(
206
210
  assert.equal(typeof claim('sidebar.right.pane.tab', 'dsh-molbio-tools').component, 'function', 'the plasmid body is a component');
207
211
  assert.equal(typeof claim('sidebar.right.pane.tab', 'dsh-molbio-tools/papers').component, 'function', 'the papers body is a component');
208
212
  assert.equal(typeof claim('tool.call.toolview', 'molbio_plasmid_map').component, 'function', 'the map card is a component');
213
+ assert.equal(typeof claim('tool.call.toolview', 'molbio_sequence_logo').component, 'function', 'the logo card is a component');
214
+ assert.equal(claim('tool.call.toolview', 'molbio_sequence_logo').component, claim('tool.call.toolview', 'molbio_grna_design').component, 'the two figure tools share one card component');
215
+ assert.equal(claim('tool.call.toolview', 'molbio_grna_design').registration.inject, undefined, 'the figure card declares no injection');
209
216
  for (const key of ['dsh-molbio-tools', 'dsh-molbio-tools/papers']) {
210
217
  const face = claim('sidebar.right.pane.tab', key).registration.inject('session-1', {});
211
218
  assert.equal(typeof face.remote.workspaceFiles.list, 'function', 'the inject factory hands the body the workspace Remote');
@@ -220,13 +227,13 @@ assert.equal(claim('tool.call.toolview', 'molbio_plasmid_map').registration.inje
220
227
  // A re-declaration of the seat (the shell's declaration-epoch rule) re-runs the
221
228
  // wait: the previous entries are disposed first, so claims never accumulate.
222
229
  slots.declare('tool.call.toolview');
223
- assert.equal(slots.registrations.length, 6, 're-declaring the seat re-installs both cards instead of duplicating them');
230
+ assert.equal(slots.registrations.length, 8, 're-declaring the seat re-installs all four cards instead of duplicating them');
224
231
 
225
232
  // The Plugins page's detail seat belongs to the plugin manager. The section
226
233
  // lands when that seat is declared, and the component itself decides whether
227
234
  // the open subject is this package.
228
235
  slots.declare('plugins.detail.section');
229
- assert.equal(slots.registrations.length, 7, 'the key section lands when the plugin manager declares its seat');
236
+ assert.equal(slots.registrations.length, 9, 'the key section lands when the plugin manager declares its seat');
230
237
  const keyClaim = slots.registrations.find((entry) => entry.registration.name === 'plugins.detail.section');
231
238
  assert.ok(keyClaim, 'the key section is claimed in plugins.detail.section');
232
239
  assert.equal(keyClaim.registration.id, 'molbio-ncbi-key', 'the list seat is addressed by an id of our own');
package/test/contract.mjs CHANGED
@@ -228,7 +228,14 @@ check('the shell derives hook props as use<Capitalised(Source)>', () => {
228
228
  check('a session-scope tab body receives sessionId + useSessions (official precedent)', () => {
229
229
  const files = clientBundle('@deepseek-ai/dsh-client-ui-sidebar-files');
230
230
  assert.ok(files !== undefined, 'ui-sidebar-files is installed');
231
- assert.ok(files.text.includes('function FilesBody({ useTabInfo, sessionId'), 'the official tab body destructures sessionId from its props');
231
+ // 0.2.2-rc.1 renamed the tab body (`FilesBody` -> `FileNavigator`) without
232
+ // changing what the runtime injects into it. The CONTRACT is the props, not
233
+ // the function's name, so match the destructuring rather than one identifier.
234
+ assert.match(
235
+ files.text,
236
+ /function [A-Za-z_$][\w$]*\(\{(?=[^}]*\buseTabInfo\b)(?=[^}]*\bsessionId\b)(?=[^}]*\buseSessions\b)/,
237
+ 'the official tab body receives useTabInfo + sessionId + useSessions from its props',
238
+ );
232
239
  assert.ok(files.text.includes('useSessions((sessions) => sessions.byId[sessionId]?.cwd)'), 'and reads the workspace root exactly as the panel does');
233
240
  assert.ok(files.text.includes('"slots"') && files.text.includes('"sidebarRightTabs"'), 'and injects the same registries');
234
241
  });
@@ -432,7 +439,7 @@ check('the published tarball carries the client half and exactly one plugin mani
432
439
  // SUBMISSION_PACKAGE_AMBIGUOUS, plus a duplicate 427 KB client bundle in every
433
440
  // download. The panel does not disappear for npm users: the root package's own
434
441
  // `lib/client.js` already carries it (the panel-only package exists for people
435
- // who want the tab without the 60 tools).
442
+ // who want the tab without the 64 tools).
436
443
  assert.ok(
437
444
  !files.includes('packages'),
438
445
  'the root tarball must not embed packages/molbio-panel: a second `dsh` manifest inside one package is an ambiguous submission, and the panel is published separately',
@@ -468,8 +475,16 @@ check('the toolview card contract still holds (meta path + keyed seat)', () => {
468
475
  // path that still carries the value — consulted, snapshotted, attached as
469
476
  // `meta` — rather than a literal upstream is free to drop (the panel does not
470
477
  // depend on nested calls being excluded, only on the root call carrying it).
471
- const tools = hostHalf('@deepseek-ai/dsh-tools');
472
- assert.ok(tools !== undefined, 'dsh-tools is installed');
478
+ // The projection lives in the assembly pipeline. 0.2.2-rc.1 moved that code
479
+ // out of `lib/index.js` into `lib/pipeline.js` (same call, same snapshot, same
480
+ // attachment), so read whichever files the installed package ships instead of
481
+ // pinning the one upstream happened to use before.
482
+ const toolsDir = packageDir('@deepseek-ai/dsh-tools');
483
+ const toolsFiles = ['lib/index.js', 'lib/pipeline.js']
484
+ .map((rel) => join(toolsDir, rel))
485
+ .filter((path) => existsSync(path));
486
+ assert.ok(toolsFiles.length > 0, 'dsh-tools is installed');
487
+ const tools = { text: toolsFiles.map((path) => readFileSync(path, 'utf8')).join('\n') };
473
488
  assert.ok(/output\.presentationMeta\s*!==\s*void 0/.test(tools.text), 'the tool layer still consults output.presentationMeta');
474
489
  assert.ok(tools.text.includes('snapshotProjection(tool.name, "presentationMeta"'), 'and records the projection on the result');
475
490
  assert.ok(
@@ -493,16 +508,25 @@ check('the toolview card contract still holds (meta path + keyed seat)', () => {
493
508
  // key whose tool forgot `presentationMeta`.
494
509
  const source = readFileSync(join(packageRoot, 'index.mjs'), 'utf8');
495
510
  const bundle = readFileSync(join(packageRoot, 'lib', 'client.js'), 'utf8');
496
- const keys = /const MAP_TOOL_KEYS = \[([^\]]*)\]/.exec(bundle);
497
- assert.ok(keys !== null, 'the bundle declares the tool keys whose cards it draws');
498
- const claimed = keys[1].split(',').map((entry) => entry.trim().replace(/^['"]|['"]$/g, '')).filter((entry) => entry !== '');
511
+ const keyList = (name) => {
512
+ const match = new RegExp('const ' + name + ' = \\[([^\\]]*)\\]').exec(bundle);
513
+ assert.ok(match !== null, 'the bundle declares ' + name + ', the tool keys whose cards it draws');
514
+ return match[1].split(',').map((entry) => entry.trim().replace(/^['"]|['"]$/g, '')).filter((entry) => entry !== '');
515
+ };
516
+ // Two card shapes share the seat: the plasmid-map card (MAP_TOOL_KEYS) and the
517
+ // figure card (FIGURE_TOOL_KEYS). Both lists are read from the bundle, so a
518
+ // third key whose tool forgets its projection fails here either way.
519
+ const claimed = [...keyList('MAP_TOOL_KEYS'), ...keyList('FIGURE_TOOL_KEYS')];
499
520
  assert.ok(claimed.length > 0, 'the bundle claims at least one card key');
500
521
  assert.equal(
501
- (source.match(/presentationMeta\(_args, value\)/g) ?? []).length,
522
+ // Anchored: the file-header comment also mentions the signature, and a
523
+ // substring count would treat that prose as a fourth implementation.
524
+ (source.match(/^ presentationMeta\((?:args|_args), value\)/gm) ?? []).length,
502
525
  claimed.length,
503
526
  'every claimed card key belongs to a tool that declares the projection',
504
527
  );
505
- assert.ok(source.includes('mapCardMeta'), 'and build it through the shared projection');
528
+ assert.ok(source.includes('mapCardMeta'), 'and build it through the shared map projection');
529
+ assert.ok(source.includes('figureCardMeta'), 'and the shared figure projection');
506
530
  for (const key of claimed) {
507
531
  // The host half registers a tool under that exact name.
508
532
  assert.ok(source.includes(`name: '${key}'`), `the host registers the tool ${key} whose card the bundle claims`);
@@ -710,8 +734,8 @@ check('a tool may show the model a picture the way read_image does', () => {
710
734
  // The count is deliberate: it is what catches a NEW picture tool that forgets
711
735
  // the hand-off (or a report tool that quietly grows it).
712
736
  const index = readFileSync(join(packageRoot, 'index.mjs'), 'utf8');
713
- assert.equal((index.match(/image: ATTACHED_IMAGE_SCHEMA/g) ?? []).length, 15, 'all 15 picture tools declare the image output field');
714
- assert.equal((index.match(/\.\.\.ATTACH_IMAGE_PARAM/g) ?? []).length, 15, 'and offer the opt-in attach_image parameter');
737
+ assert.equal((index.match(/image: ATTACHED_IMAGE_SCHEMA/g) ?? []).length, 17, 'all 17 picture tools declare the image output field');
738
+ assert.equal((index.match(/\.\.\.ATTACH_IMAGE_PARAM/g) ?? []).length, 17, 'and offer the opt-in attach_image parameter');
715
739
  assert.ok(/output\.render/.test(index) || index.includes('imageBlockFor(value)'), 'the block builder is wired into define()');
716
740
  });
717
741
 
@@ -724,7 +748,15 @@ check('the workspace still cannot hold a binary file (the reason the picture rid
724
748
  // the local backend (the base is consulted last, only as a presence fallback).
725
749
  const fsTypes = declarationEvidence('@deepseek-ai/dsh-fs', ['lib/types/types.d.ts', '../dsh-fs-local/lib/index.js', 'lib/index.js']).text;
726
750
  const fsReadme = readFileSync(join(packageDir('@deepseek-ai/dsh-fs'), 'README.md'), 'utf8');
727
- assert.match(fsReadme, /binary-safe mutations remain deferred/i, 'and still documents binary mutations as deferred');
751
+ // 0.2.2-rc.1 reworded this: the old "binary-safe mutations remain deferred"
752
+ // note became the precise rule under "Known Limitations and Deferred Work"
753
+ // ("Literal edits, patches, and moves require UTF-8 text"). Accept either
754
+ // spelling of the SAME policy — the seam assertions below are the real gate.
755
+ assert.match(
756
+ fsReadme,
757
+ /binary-safe mutations remain deferred|Literal edits, patches, and moves require UTF-8 text/i,
758
+ 'and still documents the text-only mutation rule the picture hand-off depends on',
759
+ );
728
760
  const fsIndex = readFileSync(join(packageDir('@deepseek-ai/dsh-fs'), 'lib', 'index.js'), 'utf8');
729
761
  assertFsSeamDeclared(fsTypes, fsIndex);
730
762
  });
@@ -29,7 +29,7 @@ import { existsSync } from 'node:fs';
29
29
  import { dirname, join, resolve } from 'node:path';
30
30
  import { fileURLToPath } from 'node:url';
31
31
 
32
- import { compositionDrift, toolCountDrift, presetPlugins, asPresetPatch, relativeSpecifiers, presetOrderDeclared, orderCollisions } from './preset-health.mjs';
32
+ import { compositionDrift, toolCountDrift, presetPlugins, asPresetPatch, relativeSpecifiers, presetOrderDeclared, orderCollisions, specifierProblem } from './preset-health.mjs';
33
33
  // The three helpers this probe used to carry its own copies of. Duplicating
34
34
  // `findHarnessRoot` was the worst of them: the probe could resolve a DIFFERENT
35
35
  // harness than `preset-health` validated against, so a mutation experiment could
@@ -215,6 +215,20 @@ check('the specifier guard is quiet on a package name and catches a relative pat
215
215
  );
216
216
  });
217
217
 
218
+ check('a row naming a removed package subpath is caught (the 0.2.2-rc.1 list-agents trap)', () => {
219
+ // 0.2.2-rc.1 folded `list_agents` into `@deepseek-ai/dsh-tool-subagent-control`
220
+ // and dropped its `./list-agents` export. `packageInstalled` still finds the
221
+ // package ROOT, so only a real specifier resolution notices — and this probe
222
+ // must not depend on one version's removals to prove the guard works. A
223
+ // subpath no release can export is the version-independent mutation.
224
+ const base = '@deepseek-ai/dsh-tool-subagent-control';
225
+ assert.equal(specifierProblem(base, harnessRoot), undefined, 'the package root resolves');
226
+ assert.equal(specifierProblem(`${base}/package.json`, harnessRoot), undefined, 'an exported subpath resolves');
227
+ const problem = specifierProblem(`${base}/no-such-subpath`, harnessRoot);
228
+ assert.ok(problem !== undefined, 'an unexported subpath is rejected');
229
+ assert.match(problem, /does not resolve/, problem);
230
+ });
231
+
218
232
  check('the order guard reads the order under config, not the declaration row id', () => {
219
233
  const layer = [
220
234
  ' - id: preset-mine',