dsh-molbio-tools 0.22.2 → 0.24.1

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 (57) hide show
  1. package/CHANGELOG.md +221 -0
  2. package/README.md +51 -19
  3. package/batch.mjs +5 -1
  4. package/benchmark/README.md +7 -7
  5. package/benchmark/changed.mjs +4 -3
  6. package/benchmark/fixtures.mjs +32 -4
  7. package/benchmark/profile.mjs +1 -1
  8. package/benchmark/run.mjs +1 -0
  9. package/benchmark/sequences.mjs +1 -0
  10. package/benchmark/tasks/02-primers.json +3 -3
  11. package/benchmark/tasks/06-analysis.json +111 -1
  12. package/benchmark/tasks/09-formats.json +44 -1
  13. package/benchmark/verifications.mjs +13 -0
  14. package/build/client-bundle.mjs +1 -1
  15. package/build/client-entry.mjs +80 -1
  16. package/build/panel-core.mjs +85 -0
  17. package/build/preset-patch.mjs +1 -1
  18. package/build/release-preflight.mjs +1 -1
  19. package/charts.mjs +35 -16
  20. package/contamination.mjs +229 -0
  21. package/cordis.patch.yml +10 -6
  22. package/design.mjs +111 -78
  23. package/docs/capability-gap-survey.md +5 -0
  24. package/docs/client-panel.md +10 -8
  25. package/docs/history.md +86 -0
  26. package/docs/maintainer.md +8 -5
  27. package/docs/roadmap.md +37 -15
  28. package/docs/rules.md +5 -2
  29. package/docs/store-evidence.md +30 -23
  30. package/docs/store-preset-exemption-issue.md +1 -1
  31. package/docs/store-preset-exemption.md +1 -1
  32. package/docs/v24-plan.md +178 -0
  33. package/docs/workflow.md +11 -10
  34. package/dotplot.mjs +257 -0
  35. package/fastq-qc.mjs +31 -16
  36. package/fastq-trim.mjs +238 -0
  37. package/host.mjs +2 -2
  38. package/index.mjs +521 -34
  39. package/lib/client.js +376 -109
  40. package/package.json +8 -3
  41. package/papers.mjs +10 -0
  42. package/plot.mjs +24 -8
  43. package/preset/install.mjs +8 -0
  44. package/preset/molbio-lab/agent.cordis.yml +19 -13
  45. package/preset/molbio-lab/preset.yml +1 -1
  46. package/protein-charge.mjs +158 -0
  47. package/protein-structure.mjs +10 -2
  48. package/protein.mjs +36 -14
  49. package/records.mjs +17 -2
  50. package/seqio.mjs +49 -14
  51. package/test/charts.mjs +11 -0
  52. package/test/client.mjs +12 -5
  53. package/test/contract.mjs +44 -12
  54. package/test/drift-probe.mjs +15 -1
  55. package/test/map-card.mjs +139 -1
  56. package/test/preset-health.mjs +39 -0
  57. package/test/smoke.mjs +378 -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,
