@microsoft/m365-copilot-eval 1.1.1-preview.1 → 1.2.0-preview.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.
@@ -9,6 +9,7 @@ import { ensureVenv, executePythonCli } from '../lib/venv-manager.js';
9
9
  import { getCacheStats, clearCache, formatBytes } from '../lib/cache-utils.js';
10
10
  import { checkPackageExpiry } from '../lib/expiry-check.js';
11
11
  import { ProgressReporter } from '../lib/progress.js';
12
+ import { _loadEnvFile as loadEnvFile, _loadUserEnvOverride } from '../lib/env-loader.js';
12
13
 
13
14
  // Check package expiry (exits if expired, warns if close to expiry)
14
15
  checkPackageExpiry();
@@ -49,58 +50,12 @@ async function setDefaultEnvironmentConstants() {
49
50
  }
50
51
 
51
52
  /**
52
- * Load environment variables from .env file
53
+ * Check for required environment variables and provide helpful guidance.
54
+ * @param {string} envName - Environment name (e.g. 'dev')
55
+ * @param {boolean} [quiet=false] - Suppress output
56
+ * @returns {boolean} true if all required vars are present
53
57
  */
54
- function loadEnvFile(envFilePath) {
55
- if (!fs.existsSync(envFilePath)) {
56
- return null;
57
- }
58
-
59
- const envVars = {};
60
- try {
61
- const content = fs.readFileSync(envFilePath, 'utf-8');
62
- const lines = content.split('\n');
63
-
64
- // Protected keys that cannot be overridden from .env files
65
- const PROTECTED_KEYS = [
66
- 'M365_EVAL_CLIENT_ID',
67
- 'COPILOT_API_ENDPOINT',
68
- 'COPILOT_SCOPES',
69
- 'X_SCENARIO_HEADER'
70
- ];
71
-
72
- for (const line of lines) {
73
- const trimmedLine = line.trim();
74
- if (trimmedLine && !trimmedLine.startsWith('#')) {
75
- const [key, ...valueParts] = trimmedLine.split('=');
76
- const keyName = key.trim();
77
- const value = valueParts.join('=').trim().replace(/['"]/g, '');
78
-
79
- // Skip built-in defaults - they cannot be overridden.
80
- if (PROTECTED_KEYS.includes(keyName)) {
81
- console.warn(`⚠️ Ignoring ${keyName} from .env file (using built-in value)`);
82
- continue;
83
- }
84
-
85
- if (keyName && value) {
86
- envVars[keyName] = value;
87
- // Also set in process.env for Python script
88
- process.env[keyName] = value;
89
- }
90
- }
91
- }
92
- } catch (error) {
93
- console.error(`Failed to read environment file: ${error.message}`);
94
- return null;
95
- }
96
-
97
- return envVars;
98
- }
99
-
100
- /**
101
- * Check for required environment variables and provide helpful guidance
102
- */
103
- function validateEnvironmentVariables(envName, detectedVars, quiet = false) {
58
+ function validateEnvironmentVariables(envName, quiet = false) {
104
59
  const required = [
105
60
  { key: 'TENANT_ID', description: 'Your Tenant ID' },
106
61
  { key: 'AZURE_AI_OPENAI_ENDPOINT', description: 'Azure OpenAI endpoint URL' },
@@ -108,8 +63,8 @@ function validateEnvironmentVariables(envName, detectedVars, quiet = false) {
108
63
  ];
109
64
 
110
65
  const detected = [
111
- { key: 'M365_TITLE_ID', description: 'M365 Agent Title ID' }
112
- ].filter(opt => process.env[opt.key] || detectedVars[opt.key]);
66
+ { key: 'M365_AGENT_ID', description: 'M365 Agent ID' }
67
+ ].filter(opt => process.env[opt.key]);
113
68
 
114
69
  const missing = required.filter(req => !process.env[req.key]);
115
70
 
@@ -121,41 +76,26 @@ function validateEnvironmentVariables(envName, detectedVars, quiet = false) {
121
76
  if (!quiet) {
122
77
  console.error('\n❌ Missing required environment variables:\n');
123
78
 
124
- const envFile = envName ? `env/.env.${envName}` : '.env.local or env/env.local';
79
+ const envFile = envName ? `env/.env.${envName}` : '.env.local or env/.env.local';
125
80
  console.error(`Create ${envFile} with:\n`);
126
81
 
127
- for (const req of missing) {
128
- console.error(` ${req.key}="<your-${req.description.toLowerCase().replace(/\s+/g, '-')}>"`);
129
- }
82
+ for (const req of missing) {
83
+ console.error(` ${req.key}="<your-${req.description.toLowerCase().replace(/\s+/g, '-')}>"`);
84
+ }
130
85
 
131
- if (detected.length > 0) {
132
- console.error(`\n✓ Already detected:`);
133
- for (const det of detected) {
134
- const value = process.env[det.key] || detectedVars[det.key];
135
- console.error(` ${det.key}="${value}"`);
86
+ if (detected.length > 0) {
87
+ console.error(`\n✓ Already detected:`);
88
+ for (const det of detected) {
89
+ console.error(` ${det.key}="${process.env[det.key]}"`);
90
+ }
136
91
  }
137
- }
138
92
 
139
- console.error('\n📖 Setup guide: https://www.npmjs.com/package/@microsoft/m365-copilot-eval?activeTab=readme\n');
93
+ console.error('\n📖 Setup guide: https://www.npmjs.com/package/@microsoft/m365-copilot-eval?activeTab=readme\n');
140
94
  }
141
95
 
142
96
  return false;
143
97
  }
144
98
 
145
- /**
146
- * Construct agent ID from M365 variables
147
- */
148
- function constructAgentId(envVars) {
149
- const m365TitleId = envVars['M365_TITLE_ID'];
150
-
151
- if (!m365TitleId) {
152
- return null;
153
- }
154
-
155
- // Construct agent ID: {M365_TITLE_ID}.declarativeAgent
156
- return `${m365TitleId}.declarativeAgent`;
157
- }
158
-
159
99
  /**
160
100
  * Initialize the Python environment (download, venv, pip install)
161
101
  * @param {boolean} [verbose=false] - Enable verbose output
@@ -209,7 +149,7 @@ async function main() {
209
149
  .option('--prompts-file <file>', 'JSON file with prompts and expected responses')
210
150
  .option('-o, --output <file>', 'output file (JSON, CSV, or HTML)')
211
151
  .option('-i, --interactive', 'interactive mode (enter prompts interactively)')
212
- .option('--agent-id <id>', 'agent ID (overrides env vars and auto-construction)')
152
+ .option('--m365-agent-id <id>', 'agent ID (overrides env vars and auto-construction)')
213
153
  .option('--env <environment>', 'environment name (loads env/.env.<environment>)', 'local')
214
154
  .option('--init-only', 'only initialize Python environment, don\'t run evaluations')
215
155
  .option('--cache-info', 'show cache information and statistics')
@@ -286,7 +226,7 @@ async function main() {
286
226
 
287
227
  // Load environment files
288
228
  const envVars = {};
289
- let resolvedAgentId = options.agentId;
229
+ let resolvedAgentId = options.m365AgentId;
290
230
 
291
231
  // Check for .env.local in current directory (ATK projects)
292
232
  let localEnvPath = path.join(process.cwd(), '.env.local');
@@ -301,12 +241,12 @@ async function main() {
301
241
  localEnvFound = true;
302
242
  }
303
243
 
304
- // If not found, check for env.local in env subfolder
244
+ // If not found, check for .env.local in env subfolder
305
245
  if (!localEnvFound) {
306
- localEnvPath = path.join(process.cwd(), 'env', 'env.local');
246
+ localEnvPath = path.join(process.cwd(), 'env', '.env.local');
307
247
  if (fs.existsSync(localEnvPath)) {
308
248
  if (!options.quiet && options.verbose) {
309
- console.log(`📂 Loading env.local from current directory env folder`);
249
+ console.log(`📂 Loading .env.local from current directory env folder`);
310
250
  }
311
251
  const localEnvVars = loadEnvFile(localEnvPath) || {};
312
252
  Object.assign(envVars, localEnvVars);
@@ -314,6 +254,9 @@ async function main() {
314
254
  }
315
255
  }
316
256
 
257
+ // Auto-load .env.local.user as a user-specific override (never checked in, safe for secrets)
258
+ _loadUserEnvOverride(envVars, options);
259
+
317
260
  if (options.env) {
318
261
  // First check current directory's env folder
319
262
  let envFilePath = path.join(process.cwd(), 'env', `.env.${options.env}`);
@@ -340,15 +283,7 @@ async function main() {
340
283
  }
341
284
  }
342
285
 
343
- if (envFileFound) {
344
- // Auto-construct agent ID if not explicitly provided
345
- if (!resolvedAgentId) {
346
- resolvedAgentId = constructAgentId(envVars);
347
- if (resolvedAgentId && !options.quiet) {
348
- console.log(`🤖 Agent ID (from M365_TITLE_ID): ${resolvedAgentId}`);
349
- }
350
- }
351
- } else if (options.env !== 'dev') {
286
+ if (!envFileFound && options.env !== 'dev') {
352
287
  // Only warn if non-default env specified
353
288
  console.warn(`⚠️ Environment file not found: .env.${options.env}`);
354
289
  console.warn(` Searched in: ${path.join(process.cwd(), 'env')} and ${path.join(__dirname, '..', 'env')}`);
@@ -356,21 +291,17 @@ async function main() {
356
291
  }
357
292
  }
358
293
 
359
- // Auto-construct agent ID from loaded env vars if not explicitly provided
360
- if (!resolvedAgentId && Object.keys(envVars).length > 0) {
361
- resolvedAgentId = constructAgentId(envVars);
294
+ // Resolve agent ID from environment if not explicitly provided via CLI flag
295
+ // loadEnvFile already resolved aliases (e.g. M365_TITLE_ID) into M365_AGENT_ID
296
+ if (!resolvedAgentId) {
297
+ resolvedAgentId = envVars['M365_AGENT_ID'] || process.env.M365_AGENT_ID;
362
298
  if (resolvedAgentId && !options.quiet) {
363
- console.log(`🤖 Agent ID (from M365_TITLE_ID): ${resolvedAgentId}`);
299
+ console.log(`🤖 Agent ID: ${resolvedAgentId}`);
364
300
  }
365
301
  }
366
302
 
367
- // Fallback to M365_AGENT_ID env var if still not resolved
368
- if (!resolvedAgentId) {
369
- resolvedAgentId = process.env.M365_AGENT_ID;
370
- }
371
-
372
303
  // Validate required environment variables (always validate, quiet just suppresses output)
373
- if (!validateEnvironmentVariables(options.env, envVars, options.quiet)) {
304
+ if (!validateEnvironmentVariables(options.env, options.quiet)) {
374
305
  if (options.quiet) {
375
306
  console.error('📖 Setup guide: https://www.npmjs.com/package/@microsoft/m365-copilot-eval?activeTab=readme\n');
376
307
  }
@@ -383,7 +314,7 @@ async function main() {
383
314
  if (options.verbose) pythonArgs.push('--verbose');
384
315
  if (options.quiet) pythonArgs.push('--quiet');
385
316
  if (options.interactive) pythonArgs.push('--interactive');
386
- if (resolvedAgentId) pythonArgs.push('--agent-id', resolvedAgentId);
317
+ if (resolvedAgentId) pythonArgs.push('--m365-agent-id', resolvedAgentId);
387
318
 
388
319
  // Handle signout
389
320
  if (options.signout) {
@@ -2,7 +2,7 @@
2
2
  * Build-time injected default values
3
3
  * DO NOT EDIT - This file is auto-generated during build.
4
4
  *
5
- * Generated: 2026-02-04T19:14:08.282Z
5
+ * Generated: 2026-03-11T17:42:15.925Z
6
6
  *
7
7
  * @copyright Microsoft Corporation. All rights reserved.
8
8
  * @license MIT
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Environment file loading utilities.
3
+ * Handles .env.local, .env.local.user, and other env file formats.
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+
9
+ // Keys that cannot be overridden from .env files (baked in via default.js config)
10
+ const PROTECTED_KEYS = [
11
+ 'M365_EVAL_CLIENT_ID',
12
+ 'COPILOT_API_ENDPOINT',
13
+ 'COPILOT_SCOPES',
14
+ 'X_SCENARIO_HEADER',
15
+ ];
16
+
17
+ // Aliases resolved into M365_AGENT_ID (first match wins)
18
+ const AGENT_ID_ALIASES = [
19
+ { key: 'M365_TITLE_ID', transform: (v) => `${v}.declarativeAgent` },
20
+ ];
21
+
22
+ /**
23
+ * Load environment variables from a .env-style file.
24
+ * Skips blank lines and comments. Protected keys are ignored with a warning.
25
+ * Malformed lines (no '=' separator) are skipped with a warning.
26
+ * @param {string} envFilePath - Absolute path to the env file
27
+ * @returns {Object|null} Parsed key-value pairs, or null if file cannot be read
28
+ */
29
+ export function _loadEnvFile(envFilePath) {
30
+ if (!fs.existsSync(envFilePath)) {
31
+ return null;
32
+ }
33
+
34
+ const envVars = {};
35
+ try {
36
+ const content = fs.readFileSync(envFilePath, 'utf-8');
37
+ const lines = content.split('\n');
38
+
39
+ for (const line of lines) {
40
+ const trimmedLine = line.trim();
41
+ if (!trimmedLine || trimmedLine.startsWith('#')) {
42
+ continue;
43
+ }
44
+
45
+ const eqIndex = trimmedLine.indexOf('=');
46
+ if (eqIndex === -1) {
47
+ console.warn(
48
+ `⚠️ Ignoring malformed line in env file (missing '='): ${trimmedLine}`
49
+ );
50
+ continue;
51
+ }
52
+
53
+ const keyName = trimmedLine.slice(0, eqIndex).trim();
54
+ const value = trimmedLine
55
+ .slice(eqIndex + 1)
56
+ .trim()
57
+ .replace(/^(['"])(.*)\1$/, '$2');
58
+
59
+ if (!keyName) {
60
+ continue;
61
+ }
62
+
63
+ if (PROTECTED_KEYS.includes(keyName)) {
64
+ console.warn(
65
+ `⚠️ Ignoring ${keyName} from .env file (using built-in value)`
66
+ );
67
+ continue;
68
+ }
69
+
70
+ if (value) {
71
+ envVars[keyName] = value;
72
+ process.env[keyName] = value;
73
+ }
74
+ }
75
+ } catch (error) {
76
+ console.error(`Failed to read environment file: ${error.message}`);
77
+ return null;
78
+ }
79
+
80
+ // Resolve agent ID aliases into M365_AGENT_ID (first match wins)
81
+ if (!envVars['M365_AGENT_ID']) {
82
+ for (const alias of AGENT_ID_ALIASES) {
83
+ if (envVars[alias.key]) {
84
+ const agentId = alias.transform
85
+ ? alias.transform(envVars[alias.key])
86
+ : envVars[alias.key];
87
+ envVars['M365_AGENT_ID'] = agentId;
88
+ process.env['M365_AGENT_ID'] = agentId;
89
+ break;
90
+ }
91
+ }
92
+ }
93
+
94
+ return envVars;
95
+ }
96
+
97
+ /**
98
+ * Auto-load .env.local.user as a user-specific override on top of the base env vars.
99
+ * Checks cwd first, then env/ subfolder (first found wins).
100
+ * This mirrors the ATK convention: .env.local is shared/checked-in, .env.local.user holds personal secrets.
101
+ * @param {Object} envVars - Accumulated env vars object to merge overrides into (mutated in-place)
102
+ * @param {{ quiet?: boolean, verbose?: boolean }} options - CLI options
103
+ * @param {string} [cwd] - Working directory to resolve paths from (defaults to process.cwd())
104
+ * @returns {boolean} Whether a user override file was found and applied
105
+ */
106
+ export function _loadUserEnvOverride(envVars, options, cwd = process.cwd()) {
107
+ const userEnvPaths = [
108
+ path.join(cwd, '.env.local.user'),
109
+ path.join(cwd, 'env', '.env.local.user'),
110
+ ];
111
+
112
+ for (const userEnvPath of userEnvPaths) {
113
+ if (fs.existsSync(userEnvPath)) {
114
+ if (!options.quiet && options.verbose) {
115
+ console.log(
116
+ `📂 Loading user overrides from ${path.relative(cwd, userEnvPath)}`
117
+ );
118
+ }
119
+ const userEnvVars = _loadEnvFile(userEnvPath) || {};
120
+ Object.assign(envVars, userEnvVars);
121
+ return true;
122
+ }
123
+ }
124
+
125
+ return false;
126
+ }
@@ -65,14 +65,18 @@ export function isInteractiveTerminal() {
65
65
  if (!process.stdout.isTTY) return false;
66
66
 
67
67
  // Common CI environment variables
68
- if (process.env.CI) return false;
69
- if (process.env.GITHUB_ACTIONS) return false;
70
- if (process.env.JENKINS_URL) return false;
71
- if (process.env.GITLAB_CI) return false;
72
- if (process.env.TF_BUILD) return false; // Azure Pipelines
73
- if (process.env.CIRCLECI) return false;
74
- if (process.env.TRAVIS) return false;
75
- if (process.env.BUILDKITE) return false;
68
+ if (
69
+ process.env.CI ||
70
+ process.env.GITHUB_ACTIONS ||
71
+ process.env.JENKINS_URL ||
72
+ process.env.GITLAB_CI ||
73
+ process.env.TF_BUILD || // Azure Pipelines
74
+ process.env.CIRCLECI ||
75
+ process.env.TRAVIS ||
76
+ process.env.BUILDKITE
77
+ ) {
78
+ return false;
79
+ }
76
80
 
77
81
  return true;
78
82
  }
@@ -83,8 +87,7 @@ export function isInteractiveTerminal() {
83
87
  * @returns {string} Formatted string (e.g., "45.2 MB")
84
88
  */
85
89
  export function formatBytes(bytes) {
86
- if (bytes === 0) return '0 B';
87
- if (bytes < 0) return '0 B';
90
+ if (bytes <= 0) return '0 B';
88
91
 
89
92
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
90
93
  const k = 1024;
@@ -302,6 +305,26 @@ export class ProgressReporter {
302
305
  }
303
306
  }
304
307
 
308
+ /**
309
+ * Print verbose message with consistent formatting
310
+ * @param {string} message - Verbose message
311
+ * @param {Object} [options] - Display options
312
+ * @param {boolean} [options.clearInteractiveLine] - Clear current interactive line before printing
313
+ */
314
+ _printVerboseMessage(message, { clearInteractiveLine = false } = {}) {
315
+ if (!this.options.verbose || !message) return;
316
+
317
+ if (this.isInteractive) {
318
+ if (clearInteractiveLine) {
319
+ readline.clearLine(process.stdout, 0);
320
+ readline.cursorTo(process.stdout, 0);
321
+ }
322
+ console.log(` → ${message}`);
323
+ } else {
324
+ console.log(` → ${message}`);
325
+ }
326
+ }
327
+
305
328
  /**
306
329
  * Start a new phase
307
330
  * @param {string} phaseId - Phase identifier
@@ -470,13 +493,7 @@ export class ProgressReporter {
470
493
  }
471
494
 
472
495
  // Verbose mode: show additional details
473
- if (this.options.verbose && message) {
474
- if (this.isInteractive) {
475
- console.log(`\n → ${message}`);
476
- } else {
477
- console.log(` → ${message}`);
478
- }
479
- }
496
+ this._printVerboseMessage(message);
480
497
  } else if (
481
498
  phase.progressType === 'determinate' &&
482
499
  current !== undefined &&
@@ -532,15 +549,7 @@ export class ProgressReporter {
532
549
  }
533
550
 
534
551
  // Verbose mode: show package name
535
- if (this.options.verbose && message) {
536
- if (this.isInteractive) {
537
- readline.clearLine(process.stdout, 0);
538
- readline.cursorTo(process.stdout, 0);
539
- console.log(` → ${message}`);
540
- } else {
541
- console.log(` → ${message}`);
542
- }
543
- }
552
+ this._printVerboseMessage(message, { clearInteractiveLine: true });
544
553
  } else if (phase.progressType === 'indeterminate') {
545
554
  // For indeterminate phases, spinner handles display in interactive mode
546
555
  // In CI mode, output periodically
@@ -553,16 +562,7 @@ export class ProgressReporter {
553
562
  }
554
563
 
555
564
  // Verbose mode: show message if provided
556
- if (this.options.verbose && message) {
557
- if (this.isInteractive) {
558
- // Clear spinner line and print message
559
- readline.clearLine(process.stdout, 0);
560
- readline.cursorTo(process.stdout, 0);
561
- console.log(` → ${message}`);
562
- } else {
563
- console.log(` → ${message}`);
564
- }
565
- }
565
+ this._printVerboseMessage(message, { clearInteractiveLine: true });
566
566
  }
567
567
  }
568
568
 
@@ -230,12 +230,10 @@ export async function ensurePythonRuntime(verbose = false, onProgress) {
230
230
  const archivePath = path.join(cacheDir, 'downloads', distribution.filename);
231
231
 
232
232
  // Determine Python executable path based on platform
233
- let pythonExe;
234
- if (process.platform === 'win32') {
235
- pythonExe = path.join(pythonDir, 'python.exe');
236
- } else {
237
- pythonExe = path.join(pythonDir, 'bin', 'python3');
238
- }
233
+ const pythonExe =
234
+ process.platform === 'win32'
235
+ ? path.join(pythonDir, 'python.exe')
236
+ : path.join(pythonDir, 'bin', 'python3');
239
237
 
240
238
  // Check if Python is already installed (silent skip per FR-007)
241
239
  try {
@@ -12,19 +12,12 @@ import { getPythonExecutable, getCacheDir } from './python-runtime.js';
12
12
  * @param {boolean} [options.verbose] - Enable verbose output
13
13
  * @param {Function} [options.onStdout] - Callback for stdout data
14
14
  */
15
- function execCommand(command, args, options = {}) {
15
+ export function _execCommand(command, args, options = {}) {
16
16
  return new Promise((resolve, reject) => {
17
- // On Windows, we need shell for proper .exe execution, but passing args
18
- // separately with shell:true triggers DEP0190. Join into single command.
19
- const isWindows = process.platform === 'win32';
20
- const spawnArgs = isWindows ? [] : args;
21
- const spawnCommand = isWindows
22
- ? `"${command}" ${args.map((a) => `"${a}"`).join(' ')}`
23
- : command;
24
-
25
- const proc = spawn(spawnCommand, spawnArgs, {
17
+ const proc = spawn(command, args, {
26
18
  ...options,
27
- shell: isWindows,
19
+ shell: false,
20
+ windowsVerbatimArguments: false,
28
21
  });
29
22
 
30
23
  let stdout = '';
@@ -109,7 +102,7 @@ async function createVenv(pythonExe, venvDir, verbose = false, onProgress) {
109
102
 
110
103
  await fs.mkdir(venvDir, { recursive: true });
111
104
 
112
- await execCommand(pythonExe, ['-m', 'venv', venvDir], { verbose });
105
+ await _execCommand(pythonExe, ['-m', 'venv', venvDir], { verbose });
113
106
 
114
107
  if (verbose && !onProgress) {
115
108
  console.log('Virtual environment created ✓');
@@ -123,7 +116,7 @@ async function createVenv(pythonExe, venvDir, verbose = false, onProgress) {
123
116
  * @param {boolean} [verbose=false] - Enable verbose output
124
117
  * @param {Function} [onProgress] - Optional progress callback
125
118
  */
126
- async function installRequirements(
119
+ export async function _installRequirements(
127
120
  venvDir,
128
121
  requirementsPath,
129
122
  verbose = false,
@@ -140,7 +133,7 @@ async function installRequirements(
140
133
  }
141
134
 
142
135
  // Upgrade pip first (use python -m pip to avoid Windows file locking issues)
143
- await execCommand(venvPython, ['-m', 'pip', 'install', '--upgrade', 'pip'], {
136
+ await _execCommand(venvPython, ['-m', 'pip', 'install', '--upgrade', 'pip'], {
144
137
  verbose,
145
138
  });
146
139
 
@@ -150,8 +143,16 @@ async function installRequirements(
150
143
  let cachedCount = 0;
151
144
  let pipPhase = 'collecting'; // 'collecting', 'resolving', 'downloading', 'installing'
152
145
 
153
- // Install requirements with hash checking if available
154
- const args = ['-m', 'pip', 'install', '-r', requirementsPath];
146
+ // Install requirements with optimization flags
147
+ const args = [
148
+ '-m',
149
+ 'pip',
150
+ 'install',
151
+ '--prefer-binary', // Prefer binary wheels over building from source
152
+ '--no-compile', // Defer bytecode compilation to after installation for faster install time
153
+ '-r',
154
+ requirementsPath,
155
+ ];
155
156
 
156
157
  // Support proxy and certificate configuration
157
158
  if (process.env.PIP_CERT) {
@@ -232,7 +233,48 @@ async function installRequirements(
232
233
  }
233
234
  : undefined;
234
235
 
235
- await execCommand(venvPython, args, { verbose, onStdout });
236
+ await _execCommand(venvPython, args, { verbose, onStdout });
237
+
238
+ // Compile bytecode after installation to optimize first run performance
239
+ // This compensates for --no-compile flag and eliminates ~1 min delay on first eval
240
+ if (verbose) {
241
+ console.log('Compiling bytecode for faster first run...');
242
+ }
243
+
244
+ try {
245
+ // Find site-packages directory based on platform
246
+ let sitePackagesDir;
247
+ if (process.platform === 'win32') {
248
+ sitePackagesDir = path.join(venvDir, 'Lib', 'site-packages');
249
+ } else {
250
+ // shell: false means globs aren't expanded by the shell — resolve in Node
251
+ const pattern = path.join(venvDir, 'lib', 'python*', 'site-packages');
252
+ const matches = await Array.fromAsync(fs.glob(pattern));
253
+ sitePackagesDir = matches[0];
254
+ }
255
+
256
+ if (!sitePackagesDir) {
257
+ // Skip pre-compilation; bytecode will be compiled lazily on first import
258
+ return;
259
+ }
260
+
261
+ await _execCommand(
262
+ venvPython,
263
+ ['-m', 'compileall', '-q', '-j', '0', sitePackagesDir],
264
+ { verbose: false }
265
+ );
266
+
267
+ if (verbose) {
268
+ console.log('Bytecode compilation complete');
269
+ }
270
+ } catch {
271
+ // Non-fatal: bytecode will be compiled on first import if this fails
272
+ if (verbose) {
273
+ console.warn(
274
+ 'Warning: Bytecode compilation failed, will compile on first import'
275
+ );
276
+ }
277
+ }
236
278
 
237
279
  if (!onProgress) {
238
280
  console.log('Dependencies installed ✓');
@@ -320,7 +362,7 @@ export async function ensureVenv(
320
362
  // Install dependencies phase
321
363
  onProgress?.({ type: 'start', phaseId: 'deps' });
322
364
  try {
323
- await installRequirements(venvDir, requirementsPath, verbose, onProgress);
365
+ await _installRequirements(venvDir, requirementsPath, verbose, onProgress);
324
366
  onProgress?.({ type: 'complete', phaseId: 'deps' });
325
367
  } catch (error) {
326
368
  onProgress?.({ type: 'error', phaseId: 'deps', error });