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/index.mjs CHANGED
@@ -2,9 +2,9 @@
2
2
  * dsh-molbio-tools/index.mjs
3
3
  *
4
4
  * DeepSeek Harness Cordis plugin: molecular-biology computation tools for the
5
- * model. Zero runtime dependencies — the only import is the sibling lib.mjs —
6
- * so the package is a pair of files that travel with an agent preset and can
7
- * be referenced by a relative plugin row (see README.md).
5
+ * model. Zero runtime dependencies — every import is a sibling module of this
6
+ * package — so the toolset travels with an agent preset and can be referenced
7
+ * by a package-name row (see README.md and rules.md rule 1).
8
8
  *
9
9
  * Every tool is pure computation: deterministic, no network, no filesystem.
10
10
  * The plugin publishes no service, so the preset row needs no isolate realm.
@@ -50,6 +50,8 @@ import {
50
50
  } from './cloning.mjs';
51
51
  import { readTraceFromBytes, verifySanger } from './sanger.mjs';
52
52
  import { CODON_HOSTS, codonOptimize, peptideDigest, proteinProperties } from './protein.mjs';
53
+ import { chargeCurve, renderChargeCurve } from './protein-charge.mjs';
54
+ import { dotplot, renderDotplot } from './dotplot.mjs';
53
55
  import { linearFit, renderBarChart, renderGel, renderScatterChart } from './plot.mjs';
