m3triq 0.2.14 → 0.2.18

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/README.md CHANGED
@@ -78,9 +78,16 @@ GPU-accelerated simulations to validate docking poses.
78
78
  ```bash
79
79
  m3t md run --protein 5NJ8 --ligand-smiles "CCO" --mode quick
80
80
  m3t md run --diffdock-job <job-id> --mode standard
81
+ m3t md run --protein <oriented.pdb> --membrane # cell-membrane MD (POPC bilayer, CHARMM36)
82
+ m3t md run --protein 5NJ8 --ligand-smiles "CCO" --gpu h100 # run on an H100 (80GB VRAM)
83
+ m3t md run --protein 5NJ8 --ligand-smiles "CCO" --gpu h200 # …or an H200 (141GB, ~1.4× bandwidth)
81
84
  m3t md results <job-id>
82
85
  ```
83
86
 
87
+ `--membrane` runs cell-membrane MD for membrane proteins (GPCRs, transporters, ion channels): the protein is embedded in a POPC lipid bilayer with CHARMM36 instead of a plain water box. The input **must be a membrane-oriented structure** (transmembrane axis along Z, e.g. from the [OPM database](https://opm.phar.umich.edu)) — the worker does not orient the protein for you, so a raw RCSB structure produces a misaligned bilayer.
88
+
89
+ `--gpu` selects the GPU backend: `a100` (default, GCP A100-40GB), `h100` (Nebius H100-80GB) or `h200` (Nebius H200-141GB) — both Nebius GPUs live in eu-north1. For typical soluble protein-ligand MD the A100 is the cost-effective default; reach for a Nebius GPU when the system is large enough to need the big VRAM (membrane bilayers, large multi-chain complexes) or when you want lower wall-clock latency. `h200` has ~1.4× the memory bandwidth of `h100` (and MD is bandwidth-bound), so it's usually a bit faster for a modestly higher rate. Nebius runs are billed at the matching GPU rate.
90
+
84
91
  ## Structure Prediction
85
92
 
86
93
  ```bash
@@ -93,14 +100,54 @@ m3t predict esmfold2 --sequence MKFLILLFNILCL... # monomer
93
100
  m3t predict esmfold2 --chain A:EVQL... --chain B:DIQM... # complex (antibody-antigen, PPI)
94
101
  m3t predict esmfold2-batch inputs.json # fold N complexes (results in input order)
95
102
 
103
+ # AF2-Multimer — fold a protein complex (homo-/hetero-oligomer) with deep MSA + PDB templates
104
+ m3t predict af2multimer --protein MKFL... --copies 3 # homotrimer (the biological unit)
105
+ m3t predict af2multimer --protein EVQL... --protein DIQM... # hetero-complex (distinct chains)
106
+ m3t predict af2multimer --protein A... --protein B... --copies 2,1 # 2×A + 1×B
107
+
96
108
  # Boltz-2 — biomolecular complex (protein + DNA/RNA + ligand) with binding affinity
97
109
  m3t predict boltz2 --protein MKFL... --ligand "CC(=O)O" --rna GGUC...
110
+ m3t predict boltz2 --protein MKFL... --ligand "CC(=O)O" --depth exhaustive # + real per-chain MSAs
98
111
 
99
112
  # OpenFold3 — AlphaFold3-class complex (protein + DNA/RNA + ligand); real MSAs
100
113
  m3t predict openfold3 --protein EVQL... --protein DIQM... # complex, auto-paired
101
114
  m3t predict openfold3 --protein MKFL... --ligand ATP --depth exhaustive
102
115
  ```
103
116
 
117
+ ## Carbohydrate / Glycan Co-folding
118
+
119
+ Protein–carbohydrate interactions (e.g. lectins, phage receptor-binding proteins,
120
+ glycan-recognizing binders) are poorly served by classical docking — Vina/GNINA
121
+ score sugars badly. The reliable route is to **define** the glycan, then **co-fold**
122
+ it with the protein in an AlphaFold3-class model.
123
+
124
+ ```bash
125
+ # Define a glycan: IUPAC-condensed → SMILES + 3D SDF + per-residue CCD/bond decomposition
126
+ m3t glycan build "Gal(b1-4)GlcNAc"
127
+ m3t glycan build "Neu5Ac(a2-3)Gal(b1-4)Glc" --out sialyllactose.sdf --attach N-linked
128
+
129
+ # Co-fold a protein with a glycan
130
+ m3t predict openfold3 --protein MKFL... \
131
+ --glycan "Man(a1-3)[Man(a1-6)]Man(b1-4)GlcNAc(b1-4)GlcNAc" --depth exhaustive # one connected SMILES ligand (default)
132
+ m3t predict openfold3 --protein MKFL... --glycan "Gal(b1-4)GlcNAc" --glycan-ccd # experimental CCD + bondedAtomPairs (see caveat)
133
+ m3t predict boltz2 --protein MKFL... --glycan "Gal(b1-4)GlcNAc" --depth exhaustive
134
+ ```
135
+
136
+ - **IUPAC-condensed**: reducing end on the **right**, linkages in `()`, branches in `[]`
137
+ (e.g. `Neu5Ac(a2-3)Gal(b1-4)Glc`). Common monosaccharides map to PDB CCD codes.
138
+ - `--glycan` defaults to **one connected SMILES ligand**. The AF3-glycan literature
139
+ recommends per-residue CCD + `bondedAtomPairs` — but that assumes the model honors the
140
+ bonds, and the **hosted OpenFold3 NIM does not** (verified: a 2-sugar CCD glycan comes out
141
+ with the sugars ~4 Å apart, disconnected). SMILES is one bonded molecule, so it stays
142
+ connected. `--glycan-ccd` opts into the CCD path for AF3-proper backends — validate the
143
+ result with the geometry checker.
144
+ - `--glycan` on **Boltz-2** rides as a single SMILES ligand (Boltz-2 has no inter-entity
145
+ bond field).
146
+ - **pLDDT is unreliable for carbohydrates** — high confidence can accompany wrong
147
+ stereochemistry. Validate ring pucker / glycosidic torsions on the output.
148
+ - `--depth exhaustive` (metagenomic envDB) is recommended for orphan / phage / bacterial
149
+ families where standard databases come back sparse.
150
+
104
151
  ## MSA Generation
105
152
 
106
153
  Generate a multiple-sequence alignment (`.a3m`) — the evolutionary input folders use.
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ import { registerJobCommands } from './commands/jobs.js';
11
11
  import { registerDockingCommands } from './commands/docking.js';
12
12
  import { registerPredictCommands } from './commands/predict.js';
13
13
  import { registerMsaCommands } from './commands/msa.js';
14
+ import { registerGlycanCommands } from './commands/glycan.js';
14
15
  import { registerChemblCommands } from './commands/chembl.js';
15
16
  import { registerSandboxCommands } from './commands/sandbox.js';
16
17
  import { registerDesignCommands } from './commands/design.js';
@@ -25,7 +26,7 @@ const program = new Command();
25
26
  program
26
27
  .name('m3t')
27
28
  .description('M3TRIQ — protein-ligand analysis from the terminal')
28
- .version('0.2.14')
29
+ .version('0.2.18')
29
30
  .option('--json', 'Output as JSON (machine-readable)')
30
31
  .hook('preAction', (thisCommand) => {
31
32
  const opts = thisCommand.optsWithGlobals();
@@ -39,6 +40,7 @@ registerJobCommands(program);
39
40
  registerDockingCommands(program);
40
41
  registerPredictCommands(program);
41
42
  registerMsaCommands(program);
43
+ registerGlycanCommands(program);
42
44
  registerChemblCommands(program);
43
45
  registerSandboxCommands(program);
44
46
  registerDesignCommands(program);
@@ -105,5 +107,5 @@ export function getConsoleUrl() {
105
107
  /** Create an agents client for MCP tool calls. */
106
108
  export function createAgentsClient() {
107
109
  const config = getEffectiveConfig();
108
- return new AgentsClient(config.agents_url);
110
+ return new AgentsClient(config.agents_url, config.api_key ?? '');
109
111
  }
package/dist/client.d.ts CHANGED
@@ -61,6 +61,20 @@ export declare class M3triqClient {
61
61
  }): Promise<{
62
62
  job_id: string;
63
63
  }>;
64
+ createAf2MultimerComplexJob(params: {
65
+ project_id: string;
66
+ chains: {
67
+ id?: string;
68
+ sequence: string;
69
+ count?: number;
70
+ }[];
71
+ title?: string;
72
+ save_structure?: boolean;
73
+ }): Promise<{
74
+ job_id: string;
75
+ task_id?: string;
76
+ n_chains?: number;
77
+ }>;
64
78
  createEsmfold2BatchJob(params: {
65
79
  project_id: string;
66
80
  inputs: {
@@ -87,7 +101,7 @@ export declare class M3triqClient {
87
101
  id: string;
88
102
  sequence: string;
89
103
  }[];
90
- depth: 'standard' | 'exhaustive' | 'custom' | 'fast' | 'deep';
104
+ depth: 'standard' | 'exhaustive' | 'custom';
91
105
  pair: boolean;
92
106
  templates?: boolean;
93
107
  custom?: {
@@ -127,6 +141,7 @@ export declare class M3triqClient {
127
141
  */
128
142
  export declare class AgentsClient {
129
143
  private baseUrl;
130
- constructor(baseUrl: string);
144
+ private apiKey;
145
+ constructor(baseUrl: string, apiKey?: string);
131
146
  callMcpTool(server: string, tool: string, params?: Record<string, unknown>): Promise<McpCallResult>;
132
147
  }
package/dist/client.js CHANGED
@@ -123,6 +123,9 @@ export class M3triqClient {
123
123
  async createEsmcEmbedJob(params) {
124
124
  return this.request('POST', '/api/jobs/create_esmc_embed/', params);
125
125
  }
126
+ async createAf2MultimerComplexJob(params) {
127
+ return this.request('POST', '/api/jobs/create_af2_multimer_complex/', params);
128
+ }
126
129
  async createEsmfold2BatchJob(params) {
127
130
  return this.request('POST', '/api/jobs/create_esmfold2_batch/', params);
128
131
  }
@@ -166,6 +169,7 @@ export class M3triqClient {
166
169
  title: params.title,
167
170
  description: params.description ?? '',
168
171
  result_data: params.result_data,
172
+ ...(params.prediction_inputs ? { prediction_inputs: params.prediction_inputs } : {}),
169
173
  }),
170
174
  });
171
175
  if (!res.ok) {
@@ -187,6 +191,7 @@ export class M3triqClient {
187
191
  title: params.title,
188
192
  description: params.description ?? '',
189
193
  result_data: params.result_data,
194
+ ...(params.prediction_inputs ? { prediction_inputs: params.prediction_inputs } : {}),
190
195
  }),
191
196
  });
192
197
  if (!res.ok) {
@@ -227,14 +232,21 @@ export class M3triqClient {
227
232
  */
228
233
  export class AgentsClient {
229
234
  baseUrl;
230
- constructor(baseUrl) {
235
+ apiKey;
236
+ constructor(baseUrl, apiKey = '') {
231
237
  this.baseUrl = baseUrl.replace(/\/$/, '');
238
+ this.apiKey = apiKey;
232
239
  }
233
240
  async callMcpTool(server, tool, params = {}) {
234
241
  const url = `${this.baseUrl}/mcp/call`;
242
+ // /mcp/call runs MCP tools with the platform's internal credentials, so it authenticates
243
+ // the caller and checks project access. Without this header the request is rejected 403.
244
+ const headers = { 'Content-Type': 'application/json' };
245
+ if (this.apiKey)
246
+ headers['Authorization'] = `Bearer ${this.apiKey}`;
235
247
  const res = await fetch(url, {
236
248
  method: 'POST',
237
- headers: { 'Content-Type': 'application/json' },
249
+ headers,
238
250
  body: JSON.stringify({ server, tool, params }),
239
251
  });
240
252
  if (!res.ok) {
@@ -0,0 +1,4 @@
1
+ import type { Command } from 'commander';
2
+ import type { GlycanBuildResult } from '../types.js';
3
+ export declare function registerGlycanCommands(program: Command): void;
4
+ export declare function buildGlycanViaMcp(iupac: string, embed3d?: boolean, attachment?: string): Promise<GlycanBuildResult>;
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs';
2
+ import { createAgentsClient } from '../cli.js';
3
+ import { output } from '../output.js';
4
+ export function registerGlycanCommands(program) {
5
+ const glycan = program
6
+ .command('glycan')
7
+ .description('Build / define carbohydrate (glycan) ligands for co-folding and docking');
8
+ // m3t glycan build "<iupac>"
9
+ glycan
10
+ .command('build')
11
+ .argument('<iupac>', 'Glycan in IUPAC-condensed notation (reducing end on the right). e.g. "Gal(b1-4)GlcNAc"')
12
+ .description('Build a glycan from IUPAC-condensed notation → SMILES + 3D SDF + CCD/bond decomposition')
13
+ .option('--out <file.sdf>', 'Write the 3D structure to an SDF file')
14
+ .option('--no-3d', 'Skip 3D conformer generation (SMILES + decomposition only)')
15
+ .option('--attach <type>', 'Also emit a protein-glycosylation attachment template: N-linked | O-linked | O-linked-thr')
16
+ .action(async (iupac, opts) => {
17
+ await runGlycanBuild(iupac, opts);
18
+ });
19
+ }
20
+ export async function buildGlycanViaMcp(iupac, embed3d = true, attachment) {
21
+ const agents = createAgentsClient();
22
+ const data = (await agents.callMcpTool('rdkit', 'build_glycan', {
23
+ iupac,
24
+ embed_3d: embed3d,
25
+ ...(attachment ? { attachment } : {}),
26
+ }));
27
+ if (data.success === false || data.error) {
28
+ throw new Error(data.error ?? `could not build glycan "${iupac}"`);
29
+ }
30
+ return data;
31
+ }
32
+ async function runGlycanBuild(iupac, opts) {
33
+ const embed3d = opts['3d'] !== false || !!opts.out; // need 3D if writing SDF
34
+ let result;
35
+ try {
36
+ result = await buildGlycanViaMcp(iupac, embed3d, opts.attach);
37
+ }
38
+ catch (e) {
39
+ process.stderr.write(`Error: ${e.message}\n`);
40
+ process.exit(1);
41
+ return;
42
+ }
43
+ if (opts.out) {
44
+ if (!result.sdf) {
45
+ process.stderr.write('Warning: no 3D structure was generated; nothing written.\n');
46
+ }
47
+ else {
48
+ fs.writeFileSync(opts.out, result.sdf);
49
+ process.stderr.write(`Wrote 3D structure → ${opts.out}\n`);
50
+ }
51
+ }
52
+ const lines = [
53
+ `Glycan: ${iupac}`,
54
+ `SMILES: ${result.smiles}`,
55
+ `Formula: ${result.formula ?? '?'} MW: ${result.molecular_weight ?? '?'} rotatable bonds: ${result.n_rotatable_bonds ?? '?'}`,
56
+ ];
57
+ if (result.ccd_supported && result.residues) {
58
+ lines.push(`Residues (${result.n_residues}, reducing-end first): ${result.residues.map(r => `${r.name}[${r.ccd}]`).join(' · ')}`);
59
+ if (result.bonds && result.bonds.length > 0) {
60
+ lines.push(`Glycosidic bonds: ${result.bonds.map(b => `${b.child_idx}.${b.child_atom}→${b.parent_idx}.${b.parent_atom} (${b.linkage})`).join(', ')}`);
61
+ }
62
+ lines.push('Use with: m3t predict openfold3 --protein <seq> --glycan "' + iupac + '" --depth exhaustive (co-folds as one connected SMILES ligand)');
63
+ }
64
+ else if (result.ccd_note) {
65
+ lines.push(`Note: ${result.ccd_note}`);
66
+ lines.push('Use with: m3t predict openfold3 --protein <seq> --glycan "' + iupac + '" --depth exhaustive (SMILES ligand)');
67
+ }
68
+ if (result.attachment) {
69
+ const a = result.attachment;
70
+ lines.push(`Attachment: glycan ${a.glycan_atom} → protein ${a.protein_residue} ${a.protein_atom} (${a.type})`);
71
+ }
72
+ output(result, lines.join('\n'));
73
+ }
@@ -17,15 +17,25 @@ export function registerMdCommands(program) {
17
17
  .option('--mode <mode>', 'Simulation mode: quick (10ns ~1hr) or standard (50ns ~5hrs)', 'quick')
18
18
  .option('--ns <n>', 'Override simulation duration in nanoseconds (1-100)')
19
19
  .option('--temperature <K>', 'Temperature in Kelvin', '300')
20
+ .option('--membrane', 'Cell-membrane MD: embed the protein in a POPC lipid bilayer (CHARMM36) instead of a water box. For membrane proteins (GPCRs, transporters, channels). Provide a membrane-oriented structure (TM axis along Z, e.g. from OPM); larger system so it runs slower than a soluble-protein MD of the same length.')
21
+ .option('--gpu <type>', 'GPU backend: a100 (default, GCP A100-40GB), h100 (Nebius H100-80GB) or h200 (Nebius H200-141GB) — both Nebius GPUs are in eu-north1. Use a Nebius GPU for large membrane/complex systems that need the big VRAM, or for lower wall-clock latency; h200 has ~1.4× h100 bandwidth (MD is bandwidth-bound) for a modestly higher rate.', 'a100')
20
22
  .description('Submit a molecular dynamics simulation job')
21
23
  .action(async (opts) => {
22
24
  const project = requireProject();
23
25
  const agents = createAgentsClient();
26
+ const gpu = String(opts.gpu || 'a100').toLowerCase();
27
+ if (gpu !== 'a100' && gpu !== 'h100' && gpu !== 'h200') {
28
+ process.stderr.write(`Error: --gpu must be 'a100', 'h100' or 'h200' (got '${opts.gpu}')\n`);
29
+ process.exit(1);
30
+ }
24
31
  const params = {
25
32
  mode: opts.mode,
26
33
  temperature_k: parseFloat(opts.temperature),
27
34
  project_id: project.id,
35
+ gpu,
28
36
  };
37
+ if (opts.membrane)
38
+ params.membrane = true;
29
39
  // Input source: DiffDock job OR protein + ligand
30
40
  if (opts.diffdockJob) {
31
41
  params.diffdock_job_id = opts.diffdockJob;
@@ -60,7 +70,7 @@ export function registerMdCommands(program) {
60
70
  }
61
71
  if (opts.ns)
62
72
  params.simulation_ns = parseFloat(opts.ns);
63
- process.stderr.write(`Submitting MD simulation (${opts.mode} mode)...\n`);
73
+ process.stderr.write(`Submitting MD simulation (${opts.mode} mode, ${gpu.toUpperCase()} GPU)...\n`);
64
74
  const data = await agents.callMcpTool('md', 'run_md_simulation', params);
65
75
  // Extract job ID from response
66
76
  const jobId = extractJobId(data);
@@ -12,8 +12,7 @@ import { jobUrl, maybeOpenBrowser } from '../url.js';
12
12
  // default; a monomer is never paired). --no-pair to force unpaired.
13
13
  // --templates also searches the PDB and saves per-chain structural templates.
14
14
  // Produces a downloadable .a3m artifact per chain (+ templates_<chain>.json).
15
- // (Legacy --depth fast/deep still accepted: fast = standard+unpaired, deep = standard.)
16
- const MSA_DEPTHS = ['standard', 'exhaustive', 'custom', 'fast', 'deep'];
15
+ const MSA_DEPTHS = ['standard', 'exhaustive', 'custom'];
17
16
  export function registerMsaCommands(program) {
18
17
  program.command('msa')
19
18
  .description('Generate a multiple-sequence alignment (a3m) — per-chain and (for complexes) species-paired')
@@ -3,6 +3,7 @@ import { createAgentsClient, createClient, getConsoleUrl } from '../cli.js';
3
3
  import { requireProject } from '../config.js';
4
4
  import { output } from '../output.js';
5
5
  import { jobUrl, maybeOpenBrowser } from '../url.js';
6
+ import { buildGlycanViaMcp } from './glycan.js';
6
7
  export function registerPredictCommands(program) {
7
8
  const predict = program.command('predict').description('Predict 3D structure from sequence (single protein or biomolecular complex)');
8
9
  // m3t predict esmfold
@@ -62,6 +63,8 @@ export function registerPredictCommands(program) {
62
63
  .option('--dna <seq>', 'DNA sequence (or @path to file). Repeatable.', collectArg, [])
63
64
  .option('--rna <seq>', 'RNA sequence (or @path to file). Repeatable.', collectArg, [])
64
65
  .option('--ligand <smiles_or_ccd>', 'Ligand as SMILES or CCD code (e.g. ATP). Repeatable.', collectArg, [])
66
+ .option('--glycan <iupac>', 'Carbohydrate in IUPAC-condensed notation (e.g. "Gal(b1-4)GlcNAc"); built to a SMILES ligand. Repeatable.', collectArg, [])
67
+ .option('--depth <tier>', 'Attach real per-chain MSAs: none (default, NIM-internal) | standard | exhaustive | custom', 'none')
65
68
  .option('--samples <n>', 'Number of structure samples (1-25, default: 1)', (v) => parseInt(v, 10), 1)
66
69
  .option('--recycling <n>', 'Recycling steps (1-10, default: 3)', (v) => parseInt(v, 10), 3)
67
70
  .option('--sampling <n>', 'Diffusion sampling steps (10-1000, default: 50)', (v) => parseInt(v, 10), 50)
@@ -69,6 +72,16 @@ export function registerPredictCommands(program) {
69
72
  .action(async (opts) => {
70
73
  await runBoltz2(opts);
71
74
  });
75
+ // m3t predict af2multimer — fold a protein complex (homo-/hetero-oligomer) with AF2-Multimer
76
+ predict.command('af2multimer')
77
+ .description('Fold a protein complex (homo-/hetero-oligomer) with AF2-Multimer — deep MSA + PDB templates (self-hosted A100). Use --copies for an oligomer (e.g. a homotrimer).')
78
+ .option('--protein <seq>', 'Protein chain sequence (or @path to file). Repeatable for hetero-complexes (distinct chains).', collectArg, [])
79
+ .option('--copies <csv>', 'Copies per --protein: a single number applies to all (homomer), or a CSV paired by order (e.g. "2,1"). Default 1.', '1')
80
+ .option('--no-save-structure', 'Do not save the predicted structure to the project')
81
+ .option('--name <name>', 'Custom job title')
82
+ .action(async (opts) => {
83
+ await runAf2Multimer(opts);
84
+ });
72
85
  // m3t predict openfold3
73
86
  predict.command('openfold3')
74
87
  .description('Predict biomolecular complex (protein + DNA/RNA + ligand) with OpenFold3 / AlphaFold3-class (NVIDIA NIM). Real MSAs + optional PDB templates.')
@@ -76,7 +89,9 @@ export function registerPredictCommands(program) {
76
89
  .option('--dna <seq>', 'DNA sequence (or @path to file). Repeatable.', collectArg, [])
77
90
  .option('--rna <seq>', 'RNA sequence (or @path to file). Repeatable.', collectArg, [])
78
91
  .option('--ligand <smiles_or_ccd>', 'Ligand as SMILES or CCD code (e.g. ATP). Repeatable.', collectArg, [])
79
- .option('--depth <tier>', 'MSA depth for protein chains: fast | deep | exhaustive | none (default fast)', 'fast')
92
+ .option('--glycan <iupac>', 'Carbohydrate in IUPAC-condensed notation (e.g. "Gal(b1-4)GlcNAc"). Co-folded as one connected SMILES ligand (default — the hosted OpenFold3 NIM does NOT honor inter-residue bonds, so a CCD chain comes out disconnected). Repeatable.', collectArg, [])
93
+ .option('--glycan-ccd', 'EXPERIMENTAL: co-fold the glycan as per-residue CCD codes + glycosidic bondedAtomPairs (the AF3-recommended form). Verified that the hosted OpenFold3 NIM IGNORES the bonds → sugars come out ~4 A apart, disconnected; only use with a backend that honors bondedAtomPairs.')
94
+ .option('--depth <tier>', 'MSA depth for protein chains: standard (UniRef30, default) | exhaustive (+envDB, best for orphan/phage) | custom | none. (fast→standard, deep→exhaustive accepted)', 'standard')
80
95
  .option('--templates', 'Seed the fold with PDB structural templates per protein chain (OpenFold3 self-aligns).')
81
96
  .option('--samples <n>', 'Number of diffusion samples (1-25, default: 1)', (v) => parseInt(v, 10), 1)
82
97
  .option('--name <name>', 'Custom job title')
@@ -101,6 +116,11 @@ async function runPredict(sequence, method, name) {
101
116
  output(data, `${method} prediction created\nJob ID: ${result.job_id.substring(0, 8)}\nLength: ${sequence.length} residues\nEstimated: ${timeEst}`);
102
117
  }
103
118
  async function runBoltz2(opts) {
119
+ const depth = normalizeMsaDepth(opts.depth || 'none');
120
+ if (!['none', 'standard', 'exhaustive', 'custom'].includes(depth)) {
121
+ process.stderr.write(`Error: --depth must be none | standard | exhaustive | custom, got "${opts.depth}".\n`);
122
+ process.exit(1);
123
+ }
104
124
  const polymers = [];
105
125
  for (const seq of opts.protein)
106
126
  polymers.push({ molecule_type: 'protein', sequence: resolveSequence(seq) });
@@ -113,12 +133,23 @@ async function runBoltz2(opts) {
113
133
  process.exit(1);
114
134
  }
115
135
  const ligands = opts.ligand.map(parseLigand);
136
+ // Glycans → SMILES ligands (Boltz-2 has no inter-entity bond field, so a glycan
137
+ // rides in as one SMILES molecule).
138
+ for (const g of opts.glycan) {
139
+ const built = await buildGlycanViaMcp(g, false);
140
+ if (!built.smiles) {
141
+ process.stderr.write(`Error: could not build glycan "${g}".\n`);
142
+ process.exit(1);
143
+ }
144
+ process.stderr.write(`Glycan "${g}" → SMILES ligand (${built.formula ?? '?'})\n`);
145
+ ligands.push({ smiles: built.smiles });
146
+ }
116
147
  const project = requireProject();
117
148
  const client = createClient();
118
149
  const agents = createAgentsClient();
119
150
  const consoleUrl = getConsoleUrl();
120
151
  const summary = describeComplex(polymers, ligands);
121
- process.stderr.write(`Boltz-2: ${summary}\n`);
152
+ process.stderr.write(`Boltz-2: ${summary}${depth !== 'none' ? ` (MSA depth=${depth})` : ''}\n`);
122
153
  process.stderr.write('Running prediction (NVIDIA NIM, ~1-5 min)...');
123
154
  const startTime = Date.now();
124
155
  const mcpData = await agents.callMcpTool('bionemo', 'predict_structure_boltz2', {
@@ -127,6 +158,7 @@ async function runBoltz2(opts) {
127
158
  diffusion_samples: opts.samples,
128
159
  recycling_steps: opts.recycling,
129
160
  sampling_steps: opts.sampling,
161
+ msa_depth: depth,
130
162
  });
131
163
  const result = mcpData;
132
164
  if (result.error || !result.success) {
@@ -152,8 +184,18 @@ async function runBoltz2(opts) {
152
184
  quality: result.quality,
153
185
  num_structures_returned: result.num_structures_returned,
154
186
  diffusion_samples: result.diffusion_samples,
187
+ msa_depth: depth,
155
188
  model: 'boltz2',
156
189
  },
190
+ prediction_inputs: {
191
+ model: 'boltz2',
192
+ msa_depth: depth,
193
+ n_protein: opts.protein.length,
194
+ n_dna: opts.dna.length,
195
+ n_rna: opts.rna.length,
196
+ ligands: ligands.length,
197
+ glycans: opts.glycan,
198
+ },
157
199
  });
158
200
  const url = jobUrl(consoleUrl, project.id, job.job_id);
159
201
  maybeOpenBrowser(url);
@@ -181,10 +223,10 @@ async function runBoltz2(opts) {
181
223
  }, lines.join('\n'));
182
224
  }
183
225
  async function runOpenfold3(opts) {
184
- const depth = (opts.depth || 'fast').toLowerCase();
185
- const OF3_DEPTHS = ['fast', 'deep', 'exhaustive', 'custom', 'none'];
226
+ const depth = normalizeMsaDepth(opts.depth || 'standard');
227
+ const OF3_DEPTHS = ['standard', 'exhaustive', 'custom', 'none'];
186
228
  if (!OF3_DEPTHS.includes(depth)) {
187
- process.stderr.write(`Error: --depth must be one of ${OF3_DEPTHS.join(' | ')}, got "${opts.depth}".\n`);
229
+ process.stderr.write(`Error: --depth must be one of ${OF3_DEPTHS.join(' | ')} (fast/deep accepted as aliases), got "${opts.depth}".\n`);
188
230
  process.exit(1);
189
231
  }
190
232
  // Build the OpenFold3 molecules array (proteins get chain ids A, B, ...).
@@ -201,8 +243,44 @@ async function runOpenfold3(opts) {
201
243
  const parsed = parseLigand(lig);
202
244
  molecules.push({ type: 'ligand', id: chainIds[ci++], ...('ccd_code' in parsed ? { ccd_codes: [parsed.ccd_code] } : { smiles: parsed.smiles }) });
203
245
  }
246
+ // Glycans: default to ONE connected SMILES ligand. The AF3 literature recommends
247
+ // per-residue CCD + bondedAtomPairs, but that assumes the model honors the bonds —
248
+ // and the hosted OpenFold3 NIM does NOT (verified: a 2-residue CCD glycan comes out
249
+ // with the sugars ~4 A apart, disconnected). SMILES is one bonded molecule, so it
250
+ // stays connected. --glycan-ccd opts into the CCD path for AF3-proper backends.
251
+ const bonds = [];
252
+ for (const g of opts.glycan) {
253
+ const built = await buildGlycanViaMcp(g, false);
254
+ const useCcd = !!opts.glycanCcd && built.ccd_supported && !!built.residues && built.residues.length > 0;
255
+ if (useCcd) {
256
+ const idxToChain = {};
257
+ for (const res of built.residues) {
258
+ const id = chainIds[ci++];
259
+ idxToChain[res.index] = id;
260
+ molecules.push({ type: 'ligand', id, ccd_codes: [res.ccd] });
261
+ }
262
+ for (const b of built.bonds ?? []) {
263
+ bonds.push({
264
+ atom1: [idxToChain[b.child_idx], 1, b.child_atom],
265
+ atom2: [idxToChain[b.parent_idx], 1, b.parent_atom],
266
+ });
267
+ }
268
+ process.stderr.write(`Glycan "${g}" → ${built.residues.length} sugar residues + ${built.bonds?.length ?? 0} glycosidic bonds (CCD + bondedAtomPairs) — NOTE: the hosted NIM may not honor the bonds (sugars can come out disconnected); validate with the geometry checker.\n`);
269
+ }
270
+ else {
271
+ if (opts.glycanCcd && !built.ccd_supported) {
272
+ process.stderr.write(`Glycan "${g}": CCD decomposition unavailable (${built.ccd_note ?? 'unsupported monosaccharide/topology'}); using a single SMILES ligand.\n`);
273
+ }
274
+ if (!built.smiles) {
275
+ process.stderr.write(`Error: could not build glycan "${g}".\n`);
276
+ process.exit(1);
277
+ }
278
+ molecules.push({ type: 'ligand', id: chainIds[ci++], smiles: built.smiles });
279
+ process.stderr.write(`Glycan "${g}" → SMILES ligand (${built.formula ?? '?'})\n`);
280
+ }
281
+ }
204
282
  if (molecules.length === 0) {
205
- process.stderr.write('Error: at least one --protein, --dna, --rna, or --ligand is required.\n');
283
+ process.stderr.write('Error: at least one --protein, --dna, --rna, --ligand, or --glycan is required.\n');
206
284
  process.exit(1);
207
285
  }
208
286
  const project = requireProject();
@@ -210,7 +288,7 @@ async function runOpenfold3(opts) {
210
288
  const agents = createAgentsClient();
211
289
  const consoleUrl = getConsoleUrl();
212
290
  const summary = molecules.map(m => `${m.type}(${m.id})`).join(' + ');
213
- process.stderr.write(`OpenFold3: ${summary} (depth=${depth}${opts.templates ? ', +templates' : ''})\n`);
291
+ process.stderr.write(`OpenFold3: ${summary} (depth=${depth}${opts.templates ? ', +templates' : ''}${bonds.length ? `, ${bonds.length} bonds` : ''})\n`);
214
292
  process.stderr.write('Running prediction (NVIDIA NIM; real MSA may cold-start the engine, up to a few min)...');
215
293
  const startTime = Date.now();
216
294
  const mcpData = await agents.callMcpTool('bionemo', 'predict_complex_structure_openfold3', {
@@ -219,6 +297,7 @@ async function runOpenfold3(opts) {
219
297
  templates: !!opts.templates,
220
298
  diffusion_samples: opts.samples,
221
299
  output_format: 'pdb',
300
+ ...(bonds.length > 0 ? { bonds } : {}),
222
301
  });
223
302
  const result = mcpData;
224
303
  if (result.error || !result.success) {
@@ -248,6 +327,18 @@ async function runOpenfold3(opts) {
248
327
  templates_applied: !!result.templates_applied,
249
328
  model: 'openfold3',
250
329
  },
330
+ prediction_inputs: {
331
+ model: 'openfold3',
332
+ msa_depth: depth,
333
+ templates: !!opts.templates,
334
+ n_protein: opts.protein.length,
335
+ n_dna: opts.dna.length,
336
+ n_rna: opts.rna.length,
337
+ ligands: opts.ligand.length,
338
+ glycans: opts.glycan,
339
+ glycan_repr: opts.glycanCcd ? 'ccd+bonds' : 'smiles',
340
+ n_bonds: bonds.length,
341
+ },
251
342
  });
252
343
  const url = jobUrl(consoleUrl, project.id, job.job_id);
253
344
  maybeOpenBrowser(url);
@@ -265,12 +356,65 @@ async function runOpenfold3(opts) {
265
356
  lines.push(`pLDDT: ${conf.plddt}`);
266
357
  if (opts.templates)
267
358
  lines.push(`Templates: ${result.templates_applied ? 'applied' : 'requested but not applied (NIM dropped them)'}`);
359
+ if (opts.glycan.length > 0)
360
+ lines.push('Note: validate the glycan geometry (ring pucker / glycosidic torsions) — pLDDT is unreliable for carbohydrates.');
268
361
  lines.push(`View: ${url}`);
269
362
  output({ job_id: job.job_id, elapsed_seconds: elapsed, complex: result.complex, quality: result.quality, confidence: result.confidence, msa_depth: depth, templates_requested: !!opts.templates, templates_applied: !!result.templates_applied, url }, lines.join('\n'));
270
363
  }
364
+ async function runAf2Multimer(opts) {
365
+ const proteins = opts.protein.map(resolveSequence);
366
+ if (proteins.length === 0) {
367
+ process.stderr.write('Error: at least one --protein is required.\n');
368
+ process.exit(1);
369
+ }
370
+ // Parse --copies: a single value applies to every chain (homomer); a CSV pairs by order.
371
+ const copyTokens = (opts.copies || '1').split(',').map(s => parseInt(s.trim(), 10));
372
+ if (copyTokens.some(n => !Number.isFinite(n) || n < 1)) {
373
+ process.stderr.write(`Error: --copies must be positive integers, got "${opts.copies}".\n`);
374
+ process.exit(1);
375
+ }
376
+ const chains = proteins.map((sequence, i) => ({
377
+ sequence,
378
+ count: copyTokens.length === 1 ? copyTokens[0] : (copyTokens[i] ?? 1),
379
+ }));
380
+ const totalChains = chains.reduce((s, c) => s + c.count, 0);
381
+ if (totalChains > 12) {
382
+ process.stderr.write(`Error: total chains (with copies) is ${totalChains}; max 12.\n`);
383
+ process.exit(1);
384
+ }
385
+ const project = requireProject();
386
+ const client = createClient();
387
+ const consoleUrl = getConsoleUrl();
388
+ const summary = chains.map(c => `${c.sequence.length}aa×${c.count}`).join(' + ');
389
+ const result = await client.createAf2MultimerComplexJob({
390
+ project_id: project.id,
391
+ chains,
392
+ title: opts.name,
393
+ save_structure: opts.saveStructure !== false,
394
+ });
395
+ const url = jobUrl(consoleUrl, project.id, result.job_id);
396
+ maybeOpenBrowser(url);
397
+ const lines = [
398
+ `AF2-Multimer complex queued (${totalChains} chains: ${summary})`,
399
+ `Job ID: ${result.job_id.substring(0, 8)}`,
400
+ `Deep MSA + PDB templates; ~30-60s warm, ~5-8 min cold A100 start`,
401
+ `View: ${url}`,
402
+ ];
403
+ output({ job_id: result.job_id, n_chains: totalChains, chains, url }, lines.join('\n'));
404
+ }
271
405
  function collectArg(value, prev) {
272
406
  return [...prev, value];
273
407
  }
408
+ // MSA depth vocabulary aligns with the MSA service (standard|exhaustive|custom|none).
409
+ // fast/deep are accepted as legacy aliases (fast→standard, deep→exhaustive).
410
+ function normalizeMsaDepth(d) {
411
+ const v = (d || '').toLowerCase();
412
+ if (v === 'fast')
413
+ return 'standard';
414
+ if (v === 'deep')
415
+ return 'exhaustive';
416
+ return v;
417
+ }
274
418
  function resolveSequence(input) {
275
419
  // @path → read file (FASTA-aware: strip header/whitespace)
276
420
  if (input.startsWith('@')) {
package/dist/types.d.ts CHANGED
@@ -128,6 +128,36 @@ export interface Boltz2JobParams {
128
128
  title: string;
129
129
  result_data: Record<string, unknown>;
130
130
  description?: string;
131
+ prediction_inputs?: Record<string, unknown>;
132
+ }
133
+ export interface GlycanResidue {
134
+ index: number;
135
+ name: string;
136
+ ccd: string;
137
+ }
138
+ export interface GlycanBond {
139
+ child_idx: number;
140
+ parent_idx: number;
141
+ child_atom: string;
142
+ parent_atom: string;
143
+ linkage: string;
144
+ }
145
+ export interface GlycanBuildResult {
146
+ success?: boolean;
147
+ input?: string;
148
+ smiles?: string;
149
+ sdf?: string;
150
+ formula?: string;
151
+ molecular_weight?: number;
152
+ n_rotatable_bonds?: number;
153
+ n_residues?: number;
154
+ ccd_supported?: boolean;
155
+ residues?: GlycanResidue[];
156
+ bonds?: GlycanBond[];
157
+ reducing_end?: GlycanResidue;
158
+ attachment?: Record<string, unknown>;
159
+ ccd_note?: string;
160
+ error?: string;
131
161
  }
132
162
  export interface Openfold3Molecule {
133
163
  type: 'protein' | 'dna' | 'rna' | 'ligand';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "m3triq",
3
- "version": "0.2.14",
3
+ "version": "0.2.18",
4
4
  "description": "M3TRIQ \u2014 protein-ligand analysis from the terminal",
5
5
  "type": "module",
6
6
  "bin": {