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/cordis.patch.yml CHANGED
@@ -47,7 +47,7 @@
47
47
  id: molbio-lab
48
48
  order: 5
49
49
  name: Molecular Biology Lab
50
- 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 图片自动用系统默认应用打开,文献/协议/实验记录)。"
50
+ 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 图片自动用系统默认应用打开,文献/协议/实验记录)。"
51
51
  plugins:
52
52
  - id: persona
53
53
  name: '@deepseek-ai/dsh-persona'
@@ -67,6 +67,11 @@
67
67
  disabled: !!js process.platform !== 'win32'
68
68
  - id: tool-fs
69
69
  name: '@deepseek-ai/dsh-tool-fs'
70
+ config:
71
+ # Set false to drop the apply_patch tool beside edit. (0.2.2-rc.1 makes this
72
+ # explicit in the shipped standard preset; the package default is false, and
73
+ # the row is kept verbatim so the two stay comparable.)
74
+ applyPatch: false
70
75
  - id: tool-fs-search
71
76
  name: '@deepseek-ai/dsh-tool-fs-search'
72
77
  config:
@@ -129,14 +134,13 @@
129
134
  config:
130
135
  - id: tool-subagent-control
131
136
  name: '@deepseek-ai/dsh-tool-subagent-control'
132
- - id: tool-subagent-list-agents
133
- name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
134
137
  - id: tool-subagent
135
138
  name: '@deepseek-ai/dsh-tool-subagent'
136
139
  config:
137
140
  provider: spawn
138
141
  toolName: subagent
139
- modelSelectionSettings: true
142
+ modelSelection: true
143
+ modelDiscovery: true
140
144
  - id: tool-subagent-fork
141
145
  name: '@deepseek-ai/dsh-tool-subagent'
142
146
  config:
@@ -168,7 +172,7 @@
168
172
  name: '@deepseek-ai/dsh-plugin-market/tools'
169
173
  disabled: true
170
174
 
171
- # dsh-molbio-tools: molecular-biology toolset (60 tools across sequence
175
+ # dsh-molbio-tools: molecular-biology toolset (64 tools across sequence
172
176
  # analysis, primers (incl. TaqMan probes and multiplex compatibility),
173
177
  # in-silico PCR, cloning (incl. Golden Gate), enzyme catalog, methylation-aware
174
178
  # and double digests, plasmids, Sanger verification, proteins (incl. helical
package/design.mjs CHANGED
@@ -36,17 +36,17 @@
36
36
  * from `anchor` and not from the RC-scan `start`. It is the REVERSE primer,
37
37
  * and the amplicon ends at `anchor + length - 1`.
38
38
  *