54
56
  import {
55
57
  SERIES_COLORS,
@@ -65,7 +67,7 @@ import {
65
67
  renderVolcanoPlot,
66
68
  resolveTable,
67
69
  } from './charts.mjs';
68
- import { entryStats, parseFasta, parseFastq, toFasta } from './seqio.mjs';
70
+ import { entryStats, parseFasta, parseFastq, toFasta, toFastq } from './seqio.mjs';
69
71
  import { addExperiment, addProtocol, loadRecords, recordPath, saveRecords, updateProtocol } from './records.mjs';
70
72
  import { toBibtex } from './papers.mjs';
71
73
  import { parseXml } from './snapgene.mjs';
@@ -123,6 +125,8 @@ import {
123
125
  import { openDefaultViewer } from './view.mjs';
124
126
  import { renderSvgToPng } from './svgpng.mjs';
125
127
  import { fastqQcReport, renderFastqQcReport } from './fastq-qc.mjs';
128
+ import { screenContamination } from './contamination.mjs';
129
+ import { trimFastq } from './fastq-trim.mjs';
126
130
  import { CODON_FREQUENCIES, codonUsageAnalysis } from './codon.mjs';
127
131
  import {
128
132
  DISTANCE_MODELS,
@@ -2140,7 +2144,7 @@ const plasmidMapTool = (ctx) => define({
2140
2144
  },
2141
2145
  // The map card in the transcript/panel reads this meta (see mapCardMeta).
2142
2146
  presentationMeta(_args, value) {
2143
- return mapCardMeta(value, rememberedMapSvg(value.svg_path));
2147
+ return mapCardMeta(value, cardSvgFor(value.svg_path));
2144
2148
  },
2145
2149
  async execute(args, exec) {
2146
2150
  const sequence = normalizeSequence(args.sequence, 'plasmid sequence');
@@ -2182,7 +2186,7 @@ const plasmidMapTool = (ctx) => define({
2182
2186
  gc_skew: args.gc_skew === true,
2183
2187
  });
2184
2188
  const { file, viewed, attached } = await writeSvgFile(ctx, exec, args, svg, name);
2185
- rememberMapSvg(file, svg);
2189
+ rememberCardSvg(file, svg);
2186
2190
  return mergeAttachedImage({
2187
2191
  svg_path: file,
2188
2192
  name,
@@ -2334,7 +2338,7 @@ const plasmidMapFileTool = (ctx) => define({
2334
2338
  },
2335
2339
  // The map card in the transcript/panel reads this meta (see mapCardMeta).
2336
2340
  presentationMeta(_args, value) {
2337
- return mapCardMeta(value, rememberedMapSvg(value.svg_path));
2341
+ return mapCardMeta(value, cardSvgFor(value.svg_path));
2338
2342
  },
2339
2343
  async execute(args, exec) {
2340
2344
  const lower = args.path.toLowerCase();
@@ -2374,7 +2378,7 @@ const plasmidMapFileTool = (ctx) => define({
2374
2378
  gc_skew: args.gc_skew === true,
2375
2379
  });
2376
2380
  const { file, viewed, attached } = await writeSvgFile(ctx, exec, args, svg, name);
2377
- rememberMapSvg(file, svg);
2381
+ rememberCardSvg(file, svg);
2378
2382
  return mergeAttachedImage({
2379
2383
  svg_path: file,
2380
2384
  name,
@@ -2423,22 +2427,40 @@ function mapCardMeta(value, svg) {
2423
2427
  }
2424
2428
 
2425
2429
  /**
2426
- * Markup of the most recent maps, keyed by written path, so the card's
2430
+ * Build the meta a figure card reads (sequence logo, gRNA map). Shared by both
2431
+ * tools so the browser half has one shape to validate: `summary` is the header
2432
+ * line, `extra` carries bounded presentation rows, and the markup travels only
2433
+ * when it fits `CARD_SVG_MAX_BYTES`.
2434
+ */
2435
+ function figureCardMeta({ figure, summary, svgPath, extra = {} }, svg) {
2436
+ return {
2437
+ kind: 'molbio-figure',
2438
+ figure,
2439
+ summary,
2440
+ svg_path: svgPath,
2441
+ svg_bytes: svg.length,
2442
+ ...extra,
2443
+ ...svg.length === 0 ? { svg_missing: true } : svg.length <= CARD_SVG_MAX_BYTES ? { svg } : { svg_omitted: true },
2444
+ };
2445
+ }
2446
+
2447
+ /**
2448
+ * Markup of the most recent written figures, keyed by path, so a card's
2427
2449
  * projection never has to read a file back. Bounded on both axes — a handful of
2428
- * maps, each at most `CARD_SVG_MAX_BYTES` — because this is a module-level
2450
+ * figures, each at most `CARD_SVG_MAX_BYTES` — because this is a module-level
2429
2451
  * cache in a long-lived host process, not a store.
2430
2452
  */
2431
- const recentMapSvg = new Map();
2453
+ const recentCardSvg = new Map();
2432
2454
 
2433
- function rememberMapSvg(svgPath, svg) {
2434
- if (recentMapSvg.has(svgPath)) recentMapSvg.delete(svgPath);
2435
- recentMapSvg.set(svgPath, svg.length <= CARD_SVG_MAX_BYTES ? svg : '');
2436
- while (recentMapSvg.size > 8) recentMapSvg.delete(recentMapSvg.keys().next().value);
2455
+ function rememberCardSvg(svgPath, svg) {
2456
+ if (recentCardSvg.has(svgPath)) recentCardSvg.delete(svgPath);
2457
+ recentCardSvg.set(svgPath, svg.length <= CARD_SVG_MAX_BYTES ? svg : '');
2458
+ while (recentCardSvg.size > 8) recentCardSvg.delete(recentCardSvg.keys().next().value);
2437
2459
  }
2438
2460
 
2439
2461
  /** The markup remembered for one written map ('' when it was too large or is gone). */
2440
- function rememberedMapSvg(svgPath) {
2441
- return recentMapSvg.get(svgPath) ?? '';
2462
+ function cardSvgFor(svgPath) {
2463
+ return recentCardSvg.get(svgPath) ?? '';
2442
2464
  }
2443
2465
 
2444
2466
  // ── cross-intron primer design ──────────────────────────────────────────────
@@ -2449,7 +2471,7 @@ const INTRON_MISMATCH_SCHEMA = {
2449
2471
  required: ['position', 'template_base', 'primer_base', 'spliced_position', 'genomic_position', 'distance_from_3prime'],
2450
2472
  properties: {
2451
2473
  position: { type: 'integer', description: '1-based position of the mismatch in the primer (5\'→3\').' },
2452
- template_base: { type: 'string', description: 'The base a perfectly matching primer would carry at this position on the spliced transcript.' },
2474
+ template_base: { type: 'string', description: 'The base a perfectly matching primer would carry at this position: the spliced-transcript base for the forward primer, its complement for the reverse primer (which is reported as the molecule to order).' },
2453
2475
  primer_base: { type: 'string', description: 'The base the reported primer actually carries (primer_base !== template_base marks a real mismatch).' },
2454
2476
  spliced_position: { type: 'integer', description: '1-based position on the spliced transcript.' },
2455
2477
  genomic_position: { type: 'integer', description: '1-based position on the genomic sequence.' },
@@ -2501,7 +2523,7 @@ const INTRON_PRIMER_SCHEMA = {
2501
2523
 
2502
2524
  const intronPrimersTool = (ctx) => define({
2503
2525
  name: 'molbio_design_intron_primers',
2504
- description: 'Design qPCR primer pairs where the forward primer spans an exon-exon junction (>= min_junction_bases on each side) so genomic DNA cannot be amplified, and the reverse primer sits in a different exon. Provide the genomic sequence and the exon spans (1-based, inclusive). min_genomic_span enforces a minimal genomic distance between the primers (making gDNA amplification impossible or easily detectable). Coordinates come back both on the spliced transcript and on the genomic sequence. v12: Primer3-style structural filters (self-complementarity alignment scores, hairpin/dimer folding Tm at 47 °C defaults, end stability/end GC, GC clamp levels); pass max_mismatches > 0 to allow mismatched primers (never on the 3\'-terminal base) with spliced+genomic mismatch reports; pass check_mispriming: true to reject/penalize non-specific 3\'-tail annealing on the transcript.',
2526
+ description: 'Design qPCR primer pairs where ONE primer spans an exon-exon junction (>= min_junction_bases on each side) so genomic DNA cannot be amplified. In the returned pair the junction is carried by the REVERSE primer (the assay sits 5\' of the junction): the forward primer is upstream inside a single exon, and the reverse sequence is the molecule to order (the reverse complement of its spliced site), so F is upstream of R and the pair amplifies as printed. Provide the genomic sequence and the exon spans (1-based, inclusive). min_genomic_span enforces a minimal genomic distance between the primers (making gDNA amplification impossible or easily detectable). Coordinates come back both on the spliced transcript and on the genomic sequence. v12: Primer3-style structural filters (self-complementarity alignment scores, hairpin/dimer folding Tm at 47 °C defaults, end stability/end GC, GC clamp levels); pass max_mismatches > 0 to allow mismatched primers (never on the 3\'-terminal base) with spliced+genomic mismatch reports; pass check_mispriming: true to reject/penalize non-specific 3\'-tail annealing on the transcript.',
2505
2527
  parameters: {
2506
2528
  type: 'object',
2507
2529
  required: ['exons'],
@@ -2621,7 +2643,7 @@ const intronPrimersTool = (ctx) => define({
2621
2643
  }
2622
2644
  const lines = [
2623
2645
  `Tm model conditions: ${value.conditions.na_mm} mM Na+ / ${value.conditions.mg_mm} mM Mg2+ / ${value.conditions.dntp_mm} mM dNTP / ${value.conditions.primer_nm} nM primer (SantaLucia 1998 NN — an estimate).`,
2624
- `${value.pairs.length} candidate pair(s); the forward primer spans an exon-exon junction (genomic DNA will not amplify):`,
2646
+ `${value.pairs.length} candidate pair(s); the REVERSE primer spans an exon-exon junction, so genomic DNA will not amplify (forward is upstream of it):`,
2625
2647
  ];
2626
2648
  const mismatchLine = (label, primer) => {
2627
2649
  if (primer.mismatch_count > 0) {
@@ -2641,8 +2663,8 @@ const intronPrimersTool = (ctx) => define({
2641
2663
  };
2642
2664
  for (const [index, pair] of value.pairs.entries()) {
2643
2665
  lines.push(`#${index + 1} spliced amplicon ${pair.spliced_amplicon.start}-${pair.spliced_amplicon.end} (${pair.spliced_amplicon.length} bp), genomic span ${pair.genomic_amplicon_length} bp${pair.target_distance !== undefined ? `, nearer 3' end ${pair.target_distance} spliced bp from target_position` : ''}, penalty ${pair.penalty}`);
2644
- lines.push(` F ${pair.forward.sequence} (spliced ${pair.forward.spliced_start}-${pair.forward.spliced_end}; genomic ${pair.forward.genomic_start}-${pair.forward.genomic_end}; exons ${pair.forward.exons.join('/')}, junction ${pair.forward.junction_left}+${pair.forward.junction_right} bp; Tm ${pair.forward.tm} °C, self ${pair.forward.self_any}/${pair.forward.self_end}, hairpin ${pair.forward.hairpin_tm} °C)`);
2645
- lines.push(` R ${pair.reverse.sequence} (spliced ${pair.reverse.spliced_start}-${pair.reverse.spliced_end}; genomic ${pair.reverse.genomic_start}-${pair.reverse.genomic_end}; exon ${pair.reverse.exon}; Tm ${pair.reverse.tm} °C, self ${pair.reverse.self_any}/${pair.reverse.self_end}, hairpin ${pair.reverse.hairpin_tm} °C)`);
2666
+ lines.push(` F ${pair.forward.sequence} (spliced ${pair.forward.spliced_start}-${pair.forward.spliced_end}; genomic ${pair.forward.genomic_start}-${pair.forward.genomic_end}; exon ${pair.forward.exon}; Tm ${pair.forward.tm} °C, self ${pair.forward.self_any}/${pair.forward.self_end}, hairpin ${pair.forward.hairpin_tm} °C)`);
2667
+ lines.push(` R ${pair.reverse.sequence} (spliced ${pair.reverse.spliced_start}-${pair.reverse.spliced_end}; genomic ${pair.reverse.genomic_start}-${pair.reverse.genomic_end}; exons ${pair.reverse.exons.join('/')}, junction ${pair.reverse.junction_left}+${pair.reverse.junction_right} bp; Tm ${pair.reverse.tm} °C, self ${pair.reverse.self_any}/${pair.reverse.self_end}, hairpin ${pair.reverse.hairpin_tm} °C)`);
2646
2668
  mismatchLine('F', pair.forward);
2647
2669
  mismatchLine('R', pair.reverse);
2648
2670
  misprimingLine('F', pair.forward);
@@ -3524,6 +3546,141 @@ const proteinPropsTool = define({
3524
3546
  },
3525
3547
  });
3526
3548
 
3549
+ const proteinChargeTool = (ctx) => define({
3550
+ name: 'molbio_protein_charge',
3551
+ 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
+ parameters: {
3553
+ type: 'object',
3554
+ required: ['sequence'],
3555
+ properties: {
3556
+ sequence: requiredString('Protein sequence (one-letter amino acid codes).'),
3557
+ pka_set: { type: 'string', enum: ['bjellqvist', 'emboss'], description: 'pK table: bjellqvist (default, matches molbio_protein_props) or emboss (Epk.dat).' },
3558
+ ph_min: { type: 'number', description: 'Lowest pH sampled (0-14, default 0).' },
3559
+ ph_max: { type: 'number', description: 'Highest pH sampled (0-14, default 14).' },
3560
+ steps: { type: 'integer', description: 'Number of sampled points including both ends (2-141, default 29 = 0.5 pH).' },
3561
+ title: { type: 'string', description: 'Title drawn on the SVG (default "Net charge vs pH").' },
3562
+ output_path: { type: 'string', description: 'Optional SVG file path; default protein-charge.svg in the session workspace.' },
3563
+ auto_view: { type: 'boolean', description: 'Open the SVG automatically with the OS default application (default true).' },
3564
+ ...ATTACH_IMAGE_PARAM,
3565
+ },
3566
+ },
3567
+ outputSchema: {
3568
+ type: 'object',
3569
+ additionalProperties: false,
3570
+ required: ['length', 'pka_set', 'pka_label', 'pI', 'charge_at_7_4', 'charge_min', 'charge_max', 'ph_min', 'ph_max', 'steps', 'points', 'svg_path', 'auto_viewed'],
3571
+ properties: {
3572
+ length: { type: 'integer' },
3573
+ pka_set: { type: 'string', enum: ['bjellqvist', 'emboss'] },
3574
+ pka_label: { type: 'string' },
3575
+ pI: { type: 'number', description: 'Isoelectric point on the sampled pK set.' },
3576
+ charge_at_7_4: { type: 'number', description: 'Net charge at pH 7.4 (e).' },
3577
+ charge_min: { type: 'number' },
3578
+ charge_max: { type: 'number' },
3579
+ ph_min: { type: 'number' },
3580
+ ph_max: { type: 'number' },
3581
+ steps: { type: 'integer' },
3582
+ points: {
3583
+ type: 'array',
3584
+ items: {
3585
+ type: 'object',
3586
+ additionalProperties: false,
3587
+ required: ['ph', 'charge'],
3588
+ properties: {
3589
+ ph: { type: 'number' },
3590
+ charge: { type: 'number' },
3591
+ },
3592
+ },
3593
+ },
3594
+ svg_path: { type: 'string' },
3595
+ auto_viewed: { type: 'boolean' },
3596
+ image: ATTACHED_IMAGE_SCHEMA,
3597
+ image_note: { type: 'string' },
3598
+ },
3599
+ },
3600
+ render(value) {
3601
+ const direction = value.charge_at_7_4 > 0 ? 'net positive' : value.charge_at_7_4 < 0 ? 'net negative' : 'neutral';
3602
+ return [
3603
+ value.length + ' aa · ' + value.pka_label + ' pK set · pI ' + value.pI + ' · charge at pH 7.4 ' + value.charge_at_7_4 + ' e (' + direction + ')',
3604
+ 'Curve over pH ' + value.ph_min + '-' + value.ph_max + ' (' + value.steps + ' points; range ' + value.charge_min + ' .. ' + value.charge_max + ' e), SVG written to ' + value.svg_path + (value.auto_viewed === true ? ' and opened automatically' : '') + '.',
3605
+ 'Henderson-Hasselbalch estimate: the pI belongs to the named pK set and is not interchangeable with another calculator.',
3606
+ imageNote(value).trim(),
3607
+ ].filter((line) => line !== '').join('\n');
3608
+ },
3609
+ async execute(args, exec) {
3610
+ const curve = chargeCurve(args.sequence, {
3611
+ pkaSet: args.pka_set ?? 'bjellqvist',
3612
+ phMin: args.ph_min,
3613
+ phMax: args.ph_max,
3614
+ steps: args.steps,
3615
+ });
3616
+ const svg = renderChargeCurve(curve, { title: args.title });
3617
+ const { file, viewed, attached } = await writeSvgFile(ctx, exec, args, svg, 'protein-charge.svg');
3618
+ return mergeAttachedImage({ ...curve, svg_path: file, auto_viewed: viewed }, attached);
3619
+ },
3620
+ });
3621
+
3622
+ const dotplotTool = (ctx) => define({
3623
+ name: 'molbio_dotplot',
3624
+ 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
+ parameters: {
3626
+ type: 'object',
3627
+ required: ['sequence'],
3628
+ properties: {
3629
+ sequence: requiredString('DNA sequence (IUPAC; ambiguous k-mers are skipped and counted).'),
3630
+ word: { type: 'integer', description: 'Word (k-mer) length, 4-12 (default 8).' },
3631
+ mode: { type: 'string', enum: ['both', 'direct', 'inverted'], description: 'Which repeats to plot: both (default), direct only, or inverted only.' },
3632
+ title: { type: 'string', description: 'Title drawn on the SVG (default "Sequence dotplot").' },
3633
+ output_path: { type: 'string', description: 'Optional SVG file path; default dotplot.svg in the session workspace.' },
3634
+ auto_view: { type: 'boolean', description: 'Open the SVG automatically with the OS default application (default true).' },
3635
+ ...ATTACH_IMAGE_PARAM,
3636
+ },
3637
+ },
3638
+ outputSchema: {
3639
+ type: 'object',
3640
+ additionalProperties: false,
3641
+ required: ['length', 'word', 'mode', 'direct_matches', 'inverted_matches', 'direct_cells', 'inverted_cells', 'ambiguous_bases', 'truncated', 'plot_truncated', 'svg_path', 'auto_viewed'],
3642
+ properties: {
3643
+ length: { type: 'integer' },
3644
+ word: { type: 'integer' },
3645
+ mode: { type: 'string', enum: ['both', 'direct', 'inverted'] },
3646
+ direct_matches: { type: 'integer' },
3647
+ inverted_matches: { type: 'integer' },
3648
+ direct_cells: { type: 'integer', description: 'Occupied grid cells for direct repeats (the plotted count, not the match count).' },
3649
+ inverted_cells: { type: 'integer' },
3650
+ ambiguous_bases: { type: 'integer', description: 'Ambiguous k-mers skipped (IUPAC bases other than A/C/G/T).' },
3651
+ truncated: { type: 'boolean', description: 'The match scan hit the work cap; counts are lower bounds.' },
3652
+ plot_truncated: { type: 'boolean', description: 'The grid hit the cell cap; some dots are not drawn.' },
3653
+ grid: { type: 'integer', description: 'Grid resolution used for binning the plot (cells per axis).' },
3654
+ svg_path: { type: 'string' },
3655
+ auto_viewed: { type: 'boolean' },
3656
+ image: ATTACHED_IMAGE_SCHEMA,
3657
+ image_note: { type: 'string' },
3658
+ },
3659
+ },
3660
+ render(value) {
3661
+ const lines = [
3662
+ value.length + ' bp · word ' + value.word + ' · ' + value.direct_matches + ' direct / ' + value.inverted_matches + ' inverted match(es); ' + value.direct_cells + ' + ' + value.inverted_cells + ' occupied cell(s)',
3663
+ 'SVG written to ' + value.svg_path + (value.auto_viewed === true ? ' and opened automatically' : '') + '.',
3664
+ ];
3665
+ if (value.ambiguous_bases > 0) lines.push(value.ambiguous_bases + ' ambiguous k-mer(s) skipped (IUPAC bases other than A/C/G/T).');
3666
+ if (value.truncated) lines.push('The match scan hit its work cap: the counts are lower bounds. Raise word to focus on longer, rarer repeats.');
3667
+ if (value.plot_truncated) lines.push('The plotted grid hit its cell cap: some dots are not drawn.');
3668
+ lines.push('Exact k-mer view (black = direct, red = inverted); the diagonal is the sequence against itself.');
3669
+ if (imageNote(value) !== '') lines.push(imageNote(value).trim());
3670
+ return lines.join('\n');
3671
+ },
3672
+ async execute(args, exec) {
3673
+ const data = dotplot(args.sequence, {
3674
+ word: args.word ?? 8,
3675
+ mode: args.mode ?? 'both',
3676
+ });
3677
+ const svg = renderDotplot(data, { title: args.title });
3678
+ const { file, viewed, attached } = await writeSvgFile(ctx, exec, args, svg, 'dotplot.svg');
3679
+ const { directGrid, invertedGrid, ...stats } = data;
3680
+ return mergeAttachedImage({ ...stats, svg_path: file, auto_viewed: viewed }, attached);
3681
+ },
3682
+ });
3683
+
3527
3684
  const peptideDigestTool = define({
3528
3685
  name: 'molbio_peptide_digest',
3529
3686
  description: 'In silico protease digestion for mass spectrometry: trypsin, chymotrypsin, LysC or GluC cleavage rules (no cleavage before proline where applicable), optional missed cleavages (0-3), monoisotopic or average [M+H]+ peptide masses, and optional mass-range filtering.',
@@ -4907,6 +5064,7 @@ const sequenceLogoTool = (ctx) => define({
4907
5064
  smallSample: args.small_sample !== false,
4908
5065
  });
4909
5066
  const { file, viewed, attached } = await writeSvgFile(ctx, exec, args, svg, 'logo.svg');
5067
+ rememberCardSvg(file, svg);
4910
5068
  const total = compositions.reduce((sum, column) => sum + column.bits, 0);
4911
5069
  const best = compositions.reduce((top, column) => (column.bits > top.bits ? column : top), compositions[0]);
4912
5070
  const round2 = (x) => Math.round(x * 100) / 100;
@@ -4924,6 +5082,17 @@ const sequenceLogoTool = (ctx) => define({
4924
5082
  auto_viewed: viewed,
4925
5083
  }, attached);
4926
5084
  },
5085
+ // The transcript card draws the logo instead of asking the reader to open the
5086
+ // file. The meta is a projection: cheap, total, and bounded (the markup is
5087
+ // dropped when it exceeds CARD_SVG_MAX_BYTES).
5088
+ presentationMeta(args, value) {
5089
+ return figureCardMeta({
5090
+ figure: 'logo',
5091
+ summary: `Sequence logo · ${value.sequence_count} sequences × ${value.columns} columns · ${value.total_bits} bits (${value.score_type})`,
5092
+ svgPath: value.svg_path,
5093
+ extra: { title: args?.title ?? 'Sequence logo' },
5094
+ }, cardSvgFor(value.svg_path));
5095
+ },
4927
5096
  });
4928
5097
 
4929
5098
  /**
@@ -5199,6 +5368,7 @@ const grnaDesignTool = (ctx) => define({
5199
5368
  mapPath = written.file;
5200
5369
  viewed = written.viewed;
5201
5370
  attached = written.attached;
5371
+ rememberCardSvg(written.file, svg);
5202
5372
  }
5203
5373
  return mergeAttachedImage({
5204
5374
  target_name: name,
@@ -5224,6 +5394,36 @@ const grnaDesignTool = (ctx) => define({
5224
5394
  auto_viewed: viewed,
5225
5395
  }, attached);
5226
5396
  },
5397
+ // The transcript card shows the ranked guides (top five) and, when the caller
5398
+ // asked for map_path, the map itself. The summary is built here so the card
5399
+ // stays a pure function of this projection.
5400
+ presentationMeta(_args, value) {
5401
+ const guides = value.guides.slice(0, 5).map((guide) => ({
5402
+ rank: guide.rank,
5403
+ sequence: guide.sequence,
5404
+ pam: guide.pam,
5405
+ strand: guide.strand,
5406
+ start: guide.start,
5407
+ end: guide.end,
5408
+ score: guide.score,
5409
+ off_target_count: guide.off_target_count ?? 0,
5410
+ }));
5411
+ const recognition = value.pam === '' ? 'no PAM (Cas13)' : value.pam + ' PAM';
5412
+ return figureCardMeta({
5413
+ figure: 'grna',
5414
+ summary: `${value.cas_label} · ${value.guides.length} of ${value.candidate_count} guide(s) · ${recognition} · ${value.target_name} (${value.target_length} bp)`,
5415
+ svgPath: value.map_path,
5416
+ extra: {
5417
+ guides,
5418
+ guide_count: value.guides.length,
5419
+ candidate_count: value.candidate_count,
5420
+ csv_path: value.saved_to,
5421
+ map_path: value.map_path,
5422
+ cas_family: value.cas_family,
5423
+ pam: value.pam,
5424
+ },
5425
+ }, value.map_path === '' ? '' : cardSvgFor(value.map_path));
5426
+ },
5227
5427
  });
5228
5428
 
5229
5429
  /** CSV of the returned guides, for pasting into an ordering sheet. */
@@ -6481,6 +6681,248 @@ const fastqQcTool = (ctx) => define({
6481
6681
  },
6482
6682
  });
6483
6683
 
6684
+
6685
+ const contaminationScreenTool = (ctx) => define({
6686
+ name: 'molbio_contamination_screen',
6687
+ description: 'Screen a DNA sequence, or every entry in a FASTA/FASTQ file, for common vector-backbone/promoter/origin and Illumina/Nextera adapter signatures. The signature table is SHORT and embedded (no reference database, no network): a hit is evidence worth following up, and a negative result does NOT prove the sample is clean. Matching is both-strand and substitution-only (no gaps); max_mismatches adds a seed-and-verify search (0-2). Reports per-signature read counts, the best example position/strand, and the criteria that produced them. Use it for "is this assembly contaminated with vector backbone or adapter" — not as a BLAST replacement.',
6688
+ parameters: {
6689
+ type: 'object',
6690
+ properties: {
6691
+ sequence: { type: 'string', description: 'Inline DNA sequence to screen. Provide exactly one of sequence or path.' },
6692
+ path: { type: 'string', description: 'Path to a FASTA/FASTQ file, or a plain sequence file. Provide exactly one of sequence or path.' },
6693
+ max_mismatches: { type: 'integer', description: 'Substitutions allowed per signature (0-2, default 0). A hit still needs one exact seed chunk.' },
6694
+ max_reads: { type: 'integer', description: 'Scan at most this many entries (default 0 = all, capped at 200000).' },
6695
+ kinds: { type: 'string', enum: ['both', 'adapter', 'vector'], description: 'Restrict the table to adapters, vectors, or both (default both).' },
6696
+ },
6697
+ },
6698
+ outputSchema: {
6699
+ type: 'object',
6700
+ additionalProperties: false,
6701
+ required: ['input_type', 'total_entries', 'scanned_entries', 'bases', 'ambiguous_bases', 'hits', 'hit_count', 'reads_with_hit', 'truncated', 'criteria'],
6702
+ properties: {
6703
+ input_type: { type: 'string', enum: ['sequence', 'fasta', 'fastq'] },
6704
+ total_entries: { type: 'integer' },
6705
+ scanned_entries: { type: 'integer' },
6706
+ bases: { type: 'integer' },
6707
+ ambiguous_bases: { type: 'integer' },
6708
+ hits: {
6709
+ type: 'array',
6710
+ items: {
6711
+ type: 'object',
6712
+ additionalProperties: false,
6713
+ required: ['name', 'kind', 'category', 'signature', 'reads', 'percent', 'best_mismatches', 'example'],
6714
+ properties: {
6715
+ name: { type: 'string' },
6716
+ kind: { type: 'string', enum: ['adapter', 'vector'] },
6717
+ category: { type: 'string' },
6718
+ signature: { type: 'string' },
6719
+ reads: { type: 'integer' },
6720
+ percent: { type: 'number' },
6721
+ best_mismatches: { type: 'integer' },
6722
+ example: {
6723
+ type: 'object',
6724
+ additionalProperties: false,
6725
+ required: ['id', 'start', 'end', 'strand', 'mismatches'],
6726
+ properties: {
6727
+ id: { type: 'string' },
6728
+ start: { type: 'integer' },
6729
+ end: { type: 'integer' },
6730
+ strand: { type: 'string', enum: ['+', '-'] },
6731
+ mismatches: { type: 'integer' },
6732
+ },
6733
+ },
6734
+ },
6735
+ },
6736
+ },
6737
+ hit_count: { type: 'integer' },
6738
+ reads_with_hit: { type: 'integer' },
6739
+ truncated: { type: 'boolean' },
6740
+ criteria: {
6741
+ type: 'object',
6742
+ additionalProperties: false,
6743
+ required: ['max_mismatches', 'kinds', 'signature_count', 'max_reads', 'match_basis'],
6744
+ properties: {
6745
+ max_mismatches: { type: 'integer' },
6746
+ kinds: { type: 'string', enum: ['both', 'adapter', 'vector'] },
6747
+ signature_count: { type: 'integer' },
6748
+ max_reads: { type: 'integer' },
6749
+ match_basis: { type: 'string' },
6750
+ },
6751
+ },
6752
+ },
6753
+ },
6754
+ render(value) {
6755
+ const lines = [
6756
+ value.input_type + ' input: ' + value.scanned_entries + ' of ' + value.total_entries + ' entr' + (value.total_entries === 1 ? 'y' : 'ies') + ', ' + value.bases + ' bp; ' + value.hit_count + ' signature(s) hit, ' + value.reads_with_hit + ' entr' + (value.total_entries === 1 ? 'y' : 'ies') + ' with a hit.',
6757
+ ];
6758
+ if (value.hits.length === 0) {
6759
+ lines.push('No embedded vector/adapter signature matched.');
6760
+ } else {
6761
+ for (const hit of value.hits) {
6762
+ lines.push(' ' + hit.name + ' [' + hit.kind + '/' + hit.category + '] ' + hit.reads + '/' + value.scanned_entries + ' (' + hit.percent + '%), best ' + hit.best_mismatches + ' mismatch(es); e.g. ' + hit.example.id + ' ' + hit.example.start + '-' + hit.example.end + ' (' + hit.example.strand + ')');
6763
+ }
6764
+ }
6765
+ if (value.ambiguous_bases > 0) lines.push(value.ambiguous_bases + ' ambiguous base(s) were scanned literally (they can only match a signature at an unambiguous position).');
6766
+ if (value.truncated) lines.push('Only the first ' + value.scanned_entries + ' entr' + (value.scanned_entries === 1 ? 'y was' : 'ies were') + ' scanned; max_reads capped the input.');
6767
+ lines.push('Short embedded signatures, both strands, substitutions only (no gaps). A negative result does not prove the sample is clean.');
6768
+ return lines.join('\n');
6769
+ },
6770
+ async execute(args, exec) {
6771
+ const hasSequence = typeof args.sequence === 'string' && args.sequence !== '';
6772
+ const hasPath = typeof args.path === 'string' && args.path !== '';
6773
+ if (hasSequence === hasPath) throw new MolbioInputError('provide exactly one of sequence or path');
6774
+ let entries;
6775
+ let inputType;
6776
+ if (hasSequence) {
6777
+ entries = [{ id: 'input', sequence: normalizeSequence(args.sequence, 'sequence') }];
6778
+ inputType = 'sequence';
6779
+ } else {
6780
+ const bytes = await readFileBytes(ctx, exec, args.path);
6781
+ const text = new TextDecoder().decode(bytes).replace(/^\uFEFF/, '');
6782
+ const first = text.trimStart();
6783
+ if (first.startsWith('@')) {
6784
+ entries = parseFastq(text);
6785
+ inputType = 'fastq';
6786
+ } else if (first.startsWith('>')) {
6787
+ entries = parseFasta(text);
6788
+ inputType = 'fasta';
6789
+ } else {
6790
+ entries = [{ id: 'input', sequence: normalizeSequence(text, 'sequence') }];
6791
+ inputType = 'sequence';
6792
+ }
6793
+ }
6794
+ const result = screenContamination(entries, {
6795
+ maxMismatches: args.max_mismatches,
6796
+ maxReads: args.max_reads,
6797
+ kinds: args.kinds,
6798
+ });
6799
+ return { input_type: inputType, ...result };
6800
+ },
6801
+ });
6802
+
6803
+ const FASTQ_TRIM_TOOL_SCHEMA = {
6804
+ type: 'object',
6805
+ additionalProperties: false,
6806
+ required: ['reads_in', 'total_reads', 'reads_out', 'bases_in', 'bases_out', 'trimmed_reads', 'dropped_reads', 'trim_counts', 'drop_counts', 'truncated', 'criteria', 'saved_to', 'output_format'],
6807
+ properties: {
6808
+ reads_in: { type: 'integer' },
6809
+ total_reads: { type: 'integer' },
6810
+ reads_out: { type: 'integer' },
6811
+ bases_in: { type: 'integer' },
6812
+ bases_out: { type: 'integer' },
6813
+ trimmed_reads: { type: 'integer' },
6814
+ dropped_reads: { type: 'integer' },
6815
+ trim_counts: {
6816
+ type: 'object',
6817
+ additionalProperties: false,
6818
+ required: ['quality', 'adapter', 'poly_g', 'poly_a', 'primer'],
6819
+ properties: {
6820
+ quality: { type: 'integer' },
6821
+ adapter: { type: 'integer' },
6822
+ poly_g: { type: 'integer' },
6823
+ poly_a: { type: 'integer' },
6824
+ primer: { type: 'integer' },
6825
+ },
6826
+ },
6827
+ drop_counts: {
6828
+ type: 'object',
6829
+ additionalProperties: false,
6830
+ required: ['length', 'n'],
6831
+ properties: {
6832
+ length: { type: 'integer' },
6833
+ n: { type: 'integer' },
6834
+ },
6835
+ },
6836
+ truncated: { type: 'boolean' },
6837
+ criteria: {
6838
+ type: 'object',
6839
+ additionalProperties: false,
6840
+ required: ['min_quality', 'quality_window', 'poly_min', 'min_length', 'max_n_percent', 'phred', 'max_reads', 'adapter', 'primer', 'poly_g', 'poly_a'],
6841
+ properties: {
6842
+ min_quality: { type: 'integer' },
6843
+ quality_window: { type: 'integer' },
6844
+ poly_min: { type: 'integer' },
6845
+ min_length: { type: 'integer' },
6846
+ max_n_percent: { type: 'number' },
6847
+ phred: { type: 'integer', enum: [33, 64] },
6848
+ max_reads: { type: 'integer' },
6849
+ adapter: { type: 'string' },
6850
+ primer: { type: 'string' },
6851
+ poly_g: { type: 'boolean' },
6852
+ poly_a: { type: 'boolean' },
6853
+ },
6854
+ },
6855
+ saved_to: { type: 'string' },
6856
+ output_format: { type: 'string', enum: ['fastq', 'fasta'] },
6857
+ },
6858
+ };
6859
+
6860
+ const fastqTrimTool = (ctx) => define({
6861
+ safe: false,
6862
+ name: 'molbio_fastq_trim',
6863
+ description: 'Trim and filter a FASTQ file, then WRITE the surviving reads as FASTQ or FASTA. Supported operations, applied in this order: remove an exact 5\' primer, remove an exact 3\' adapter and everything after it, trim a low-quality 3\' tail with a sliding mean-quality window, trim a trailing poly-G or poly-A run, then drop reads below min_length or above max_n_percent. Phred+33 and Phred+64 are supported (quality characters are preserved). This is not a paired-end merger, not a semiglobal adapter aligner, and not a UMI pipeline; those are deliberately out of scope. Returns before/after counts, per-stage trim counts, and the written path.',
6864
+ parameters: {
6865
+ type: 'object',
6866
+ required: ['path', 'output_path'],
6867
+ properties: {
6868
+ path: { type: 'string', description: 'Path to the input .fastq/.fq file.' },
6869
+ output_path: { type: 'string', description: 'Path to write the trimmed reads (FASTQ or FASTA, per output_format).' },
6870
+ output_format: { type: 'string', enum: ['fastq', 'fasta'], description: 'Output format (default fastq).' },
6871
+ min_quality: { type: 'integer', description: 'Minimum mean Phred in the sliding window (0-60, default 20). Set 0 to disable quality trimming.' },
6872
+ quality_window: { type: 'integer', description: 'Sliding-window width in bases (1-100, default 4).' },
6873
+ poly_g: { type: 'boolean', description: 'Trim a trailing poly-G run (default false; useful for NovaSeq/NextSeq).' },
6874
+ poly_a: { type: 'boolean', description: 'Trim a trailing poly-A run (default false).' },
6875
+ poly_min: { type: 'integer', description: 'Minimum trailing run length to trim (2-100, default 8).' },
6876
+ adapter: { type: 'string', description: 'Exact 3\' adapter sequence; it and everything after it are removed. Empty = no adapter trim.' },
6877
+ primer: { type: 'string', description: 'Exact 5\' primer sequence; it is removed when the read starts with it. Empty = no primer trim.' },
6878
+ min_length: { type: 'integer', description: 'Drop reads shorter than this after trimming (default 0).' },
6879
+ max_n_percent: { type: 'number', description: 'Drop reads whose trimmed sequence has more than this percent of non-ACGT bases (0-100, default 100 = no filter).' },
6880
+ phred: { type: 'integer', enum: [33, 64], description: 'Quality encoding of the input (33 = Sanger/Illumina 1.8+, 64 = old Illumina). Output keeps the same encoding.' },
6881
+ max_reads: { type: 'integer', description: 'Process at most this many reads (default 0 = all, capped at 200000).' },
6882
+ },
6883
+ },
6884
+ outputSchema: FASTQ_TRIM_TOOL_SCHEMA,
6885
+ render(value) {
6886
+ const counts = value.trim_counts;
6887
+ const lines = [
6888
+ 'FASTQ trim: ' + value.reads_in + ' of ' + value.total_reads + ' read(s) scanned (' + value.bases_in + ' bases) -> ' + value.reads_out + ' read(s) (' + value.bases_out + ' bases); ' + value.trimmed_reads + ' trimmed, ' + value.dropped_reads + ' dropped.',
6889
+ ' quality ' + counts.quality + ' · adapter ' + counts.adapter + ' · poly-G ' + counts.poly_g + ' · poly-A ' + counts.poly_a + ' · primer ' + counts.primer + '; dropped by length ' + value.drop_counts.length + ', by N ' + value.drop_counts.n,
6890
+ 'Saved ' + value.output_format.toUpperCase() + ' to ' + value.saved_to + '.',
6891
+ ];
6892
+ if (value.truncated) lines.push('Only the first ' + value.reads_in + ' read(s) were processed; max_reads capped the input.');
6893
+ lines.push('Settings: Phred+' + value.criteria.phred + ', min Q' + value.criteria.min_quality + ' over ' + value.criteria.quality_window + ' base(s), poly >= ' + value.criteria.poly_min + ', min length ' + value.criteria.min_length + ', max N ' + value.criteria.max_n_percent + '%.');
6894
+ if (value.criteria.adapter !== '') lines.push('Adapter: ' + value.criteria.adapter + '.');
6895
+ if (value.criteria.primer !== '') lines.push('Primer: ' + value.criteria.primer + '.');
6896
+ return lines.join('\n');
6897
+ },
6898
+ async execute(args, exec) {
6899
+ const bytes = await readFileBytes(ctx, exec, args.path);
6900
+ const text = new TextDecoder().decode(bytes).replace(/^\uFEFF/, '');
6901
+ if (text.trimStart().startsWith('>')) throw new MolbioInputError('this looks like FASTA, not FASTQ: molbio_fastq_trim needs per-base quality strings');
6902
+ const entries = parseFastq(text);
6903
+ const trimmed = trimFastq(entries, {
6904
+ minQuality: args.min_quality,
6905
+ qualityWindow: args.quality_window,
6906
+ polyG: args.poly_g,
6907
+ polyA: args.poly_a,
6908
+ polyMin: args.poly_min,
6909
+ adapter: args.adapter,
6910
+ primer: args.primer,
6911
+ minLength: args.min_length,
6912
+ maxNPercent: args.max_n_percent,
6913
+ phred: args.phred,
6914
+ maxReads: args.max_reads,
6915
+ });
6916
+ const outputFormat = args.output_format ?? 'fastq';
6917
+ const fs = fsService(ctx);
6918
+ const sandboxPolicyService = ctx.get('sandboxPolicy');
6919
+ const policy = sandboxPolicyService?.resolve({ ...exec?.agent !== undefined ? { session: exec.agent.session } : {} });
6920
+ const file = workspaceFilePath(args.output_path, exec, policy?.workspaceRoot);
6921
+ await writeWorkspaceFile(fs, file, outputFormat === 'fasta' ? toFasta(trimmed.entries) : toFastq(trimmed.entries), policy);
6922
+ return { ...trimmed.stats, saved_to: file, output_format: outputFormat };
6923
+ },
6924
+ });
6925
+
6484
6926
  const RARE_CODON_SCHEMA = {
6485
6927
  type: 'object',
6486
6928
  additionalProperties: false,
@@ -7602,11 +8044,11 @@ const PROMPT_SECTION = `Molecular-biology tools (dsh-molbio-tools) are available
7602
8044
  - Proteins: molbio_protein_props (MW/pI/A280/GRAVY — estimates), molbio_peptide_digest (trypsin etc. for MS), molbio_codon_optimize (E. coli/yeast/human, can avoid restriction sites), molbio_helical_wheel (Schiffer-Edmundson wheel + Eisenberg hydrophobic moment, written as an SVG), molbio_hydropathy_plot (Kyte-Doolittle sliding-window profile with the 1.6 threshold peaks, written as an SVG).
7603
8045
  - Quantitation: molbio_qpcr_efficiency (standard curve + plot), molbio_plot (experiment-data charts written to files), molbio_virtual_gel (expected band pattern as an SVG gel image with a size ladder).
7604
8046
  - molbio_plot kinds: bar (labelled values + optional SD error bars) and scatter (x/y + optional least-squares line) take their arrays directly in the call; the table kinds read a workspace CSV/TSV — pass data_path plus columns naming which column plays which role, or pass data with the table text inline. Use a table kind whenever the numbers are already in a file: do NOT retype a column into the call. line = one series per group_column; histogram = distribution of one column (bins default to Freedman-Diaconis unless you pass bins); box = Q1-Q3 box, median, 1.5xIQR whiskers, outliers as dots; violin = the same samples as a kernel-density silhouette with the box summary inside (use it when the SHAPE matters, not just the five numbers); volcano = effect size against significance from a log2-fold-change column and a p column, with dashed thresholds — it computes no statistics of its own, so the p-values must already be in the table; heatmap = a row x column matrix, one cell per value plus a colour bar, accepting either a wide table (first column = row labels, the rest = samples) or the long form (columns.row/column/value), with scale row_zscore|log2 as an explicitly-reported DISPLAY transform. The result carries the computed statistics (medians, quartiles, outliers, bin counts, axis ranges, significance counts, matrix extent), so quote THOSE instead of estimating values off the picture.
7605
- - Sequences & files: molbio_align (local alignment with a readable match line), molbio_msa_align (progressive multiple sequence alignment — pass an array of sequences or a workspace FASTA), molbio_conservation (consensus + per-column identity/conservation + variable positions; accepts aligned rows or raw sequences it aligns first), molbio_sequence_logo (draws the same alignment as a sequence logo SVG — pass the molbio_msa_align output rows as the alignment argument; letters are sized by per-position information content), molbio_fasta_fastq (FASTA/FASTQ stats/extract/convert/QC on workspace files), molbio_batch_analyze (run one summary/ORF/enzyme analysis over every matching FASTA/FASTQ/GenBank/.dna file in a directory and write one CSV/TSV table), molbio_extract_region (pull a CDS/promoter/region from a plasmid file by feature label or coordinates).
8047
+ - Sequences & files: molbio_align (local alignment with a readable match line), molbio_msa_align (progressive multiple sequence alignment — pass an array of sequences or a workspace FASTA), molbio_conservation (consensus + per-column identity/conservation + variable positions; accepts aligned rows or raw sequences it aligns first), molbio_sequence_logo (draws the same alignment as a sequence logo SVG — pass the molbio_msa_align output rows as the alignment argument; letters are sized by per-position information content), molbio_fasta_fastq (FASTA/FASTQ stats/extract/convert/QC on workspace files), molbio_contamination_screen (embedded short vector/adapter signatures, both strands, exact or 0-2 mismatches — a short-signature screen, not BLAST), molbio_batch_analyze (run one summary/ORF/enzyme analysis over every matching FASTA/FASTQ/GenBank/.dna file in a directory and write one CSV/TSV table), molbio_extract_region (pull a CDS/promoter/region from a plasmid file by feature label or coordinates).
7606
8048
  - CRISPR: molbio_grna_design (family-aware guide RNAs over a sequence or plasmid file: cas_family spcas9/sacas9/cas12a/cas13a/cas13b/cas13d selects the PAM side, crRNA length, seed and target type — Cas12a uses a 5' TTTV PAM and 23 nt crRNA; Cas13 targets RNA and reports a PFS preference rather than a PAM. Both strands are scanned for DNA families, the sense strand by default for Cas13; guides are filtered on GC/poly-T/self-complementarity and ranked, with a mismatch-tolerant off-target search over the same sequence — reaching a whole genome simply means passing it as the target). Pass the top candidates on for ordering with save_path (CSV) or map_path (SVG map); the ranking is a heuristic and says so, so present it as candidate ranking, never as a validated efficiency prediction.
7607
8049
  - Map extras: pass gc_skew: true for the GC skew ring, show_unique_cutters: true to mark single-cutting enzymes on the map.
7608
8050
  - Literature & records: molbio_pubmed_abstract (fetch abstracts by PMID — needs web fetch; an NCBI key comes from the api_key argument, the credential store's NCBI_API_KEY, or the environment), molbio_paper_export_bibtex (papers.json → .bib), molbio_protocol_add / molbio_protocol_list / molbio_protocol_update (protocols.json), molbio_experiment_log / molbio_experiment_list (experiments.json).
7609
- - Bench analysis (v19): molbio_fastq_qc (read-level QC for a FASTQ file: per-base quality with quartiles, per-read quality, base content, GC distribution, length, exact duplication, over-represented sequences, adapter screen, plus a report SVG — use it for "is this run good"), molbio_codon_usage (CAI/RSCU/Nc/GC3, rare-codon list and CDS CpG against E. coli/yeast/human — use it for "will this gene express"; it explains, molbio_codon_optimize rewrites), molbio_phylogenetic_tree (distance method: p-distance/JC/K2P/Tamura-Nei, UPGMA or neighbour-joining, bootstrap support, consensus, Newick file and an SVG tree — it is NOT a maximum-likelihood or Bayesian analysis, and bootstrap percentages are not p-values), molbio_pcr_simulate (where a primer pair amplifies: product sizes and coordinates on both strands, mismatch detail, the 3'-end exact-match requirement, circular templates, screening other templates, and an expected gel — use it for "what band will I see"), molbio_gc_composition (sliding-window GC, CpG islands under the Gardiner-Garden or Takai criteria, GC/AT skew with the cumulative-skew origin/terminus indication, dinucleotide obs/exp, entropy, complexity, N50/L50, plus a figure — use it for "is this a promoter or an origin").
8051
+ - Bench analysis (v19): molbio_fastq_qc (read-level QC for a FASTQ file: per-base quality with quartiles, per-read quality, base content, GC distribution, length, exact duplication, over-represented sequences, adapter screen, plus a report SVG — use it for "is this run good"), molbio_codon_usage (CAI/RSCU/Nc/GC3, rare-codon list and CDS CpG against E. coli/yeast/human — use it for "will this gene express"; it explains, molbio_codon_optimize rewrites), molbio_phylogenetic_tree (distance method: p-distance/JC/K2P/Tamura-Nei, UPGMA or neighbour-joining, bootstrap support, consensus, Newick file and an SVG tree — it is NOT a maximum-likelihood or Bayesian analysis, and bootstrap percentages are not p-values), molbio_pcr_simulate (where a primer pair amplifies: product sizes and coordinates on both strands, mismatch detail, the 3'-end exact-match requirement, circular templates, screening other templates, and an expected gel — use it for "what band will I see"), molbio_gc_composition (sliding-window GC, CpG islands under the Gardiner-Garden or Takai criteria, GC/AT skew with the cumulative-skew origin/terminus indication, dinucleotide obs/exp, entropy, complexity, N50/L50, plus a figure — use it for "is this a promoter or an origin"), molbio_fastq_trim (trim low-quality 3' tails, exact adapters/primers, poly-G/poly-A, then filter by length/N and write FASTQ/FASTA — not a paired-end merger).
7610
8052
  - Literature: molbio_pubmed_search — with an NCBI API key (api_key argument, the credential store under NCBI_API_KEY / MOLBIO_NCBI_API_KEY, or the environment) it uses the official PubMed E-utilities API and returns PMIDs, titles, journal/year, authors and DOI; without a key it falls back to the harness web search. The reading library is molbio_paper_add / molbio_paper_list / molbio_paper_update / molbio_paper_remove (papers.json in the workspace).
7611
8053
 
7612
8054
  Cloning workflow rules (follow them to avoid slow re-derivation):
@@ -7650,6 +8092,7 @@ export function apply(ctx) {
7650
8092
  silentSiteTool,
7651
8093
  verifySangerTool(ctx),
7652
8094
  proteinPropsTool,
8095
+ proteinChargeTool(ctx),
7653
8096
  peptideDigestTool,
7654
8097
  codonOptimizeTool,
7655
8098
  helicalWheelTool(ctx),
@@ -7666,10 +8109,13 @@ export function apply(ctx) {
7666
8109
  fastaFastqTool(ctx),
7667
8110
  batchAnalyzeTool(ctx),
7668
8111
  fastqQcTool(ctx),
8112
+ contaminationScreenTool(ctx),
8113
+ fastqTrimTool(ctx),
7669
8114
  codonUsageTool,
7670
8115
  phylogeneticTreeTool(ctx),
7671
8116
  pcrSimulateTool(ctx),
7672
8117
  gcCompositionTool(ctx),
8118
+ dotplotTool(ctx),
7673
8119
  extractRegionTool(ctx),
7674
8120
  pubmedAbstractTool(ctx),
7675
8121
  bibtexTool(ctx),