escribano 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,16 @@ import { resolve } from 'node:path';
10
10
  export const ESCRIBANO_HOME = resolve(homedir(), '.escribano');
11
11
  export const ESCRIBANO_VENV = resolve(ESCRIBANO_HOME, 'venv');
12
12
  export const ESCRIBANO_VENV_PYTHON = resolve(ESCRIBANO_VENV, 'bin', 'python3');
13
+ /**
14
+ * Check if a path is inside the current working directory (project-local).
15
+ * Used to skip VIRTUAL_ENV/UV_PROJECT_ENVIRONMENT that are dev venvs for
16
+ * the project itself, not suitable as Escribano's Python runtime.
17
+ */
18
+ function isInsideCwd(path) {
19
+ const absPath = resolve(path);
20
+ const cwd = process.cwd();
21
+ return absPath.startsWith(`${cwd}/`) || absPath.startsWith(`${cwd}\\`);
22
+ }
13
23
  /**
14
24
  * Get explicitly configured Python path.
15
25
  * Returns null when nothing is explicitly configured or found via well-known
@@ -19,29 +29,37 @@ export const ESCRIBANO_VENV_PYTHON = resolve(ESCRIBANO_VENV, 'bin', 'python3');
19
29
  *
20
30
  * Priority:
21
31
  * 1. ESCRIBANO_PYTHON_PATH env var (explicit override)
22
- * 2. Active virtual environment (VIRTUAL_ENV)
23
- * 3. UV_PROJECT_ENVIRONMENT (uv project-synced venv)
24
- * 4. Project-local .venv (created by `uv venv` in CWD)
25
- * 5. ~/.venv/bin/python3 (home-level venv)
26
- * 6. null no environment detected; auto-venv will be created
32
+ * 2. ~/.escribano/venv (managed venv, if it exists — preferred once created)
33
+ * 3. Active virtual environment (VIRTUAL_ENV, unless inside CWD)
34
+ * 4. UV_PROJECT_ENVIRONMENT (uv project-synced venv, unless inside CWD)
35
+ * 5. Project-local .venv (created by `uv venv` in CWD)
36
+ * 6. ~/.venv/bin/python3 (home-level venv)
37
+ * 7. null — no environment detected; auto-venv will be created
27
38
  */