39
- * NOTE — `designIntronSpanningPrimers` (tool: `molbio_design_intron_primers`)
40
- * deliberately does NOT follow this convention and is unchanged in 0.16.0: it
41
- * reports both primers as sense substrings of the spliced transcript, which is
42
- * what makes its `exon`/`junction_left`/`junction_right` geometry, its
43
- * spliced-vs-genomic mismatch report and its 3'-tail mispriming check read
44
- * uniformly on one sequence. Both ends are therefore reported 5'->3' on the
45
- * sense strand, `forward` is downstream of `reverse`, and the molecule to order
46
- * as the reverse primer is the reverse complement of the reported `reverse`.
47
- * That is a real usability wart, not a silent one (nothing feeds this pair to
48
- * `molbio_pcr_simulate`), so it is recorded on the roadmap rather than changed
49
- * in the same release that moves `designPrimerPairs`.
39
+ * `designIntronSpanningPrimers` (tool: `molbio_design_intron_primers`)
40
+ * follows the SAME convention since 0.23.0. The junction is carried by the
41
+ * REVERSE primer (the assay sits 5' of the junction), so the reverse candidate
42
+ * is scanned on the reverse complement of the spliced transcript: every 3'-end
43
+ * constraint is then evaluated on the molecule that is ordered, and the hit is
44
+ * mapped back to sense coordinates through `anchor` (its 3' end, the LEFT edge
45
+ * of its site). `forward` is upstream of `reverse`, the spliced amplicon runs
46
+ * from the forward 5' end to the reverse 5' end, and the reported reverse
47
+ * sequence is what goes on the order form. Before 0.23.0 the tool reported both
48
+ * primers as sense substrings, so `forward` sat downstream of `reverse` and the
49
+ * reported reverse was the reverse complement of the molecule to order.
50
50
  */
51
51
 
52
52
  import {
@@ -738,37 +738,60 @@ export function designPrimers(rawTemplate, rawOptions) {
738
738
  // ── cross-intron primer design ──────────────────────────────────────────────
739
739
 
740
740
  /**
741
- * Mismatch report for cross-intron candidates. Both primer scans run on the
742
- * spliced transcript in the sense orientation (the reported reverse.sequence
743
- * follows the established sense-substring convention of this tool), so the
744
- * mapping is uniform: `template_base` is what a perfectly matching primer
745
- * carries at the position on the spliced transcript, and coordinates come back
746
- * both spliced and genomic.
741
+ * Mismatch report for cross-intron candidates. The forward primer is the
742
+ * sense-strand substring at its binding site; the reverse primer is the REVERSE
743
+ * COMPLEMENT of the sense-strand substring at its site (the molecule that is
744
+ * ordered). `anchorStart` is the 0-based sense-strand position of the primer's
745
+ * 3' end — for the reverse primer that is the LEFT edge of its site — and
746
+ * `isReverse` flips both the perfect-match base and how a primer offset maps
747
+ * onto the transcript.
747
748
  */
748
- function intronMismatchReport(candidate, spliced, splicedToGenomic) {
749
+ function intronMismatchReport(candidate, spliced, splicedToGenomic, anchorStart, isReverse) {
749
750
  const len = candidate.length;
750
- return candidate.mismatches.map(({ p, base }) => ({
751
- position: p + 1,
752
- template_base: spliced[candidate.start + p],
753
- primer_base: base,
754
- spliced_position: candidate.start + p + 1,
755
- genomic_position: splicedToGenomic[candidate.start + p] + 1,
756
- distance_from_3prime: len - 1 - p,
757
- }));
751
+ return candidate.mismatches.map(({ p, base }) => {
752
+ const offset = isReverse ? len - 1 - p : p;
753
+ const index = anchorStart + offset;
754
+ return {
755
+ position: p + 1,
756
+ template_base: isReverse ? complement(spliced[index]) : spliced[index],
757
+ primer_base: base,
758
+ spliced_position: index + 1,
759
+ genomic_position: splicedToGenomic[index] + 1,
760
+ distance_from_3prime: len - 1 - p,
761
+ };
762
+ });
758
763
  }
759
764
 
760
765
  /**
761
- * Design qPCR primer pairs where the forward primer spans an exon-exon
762
- * junction (>= min_junction_bases on each side) so genomic DNA cannot be
763
- * amplified, and the reverse primer sits in a different exon. Coordinates are
764
- * reported both on the spliced transcript and on the genomic sequence.
766
+ * The same weights as `mismatchPenalty`, over the snake_case report fields.
767
+ * The old intron path handed snake_case reports to the camelCase helper, so the
768
+ * 3'-zone surcharge never applied there; this keeps the two conventions apart on
769
+ * purpose instead of loosening the shared helper.
770
+ */
771
+ function intronMismatchPenalty(report, zone) {
772
+ return report.reduce((sum, mismatch) => sum + 8 + (mismatch.distance_from_3prime <= zone ? 4 : 0), 0);
773
+ }
774
+
775
+ /**
776
+ * Design qPCR primer pairs across an exon-exon junction so genomic DNA cannot
777
+ * be amplified: the REVERSE primer straddles the junction (>= minJunctionBases
778
+ * on each side), the FORWARD primer sits upstream inside a single exon, and the
779
+ * spliced amplicon crosses the junction inside the reverse primer's site.
780
+ *
781
+ * Coordinates are reported both on the spliced transcript and on the genomic
782
+ * sequence. Orientation follows `designPrimerPairs` (see the file header):
783
+ * `forward.sequence` is the sense-strand substring at its site and
784
+ * `reverse.sequence` is the reverse complement of the sense-strand substring at
785
+ * its site. The reverse candidate is scanned on the reverse complement of the
786
+ * spliced transcript, so every 3'-end constraint (GC clamp, end stability, end
787
+ * GC, self-end, hairpin) applies to the molecule that is ordered.
765
788
  */
766
789
  export function designIntronSpanningPrimers(genomic, exons, opts) {
767
790
  if (!Array.isArray(exons) || exons.length < 2) throw new MolbioInputError('exons must be an array of at least two {start, end} spans');
768
791
  const sorted = [...exons].sort((a, b) => a.start - b.start);
769
792
  for (const exon of sorted) {
770
793
  if (!Number.isInteger(exon.start) || !Number.isInteger(exon.end) || exon.start < 1 || exon.end > genomic.length || exon.start > exon.end) {
771
- throw new MolbioInputError(`exon ${JSON.stringify(exon)} is outside the genomic sequence (length ${genomic.length})`);
794
+ throw new MolbioInputError('exon ' + JSON.stringify(exon) + ' is outside the genomic sequence (length ' + genomic.length + ')');
772
795
  }
773
796
  }
774
797
  for (let i = 1; i < sorted.length; i++) {
@@ -791,33 +814,39 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
791
814
  }
792
815
  });
793
816
  if (spliced.length < 24) throw new MolbioInputError('the spliced transcript is too short for primer design');
817
+ const splicedLength = spliced.length;
818
+ const rcSpliced = reverseComplement(spliced);
794
819
 
795
820
  // v13 target position preference (coordinates on the spliced transcript).
796
821
  const targetPosition = opts.targetPosition;
797
- if (targetPosition !== undefined && (!Number.isInteger(targetPosition) || targetPosition < 1 || targetPosition > spliced.length)) {
798
- throw new MolbioInputError(`target_position ${targetPosition} is outside the spliced transcript (length ${spliced.length})`);
822
+ if (targetPosition !== undefined && (!Number.isInteger(targetPosition) || targetPosition < 1 || targetPosition > splicedLength)) {
823
+ throw new MolbioInputError('target_position ' + targetPosition + ' is outside the spliced transcript (length ' + splicedLength + ')');
799
824
  }
800
825
  const targetWeight = opts.targetPenalty ?? DESIGN_DEFAULTS.targetPenalty;
801
826
 
802
- // Forward candidates must span a junction with minSide bases on each side.
803
- const fwd = [];
804
- for (const candidate of scanCandidates(spliced, 0, spliced.length - opts.lenMin, opts)) {
827
+ // Forward candidates: entirely inside one exon on the sense strand.
828
+ const fwd = scanCandidates(spliced, 0, splicedLength - opts.lenMin, opts)
829
+ .filter((candidate) => splicedToExon[candidate.start] === splicedToExon[candidate.start + candidate.length - 1])
830
+ .map((candidate) => ({ ...candidate, exon: splicedToExon[candidate.start] }))
831
+ .sort((a, b) => a.start - b.start);
832
+
833
+ // Reverse candidates: scanned on the reverse complement (the ordered
834
+ // molecule), then mapped back to the sense-strand 3' end `anchor`.
835
+ const rev = [];
836
+ for (const candidate of scanCandidates(rcSpliced, 0, splicedLength - opts.lenMin, opts)) {
837
+ const anchor = splicedLength - (candidate.start + candidate.length);
838
+ const topEnd = anchor + candidate.length - 1;
805
839
  for (const junction of junctionPositions) {
806
- const left = junction.splicedPos - candidate.start;
807
- const right = candidate.start + candidate.length - junction.splicedPos;
840
+ const left = junction.splicedPos - anchor;
841
+ const right = topEnd + 1 - junction.splicedPos;
808
842
  if (left >= minSide && right >= minSide) {
809
- fwd.push({ ...candidate, junction, junction_left: left, junction_right: right });
843
+ rev.push({ ...candidate, anchor, junction, junction_left: left, junction_right: right });
810
844
  break;
811
845
  }
812
846
  }
813
847
  }
814
-
815
- // Reverse candidates sit entirely inside one exon.
816
- const rev = scanCandidates(spliced, 0, spliced.length - opts.lenMin, opts)
817
- .filter((candidate) => splicedToExon[candidate.start] === splicedToExon[candidate.start + candidate.length - 1])
818
- .map((candidate) => ({ ...candidate, exon: splicedToExon[candidate.start] }))
819
- .sort((a, b) => a.start - b.start);
820
- const revStarts = rev.map((candidate) => candidate.start);
848
+ rev.sort((a, b) => a.anchor - b.anchor);
849
+ const revAnchors = rev.map((candidate) => candidate.anchor);
821
850
 
822
851
  const tmCenter = (opts.tmMin + opts.tmMax) / 2;
823
852
  const misprimingIndex = opts.checkMispriming
@@ -826,48 +855,52 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
826
855
  const maxSites = opts.misprimingMaxSites ?? DESIGN_DEFAULTS.misprimingMaxSites;
827
856
  const pairs = [];
828
857
  for (const f of fwd) {
829
- const fwdEnd = f.start + f.length - 1;
830
- const aMin = fwdEnd - opts.ampliconMax + 1;
831
- const aMax = fwdEnd - opts.ampliconMin + 1;
858
+ // The reverse site must start after the forward 3' end, and the amplicon
859
+ // (forward 5' -> reverse 5') must fit the window. The bounds are widened by
860
+ // the primer-length range and the exact length is checked below, because a
861
+ // long reverse primer can start earlier and still close inside the window.
862
+ const aMin = Math.max(f.start + f.length, f.start + opts.ampliconMin - opts.lenMax);
863
+ const aMax = f.start + opts.ampliconMax - opts.lenMin;
864
+ if (aMax < aMin) continue;
832
865
  let lo = 0;
833
- let hi = revStarts.length;
866
+ let hi = revAnchors.length;
834
867
  while (lo < hi) {
835
868
  const mid = (lo + hi) >> 1;
836
- if (revStarts[mid] < aMin) lo = mid + 1;
869
+ if (revAnchors[mid] < aMin) lo = mid + 1;
837
870
  else hi = mid;
838
871
  }
839
872
  for (let i = lo; i < rev.length; i++) {
840
873
  const r = rev[i];
841
- if (r.start > aMax) break;
842
- if (r.exon === f.junction.down) continue; // reverse primer must sit in a different exon
843
- const gEnd = splicedToGenomic[fwdEnd];
844
- const gStart = splicedToGenomic[r.start];
874
+ if (r.anchor > aMax) break;
875
+ const ampliconStart = f.start;
876
+ const ampliconEnd = r.anchor + r.length - 1;
877
+ const ampliconLength = ampliconEnd - ampliconStart + 1;
878
+ if (ampliconLength < opts.ampliconMin || ampliconLength > opts.ampliconMax) continue;
879
+ const gStart = splicedToGenomic[ampliconStart];
880
+ const gEnd = splicedToGenomic[ampliconEnd];
845
881
  if (gEnd - gStart + 1 < minGenomicSpan) continue;
846
882
  if (Math.abs(f.tm - r.tm) > opts.maxTmDelta) continue;
847
883
  const dimer = dimerThermo(f.sequence, r.sequence, opts.ctMolar ?? DEFAULT_CT_MOLAR);
848
884
  if (dimer.any_tm > opts.maxDimerTm || dimer.end_tm > opts.maxDimerEndTm) continue;
885
+ const misK = opts.mispriming3PrimeBases ?? DESIGN_DEFAULTS.mispriming3PrimeBases;
849
886
  let fMispriming = { count: 0, sites: [] };
850
887
  let rMispriming = { count: 0, sites: [] };
851
888
  if (misprimingIndex !== undefined) {
852
- const misK = opts.mispriming3PrimeBases ?? DESIGN_DEFAULTS.mispriming3PrimeBases;
853
- fMispriming = misprimingForCandidate(f, f.sequence.slice(-misK), misprimingIndex, opts, f.start);
889
+ // Exclude each primer's own 3'-tail window (not its 5' end).
890
+ fMispriming = misprimingForCandidate(f, f.sequence.slice(-misK), misprimingIndex, opts, f.start + f.length - misK);
854
891
  if (fMispriming.count > maxSites) continue;
855
- // the reported reverse sequence follows the sense-substring convention:
856
- // the real oligo is its reverse complement, so its 3' tail is the RC
857
- // of the FIRST k bases and the intended site is r.start
858
- const rTail = reverseComplement(r.sequence.slice(0, misK));
859
- rMispriming = misprimingForCandidate(r, rTail, misprimingIndex, opts, r.start);
892
+ rMispriming = misprimingForCandidate(r, r.sequence.slice(-misK), misprimingIndex, opts, r.anchor);
860
893
  if (rMispriming.count > maxSites) continue;
861
894
  }
862
- const fMismatches = intronMismatchReport(f, spliced, splicedToGenomic);
863
- const rMismatches = intronMismatchReport(r, spliced, splicedToGenomic);
895
+ const fMismatches = intronMismatchReport(f, spliced, splicedToGenomic, f.start, false);
896
+ const rMismatches = intronMismatchReport(r, spliced, splicedToGenomic, r.anchor, true);
864
897
  const zone = opts.mismatch3PrimeZone ?? DESIGN_DEFAULTS.mismatch3PrimeZone;
865
898
  let fTargetDistance;
866
899
  let rTargetDistance;
867
900
  let targetDistance;
868
901
  if (targetPosition !== undefined) {
869
- fTargetDistance = Math.abs(f.start + f.length - targetPosition); // spliced 1-based 3' end
870
- rTargetDistance = Math.abs(r.start + 1 - targetPosition);
902
+ fTargetDistance = Math.abs(f.start + f.length - targetPosition); // forward 3' end (1-based)
903
+ rTargetDistance = Math.abs(r.anchor + 1 - targetPosition); // reverse 3' end (1-based)
871
904
  targetDistance = Math.min(fTargetDistance, rTargetDistance);
872
905
  }
873
906
  const penalty = 0.6 * Math.abs(f.tm - r.tm) + Math.abs(f.tm - tmCenter) + Math.abs(r.tm - tmCenter)
@@ -875,8 +908,8 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
875
908
  + 1.0 * Math.max(0, f.self_end - 1) + 1.0 * Math.max(0, r.self_end - 1)
876
909
  + 0.2 * Math.max(0, f.hairpin_tm - 40) + 0.2 * Math.max(0, r.hairpin_tm - 40)
877
910
  + 0.2 * Math.max(0, dimer.any_tm - 40) + 0.2 * Math.max(0, dimer.end_tm - 40)
878
- + mismatchPenalty(fMismatches, zone)
879
- + mismatchPenalty(rMismatches, zone)
911
+ + intronMismatchPenalty(fMismatches, zone)
912
+ + intronMismatchPenalty(rMismatches, zone)
880
913
  + 8 * fMispriming.count + 8 * rMispriming.count
881
914
  + (targetDistance !== undefined ? targetWeight * targetDistance : 0);
882
915
  pairs.push({
@@ -894,9 +927,7 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
894
927
  spliced_end: f.start + f.length,
895
928
  genomic_start: splicedToGenomic[f.start] + 1,
896
929
  genomic_end: splicedToGenomic[f.start + f.length - 1] + 1,
897
- exons: [String(f.junction.up + 1), String(f.junction.down + 1)],
898
- junction_left: f.junction_left,
899
- junction_right: f.junction_right,
930
+ exon: f.exon + 1,
900
931
  mismatch_count: fMismatches.length,
901
932
  mismatches: fMismatches,
902
933
  mispriming_count: fMispriming.count,
@@ -918,11 +949,13 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
918
949
  hairpin_tm: r.hairpin_tm,
919
950
  end_stability_kcal: r.end_stability_kcal,
920
951
  end_gc_count: r.end_gc_count,
921
- spliced_start: r.start + 1,
922
- spliced_end: r.start + r.length,
923
- genomic_start: splicedToGenomic[r.start] + 1,
924
- genomic_end: splicedToGenomic[r.start + r.length - 1] + 1,
925
- exon: r.exon + 1,
952
+ spliced_start: r.anchor + 1,
953
+ spliced_end: r.anchor + r.length,
954
+ genomic_start: splicedToGenomic[r.anchor] + 1,
955
+ genomic_end: splicedToGenomic[r.anchor + r.length - 1] + 1,
956
+ exons: [String(r.junction.up + 1), String(r.junction.down + 1)],
957
+ junction_left: r.junction_left,
958
+ junction_right: r.junction_right,
926
959
  mismatch_count: rMismatches.length,
927
960
  mismatches: rMismatches,
928
961
  mispriming_count: rMispriming.count,
@@ -934,7 +967,7 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
934
967
  })),
935
968
  ...(rTargetDistance !== undefined ? { target_distance: rTargetDistance } : {}),
936
969
  },
937
- spliced_amplicon: { start: r.start + 1, end: fwdEnd + 1, length: fwdEnd - r.start + 1 },
970
+ spliced_amplicon: { start: ampliconStart + 1, end: ampliconEnd + 1, length: ampliconLength },
938
971
  genomic_amplicon_length: gEnd - gStart + 1,
939
972
  ...(targetDistance !== undefined ? { target_distance: targetDistance } : {}),
940
973
  penalty: Math.round(penalty * 100) / 100,
@@ -945,7 +978,7 @@ export function designIntronSpanningPrimers(genomic, exons, opts) {
945
978
  const seen = new Set();
946
979
  const unique = [];
947
980
  for (const pair of pairs) {
948
- const key = `${pair.spliced_amplicon.start}:${pair.spliced_amplicon.end}`;
981
+ const key = pair.spliced_amplicon.start + ':' + pair.spliced_amplicon.end;
949
982
  if (seen.has(key)) continue;
950
983
  seen.add(key);
951
984
  unique.push(pair);
@@ -6,6 +6,11 @@ mainstream bioinformatics landscape offer that this toolset does **not**, and wh
6
6
  deterministic algorithms in a few hundred lines of dependency-free JS with **no external data files,
7
7
  binaries, network, or reference databases**?
8
8
 
9
+ > **Status note (0.24.0).** The baseline below is the 52-tool package this survey was written
10
+ > against. Since then rows 1–7, 11, 12, 23, 30, 34, 40 and the Cas-family half of row 20 have shipped
11
+ > (see [history.md](history.md) and the roadmap); the remaining rows are still candidates unless
12
+ > §3 names a blocker. The rankings are left as written so the original judgement can be audited.
13
+
9
14
  **Method.** Surveyed nine source families (commercial plasmid/cloning suites; bench web calculators;
10
15
  Biopython/BioPerl/SeqKit/EMBOSS command-line toolkits; standard sequence/statistics analyses;
11
16
  microbiome; population genetics; protein prediction; alignment post-processing; sequencing QC),
@@ -13,6 +13,7 @@
13
13
  | **Molbio**(右栏 tab) | 列出会话工作区里的序列文件,选中即画:`.dna`/`.gb`/`.gbk` → 质粒图谱 + 特征表;`.fa`/`.fasta`(≥2 条)→ 序列标识图 | `workspaceFiles.readAll`(二进制,含 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
+ | **图形调用卡**(`tool.call.toolview`) | `molbio_sequence_logo` / `molbio_grna_design` 的调用卡:logo 直接画图;gRNA 画 `map_path` 并列出前 5 条 guide(无 map 时仍有摘要与表格) | 同上,kind = `molbio-figure` |
16
17
  | **PubMed API key**(`plugins.detail.section`) | 插件 → 已安装插件 → `dsh-molbio-tools` 详情页里的一段表单:password 输入 + Save/Remove,状态只报「已配置 / 来源 / 未配置」,永不回显值 | `ctx.remote.credentials.describe` / `set` / `unset`(host 的凭据 seam) |
17
18
 
18
19
  **解析与渲染全部在浏览器里跑本仓库自己的模块**(`lib/genbank/snapgene/plasmid/msa/logo`),
@@ -47,11 +48,11 @@ SVG 随 meta 传输有上限(256 KB):真实载体的图谱 20-60 KB,但
47
48
  | `build/client-bundle-core.mjs` | 打包器的**生成逻辑**(可 import):`createGenerator({entry, baseDir})` → 模块图 + `renderBundle(packageName)`;`test/contract.mjs` 在进程内用它重算产物做新鲜度检查(不 spawn,受限沙箱下也能验证) |
48
49
  | `build/browser-api.mjs` | 浏览器安全面:从**包根的 `.mjs` 源文件**再导出面板可用的一切(单一事实来源) |
49
50
  | `build/panel-core.mjs` | 面板的数据通路(分类/解码/解析/渲染/文献库投影),不含 React,可在 Node 里单测 |
50
- | `build/client-entry.mjs` | 浏览器半本体:两个 tab 类型的注册、正文、标题 chip,两个 map 工具的调用卡,以及插件详情页的 key 配置段 |
51
+ | `build/client-entry.mjs` | 浏览器半本体:两个 tab 类型的注册、正文、标题 chip,两个 map 工具与两个 figure 工具的调用卡,以及插件详情页的 key 配置段 |
51
52
  | `lib/client.js` | **构建产物**(`exports["./client"]` 指向它;由 `npm run build:client` 生成) |
52
- | `packages/molbio-panel/` | 面板专用包(宿主半边空实现),面板与 57 个工具解耦的交付通道 |
53
+ | `packages/molbio-panel/` | 面板专用包(宿主半边空实现),面板与 64 个工具解耦的交付通道 |
53
54
  | `test/client.mjs` | 按加载器的方式执行产物 + 驱动两条数据通路(含真实 pUC118 夹具) |
54
- | `test/map-card.mjs` | 调用卡的**跨界**检查:真实工具的投影 → 卡片读取 → 渲染出 SVG(含四种降级路径) |
55
+ | `test/map-card.mjs` | 调用卡的**跨界**检查:地图卡与图形卡(logo/gRNA)的真实投影 → 卡片读取 → 渲染出 SVG(含降级、超限、坏 meta 路径) |
55
56
  | `test/client-mount.mjs` | 复刻宿主侧图扫描,证明两个包都能挂上、依赖可解析 |
56
57
 
57
58
  命令:`npm run build:client`(构建)、`npm test`(全部测试)、
@@ -250,10 +251,11 @@ shadow(无冲突),面板的 `remote`/`slot` 服务不受影响。
250
251
  - **插件详情页的 key 配置**:✅ 已落地(0.22.1,见第 1 与第 4 节)。后续可把同一个
251
252
  `plugins.detail.section` 模式用于其它凭据(例如文献库的机构代理),前提是它们也有一个
252
253
  稳定的环境变量名。
253
- - **`tool.call.toolview`**:✅ 已落地(见第 1 与第 3 节),覆盖两个 map 工具。可继续做的
254
- 是给 `molbio_sequence_logo` / `molbio_grna_design` 之类的工具也加上卡片(同一套
255
- `presentationMeta` + 卡片模式)。**注意上线前先跑 `test/client.mjs` 的抢座位顺序那一层**:
256
- 0.7.1 的卡片因为裸 `register()` 抢座位而让 GUI 起不来(0.7.2 修复,见 §4 第一条)。
254
+ - **`tool.call.toolview`**:✅ 已落地(见第 1 与第 3 节),覆盖两个 map 工具与
255
+ `molbio_sequence_logo` / `molbio_grna_design`(0.23.0,`molbio-figure` 投影)。
256
+ 后续可考虑把同样的卡片给 `molbio_dotplot` 等新画图工具。**任何新卡片上线前先跑
257
+ `test/client.mjs` 的抢座位顺序那一层**:0.7.1 的卡片因为裸 `register()` 抢座位而让 GUI
258
+ 起不来(0.7.2 修复,见 §4 第一条);`contract` 会从产物里的 key 表反推投影数。
257
259
  - **文献库写回**(可选):若要面板内编辑笔记/标签,需要先定义与 `molbio_paper_*` 工具
258
260
  一致的并发契约(乐观版本号或串行化队列),或让工具走同一条 RPC。
259
261
  - **上游提案(路径 B)**:preset 渠道挂 client 仍是真实生态需求,可以在社区反馈时
package/docs/history.md CHANGED
@@ -16,6 +16,8 @@
16
16
 
17
17
  | 包版本 | preset 版本 | 内容 | 工具数 |
18
18
  | --- | --- | --- | --- |
19
+ | 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
+ | 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 |
19
21
  | 0.22.2 | — | **DSH 0.2.1-rc.5 适配**:基线 rc.3 → rc.5(上游 standard 逐字节未变);contract 声明守卫兼容桌面版裁掉 `lib/types` 的打包形态(+突变自检);engines.node 与 CI 降到 24 | 60 |
20
22
  | 0.22.1 | — | **PubMed key 改走 DSH 凭据库**:解析顺序 `api_key` → 凭据库 → 环境变量;无凭据库时行为同 0.22.0 | 60 |
21
23
  | 0.22.0 | — | **DSH 0.2.1-rc.3 适配 + PubMed 官方 API**:`molbio_pubmed_search`/`_abstract` 支持 NCBI API key(E-utilities),无 key 行为不变 | 60 |
@@ -47,6 +49,66 @@
47
49
 
48
50
  ---
49
51
 
52
+ ## v0.24.0(2026-11-05,包 0.24.0)
53
+
54
+ **三条教训,都关于“表/边界看起来对、实际不对”。**
55
+
56
+ **(1) 接头表是 RNA,reads 是 DNA。** `fastq-qc.mjs` 的 Illumina Small RNA 5' 条目一直是
57
+ `GUUCAG…`(RNA,含 U),而 `adapterContent` 直接拿它去 DNA reads 里做 `indexOf`。正向分子
58
+ 永远匹配不上,只有它的反向互补能命中,所以旧测试/使用中会看到“接头只报一半”。这不是算法错,
59
+ 是**数据表示**错:现在表放在 `contamination.mjs` 并以 DNA 形式定义(U→T),`fastq-qc` 只是
60
+ 导入/再导出;smoke 用正向与反向互补两条 read 断言两者都命中。
61
+
62
+ **(2) 短签名筛查必须把“不是数据库”写进输出。** 内置表能回答“有没有 pUC/pET/attB/Illumina
63
+ 接头”,但不能回答“有没有未知载体”。描述、README 与 render 都写明:命中是线索,**未命中
64
+ 不等于干净**;默认 exact,`max_mismatches` 才启用 seed-and-verify(不扫描每个窗口)。
65
+
66
+ **(3) 修剪工具的价值在“顺序与边界”,不在旋钮数量。** `molbio_fastq_trim` 固定 5' 引物 →
67
+ 3' 接头 → 3' 质量尾 → poly 尾 → 长度/N 的顺序,并把每个阶段的计数与条件回显在 `criteria` 里。
68
+ 不做 paired-end merge、semiglobal adapter alignment、UMI;这些另开工具比塞进一个“全能”工具
69
+ 更诚实。
70
+
71
+ **顺带记录**:`seqio.mjs` 新增 `toFastq` 进了浏览器半的模块图,所以 0.24.0 虽然不改面板行为,
72
+ 仍重建了两个 `lib/client.js` 并把面板包 `0.1.8 → 0.1.9`;不 bump 会在发布时用同版本号发不同字节。
73
+ 定向模型跑分覆盖 `contamination-screen` 与 `fastq-trim`,两题 2/2(409,576 tokens,70 s;详见 CHANGELOG)。
74
+
75
+ ## v0.23.0(2026-10-31,包 0.23.0)
76
+
77
+ 三条教训,都关于"一个约定改了,哪些地方必须跟着改"。
78
+
79
+ **(1) 方向不是标签问题,是约束落在哪个分子上的问题。** 跨内含子工具把两条引物都报成 sense
80
+ 子串,看起来只是"名字反了";真正的代价是 GC clamp、末端稳定性、末端 GC、self-end 与发夹
81
+ **全都检查在反向引物的 5' 端**(因为扫描方向反了),而报告的那条分子还不是要订购的。
82
+ 修法不是交换两个字段:反向候选要改到剪接转录本的**反向互补**上扫描,才让 3' 约束落到
83
+ 真正下单的分子上,再由 `anchor` 映回 sense 坐标。这也解释了为什么 smoke 夹具必须换 seed:
84
+ 旧夹具能在错误的约束下通过,改正后同一段序列不再产生合法候选。
85
+
86
+ **(2) 两份字段命名(snake_case/camelCase)之间"看起来能跑"的调用,是静默失效的温床。**
87
+ 旧代码把 `mismatchReport` 的 `distance_from_3prime` 报告直接喂给读 `distanceFrom3Prime` 的
88
+ `mismatchPenalty`,于是 3' 关键区加罚在跨内含子模式里**从未生效**——没有报错、没有测试红,
89
+ 只是排序少了一项。现在用专门的 `intronMismatchPenalty`,两种命名不再混用。
90
+
91
+ **(3) 把"一个 key 列表"写成常量时,第二个列表不会自己出现。** 加调用卡时最容易漏的是
92
+ 守卫:`contract` 原本只读 `MAP_TOOL_KEYS`,新卡片的工具即使没有 `presentationMeta` 也照样绿。
93
+ 现在它从产物里的 `MAP_TOOL_KEYS` 与 `FIGURE_TOOL_KEYS` **两处**读 key,并用行首锚定的正则
94
+ 数投影——文件头注释里也提到 `presentationMeta(args, value)`,子串计数会把散文当成第四个实现。
95
+ 这类"守卫自己的样本过期"在 0.21.0 的 drift-probe 上已经付过一次账。
96
+
97
+ **(4) 包还在,不等于行能挂载。** 0.2.2-rc.1 把 `list_agents` 并入了
98
+ `@deepseek-ai/dsh-tool-subagent-control` 主入口,删掉了 `./list-agents` 子路径导出。preset 仍抄着
99
+ rc.5 的那一行,而 `preset-health` 的 `packageInstalled` 只证明**包目录存在**,随后 import 的还是
100
+ 包根的 `exports["."]`——于是"包在、子路径没了"的行一路绿。真正的解析发生在 Loader 里
101
+ (`import.meta.resolve`,受 `exports` 映射约束),所以现在的守卫先用 `createRequire(...).resolve`
102
+ 解析行名,再用 `no-such-subpath` 做突变自检。**组合树正确不代表行能挂载**,这和规则 1 记录的是
103
+ 同一类失败:静态检查看不见,只有真正解析/启动一次才会暴露。
104
+
105
+ **顺带记录**:新工具的 pK 表与电荷函数从 `protein.mjs` 导出(`PK_SETS`/`netCharge`),
106
+ 而不是在 `protein-charge.mjs` 里复制一份;两个工具各自只加一条 benchmark 任务,
107
+ `benchmark-coverage` 的 62/62 与 `benchmark-profile` 的 62 工具是同一批断言的两种读法。
108
+ `dotplot` 的版式在收尾时从 720×720 改为 720×760(绘图区仍是 628 px 正方形),让 x 轴标题与
109
+ 图例各占一行;`test/smoke.mjs` 用几何断言盯住「标题不贴底、图例在画布内且在标题下方」。
110
+ 定向真实模型跑分(2026-11-05)覆盖 `intron-qpcr` / `protein-charge` / `dotplot`,三题 3/3。
111
+
50
112
  ## v0.22.1(2026-10-25,包 0.22.1)
51
113
 
52
114
  **环境变量不是唯一选择。** 0.22.0 只从 `api_key` 参数与 `process.env` 读 key;这一版把 DSH 的
@@ -30,13 +30,13 @@
30
30
 
31
31
  ## 60 秒速览
32
32
 
33
- - **包**:`dsh-molbio-tools`,零依赖 DSH 插件包,**60 个 `molbio_*` 工具**(其中
33
+ - **包**:`dsh-molbio-tools`,零依赖 DSH 插件包,**64 个 `molbio_*` 工具**(其中
34
34
  `molbio_plot` 一个工具承载 8 种图表)。
35
- - **基线**:DSH **0.2.1-rc.5**;`package.json` 版本 0.22.2(DSH 桌面版内嵌 Node 24.21.0)。
35
+ - **基线**:DSH **0.2.2-rc.1**;`package.json` 版本 0.24.0(DSH 桌面版内嵌 Node 24.21.0)。
36
36
  - **两条分发渠道**:根包(工具 + 面板)与 `packages/molbio-panel`(只面板),
37
37
  **两个独立的 npm 条目、两条独立的版本线**,必须分别发布。
38
38
  - **工具只在专属模式里出现**(设计如此):bundle 的 `cordis.patch.yml` 只插一行**惰性宿主锚点**
39
- (`molbio-client` → `./host.mjs`),60 个工具由同文件里的 preset insert 承载,
39
+ (`molbio-client` → `./host.mjs`),64 个工具由同文件里的 preset insert 承载,
40
40
  只会挂进 "Molecular Biology Lab" 模式。
41
41
  - **测试**:`npm test` = 插件(`smoke`)+ 画面(`svgpng`/`svgio`/`charts`)+ 客户端四套件 +
42
42
  DSH 契约(`contract`)+ 组合(`preset-health`)+ 漂移守卫(`drift-probe`)+
@@ -45,14 +45,14 @@
45
45
  `build/release-preflight.mjs`,然后用 **OIDC 可信发布**(仓库里没有任何长期 token)分两步发
46
46
  两个 npm 条目,最后用 CHANGELOG 该版本段落建 GitHub Release。**首次发布必须手动做一次**
47
47
  (可信发布要求包已存在),完整步骤见 [workflow.md](workflow.md) 第 4 节的 CD 段。
48
- - **benchmark**:`npm run bench` 用真实模型跑 core 档 14 题(`bench:full` 全部 61 题),分
48
+ - **benchmark**:`npm run bench` 用真实模型跑 core 档 14 题(`bench:full` 全部 65 题),分
49
49
  `tools_ok` / `args_ok` / `answer_ok` 三项判分;`npm run bench:offline` 零成本校验期望值。
50
50
 
51
51
  包内目录结构:
52
52
 
53
53
  ```
54
54
  dsh-molbio-tools/
55
- ├── index.mjs # 插件入口:export { name, inject, apply },注册 60 个工具
55
+ ├── index.mjs # 插件入口:export { name, inject, apply },注册 64 个工具
56
56
  ├── lib.mjs # 基础库:IUPAC、翻译、酶表、NN 热力学、qPCR、lab math、甲基化/buffer 参考表
57
57
  ├── design.mjs # 引物自动设计(含跨内含子 qPCR)
58
58
  ├── taqman.mjs # TaqMan 水解探针设计(复用 design.mjs 的引物引擎)
@@ -60,6 +60,9 @@ dsh-molbio-tools/
60
60
  ├── methylation.mjs # 甲基化敏感位点检查 + 双酶切 buffer 兼容
61
61
  ├── protein.mjs # 蛋白性质 / 肽段酶切 / 密码子优化
62
62
  ├── protein-structure.mjs # 螺旋轮与疏水性图
63
+ ├── protein-charge.mjs # 电荷-pH 滴定曲线 + pI(两套 pK 表)
64
+ ├── contamination.mjs # 内置载体/接头短签名 + 双链污染筛查
65
+ ├── fastq-trim.mjs # FASTQ 修剪/过滤(质量/接头/引物/poly/N/长度)
63
66
  ├── genbank.mjs # GenBank flatfile 解析器
64
67
  ├── snapgene.mjs # SnapGene .dna 二进制解析器
65
68
  ├── plasmid.mjs # SVG 质粒图谱渲染器