@@ -2207,7 +2211,14 @@ function fsService(ctx) {
2207
2211
 
2208
2212
  async function readFileBytes(ctx, exec, path) {
2209
2213
  const fs = fsService(ctx);
2210
- const target = await fs.resolve(path);
2214
+ // Resolve a model-supplied relative name against the SESSION workspace, not
2215
+ // the provider's process cwd. `ctx.fs.resolve(path)` alone uses the backend
2216
+ // default (process.cwd()), which is why tool-fs asks `workingDirectory` for
2217
+ // the agent's cwd; the sandbox policy carries the same root here. Absolute
2218
+ // paths are still accepted unchanged (workspaceFilePath leaves them alone).
2219
+ const sandboxPolicyService = ctx.get('sandboxPolicy');
2220
+ const policy = sandboxPolicyService?.resolve({ ...exec?.agent !== undefined ? { session: exec.agent.session } : {} });
2221
+ const target = await fs.resolve(workspaceFilePath(path, exec, policy?.workspaceRoot), { signal: exec?.signal });
2211
2222
  return await fs.readBytes(target, exec?.signal, MAX_DNA_BYTES);
2212
2223
  }
2213
2224
 
@@ -2334,7 +2345,7 @@ const plasmidMapFileTool = (ctx) => define({
2334
2345
  },
2335
2346
  // The map card in the transcript/panel reads this meta (see mapCardMeta).
2336
2347
  presentationMeta(_args, value) {
2337
- return mapCardMeta(value, rememberedMapSvg(value.svg_path));
2348
+ return mapCardMeta(value, cardSvgFor(value.svg_path));
2338
2349
  },
2339
2350
  async execute(args, exec) {
2340
2351
  const lower = args.path.toLowerCase();
@@ -2374,7 +2385,7 @@ const plasmidMapFileTool = (ctx) => define({
2374
2385
  gc_skew: args.gc_skew === true,
2375
2386
  });
2376
2387
  const { file, viewed, attached } = await writeSvgFile(ctx, exec, args, svg, name);
2377
- rememberMapSvg(file, svg);
2388
+ rememberCardSvg(file, svg);
2378
2389
  return mergeAttachedImage({
2379
2390
  svg_path: file,
2380
2391
  name,
@@ -2423,22 +2434,40 @@ function mapCardMeta(value, svg) {
2423
2434
  }
2424
2435
 
2425
2436
  /**
2426
- * Markup of the most recent maps, keyed by written path, so the card's
2437
+ * Build the meta a figure card reads (sequence logo, gRNA map). Shared by both
2438
+ * tools so the browser half has one shape to validate: `summary` is the header
2439
+ * line, `extra` carries bounded presentation rows, and the markup travels only
2440
+ * when it fits `CARD_SVG_MAX_BYTES`.
2441
+ */
2442
+ function figureCardMeta({ figure, summary, svgPath, extra = {} }, svg) {
2443
+ return {
2444
+ kind: 'molbio-figure',
2445
+ figure,
2446
+ summary,
2447
+ svg_path: svgPath,
2448
+ svg_bytes: svg.length,
2449
+ ...extra,
2450
+ ...svg.length === 0 ? { svg_missing: true } : svg.length <= CARD_SVG_MAX_BYTES ? { svg } : { svg_omitted: true },
2451
+ };
2452
+ }
2453
+
2454
+ /**
2455
+ * Markup of the most recent written figures, keyed by path, so a card's
2427
2456
  * 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
2457
+ * figures, each at most `CARD_SVG_MAX_BYTES` — because this is a module-level
2429
2458
  * cache in a long-lived host process, not a store.
2430
2459
  */
2431
- const recentMapSvg = new Map();
2460
+ const recentCardSvg = new Map();
2432
2461
 
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);
2462
+ function rememberCardSvg(svgPath, svg) {
2463
+ if (recentCardSvg.has(svgPath)) recentCardSvg.delete(svgPath);
2464
+ recentCardSvg.set(svgPath, svg.length <= CARD_SVG_MAX_BYTES ? svg : '');
2465
+ while (recentCardSvg.size > 8) recentCardSvg.delete(recentCardSvg.keys().next().value);
2437
2466
  }
2438
2467
 
2439
2468
  /** The markup remembered for one written map ('' when it was too large or is gone). */
2440
- function rememberedMapSvg(svgPath) {
2441
- return recentMapSvg.get(svgPath) ?? '';
2469
+ function cardSvgFor(svgPath) {
2470
+ return recentCardSvg.get(svgPath) ?? '';
2442
2471
  }
2443
2472
 
2444
2473
  // ── cross-intron primer design ──────────────────────────────────────────────
@@ -2449,7 +2478,7 @@ const INTRON_MISMATCH_SCHEMA = {
2449
2478
  required: ['position', 'template_base', 'primer_base', 'spliced_position', 'genomic_position', 'distance_from_3prime'],
2450
2479
  properties: {
2451
2480
  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.' },
2481
+ 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
2482
  primer_base: { type: 'string', description: 'The base the reported primer actually carries (primer_base !== template_base marks a real mismatch).' },
2454
2483
  spliced_position: { type: 'integer', description: '1-based position on the spliced transcript.' },
2455
2484
  genomic_position: { type: 'integer', description: '1-based position on the genomic sequence.' },
@@ -2501,7 +2530,7 @@ const INTRON_PRIMER_SCHEMA = {
2501
2530
 
2502
2531
  const intronPrimersTool = (ctx) => define({
2503
2532
  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.',
2533
+ 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
2534
  parameters: {
2506
2535
  type: 'object',
2507
2536
  required: ['exons'],
@@ -2621,7 +2650,7 @@ const intronPrimersTool = (ctx) => define({
2621
2650
  }
2622
2651
  const lines = [
2623
2652
  `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):`,
2653
+ `${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
2654
  ];
2626
2655
  const mismatchLine = (label, primer) => {
2627
2656
  if (primer.mismatch_count > 0) {
@@ -2641,8 +2670,8 @@ const intronPrimersTool = (ctx) => define({
2641
2670
  };
2642
2671
  for (const [index, pair] of value.pairs.entries()) {
2643
2672
  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)`);
2673
+ 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)`);
2674
+ 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
2675
  mismatchLine('F', pair.forward);
2647
2676
  mismatchLine('R', pair.reverse);
2648
2677
  misprimingLine('F', pair.forward);
@@ -3524,6 +3553,145 @@ const proteinPropsTool = define({
3524
3553
  },
3525
3554
  });
3526
3555
 
3556
+ const proteinChargeTool = (ctx) => define({
3557
+ // Always writes protein-charge.svg and may open a viewer.
3558
+ safe: false,
3559
+ name: 'molbio_protein_charge',
3560
+ description: 'Plot a protein\'s net charge across a pH range and report the isoelectric point on the same curve (Henderson-Hasselbalch summation over the terminal groups, K/R/H positive and D/E/C/Y negative). pka_set selects the pK table: bjellqvist (the set molbio_protein_props uses, so the pI matches) or emboss (the Epk.dat values). The SVG is written to the workspace and opened with the OS default viewer (auto_view, default true); the sampled curve and the charge at pH 7.4 are returned too. pI is an estimate and is only meaningful together with the named pK set.',
3561
+ parameters: {
3562
+ type: 'object',
3563
+ required: ['sequence'],
3564
+ properties: {
3565
+ sequence: requiredString('Protein sequence (one-letter amino acid codes).'),
3566
+ pka_set: { type: 'string', enum: ['bjellqvist', 'emboss'], description: 'pK table: bjellqvist (default, matches molbio_protein_props) or emboss (Epk.dat).' },
3567
+ ph_min: { type: 'number', description: 'Lowest pH sampled (0-14, default 0).' },
3568
+ ph_max: { type: 'number', description: 'Highest pH sampled (0-14, default 14).' },
3569
+ steps: { type: 'integer', description: 'Number of sampled points including both ends (2-141, default 29 = 0.5 pH).' },
3570
+ title: { type: 'string', description: 'Title drawn on the SVG (default "Net charge vs pH").' },
3571
+ output_path: { type: 'string', description: 'Optional SVG file path; default protein-charge.svg in the session workspace.' },
3572
+ auto_view: { type: 'boolean', description: 'Open the SVG automatically with the OS default application (default true).' },
3573
+ ...ATTACH_IMAGE_PARAM,
3574
+ },
3575
+ },
3576
+ outputSchema: {
3577
+ type: 'object',
3578
+ additionalProperties: false,
3579
+ 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'],
3580
+ properties: {
3581
+ length: { type: 'integer' },
3582
+ pka_set: { type: 'string', enum: ['bjellqvist', 'emboss'] },
3583
+ pka_label: { type: 'string' },
3584
+ pI: { type: 'number', description: 'Isoelectric point on the sampled pK set.' },
3585
+ charge_at_7_4: { type: 'number', description: 'Net charge at pH 7.4 (e).' },
3586
+ charge_min: { type: 'number' },
3587
+ charge_max: { type: 'number' },
3588
+ ph_min: { type: 'number' },
3589
+ ph_max: { type: 'number' },
3590
+ steps: { type: 'integer' },
3591
+ points: {
3592
+ type: 'array',
3593
+ items: {
3594
+ type: 'object',
3595
+ additionalProperties: false,
3596
+ required: ['ph', 'charge'],
3597
+ properties: {
3598
+ ph: { type: 'number' },
3599
+ charge: { type: 'number' },
3600
+ },
3601
+ },
3602
+ },
3603
+ svg_path: { type: 'string' },
3604
+ auto_viewed: { type: 'boolean' },
3605
+ image: ATTACHED_IMAGE_SCHEMA,
3606
+ image_note: { type: 'string' },
3607
+ },
3608
+ },
3609
+ render(value) {
3610
+ const direction = value.charge_at_7_4 > 0 ? 'net positive' : value.charge_at_7_4 < 0 ? 'net negative' : 'neutral';
3611
+ return [
3612
+ value.length + ' aa · ' + value.pka_label + ' pK set · pI ' + value.pI + ' · charge at pH 7.4 ' + value.charge_at_7_4 + ' e (' + direction + ')',
3613
+ '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' : '') + '.',
3614
+ 'Henderson-Hasselbalch estimate: the pI belongs to the named pK set and is not interchangeable with another calculator.',
3615
+ imageNote(value).trim(),
3616
+ ].filter((line) => line !== '').join('\n');
3617
+ },
3618
+ async execute(args, exec) {
3619
+ const curve = chargeCurve(args.sequence, {
3620
+ pkaSet: args.pka_set ?? 'bjellqvist',
3621
+ phMin: args.ph_min,
3622
+ phMax: args.ph_max,
3623
+ steps: args.steps,
3624
+ });
3625
+ const svg = renderChargeCurve(curve, { title: args.title });
3626
+ const { file, viewed, attached } = await writeSvgFile(ctx, exec, args, svg, 'protein-charge.svg');
3627
+ return mergeAttachedImage({ ...curve, svg_path: file, auto_viewed: viewed }, attached);
3628
+ },
3629
+ });
3630
+
3631
+ const dotplotTool = (ctx) => define({
3632
+ // Always writes dotplot.svg and may open a viewer.
3633
+ safe: false,
3634
+ name: 'molbio_dotplot',
3635
+ description: 'Compute and draw a self-comparison dotplot of a DNA sequence by exact word matching: a black cell at (i,j) means the k-mer at i equals the k-mer at j (direct repeat); a red cell means it equals the REVERSE COMPLEMENT of the k-mer at j (inverted repeat). word defaults to 8 (4-12); lower words find shorter repeats and produce more dots. The plot is binned to a fixed grid so a repetitive sequence cannot produce an enormous file; direct/inverted match counts and any scan/plot caps are reported. The SVG is written and opened with the OS default viewer (auto_view, default true). Exact k-mer view — not a windowed identity/scoring dotplot.',
3636
+ parameters: {
3637
+ type: 'object',
3638
+ required: ['sequence'],
3639
+ properties: {
3640
+ sequence: requiredString('DNA sequence (IUPAC; ambiguous k-mers are skipped and counted).'),
3641
+ word: { type: 'integer', description: 'Word (k-mer) length, 4-12 (default 8).' },
3642
+ mode: { type: 'string', enum: ['both', 'direct', 'inverted'], description: 'Which repeats to plot: both (default), direct only, or inverted only.' },
3643
+ title: { type: 'string', description: 'Title drawn on the SVG (default "Sequence dotplot").' },
3644
+ output_path: { type: 'string', description: 'Optional SVG file path; default dotplot.svg in the session workspace.' },
3645
+ auto_view: { type: 'boolean', description: 'Open the SVG automatically with the OS default application (default true).' },
3646
+ ...ATTACH_IMAGE_PARAM,
3647
+ },
3648
+ },
3649
+ outputSchema: {
3650
+ type: 'object',
3651
+ additionalProperties: false,
3652
+ required: ['length', 'word', 'mode', 'direct_matches', 'inverted_matches', 'direct_cells', 'inverted_cells', 'ambiguous_bases', 'truncated', 'plot_truncated', 'svg_path', 'auto_viewed'],
3653
+ properties: {
3654
+ length: { type: 'integer' },
3655
+ word: { type: 'integer' },
3656
+ mode: { type: 'string', enum: ['both', 'direct', 'inverted'] },
3657
+ direct_matches: { type: 'integer' },
3658
+ inverted_matches: { type: 'integer' },
3659
+ direct_cells: { type: 'integer', description: 'Occupied grid cells for direct repeats (the plotted count, not the match count).' },
3660
+ inverted_cells: { type: 'integer' },
3661
+ ambiguous_bases: { type: 'integer', description: 'Ambiguous k-mers skipped (IUPAC bases other than A/C/G/T).' },
3662
+ truncated: { type: 'boolean', description: 'The match scan hit the work cap; counts are lower bounds.' },
3663
+ plot_truncated: { type: 'boolean', description: 'The grid hit the cell cap; some dots are not drawn.' },
3664
+ grid: { type: 'integer', description: 'Grid resolution used for binning the plot (cells per axis).' },
3665
+ svg_path: { type: 'string' },
3666
+ auto_viewed: { type: 'boolean' },
3667
+ image: ATTACHED_IMAGE_SCHEMA,
3668
+ image_note: { type: 'string' },
3669
+ },
3670
+ },
3671
+ render(value) {
3672
+ const lines = [
3673
+ 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)',
3674
+ 'SVG written to ' + value.svg_path + (value.auto_viewed === true ? ' and opened automatically' : '') + '.',
3675
+ ];
3676
+ if (value.ambiguous_bases > 0) lines.push(value.ambiguous_bases + ' ambiguous k-mer(s) skipped (IUPAC bases other than A/C/G/T).');
3677
+ 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.');
3678
+ if (value.plot_truncated) lines.push('The plotted grid hit its cell cap: some dots are not drawn.');
3679
+ lines.push('Exact k-mer view (black = direct, red = inverted); the diagonal is the sequence against itself.');
3680
+ if (imageNote(value) !== '') lines.push(imageNote(value).trim());
3681
+ return lines.join('\n');
3682
+ },
3683
+ async execute(args, exec) {
3684
+ const data = dotplot(args.sequence, {
3685
+ word: args.word ?? 8,
3686
+ mode: args.mode ?? 'both',
3687
+ });
3688
+ const svg = renderDotplot(data, { title: args.title });
3689
+ const { file, viewed, attached } = await writeSvgFile(ctx, exec, args, svg, 'dotplot.svg');
3690
+ const { directGrid, invertedGrid, ...stats } = data;
3691
+ return mergeAttachedImage({ ...stats, svg_path: file, auto_viewed: viewed }, attached);
3692
+ },
3693
+ });
3694
+
3527
3695
  const peptideDigestTool = define({
3528
3696
  name: 'molbio_peptide_digest',
3529
3697
  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.',
@@ -3646,7 +3814,9 @@ const WHEEL_RESIDUE_SCHEMA = {
3646
3814
  };
3647
3815
 
3648
3816
  const helicalWheelTool = (ctx) => define({
3649
- safe: (args) => args.output_path === undefined || args.output_path === '',
3817
+ // It always writes the SVG (output_path only chooses the name), so it is
3818
+ // never concurrency-safe; the old predicate marked the common case safe.
3819
+ safe: false,
3650
3820
  name: 'molbio_helical_wheel',
3651
3821
  description: 'Draw the helical wheel (Schiffer-Edmundson projection) of a peptide or protein stretch and analyse it: residues are placed 100° apart on the circle (3.6 residues per turn of an ideal alpha-helix), coloured by residue class (hydrophobic/polar/acidic/basic), with the Eisenberg hydrophobic moment μH of the stretch and the maximum μH over every window of moment_window residues (11 by default). An amphipathic helix shows one hydrophobic face and one polar face. The tool WRITES an SVG file and opens it with the OS default viewer (auto_view, default true); all values are sequence-propensity estimates from published scales, not a structure prediction.',
3652
3822
  parameters: {
@@ -3765,7 +3935,8 @@ const HYDROPATHY_PEAK_SCHEMA = {
3765
3935
  };
3766
3936
 
3767
3937
  const hydropathyTool = (ctx) => define({
3768
- safe: (args) => args.output_path === undefined || args.output_path === '',
3938
+ // Always writes the SVG; see the helical-wheel note.
3939
+ safe: false,
3769
3940
  name: 'molbio_hydropathy_plot',
3770
3941
  description: 'Plot the Kyte-Doolittle hydropathy profile of a protein: a sliding-window average (window, default 9; use 19-21 for transmembrane spans), the GRAVY value, and every peak whose window average reaches the threshold (default 1.6, the classic membrane-spanning cutoff). The tool WRITES an SVG file and opens it with the OS default viewer (auto_view, default true); the per-residue profile is also returned so it can feed further analysis. Values are the Kyte-Doolittle propensity scale, not a topology prediction.',
3771
3942
  parameters: {
@@ -4907,6 +5078,7 @@ const sequenceLogoTool = (ctx) => define({
4907
5078
  smallSample: args.small_sample !== false,
4908
5079
  });
4909
5080
  const { file, viewed, attached } = await writeSvgFile(ctx, exec, args, svg, 'logo.svg');
5081
+ rememberCardSvg(file, svg);
4910
5082
  const total = compositions.reduce((sum, column) => sum + column.bits, 0);
4911
5083
  const best = compositions.reduce((top, column) => (column.bits > top.bits ? column : top), compositions[0]);
4912
5084
  const round2 = (x) => Math.round(x * 100) / 100;
@@ -4924,6 +5096,17 @@ const sequenceLogoTool = (ctx) => define({
4924
5096
  auto_viewed: viewed,
4925
5097
  }, attached);
4926
5098
  },
5099
+ // The transcript card draws the logo instead of asking the reader to open the
5100
+ // file. The meta is a projection: cheap, total, and bounded (the markup is
5101
+ // dropped when it exceeds CARD_SVG_MAX_BYTES).
5102
+ presentationMeta(args, value) {
5103
+ return figureCardMeta({
5104
+ figure: 'logo',
5105
+ summary: `Sequence logo · ${value.sequence_count} sequences × ${value.columns} columns · ${value.total_bits} bits (${value.score_type})`,
5106
+ svgPath: value.svg_path,
5107
+ extra: { title: args?.title ?? 'Sequence logo' },
5108
+ }, cardSvgFor(value.svg_path));
5109
+ },
4927
5110
  });
4928
5111
 
4929
5112
  /**
@@ -5199,6 +5382,7 @@ const grnaDesignTool = (ctx) => define({
5199
5382
  mapPath = written.file;
5200
5383
  viewed = written.viewed;
5201
5384
  attached = written.attached;
5385
+ rememberCardSvg(written.file, svg);
5202
5386
  }
5203
5387
  return mergeAttachedImage({
5204
5388
  target_name: name,
@@ -5224,6 +5408,36 @@ const grnaDesignTool = (ctx) => define({
5224
5408
  auto_viewed: viewed,
5225
5409
  }, attached);
5226
5410
  },
5411
+ // The transcript card shows the ranked guides (top five) and, when the caller
5412
+ // asked for map_path, the map itself. The summary is built here so the card
5413
+ // stays a pure function of this projection.
5414
+ presentationMeta(_args, value) {
5415
+ const guides = value.guides.slice(0, 5).map((guide) => ({
5416
+ rank: guide.rank,
5417
+ sequence: guide.sequence,
5418
+ pam: guide.pam,
5419
+ strand: guide.strand,
5420
+ start: guide.start,
5421
+ end: guide.end,
5422
+ score: guide.score,
5423
+ off_target_count: guide.off_target_count ?? 0,
5424
+ }));
5425
+ const recognition = value.pam === '' ? 'no PAM (Cas13)' : value.pam + ' PAM';
5426
+ return figureCardMeta({
5427
+ figure: 'grna',
5428
+ summary: `${value.cas_label} · ${value.guides.length} of ${value.candidate_count} guide(s) · ${recognition} · ${value.target_name} (${value.target_length} bp)`,
5429
+ svgPath: value.map_path,
5430
+ extra: {
5431
+ guides,
5432
+ guide_count: value.guides.length,
5433
+ candidate_count: value.candidate_count,
5434
+ csv_path: value.saved_to,
5435
+ map_path: value.map_path,
5436
+ cas_family: value.cas_family,
5437
+ pam: value.pam,
5438
+ },
5439
+ }, value.map_path === '' ? '' : cardSvgFor(value.map_path));
5440
+ },
5227
5441
  });
5228
5442
 
5229
5443
  /** CSV of the returned guides, for pasting into an ordering sheet. */
@@ -5284,7 +5498,10 @@ function validateGrnaOptions(args) {
5284
5498
  }
5285
5499
 
5286
5500
  const fastaFastqTool = (ctx) => define({
5287
- safe: (args) => args.action !== 'convert' && args.action !== 'extract',
5501
+ // convert always writes; extract writes only when output_path is given, so
5502
+ // stats/qc (and extract without a path) are the pure cases.
5503
+ safe: (args) => !(args.action === 'convert'
5504
+ || (args.action === 'extract' && args.output_path !== undefined && args.output_path !== '')),
5288
5505
  name: 'molbio_fasta_fastq',
5289
5506
  description: 'Work with FASTA/FASTQ files in the workspace. action=stats: per-entry and overall statistics (length/GC). action=extract: pull entries by id (exact or substring) and optionally write them to a FASTA file. action=convert: FASTQ → FASTA (output_path required). action=qc: FASTQ quality statistics (mean/min/max Phred, low-quality fraction, per-position means).',
5290
5507
  parameters: {
@@ -5452,13 +5669,22 @@ async function batchFileList(ctx, exec, args, policy, root) {
5452
5669
  const files = [];
5453
5670
  let truncated = false;
5454
5671
  if (Array.isArray(args.paths) && args.paths.length > 0) {
5455
- for (const supplied of args.paths) {
5672
+ // The directory walk enforces max_files; the explicit list must too, or the
5673
+ // documented 1-500 bound disappears for the easier input form.
5674
+ const maxFiles = Math.min(args.max_files ?? 50, BATCH_LIMITS.maxFiles);
5675
+ for (const supplied of args.paths.slice(0, maxFiles)) {
5456
5676
  const absolute = workspaceFilePath(supplied, exec, root);
5457
- const target = await fs.resolve(absolute);
5458
5677
  const label = relative(root, absolute).split(sep).join('/');
5459
- files.push({ target, label });
5678
+ try {
5679
+ const target = await fs.resolve(absolute);
5680
+ files.push({ target, label });
5681
+ } catch (error) {
5682
+ // A bad explicit path is one error ROW, not a failed batch: the same
5683
+ // contract the directory walk provides for files it cannot parse.
5684
+ files.push({ label, resolveError: error instanceof Error ? error.message : String(error) });
5685
+ }
5460
5686
  }
5461
- return { fs, files, truncated, maxBytes };
5687
+ return { fs, files, truncated: args.paths.length > files.length, maxBytes };
5462
5688
  }
5463
5689
  if (typeof fs.listDir !== 'function') {
5464
5690
  throw new MolbioInputError('this harness fs service has no listDir; pass an explicit paths array instead of a directory');
@@ -5618,6 +5844,10 @@ const batchAnalyzeTool = (ctx) => define({
5618
5844
  const rows = [];
5619
5845
  for (const file of files) {
5620
5846
  const format = detectBatchFormat(file.label, '');
5847
+ if (file.resolveError !== undefined) {
5848
+ rows.push(batchErrorRow(file.label, format, file.resolveError));
5849
+ continue;
5850
+ }
5621
5851
  try {
5622
5852
  const info = file.size === undefined ? await fs.stat(file.target) : { size: file.size };
5623
5853
  if (info?.size !== undefined && info.size > maxBytes) {
@@ -6481,6 +6711,248 @@ const fastqQcTool = (ctx) => define({
6481
6711
  },
6482
6712
  });
6483
6713
 
6714
+
6715
+ const contaminationScreenTool = (ctx) => define({
6716
+ name: 'molbio_contamination_screen',
6717
+ 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.',
6718
+ parameters: {
6719
+ type: 'object',
6720
+ properties: {
6721
+ sequence: { type: 'string', description: 'Inline DNA sequence to screen. Provide exactly one of sequence or path.' },
6722
+ path: { type: 'string', description: 'Path to a FASTA/FASTQ file, or a plain sequence file. Provide exactly one of sequence or path.' },
6723
+ max_mismatches: { type: 'integer', description: 'Substitutions allowed per signature (0-2, default 0). A hit still needs one exact seed chunk.' },
6724
+ max_reads: { type: 'integer', description: 'Scan at most this many entries (default 0 = all, capped at 200000).' },
6725
+ kinds: { type: 'string', enum: ['both', 'adapter', 'vector'], description: 'Restrict the table to adapters, vectors, or both (default both).' },
6726
+ },
6727
+ },
6728
+ outputSchema: {
6729
+ type: 'object',
6730
+ additionalProperties: false,
6731
+ required: ['input_type', 'total_entries', 'scanned_entries', 'bases', 'ambiguous_bases', 'hits', 'hit_count', 'reads_with_hit', 'truncated', 'criteria'],
6732
+ properties: {
6733
+ input_type: { type: 'string', enum: ['sequence', 'fasta', 'fastq'] },
6734
+ total_entries: { type: 'integer' },
6735
+ scanned_entries: { type: 'integer' },
6736
+ bases: { type: 'integer' },
6737
+ ambiguous_bases: { type: 'integer' },
6738
+ hits: {
6739
+ type: 'array',
6740
+ items: {
6741
+ type: 'object',
6742
+ additionalProperties: false,
6743
+ required: ['name', 'kind', 'category', 'signature', 'reads', 'percent', 'best_mismatches', 'example'],
6744
+ properties: {
6745
+ name: { type: 'string' },
6746
+ kind: { type: 'string', enum: ['adapter', 'vector'] },
6747
+ category: { type: 'string' },
6748
+ signature: { type: 'string' },
6749
+ reads: { type: 'integer' },
6750
+ percent: { type: 'number' },
6751
+ best_mismatches: { type: 'integer' },
6752
+ example: {
6753
+ type: 'object',
6754
+ additionalProperties: false,
6755
+ required: ['id', 'start', 'end', 'strand', 'mismatches'],
6756
+ properties: {
6757
+ id: { type: 'string' },
6758
+ start: { type: 'integer' },
6759
+ end: { type: 'integer' },
6760
+ strand: { type: 'string', enum: ['+', '-'] },
6761
+ mismatches: { type: 'integer' },
6762
+ },
6763
+ },
6764
+ },
6765
+ },
6766
+ },
6767
+ hit_count: { type: 'integer' },
6768
+ reads_with_hit: { type: 'integer' },
6769
+ truncated: { type: 'boolean' },
6770
+ criteria: {
6771
+ type: 'object',
6772
+ additionalProperties: false,
6773
+ required: ['max_mismatches', 'kinds', 'signature_count', 'max_reads', 'match_basis'],
6774
+ properties: {
6775
+ max_mismatches: { type: 'integer' },
6776
+ kinds: { type: 'string', enum: ['both', 'adapter', 'vector'] },
6777
+ signature_count: { type: 'integer' },
6778
+ max_reads: { type: 'integer' },
6779
+ match_basis: { type: 'string' },
6780
+ },
6781
+ },
6782
+ },
6783
+ },
6784
+ render(value) {
6785
+ const lines = [
6786
+ 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.',
6787
+ ];
6788
+ if (value.hits.length === 0) {
6789
+ lines.push('No embedded vector/adapter signature matched.');
6790
+ } else {
6791
+ for (const hit of value.hits) {
6792
+ 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 + ')');
6793
+ }
6794
+ }
6795
+ 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).');
6796
+ 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.');
6797
+ lines.push('Short embedded signatures, both strands, substitutions only (no gaps). A negative result does not prove the sample is clean.');
6798
+ return lines.join('\n');
6799
+ },
6800
+ async execute(args, exec) {
6801
+ const hasSequence = typeof args.sequence === 'string' && args.sequence !== '';
6802
+ const hasPath = typeof args.path === 'string' && args.path !== '';
6803
+ if (hasSequence === hasPath) throw new MolbioInputError('provide exactly one of sequence or path');
6804
+ let entries;
6805
+ let inputType;
6806
+ if (hasSequence) {
6807
+ entries = [{ id: 'input', sequence: normalizeSequence(args.sequence, 'sequence') }];
6808
+ inputType = 'sequence';
6809
+ } else {
6810
+ const bytes = await readFileBytes(ctx, exec, args.path);
6811
+ const text = new TextDecoder().decode(bytes).replace(/^\uFEFF/, '');
6812
+ const first = text.trimStart();
6813
+ if (first.startsWith('@')) {
6814
+ entries = parseFastq(text);
6815
+ inputType = 'fastq';
6816
+ } else if (first.startsWith('>')) {
6817
+ entries = parseFasta(text);
6818
+ inputType = 'fasta';
6819
+ } else {
6820
+ entries = [{ id: 'input', sequence: normalizeSequence(text, 'sequence') }];
6821
+ inputType = 'sequence';
6822
+ }
6823
+ }
6824
+ const result = screenContamination(entries, {
6825
+ maxMismatches: args.max_mismatches,
6826
+ maxReads: args.max_reads,
6827
+ kinds: args.kinds,
6828
+ });
6829
+ return { input_type: inputType, ...result };
6830
+ },
6831
+ });
6832
+
6833
+ const FASTQ_TRIM_TOOL_SCHEMA = {
6834
+ type: 'object',
6835
+ additionalProperties: false,
6836
+ 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'],
6837
+ properties: {
6838
+ reads_in: { type: 'integer' },
6839
+ total_reads: { type: 'integer' },
6840
+ reads_out: { type: 'integer' },
6841
+ bases_in: { type: 'integer' },
6842
+ bases_out: { type: 'integer' },
6843
+ trimmed_reads: { type: 'integer' },
6844
+ dropped_reads: { type: 'integer' },
6845
+ trim_counts: {
6846
+ type: 'object',
6847
+ additionalProperties: false,
6848
+ required: ['quality', 'adapter', 'poly_g', 'poly_a', 'primer'],
6849
+ properties: {
6850
+ quality: { type: 'integer' },
6851
+ adapter: { type: 'integer' },
6852
+ poly_g: { type: 'integer' },
6853
+ poly_a: { type: 'integer' },
6854
+ primer: { type: 'integer' },
6855
+ },
6856
+ },
6857
+ drop_counts: {
6858
+ type: 'object',
6859
+ additionalProperties: false,
6860
+ required: ['length', 'n'],
6861
+ properties: {
6862
+ length: { type: 'integer' },
6863
+ n: { type: 'integer' },
6864
+ },
6865
+ },
6866
+ truncated: { type: 'boolean' },
6867
+ criteria: {
6868
+ type: 'object',
6869
+ additionalProperties: false,
6870
+ required: ['min_quality', 'quality_window', 'poly_min', 'min_length', 'max_n_percent', 'phred', 'max_reads', 'adapter', 'primer', 'poly_g', 'poly_a'],
6871
+ properties: {
6872
+ min_quality: { type: 'integer' },
6873
+ quality_window: { type: 'integer' },
6874
+ poly_min: { type: 'integer' },
6875
+ min_length: { type: 'integer' },
6876
+ max_n_percent: { type: 'number' },
6877
+ phred: { type: 'integer', enum: [33, 64] },
6878
+ max_reads: { type: 'integer' },
6879
+ adapter: { type: 'string' },
6880
+ primer: { type: 'string' },
6881
+ poly_g: { type: 'boolean' },
6882
+ poly_a: { type: 'boolean' },
6883
+ },
6884
+ },
6885
+ saved_to: { type: 'string' },
6886
+ output_format: { type: 'string', enum: ['fastq', 'fasta'] },
6887
+ },
6888
+ };
6889
+
6890
+ const fastqTrimTool = (ctx) => define({
6891
+ safe: false,
6892
+ name: 'molbio_fastq_trim',
6893
+ 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.',
6894
+ parameters: {
6895
+ type: 'object',
6896
+ required: ['path', 'output_path'],
6897
+ properties: {
6898
+ path: { type: 'string', description: 'Path to the input .fastq/.fq file.' },
6899
+ output_path: { type: 'string', description: 'Path to write the trimmed reads (FASTQ or FASTA, per output_format).' },
6900
+ output_format: { type: 'string', enum: ['fastq', 'fasta'], description: 'Output format (default fastq).' },
6901
+ min_quality: { type: 'integer', description: 'Minimum mean Phred in the sliding window (0-60, default 20). Set 0 to disable quality trimming.' },
6902
+ quality_window: { type: 'integer', description: 'Sliding-window width in bases (1-100, default 4).' },
6903
+ poly_g: { type: 'boolean', description: 'Trim a trailing poly-G run (default false; useful for NovaSeq/NextSeq).' },
6904
+ poly_a: { type: 'boolean', description: 'Trim a trailing poly-A run (default false).' },
6905
+ poly_min: { type: 'integer', description: 'Minimum trailing run length to trim (2-100, default 8).' },
6906
+ adapter: { type: 'string', description: 'Exact 3\' adapter sequence; it and everything after it are removed. Empty = no adapter trim.' },
6907
+ primer: { type: 'string', description: 'Exact 5\' primer sequence; it is removed when the read starts with it. Empty = no primer trim.' },
6908
+ min_length: { type: 'integer', description: 'Drop reads shorter than this after trimming (default 0).' },
6909
+ 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).' },
6910
+ 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.' },
6911
+ max_reads: { type: 'integer', description: 'Process at most this many reads (default 0 = all, capped at 200000).' },
6912
+ },
6913
+ },
6914
+ outputSchema: FASTQ_TRIM_TOOL_SCHEMA,
6915
+ render(value) {
6916
+ const counts = value.trim_counts;
6917
+ const lines = [
6918
+ '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.',
6919
+ ' 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,
6920
+ 'Saved ' + value.output_format.toUpperCase() + ' to ' + value.saved_to + '.',
6921
+ ];
6922
+ if (value.truncated) lines.push('Only the first ' + value.reads_in + ' read(s) were processed; max_reads capped the input.');
6923
+ 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 + '%.');
6924
+ if (value.criteria.adapter !== '') lines.push('Adapter: ' + value.criteria.adapter + '.');
6925
+ if (value.criteria.primer !== '') lines.push('Primer: ' + value.criteria.primer + '.');
6926
+ return lines.join('\n');
6927
+ },
6928
+ async execute(args, exec) {
6929
+ const bytes = await readFileBytes(ctx, exec, args.path);
6930
+ const text = new TextDecoder().decode(bytes).replace(/^\uFEFF/, '');
6931
+ if (text.trimStart().startsWith('>')) throw new MolbioInputError('this looks like FASTA, not FASTQ: molbio_fastq_trim needs per-base quality strings');
6932
+ const entries = parseFastq(text);
6933
+ const trimmed = trimFastq(entries, {
6934
+ minQuality: args.min_quality,
6935
+ qualityWindow: args.quality_window,
6936
+ polyG: args.poly_g,
6937
+ polyA: args.poly_a,
6938
+ polyMin: args.poly_min,
6939
+ adapter: args.adapter,
6940
+ primer: args.primer,
6941
+ minLength: args.min_length,
6942
+ maxNPercent: args.max_n_percent,
6943
+ phred: args.phred,
6944
+ maxReads: args.max_reads,
6945
+ });
6946
+ const outputFormat = args.output_format ?? 'fastq';
6947
+ const fs = fsService(ctx);
6948
+ const sandboxPolicyService = ctx.get('sandboxPolicy');
6949
+ const policy = sandboxPolicyService?.resolve({ ...exec?.agent !== undefined ? { session: exec.agent.session } : {} });
6950
+ const file = workspaceFilePath(args.output_path, exec, policy?.workspaceRoot);
6951
+ await writeWorkspaceFile(fs, file, outputFormat === 'fasta' ? toFasta(trimmed.entries) : toFastq(trimmed.entries), policy);
6952
+ return { ...trimmed.stats, saved_to: file, output_format: outputFormat };
6953
+ },
6954
+ });
6955
+
6484
6956
  const RARE_CODON_SCHEMA = {
6485
6957
  type: 'object',
6486
6958
  additionalProperties: false,
@@ -6999,7 +7471,14 @@ const pubmedAbstractTool = (ctx) => define({
6999
7471
  }
7000
7472
  const apiKey = await pubmedKey(args, ctx);
7001
7473
  const url = pubmedFetchUrl(pmids, apiKey);
7002
- const result = await web.fetch({ url }, exec?.signal);
7474
+ let result;
7475
+ try {
7476
+ result = await web.fetch({ url }, exec?.signal);
7477
+ } catch (error) {
7478
+ // Same contract as eutilsRequest: a transport failure is a tool-input
7479
+ // error the caller can report, not a raw exception escaping the tool.
7480
+ throw new MolbioInputError(`PubMed efetch could not be reached: ${error instanceof Error ? error.message : String(error)}`);
7481
+ }
7003
7482
  const body = result?.body?.content;
7004
7483
  if (typeof body !== 'string' || body === '') {
7005
7484
  throw new MolbioInputError(`efetch returned no content (status ${result?.statusCode ?? 'unknown'})`);
@@ -7059,9 +7538,13 @@ const bibtexTool = (ctx) => define({
7059
7538
  const { fs, path, policy } = paperServiceDeps(ctx, exec, args);
7060
7539
  const library = await loadLibrary(fs, path);
7061
7540
  const bibtex = toBibtex(library.papers, args.tag_filter);
7541
+ // Never fall back to the LIBRARY path itself: a custom `file` without a
7542
+ // .json suffix used to make this replace a no-op, so the export overwrote
7543
+ // the library with BibTeX. Always derive a distinct .bib name.
7544
+ const defaultOutput = /\.json$/i.test(path) ? path.replace(/\.json$/i, '.bib') : `${path}.bib`;
7062
7545
  const output = args.output_path !== undefined && args.output_path !== ''
7063
7546
  ? workspaceFilePath(args.output_path, exec, policy?.workspaceRoot)
7064
- : path.replace(/\.json$/i, '.bib');
7547
+ : defaultOutput;
7065
7548
  await writeWorkspaceFile(fs, output, bibtex, policy);
7066
7549
  return { output_path: output, count: bibtex.trim() === '' ? 0 : library.papers.filter((paper) => args.tag_filter === undefined || args.tag_filter.length === 0 || (paper.tags !== undefined && args.tag_filter.some((tag) => paper.tags.includes(tag)))).length };
7067
7550
  },
@@ -7602,11 +8085,11 @@ const PROMPT_SECTION = `Molecular-biology tools (dsh-molbio-tools) are available
7602
8085
  - 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
8086
  - 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
8087
  - 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).
8088
+ - 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
8089
  - 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
8090
  - Map extras: pass gc_skew: true for the GC skew ring, show_unique_cutters: true to mark single-cutting enzymes on the map.
7608
8091
  - 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").
8092
+ - 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
8093
  - 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
8094
 
7612
8095
  Cloning workflow rules (follow them to avoid slow re-derivation):
@@ -7650,6 +8133,7 @@ export function apply(ctx) {
7650
8133
  silentSiteTool,
7651
8134
  verifySangerTool(ctx),
7652
8135
  proteinPropsTool,
8136
+ proteinChargeTool(ctx),
7653
8137
  peptideDigestTool,
7654
8138
  codonOptimizeTool,
7655
8139
  helicalWheelTool(ctx),
@@ -7666,10 +8150,13 @@ export function apply(ctx) {
7666
8150
  fastaFastqTool(ctx),
7667
8151
  batchAnalyzeTool(ctx),
7668
8152
  fastqQcTool(ctx),
8153
+ contaminationScreenTool(ctx),
8154
+ fastqTrimTool(ctx),
7669
8155
  codonUsageTool,
7670
8156
  phylogeneticTreeTool(ctx),
7671
8157
  pcrSimulateTool(ctx),
7672
8158
  gcCompositionTool(ctx),
8159
+ dotplotTool(ctx),
7673
8160
  extractRegionTool(ctx),
7674
8161
  pubmedAbstractTool(ctx),
7675
8162
  bibtexTool(ctx),