28
39
  export function getPythonPath() {
40
+ // 1. Explicit override always wins
29
41
  if (process.env.ESCRIBANO_PYTHON_PATH) {
30
42
  return process.env.ESCRIBANO_PYTHON_PATH;
31
43
  }
32
- if (process.env.VIRTUAL_ENV) {
44
+ // 2. Escribano's managed venv — preferred once it exists
45
+ if (existsSync(ESCRIBANO_VENV_PYTHON)) {
46
+ return ESCRIBANO_VENV_PYTHON;
47
+ }
48
+ // 3. Active virtual environment (skip if it's a project-local dev venv)
49
+ if (process.env.VIRTUAL_ENV && !isInsideCwd(process.env.VIRTUAL_ENV)) {
33
50
  return resolve(process.env.VIRTUAL_ENV, 'bin', 'python3');
34
51
  }
35
- // UV_PROJECT_ENVIRONMENT: set by uv when running inside a project with `uv sync`
36
- if (process.env.UV_PROJECT_ENVIRONMENT) {
52
+ // 4. UV_PROJECT_ENVIRONMENT (skip if inside CWD)
53
+ if (process.env.UV_PROJECT_ENVIRONMENT &&
54
+ !isInsideCwd(process.env.UV_PROJECT_ENVIRONMENT)) {
37
55
  return resolve(process.env.UV_PROJECT_ENVIRONMENT, 'bin', 'python3');
38
56
  }
39
- // Check project-local .venv (created by `uv venv` in the current working directory)
57
+ // 5. Project-local .venv (created by `uv venv` in the current working directory)
40
58
  const localVenv = resolve(process.cwd(), '.venv', 'bin', 'python3');
41
59
  if (existsSync(localVenv)) {
42
60
  return localVenv;
43
61
  }
44
- // Check common home-level venv (e.g., `uv venv ~/.venv`)
62
+ // 6. Home-level venv (e.g., `uv venv ~/.venv`)
45
63
  const uvHomeVenv = resolve(homedir(), '.venv', 'bin', 'python3');
46
64
  if (existsSync(uvHomeVenv)) {
47
65
  return uvHomeVenv;
@@ -7,6 +7,7 @@
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { dirname, resolve } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
+ import { step } from '../pipeline/context.js';
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
12
  const PERSONAL_APPS = new Set([
12
13
  'WhatsApp',
@@ -24,7 +25,7 @@ const PERSONAL_APPS = new Set([
24
25
  'Messages',
25
26
  ]);
26
27
  const PERSONAL_APP_THRESHOLD = 0.5;
27
- const SUBJECT_GROUPING_MODEL = process.env.ESCRIBANO_SUBJECT_GROUPING_MODEL || 'qwen3.5:27b';
28
+ const SUBJECT_GROUPING_MODEL = process.env.ESCRIBANO_SUBJECT_GROUPING_MODEL;
28
29
  export async function groupTopicBlocksIntoSubjects(topicBlocks, intelligence, recordingId) {
29
30
  if (topicBlocks.length === 0) {
30
31
  return {
@@ -35,16 +36,42 @@ export async function groupTopicBlocksIntoSubjects(topicBlocks, intelligence, re
35
36
  }
36
37
  const blocksForGrouping = topicBlocks.map(extractBlockForGrouping);
37
38
  const prompt = buildGroupingPrompt(blocksForGrouping);
38
- console.log(`[subject-grouping] Grouping ${topicBlocks.length} blocks into subjects (model: ${SUBJECT_GROUPING_MODEL})`);
39
+ const modelInfo = SUBJECT_GROUPING_MODEL
40
+ ? ` (model: ${SUBJECT_GROUPING_MODEL})`
41
+ : ' (auto-detected)';
42
+ console.log(`[subject-grouping] Grouping ${topicBlocks.length} blocks into subjects${modelInfo}`);
39
43
  try {
40
- const response = await intelligence.generateText(prompt, {
41
- expectJson: false,
42
- model: SUBJECT_GROUPING_MODEL,
43
- numPredict: 2000,
44
- think: false,
44
+ const response = await step('llm_subject_grouping', async () => {
45
+ return intelligence.generateText(prompt, {
46
+ expectJson: false,
47
+ model: SUBJECT_GROUPING_MODEL || undefined,
48
+ numPredict: 2000,
49
+ think: false,
50
+ debugContext: {
51
+ recordingId,
52
+ callType: 'subject_grouping',
53
+ },
54
+ });
45
55
  });
46
- console.log(`[subject-grouping] LLM response (${response.length} chars):\n${response.slice(0, 500)}${response.length > 500 ? '...' : ''}`);
47
- const grouping = parseGroupingResponse(response, topicBlocks);
56
+ // Strip thinking leakage if present
57
+ let cleaned = response.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
58
+ if (cleaned.includes('</think>')) {
59
+ // Handle orphan </think> tag (Qwen3.5 behavior)
60
+ cleaned = cleaned.split('</think>')[1].trim();
61
+ }
62
+ // Strip "Thinking Process:" prose (Qwen3.5-OptiQ format)
63
+ const tpMatch = cleaned.match(/(?:^|\n)Thinking Process:/);
64
+ if (tpMatch !== null) {
65
+ const after = cleaned.slice((tpMatch.index ?? 0) + tpMatch[0].length);
66
+ const heading = after.match(/\n(#\s|\*\*|Group\s)/);
67
+ cleaned =
68
+ heading?.index !== undefined ? after.slice(heading.index).trim() : '';
69
+ }
70
+ if (cleaned.length < 10) {
71
+ console.warn('[subject-grouping] Thinking leakage detected or response too short — parseGroupingResponse will fall back');
72
+ }
73
+ console.log(`[subject-grouping] LLM response (${cleaned.length} chars after stripping):\n${cleaned.slice(0, 500)}${cleaned.length > 500 ? '...' : ''}`);
74
+ const grouping = parseGroupingResponse(cleaned || response, topicBlocks);
48
75
  console.log(`[subject-grouping] Parsed ${grouping.groups.length} groups: ${grouping.groups.map((g) => g.label).join(', ')}`);
49
76
  const subjects = grouping.groups.map((group, index) => {
50
77
  const subjectId = `subject-${recordingId}-${index}`;
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Test different classification prompts to find the best approach
4
+ * for handling mixed-type sessions
5
+ */
6
+ import { createStorageService } from './adapters/storage.adapter.js';
7
+ // Test Prompts
8
+ const PROMPTS = {
9
+ // Approach A: Current style but clearer
10
+ strictSingle: `You are a session classifier. Analyze this transcript and output ONLY valid JSON.
11
+
12
+ Session types:
13
+ - meeting: Conversations, interviews, discussions between people
14
+ - debugging: Fixing issues, troubleshooting, error analysis
15
+ - tutorial: Teaching, explaining, demonstrating how to do something
16
+ - learning: Researching, studying, exploring new concepts
17
+
18
+ Output exactly this JSON structure:
19
+ { "type": "meeting|debugging|tutorial|learning", "confidence": 0.0-1.0 }
20
+
21
+ Transcript: {{TRANSCRIPT}}`,
22
+ // Approach B: Multi-label scoring
23
+ multiLabel: `Rate how much this transcript matches each session type (0-100):
24
+
25
+ Session types:
26
+ - meeting: Conversations, interviews, discussions between people
27
+ - debugging: Fixing issues, troubleshooting, error analysis
28
+ - tutorial: Teaching, explaining, demonstrating how to do something
29
+ - learning: Researching, studying, exploring new concepts
30
+
31
+ Output JSON with all types scored:
32
+ { "classifications": { "meeting": 85, "debugging": 10, "tutorial": 20, "learning": 45 } }
33
+
34
+ Transcript: {{TRANSCRIPT}}`,
35
+ // Approach C: Primary + Secondary
36
+ primarySecondary: `Identify the PRIMARY type and any SECONDARY types that apply.
37
+
38
+ Session types:
39
+ - meeting: Conversations, interviews, discussions between people
40
+ - debugging: Fixing issues, troubleshooting, error analysis
41
+ - tutorial: Teaching, explaining, demonstrating how to do something
42
+ - learning: Researching, studying, exploring new concepts
43
+
44
+ Output JSON with structure shown:
45
+ { "primary": { "type": "meeting|debugging|tutorial|learning", "confidence": 0.0-1.0 }, "secondary": ["type1", "type2"] }
46
+
47
+ Transcript: {{TRANSCRIPT}}`,
48
+ // Approach D: Array of applicable types
49
+ arrayTypes: `List ALL session types that apply with confidence scores.
50
+
51
+ Session types:
52
+ - meeting: Conversations, interviews, discussions between people
53
+ - debugging: Fixing issues, troubleshooting, error analysis
54
+ - tutorial: Teaching, explaining, demonstrating how to do something
55
+ - learning: Researching, studying, exploring new concepts
56
+
57
+ Output JSON with array of applicable types (include if confidence > 0.3):
58
+ { "types": [ {"type": "meeting", "confidence": 0.85} ] }
59
+
60
+ Transcript: {{TRANSCRIPT}}`,
61
+ };
62
+ async function testPrompt(name, prompt, transcript, intelligenceConfig) {
63
+ console.log(`\n${'='.repeat(60)}`);
64
+ console.log(`Testing: ${name}`);
65
+ console.log(`${'='.repeat(60)}`);
66
+ const finalPrompt = prompt.replace('{{TRANSCRIPT}}', transcript.fullText);
67
+ try {
68
+ const controller = new AbortController();
69
+ const timeoutId = setTimeout(() => controller.abort(), 3000000);
70
+ const response = await fetch(intelligenceConfig.endpoint, {
71
+ method: 'POST',
72
+ headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify({
74
+ model: intelligenceConfig.model,
75
+ messages: [{ role: 'system', content: finalPrompt }],
76
+ stream: false,
77
+ format: 'json',
78
+ temperature: 0.3,
79
+ }),
80
+ signal: controller.signal,
81
+ });
82
+ clearTimeout(timeoutId);
83
+ if (!response.ok) {
84
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
85
+ }
86
+ const result = await response.json();
87
+ console.log('Result:');
88
+ console.log(JSON.stringify(result, null, 2));
89
+ return result;
90
+ }
91
+ catch (error) {
92
+ if (error instanceof Error && error.name === 'AbortError') {
93
+ console.error('Error: Request timed out after 30s');
94
+ }
95
+ else {
96
+ console.error('Error:', error);
97
+ }
98
+ return null;
99
+ }
100
+ }
101
+ async function main() {
102
+ const sessionId = process.argv[2];
103
+ if (!sessionId) {
104
+ console.error('Usage: pnpm run test-prompts <session-id>');
105
+ console.error('Example: pnpm run test-prompts "Your Recording.cap"');
106
+ process.exit(1);
107
+ }
108
+ console.log(`\n🔍 Testing classification prompts for: ${sessionId}`);
109
+ const storage = createStorageService();
110
+ const session = await storage.loadSession(sessionId);
111
+ if (!session || !session.transcripts || session.transcripts.length === 0) {
112
+ console.error(`\n❌ Session not found or has no transcripts: ${sessionId}`);
113
+ console.error('\nTo find sessions, run:');
114
+ console.error(' pnpm run list');
115
+ process.exit(1);
116
+ }
117
+ const transcript = session.transcripts[0].transcript;
118
+ console.log(`✓ Transcript length: ${transcript.fullText.length} chars`);
119
+ console.log(`✓ Number of segments: ${transcript.segments.length}`);
120
+ console.log(`✓ Audio source: ${session.transcripts[0].source}`);
121
+ console.log(`\n⏱️ Running 4 classification tests...\n`);
122
+ // Intelligence config
123
+ const intelligenceConfig = {
124
+ provider: 'ollama',
125
+ endpoint: 'http://localhost:11434/v1/chat/completions',
126
+ model: 'qwen3:32b',
127
+ maxRetries: 1,
128
+ timeout: 3000000,
129
+ };
130
+ // Test each prompt
131
+ const results = {};
132
+ let testNum = 1;
133
+ for (const [name, prompt] of Object.entries(PROMPTS)) {
134
+ console.log(`\n[${testNum}/4] Running test...`);
135
+ results[name] = await testPrompt(name, prompt, transcript, intelligenceConfig);
136
+ testNum++;
137
+ await new Promise((resolve) => setTimeout(resolve, 1000));
138
+ }
139
+ // Summary
140
+ console.log(`\n${'='.repeat(60)}`);
141
+ console.log('📊 FINAL SUMMARY');
142
+ console.log(`${'='.repeat(60)}`);
143
+ for (const [name, result] of Object.entries(results)) {
144
+ console.log(`\n${name
145
+ .toUpperCase()
146
+ .replace(/([A-Z])/g, ' $1')
147
+ .trim()}:`);
148
+ if (result) {
149
+ // Try to extract key info for quick comparison
150
+ if (result.type) {
151
+ console.log(` Primary Type: ${result.type}`);
152
+ if (result.confidence)
153
+ console.log(` Confidence: ${(result.confidence * 100).toFixed(0)}%`);
154
+ }
155
+ else if (result.classifications) {
156
+ console.log(' Scores:', JSON.stringify(result.classifications));
157
+ }
158
+ else if (result.primary) {
159
+ console.log(` Primary: ${result.primary.type} (${(result.primary.confidence * 100).toFixed(0)}%)`);
160
+ if (result.secondary?.length)
161
+ console.log(` Secondary: [${result.secondary.join(', ')}]`);
162
+ }
163
+ else if (result.types) {
164
+ console.log(` Types: ${result.types.map((t) => `${t.type} (${(t.confidence * 100).toFixed(0)}%)`).join(', ')}`);
165
+ }
166
+ else {
167
+ console.log(` Raw:`, JSON.stringify(result));
168
+ }
169
+ }
170
+ else {
171
+ console.log(' ❌ Failed or timed out');
172
+ }
173
+ }
174
+ console.log(`\n${'='.repeat(60)}`);
175
+ console.log('✅ All tests complete!');
176
+ console.log(`${'='.repeat(60)}\n`);
177
+ }
178
+ main().catch((error) => {
179
+ console.error('Fatal error:', error);
180
+ process.exit(1);
181
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Cap Adapter Tests
3
+ */
4
+ import { describe, expect, it } from 'vitest';
5
+ import { createCapSource } from '../adapters/cap.adapter';
6
+ describe('Cap Adapter', () => {
7
+ it('should create a CapSource', () => {
8
+ const capSource = createCapSource({
9
+ recordingsPath: '~/tmp/recordings',
10
+ });
11
+ expect(capSource).toBeDefined();
12
+ expect(capSource.getLatestRecording).toBeInstanceOf(Function);
13
+ });
14
+ it('should handle nonexistent directory gracefully', async () => {
15
+ const capSource = createCapSource({
16
+ recordingsPath: '/nonexistent/path',
17
+ });
18
+ const latest = await capSource.getLatestRecording();
19
+ expect(latest).toBeNull();
20
+ });
21
+ it('should validate Cap recording metadata structure', async () => {
22
+ const mockMeta = {
23
+ platform: 'MacOS',
24
+ pretty_name: 'Cap 2026-01-08 at 16.46.37',
25
+ segments: [
26
+ {
27
+ display: {
28
+ path: 'content/segments/segment-0/display.mp4',
29
+ fps: 37,
30
+ },
31
+ mic: {
32
+ path: 'content/segments/segment-0/audio-input.ogg',
33
+ start_time: -0.032719958,
34
+ },
35
+ cursor: 'content/segments/segment-0/cursor.json',
36
+ },
37
+ ],
38
+ };
39
+ expect(mockMeta.segments).toBeDefined();
40
+ expect(mockMeta.segments[0]).toBeDefined();
41
+ expect(mockMeta.segments[0].display?.path).toBe('content/segments/segment-0/display.mp4');
42
+ expect(mockMeta.segments[0].mic?.path).toBe('content/segments/segment-0/audio-input.ogg');
43
+ });
44
+ it('should identify recordings without mic/audio field', async () => {
45
+ const mockMeta = {
46
+ platform: 'MacOS',
47
+ pretty_name: 'Cap 2026-01-08 at 16.46.37',
48
+ segments: [
49
+ {
50
+ display: {
51
+ path: 'content/segments/segment-0/display.mp4',
52
+ fps: 37,
53
+ },
54
+ cursor: 'content/segments/segment-0/cursor.json',
55
+ },
56
+ ],
57
+ };
58
+ expect(mockMeta.segments[0]?.mic).toBeUndefined();
59
+ });
60
+ it('should identify missing segments array', async () => {
61
+ const invalidMeta = {
62
+ platform: 'MacOS',
63
+ pretty_name: 'Cap 2026-01-08 at 16.46.37',
64
+ };
65
+ expect(invalidMeta.segments).toBeUndefined();
66
+ });
67
+ it('should identify empty segments array', async () => {
68
+ const invalidMeta = {
69
+ platform: 'MacOS',
70
+ pretty_name: 'Cap 2026-01-08 at 16.46.37',
71
+ segments: [],
72
+ };
73
+ expect(invalidMeta.segments?.length).toBe(0);
74
+ });
75
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Intelligence Adapter Tests
3
+ */
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { createIntelligenceService } from '../adapters/intelligence.adapter.js';
6
+ const mockConfig = {
7
+ provider: 'ollama',
8
+ endpoint: 'http://localhost:11434/v1/chat/completions',
9
+ model: 'qwen3:32b',
10
+ maxRetries: 3,
11
+ timeout: 30000,
12
+ };
13
+ const mockTranscript = {
14
+ fullText: 'This is a debugging session about authentication errors.',
15
+ segments: [
16
+ {
17
+ id: 'seg-0',
18
+ start: 0,
19
+ end: 5,
20
+ text: 'I fixed the authentication bug.',
21
+ },
22
+ {
23
+ id: 'seg-1',
24
+ start: 5,
25
+ end: 10,
26
+ text: 'Used JWT tokens for security.',
27
+ },
28
+ ],
29
+ language: 'en',
30
+ duration: 10,
31
+ };
32
+ describe('IntelligenceService', () => {
33
+ beforeEach(() => {
34
+ vi.resetAllMocks();
35
+ });
36
+ it('should create an intelligence service', () => {
37
+ const service = createIntelligenceService(mockConfig);
38
+ expect(service).toBeDefined();
39
+ expect(service.classify).toBeInstanceOf(Function);
40
+ expect(service.generate).toBeInstanceOf(Function);
41
+ });
42
+ it('should classify a debugging session', async () => {
43
+ const mockResponse = JSON.stringify({
44
+ message: {
45
+ content: JSON.stringify({
46
+ meeting: 10,
47
+ debugging: 90,
48
+ tutorial: 15,
49
+ learning: 20,
50
+ working: 5,
51
+ }),
52
+ },
53
+ });
54
+ global.fetch = vi.fn().mockResolvedValue({
55
+ ok: true,
56
+ json: async () => JSON.parse(mockResponse),
57
+ });
58
+ const service = createIntelligenceService(mockConfig);
59
+ const result = await service.classify(mockTranscript);
60
+ expect(result.debugging).toBe(90);
61
+ expect(result.meeting).toBe(10);
62
+ expect(result.tutorial).toBe(15);
63
+ expect(result.learning).toBe(20);
64
+ expect(result.working).toBe(5);
65
+ });
66
+ it('should retry on API failures', async () => {
67
+ let attempts = 0;
68
+ global.fetch = vi.fn().mockImplementation(async () => {
69
+ attempts++;
70
+ if (attempts < 3) {
71
+ throw new Error('API timeout');
72
+ }
73
+ return {
74
+ ok: true,
75
+ json: async () => ({
76
+ message: {
77
+ content: JSON.stringify({
78
+ meeting: 80,
79
+ debugging: 10,
80
+ tutorial: 5,
81
+ learning: 15,
82
+ working: 5,
83
+ }),
84
+ },
85
+ }),
86
+ };
87
+ });
88
+ const service = createIntelligenceService(mockConfig);
89
+ const result = await service.classify(mockTranscript);
90
+ expect(attempts).toBe(3);
91
+ expect(result.meeting).toBe(80);
92
+ });
93
+ it('should handle fetch errors correctly', async () => {
94
+ global.fetch = vi.fn().mockResolvedValue({
95
+ ok: false,
96
+ status: 500,
97
+ statusText: 'Internal Server Error',
98
+ });
99
+ const service = createIntelligenceService(mockConfig);
100
+ await expect(service.classify(mockTranscript)).rejects.toThrow('Classification failed after 3 retries');
101
+ });
102
+ });
@@ -22,7 +22,7 @@ vi.mock('node:child_process', () => ({
22
22
  kill: vi.fn(),
23
23
  })),
24
24
  }));
25
- import { existsSync } from 'node:fs';
25
+ import { existsSync, mkdirSync } from 'node:fs';
26
26
  import { resolvePythonPath } from '../adapters/intelligence.mlx.adapter.js';
27
27
  import { getPythonPath } from '../python-utils.js';
28
28
  const mockExistsSync = vi.mocked(existsSync);
@@ -178,8 +178,10 @@ describe('resolvePythonPath', () => {
178
178
  });
179
179
  it('installs mlx-vlm when the import probe fails', async () => {
180
180
  const venvPython = resolve(homedir(), '.escribano', 'venv', 'bin', 'python3');
181
- // Simulate: managed venv python exists
182
- mockExistsSync.mockImplementation((p) => p === venvPython);
181
+ const escribanoHome = resolve(homedir(), '.escribano');
182
+ mockExistsSync.mockImplementation((p) => p === escribanoHome);
183
+ const mockMkdirSync = vi.mocked(mkdirSync);
184
+ mockMkdirSync.mockReturnValue(undefined);
183
185
  const { spawn } = await import('node:child_process');
184
186
  const mockSpawn = vi.mocked(spawn);
185
187
  mockSpawn.mockClear();
@@ -189,9 +191,12 @@ describe('resolvePythonPath', () => {
189
191
  const emitter = {
190
192
  on: vi.fn((event, cb) => {
191
193
  if (event === 'exit') {
192
- // First call: import probe fails (non-zero exit)
193
- // All subsequent calls (ensurepip, pip install, ...): succeed
194
- cb(thisCall === 0 ? 1 : 0);
194
+ if (thisCall === 1) {
195
+ cb(1);
196
+ }
197
+ else {
198
+ cb(0);
199
+ }
195
200
  }
196
201
  return emitter;
197
202
  }),
@@ -202,8 +207,7 @@ describe('resolvePythonPath', () => {
202
207
  return emitter;
203
208
  });
204
209
  await expect(resolvePythonPath()).resolves.toBe(venvPython);
205
- expect(mockSpawn.mock.calls.length).toBeGreaterThanOrEqual(2);
206
- // Find the pip install call regardless of its position (robust to ensurepip being inserted)
210
+ expect(mockSpawn.mock.calls.length).toBeGreaterThanOrEqual(3);
207
211
  const installCall = mockSpawn.mock.calls.find(([_cmd, args]) => Array.isArray(args) &&
208
212
  args.includes('-m') &&
209
213
  args.includes('pip') &&
@@ -217,6 +221,7 @@ describe('resolvePythonPath', () => {
217
221
  'mlx-vlm',
218
222
  'torch',
219
223
  'torchvision',
224
+ 'mlx-lm',
220
225
  ]));
221
226
  });
222
227
  });