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.
- package/CHANGELOG.md +221 -0
- package/README.md +51 -19
- package/batch.mjs +5 -1
- package/benchmark/README.md +7 -7
- package/benchmark/changed.mjs +4 -3
- package/benchmark/fixtures.mjs +32 -4
- package/benchmark/profile.mjs +1 -1
- package/benchmark/run.mjs +1 -0
- package/benchmark/sequences.mjs +1 -0
- package/benchmark/tasks/02-primers.json +3 -3
- package/benchmark/tasks/06-analysis.json +111 -1
- package/benchmark/tasks/09-formats.json +44 -1
- package/benchmark/verifications.mjs +13 -0
- package/build/client-bundle.mjs +1 -1
- package/build/client-entry.mjs +80 -1
- package/build/panel-core.mjs +85 -0
- package/build/preset-patch.mjs +1 -1
- package/build/release-preflight.mjs +1 -1
- package/charts.mjs +35 -16
- package/contamination.mjs +229 -0
- package/cordis.patch.yml +10 -6
- package/design.mjs +111 -78
- package/docs/capability-gap-survey.md +5 -0
- package/docs/client-panel.md +10 -8
- package/docs/history.md +86 -0
- package/docs/maintainer.md +8 -5
- package/docs/roadmap.md +37 -15
- package/docs/rules.md +5 -2
- package/docs/store-evidence.md +30 -23
- package/docs/store-preset-exemption-issue.md +1 -1
- package/docs/store-preset-exemption.md +1 -1
- package/docs/v24-plan.md +178 -0
- package/docs/workflow.md +11 -10
- package/dotplot.mjs +257 -0
- package/fastq-qc.mjs +31 -16
- package/fastq-trim.mjs +238 -0
- package/host.mjs +2 -2
- package/index.mjs +521 -34
- package/lib/client.js +376 -109
- package/package.json +8 -3
- package/papers.mjs +10 -0
- package/plot.mjs +24 -8
- package/preset/install.mjs +8 -0
- package/preset/molbio-lab/agent.cordis.yml +19 -13
- package/preset/molbio-lab/preset.yml +1 -1
- package/protein-charge.mjs +158 -0
- package/protein-structure.mjs +10 -2
- package/protein.mjs +36 -14
- package/records.mjs +17 -2
- package/seqio.mjs +49 -14
- package/test/charts.mjs +11 -0
- package/test/client.mjs +12 -5
- package/test/contract.mjs +44 -12
- package/test/drift-probe.mjs +15 -1
- package/test/map-card.mjs +139 -1
- package/test/preset-health.mjs +39 -0
- 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 —
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
2460
|
+
const recentCardSvg = new Map();
|
|
2432
2461
|
|
|
2433
|
-
function
|
|
2434
|
-
if (
|
|
2435
|
-
|
|
2436
|
-
while (
|
|
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
|
|
2441
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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};
|
|
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};
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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),
|