escribano 0.2.2 → 0.4.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.
@@ -38,9 +38,9 @@ function runRecordingRepositoryTests(name, createRepo) {
38
38
  });
39
39
  const found = repo.findById(id);
40
40
  expect(found).not.toBeNull();
41
- expect(found.id).toBe(id);
42
- expect(found.duration).toBe(120.5);
43
- expect(found.status).toBe('raw');
41
+ expect(found?.id).toBe(id);
42
+ expect(found?.duration).toBe(120.5);
43
+ expect(found?.status).toBe('raw');
44
44
  });
45
45
  it('returns null for non-existent recording', () => {
46
46
  const found = repo.findById('nonexistent');
@@ -63,8 +63,8 @@ function runRecordingRepositoryTests(name, createRepo) {
63
63
  });
64
64
  repo.updateStatus(id, 'processing', 'clustering');
65
65
  const found = repo.findById(id);
66
- expect(found.status).toBe('processing');
67
- expect(found.processing_step).toBe('clustering');
66
+ expect(found?.status).toBe('processing');
67
+ expect(found?.processing_step).toBe('clustering');
68
68
  });
69
69
  it('finds pending recordings', () => {
70
70
  const id1 = generateId();
@@ -146,8 +146,8 @@ function runContextRepositoryTests(name, createRepos) {
146
146
  });
147
147
  const found = contextRepo.findById(id);
148
148
  expect(found).not.toBeNull();
149
- expect(found.id).toBe(id);
150
- expect(found.name).toBe('escribano');
149
+ expect(found?.id).toBe(id);
150
+ expect(found?.name).toBe('escribano');
151
151
  });
152
152
  it('finds by type and name', () => {
153
153
  const id = generateId();
@@ -159,7 +159,7 @@ function runContextRepositoryTests(name, createRepos) {
159
159
  });
160
160
  const found = contextRepo.findByTypeAndName('app', 'vscode');
161
161
  expect(found).not.toBeNull();
162
- expect(found.id).toBe(id);
162
+ expect(found?.id).toBe(id);
163
163
  });
164
164
  it('links and unlinks observations', () => {
165
165
  const recordingId = generateId();
@@ -0,0 +1,102 @@
1
+ import { chmod, mkdir, rm, stat, symlink, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ const VIDEO_EXTENSIONS = ['.mov', '.mp4', '.mkv', '.avi', '.webm'];
6
+ function expandPath(inputPath) {
7
+ if (inputPath.startsWith('~/')) {
8
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
9
+ return path.join(homeDir, inputPath.slice(2));
10
+ }
11
+ return inputPath;
12
+ }
13
+ async function findLatestVideo(dirPath) {
14
+ const resolvedPath = expandPath(dirPath);
15
+ const { readdir } = await import('node:fs/promises');
16
+ const entries = await readdir(resolvedPath, { withFileTypes: true });
17
+ const videoFiles = entries.filter((entry) => entry.isFile() &&
18
+ VIDEO_EXTENSIONS.some((ext) => entry.name.toLowerCase().endsWith(ext)));
19
+ if (videoFiles.length === 0) {
20
+ throw new Error(`No video files found in: ${resolvedPath}`);
21
+ }
22
+ const filesWithMtime = await Promise.all(videoFiles.map(async (entry) => {
23
+ const fullPath = path.join(resolvedPath, entry.name);
24
+ try {
25
+ const fileStat = await stat(fullPath);
26
+ return { path: fullPath, mtime: fileStat.mtime };
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }));
32
+ const validFiles = filesWithMtime.filter((f) => f !== null);
33
+ if (validFiles.length === 0) {
34
+ throw new Error(`No accessible video files found in: ${resolvedPath}`);
35
+ }
36
+ validFiles.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
37
+ return validFiles[0].path;
38
+ }
39
+ describe('findLatestVideo', () => {
40
+ let testDir;
41
+ beforeEach(async () => {
42
+ testDir = path.join(tmpdir(), `escribano-test-${Date.now()}`);
43
+ await mkdir(testDir, { recursive: true });
44
+ });
45
+ afterEach(async () => {
46
+ await rm(testDir, { recursive: true, force: true });
47
+ });
48
+ it('returns the most recently modified video file', async () => {
49
+ const file1 = path.join(testDir, 'old.mp4');
50
+ const file2 = path.join(testDir, 'new.mov');
51
+ await writeFile(file1, 'old');
52
+ await new Promise((r) => setTimeout(r, 10));
53
+ await writeFile(file2, 'new');
54
+ const result = await findLatestVideo(testDir);
55
+ expect(result).toBe(file2);
56
+ });
57
+ it('skips broken symlinks and returns valid file', async () => {
58
+ const validFile = path.join(testDir, 'valid.mp4');
59
+ const brokenLink = path.join(testDir, 'broken.mov');
60
+ await writeFile(validFile, 'content');
61
+ await symlink('/nonexistent/path.mp4', brokenLink);
62
+ const result = await findLatestVideo(testDir);
63
+ expect(result).toBe(validFile);
64
+ });
65
+ it('skips files with permission denied and returns valid file', async () => {
66
+ const readable = path.join(testDir, 'readable.mp4');
67
+ const unreadable = path.join(testDir, 'unreadable.mov');
68
+ await writeFile(readable, 'content');
69
+ await writeFile(unreadable, 'content');
70
+ await chmod(unreadable, 0o000);
71
+ const result = await findLatestVideo(testDir);
72
+ expect(result).toBe(readable);
73
+ });
74
+ it('throws when no video files exist', async () => {
75
+ await writeFile(path.join(testDir, 'file.txt'), 'not a video');
76
+ await expect(findLatestVideo(testDir)).rejects.toThrow('No video files found');
77
+ });
78
+ it('throws when all video files are inaccessible', async () => {
79
+ const broken = path.join(testDir, 'broken.mov');
80
+ await symlink('/nonexistent', broken);
81
+ await chmod(testDir, 0o111);
82
+ try {
83
+ await expect(findLatestVideo(testDir)).rejects.toThrow();
84
+ }
85
+ finally {
86
+ await chmod(testDir, 0o755);
87
+ }
88
+ });
89
+ it('expands ~ to home directory', () => {
90
+ const result = expandPath('~/Videos/test.mp4');
91
+ expect(result).toMatch(/\/Videos\/test\.mp4$/);
92
+ expect(result).not.toContain('~');
93
+ });
94
+ it('handles various video extensions', async () => {
95
+ const files = ['a.mov', 'b.mp4', 'c.mkv', 'd.avi', 'e.webm'];
96
+ for (const f of files) {
97
+ await writeFile(path.join(testDir, f), 'content');
98
+ }
99
+ const result = await findLatestVideo(testDir);
100
+ expect(result).toMatch(/\.(mov|mp4|mkv|avi|webm)$/);
101
+ });
102
+ });
@@ -0,0 +1,222 @@
1
+ /**
2
+ * MLX Intelligence Adapter Tests
3
+ *
4
+ * Tests for Python path detection and auto-venv setup logic used to locate
5
+ * (or create) the correct Python interpreter with mlx-vlm installed.
6
+ */
7
+ import { homedir } from 'node:os';
8
+ import { resolve } from 'node:path';
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ // Mock node:fs so we can control which paths "exist"
11
+ vi.mock('node:fs', () => ({
12
+ existsSync: vi.fn(() => false),
13
+ mkdirSync: vi.fn(),
14
+ unlinkSync: vi.fn(),
15
+ }));
16
+ // Mock node:child_process so we don't actually spawn anything
17
+ vi.mock('node:child_process', () => ({
18
+ spawn: vi.fn(() => ({
19
+ on: vi.fn(),
20
+ stdout: { on: vi.fn() },
21
+ stderr: { on: vi.fn() },
22
+ kill: vi.fn(),
23
+ })),
24
+ }));
25
+ import { existsSync } from 'node:fs';
26
+ import { resolvePythonPath } from '../adapters/intelligence.mlx.adapter.js';
27
+ import { getPythonPath } from '../python-utils.js';
28
+ const mockExistsSync = vi.mocked(existsSync);
29
+ // Keys cleared/restored around each test
30
+ const MANAGED_KEYS = [
31
+ 'ESCRIBANO_PYTHON_PATH',
32
+ 'VIRTUAL_ENV',
33
+ 'UV_PROJECT_ENVIRONMENT',
34
+ ];
35
+ describe('getPythonPath', () => {
36
+ const saved = {};
37
+ beforeEach(() => {
38
+ for (const key of MANAGED_KEYS) {
39
+ saved[key] = process.env[key];
40
+ delete process.env[key];
41
+ }
42
+ mockExistsSync.mockReturnValue(false);
43
+ });
44
+ afterEach(() => {
45
+ for (const key of MANAGED_KEYS) {
46
+ if (saved[key] === undefined) {
47
+ delete process.env[key];
48
+ }
49
+ else {
50
+ process.env[key] = saved[key];
51
+ }
52
+ }
53
+ });
54
+ it('returns ESCRIBANO_PYTHON_PATH when set (highest priority)', () => {
55
+ process.env.ESCRIBANO_PYTHON_PATH = '/custom/python3';
56
+ expect(getPythonPath()).toBe('/custom/python3');
57
+ });
58
+ it('returns VIRTUAL_ENV python when set', () => {
59
+ process.env.VIRTUAL_ENV = '/home/user/myenv';
60
+ expect(getPythonPath()).toBe(resolve('/home/user/myenv', 'bin', 'python3'));
61
+ });
62
+ it('prefers ESCRIBANO_PYTHON_PATH over VIRTUAL_ENV', () => {
63
+ process.env.ESCRIBANO_PYTHON_PATH = '/explicit/python3';
64
+ process.env.VIRTUAL_ENV = '/some/venv';
65
+ expect(getPythonPath()).toBe('/explicit/python3');
66
+ });
67
+ it('returns UV_PROJECT_ENVIRONMENT python when set', () => {
68
+ process.env.UV_PROJECT_ENVIRONMENT = '/project/.venv';
69
+ expect(getPythonPath()).toBe(resolve('/project/.venv', 'bin', 'python3'));
70
+ });
71
+ it('prefers VIRTUAL_ENV over UV_PROJECT_ENVIRONMENT', () => {
72
+ process.env.VIRTUAL_ENV = '/active/venv';
73
+ process.env.UV_PROJECT_ENVIRONMENT = '/project/.venv';
74
+ expect(getPythonPath()).toBe(resolve('/active/venv', 'bin', 'python3'));
75
+ });
76
+ it('returns project-local .venv python when it exists', () => {
77
+ const localVenvPython = resolve(process.cwd(), '.venv', 'bin', 'python3');
78
+ mockExistsSync.mockImplementation((p) => p === localVenvPython);
79
+ expect(getPythonPath()).toBe(localVenvPython);
80
+ });
81
+ it('returns home .venv python when it exists and local .venv does not', () => {
82
+ const homeVenvPython = resolve(homedir(), '.venv', 'bin', 'python3');
83
+ mockExistsSync.mockImplementation((p) => p === homeVenvPython);
84
+ expect(getPythonPath()).toBe(homeVenvPython);
85
+ });
86
+ it('prefers local .venv over home .venv', () => {
87
+ const localVenvPython = resolve(process.cwd(), '.venv', 'bin', 'python3');
88
+ const homeVenvPython = resolve(homedir(), '.venv', 'bin', 'python3');
89
+ mockExistsSync.mockImplementation((p) => p === localVenvPython || p === homeVenvPython);
90
+ expect(getPythonPath()).toBe(localVenvPython);
91
+ });
92
+ it('returns null when nothing is explicitly configured (triggers auto-venv)', () => {
93
+ mockExistsSync.mockReturnValue(false);
94
+ expect(getPythonPath()).toBeNull();
95
+ });
96
+ });
97
+ describe('resolvePythonPath', () => {
98
+ const saved = {};
99
+ beforeEach(() => {
100
+ for (const key of MANAGED_KEYS) {
101
+ saved[key] = process.env[key];
102
+ delete process.env[key];
103
+ }
104
+ mockExistsSync.mockReturnValue(false);
105
+ });
106
+ afterEach(() => {
107
+ for (const key of MANAGED_KEYS) {
108
+ if (saved[key] === undefined) {
109
+ delete process.env[key];
110
+ }
111
+ else {
112
+ process.env[key] = saved[key];
113
+ }
114
+ }
115
+ vi.restoreAllMocks();
116
+ });
117
+ it('returns the explicit path immediately when ESCRIBANO_PYTHON_PATH is set', async () => {
118
+ process.env.ESCRIBANO_PYTHON_PATH = '/my/python3';
119
+ await expect(resolvePythonPath()).resolves.toBe('/my/python3');
120
+ });
121
+ it('returns the explicit path immediately when VIRTUAL_ENV is set', async () => {
122
+ process.env.VIRTUAL_ENV = '/my/venv';
123
+ await expect(resolvePythonPath()).resolves.toBe(resolve('/my/venv', 'bin', 'python3'));
124
+ });
125
+ it('falls through to managed venv python when nothing is configured', async () => {
126
+ // Simulate: no explicit config, venv already exists, mlx-vlm already installed
127
+ const venvPython = resolve(homedir(), '.escribano', 'venv', 'bin', 'python3');
128
+ mockExistsSync.mockImplementation((p) => p === venvPython);
129
+ // Mock spawn so the mlx-vlm probe exits with 0 (already installed)
130
+ const { spawn } = await import('node:child_process');
131
+ const mockSpawn = vi.mocked(spawn);
132
+ mockSpawn.mockImplementation((_cmd, _args, _opts) => {
133
+ const emitter = {
134
+ on: vi.fn((event, cb) => {
135
+ if (event === 'exit')
136
+ cb(0);
137
+ return emitter;
138
+ }),
139
+ stdout: { on: vi.fn() },
140
+ stderr: { on: vi.fn() },
141
+ kill: vi.fn(),
142
+ };
143
+ return emitter;
144
+ });
145
+ await expect(resolvePythonPath()).resolves.toBe(venvPython);
146
+ });
147
+ it('creates the managed venv when it does not exist', async () => {
148
+ // beforeEach has mockExistsSync default to false, simulating a missing venv
149
+ const venvDir = resolve(homedir(), '.escribano', 'venv');
150
+ const { spawn } = await import('node:child_process');
151
+ const mockSpawn = vi.mocked(spawn);
152
+ mockSpawn.mockClear();
153
+ // Set up spawn to call exit(0) for all calls so the async function resolves.
154
+ mockSpawn.mockImplementation((_cmd, _args, _opts) => {
155
+ const emitter = {
156
+ on: vi.fn((event, cb) => {
157
+ if (event === 'exit')
158
+ cb(0);
159
+ return emitter;
160
+ }),
161
+ stdout: { on: vi.fn() },
162
+ stderr: { on: vi.fn() },
163
+ kill: vi.fn(),
164
+ };
165
+ return emitter;
166
+ });
167
+ await resolvePythonPath();
168
+ // Expect that we attempted to create a virtual environment in the managed directory
169
+ expect(mockSpawn).toHaveBeenCalled();
170
+ // Find the venv-creation call: command is python3 with -m venv <dir>
171
+ const venvCall = mockSpawn.mock.calls.find(([cmd, args]) => typeof cmd === 'string' &&
172
+ (cmd === 'python3' || cmd === 'python') &&
173
+ Array.isArray(args) &&
174
+ args.includes('-m') &&
175
+ args.includes('venv'));
176
+ expect(venvCall).toBeDefined();
177
+ expect(venvCall?.[1]).toContain(venvDir);
178
+ });
179
+ it('installs mlx-vlm when the import probe fails', async () => {
180
+ const venvPython = resolve(homedir(), '.escribano', 'venv', 'bin', 'python3');
181
+ // Simulate: managed venv python exists
182
+ mockExistsSync.mockImplementation((p) => p === venvPython);
183
+ const { spawn } = await import('node:child_process');
184
+ const mockSpawn = vi.mocked(spawn);
185
+ mockSpawn.mockClear();
186
+ let callIndex = 0;
187
+ mockSpawn.mockImplementation((_cmd, _args, _opts) => {
188
+ const thisCall = callIndex++;
189
+ const emitter = {
190
+ on: vi.fn((event, cb) => {
191
+ 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);
195
+ }
196
+ return emitter;
197
+ }),
198
+ stdout: { on: vi.fn() },
199
+ stderr: { on: vi.fn() },
200
+ kill: vi.fn(),
201
+ };
202
+ return emitter;
203
+ });
204
+ 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)
207
+ const installCall = mockSpawn.mock.calls.find(([_cmd, args]) => Array.isArray(args) &&
208
+ args.includes('-m') &&
209
+ args.includes('pip') &&
210
+ args.includes('install') &&
211
+ args.includes('mlx-vlm'));
212
+ expect(installCall).toBeDefined();
213
+ expect(installCall?.[1]).toEqual(expect.arrayContaining([
214
+ '-m',
215
+ 'pip',
216
+ 'install',
217
+ 'mlx-vlm',
218
+ 'torch',
219
+ 'torchvision',
220
+ ]));
221
+ });
222
+ });
@@ -1,3 +1,4 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
1
2
  /**
2
3
  * Intelligence Adapter Tests
3
4
  */
@@ -1,3 +1,4 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
1
2
  import { describe, expect, it, vi } from 'vitest';
2
3
  import { embeddingToBlob } from '../../db/helpers.js';
3
4
  import { clusterObservations } from '../../services/clustering.js';
@@ -126,7 +126,7 @@ describe('adaptiveSampleWithScenes', () => {
126
126
  it('should produce fewer total frames with high scene density', () => {
127
127
  const frames = makeFrames(10); // 10 minutes, 300 frames
128
128
  // Low density: 5 scene changes
129
- const lowDensityResult = adaptiveSampleWithScenes(frames, [30, 100, 200, 400, 500], { baseIntervalSeconds: 10 });
129
+ const _lowDensityResult = adaptiveSampleWithScenes(frames, [30, 100, 200, 400, 500], { baseIntervalSeconds: 10 });
130
130
  // High density: 60 scene changes (every ~10s)
131
131
  const highDensityScenes = Array.from({ length: 60 }, (_, i) => i * 10);
132
132
  const highDensityResult = adaptiveSampleWithScenes(frames, highDensityScenes, { baseIntervalSeconds: 10 });
@@ -150,7 +150,6 @@ describe('Visual Observer Pipeline', () => {
150
150
  transcribeSegment: vi.fn().mockResolvedValue(''),
151
151
  };
152
152
  const mockVideoService = {
153
- // biome-ignore lint/suspicious/noExplicitAny: mock
154
153
  ...vi.fn(), // Other methods mocked as needed
155
154
  extractFramesAtTimestamps: vi.fn(),
156
155
  extractFramesAtInterval: vi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "escribano",
3
- "version": "0.2.2",
3
+ "version": "0.4.1",
4
4
  "description": "AI-powered session intelligence tool — turn screen recordings into structured work summaries",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -66,6 +66,7 @@
66
66
  },
67
67
  "dependencies": {
68
68
  "better-sqlite3": "^12.6.2",
69
+ "dotenv": "^17.3.1",
69
70
  "express": "^5.0.0",
70
71
  "pidusage": "^4.0.1",
71
72
  "undici": "^7.22.0",
@@ -15,14 +15,40 @@ function getCurrentTag() {
15
15
  try {
16
16
  return run("git describe --tags --exact-match");
17
17
  } catch {
18
- console.error("❌ No git tag found at current commit");
19
- console.error(" Run: git tag v<x.y.z> && git push --tags");
20
- process.exit(1);
18
+ // No tag - create one from package.json
19
+ const pkg = JSON.parse(readFileSync(join(REPO_ROOT, "package.json")));
20
+ const tag = `v${pkg.version}`;
21
+
22
+ console.log(`⚠️ No tag found, creating ${tag}...`);
23
+ run(`git tag ${tag}`);
24
+ run(`git push origin ${tag}`);
25
+ console.log(`✅ Created and pushed tag: ${tag}`);
26
+
27
+ return tag;
21
28
  }
22
29
  }
23
30
 
24
31
  function getPreviousTag(currentTag) {
25
- const allTags = run("git tag --list 'v*' | sort -V").split("\n").filter(Boolean);
32
+ // Fetch latest tags from remote
33
+ run("git fetch origin --tags");
34
+
35
+ // Get all version tags and sort them
36
+ const allTagsOutput = run("git tag --list 'v*'");
37
+ const allTags = allTagsOutput.split("\n").filter(Boolean);
38
+
39
+ // Sort by version number
40
+ allTags.sort((a, b) => {
41
+ const aVersion = a.replace(/^v/, "").split(".").map(Number);
42
+ const bVersion = b.replace(/^v/, "").split(".").map(Number);
43
+
44
+ for (let i = 0; i < Math.max(aVersion.length, bVersion.length); i++) {
45
+ const aPart = aVersion[i] || 0;
46
+ const bPart = bVersion[i] || 0;
47
+ if (aPart !== bPart) return aPart - bPart;
48
+ }
49
+ return 0;
50
+ });
51
+
26
52
  const currentIndex = allTags.indexOf(currentTag);
27
53
 
28
54
  if (currentIndex === 0) return null;
@@ -161,6 +187,29 @@ function createGitHubRelease(tag, releaseNotes) {
161
187
  }
162
188
  }
163
189
 
190
+ function commitAndPushChangelog(tag) {
191
+ try {
192
+ // Check if there are changes
193
+ const status = run("git status --porcelain CHANGELOG.md");
194
+
195
+ if (!status) {
196
+ console.log("ℹ️ CHANGELOG.md already up to date, nothing to commit");
197
+ return;
198
+ }
199
+
200
+ // Stage, commit, and push
201
+ run("git add CHANGELOG.md");
202
+ run(`git commit -m "docs: update CHANGELOG for ${tag}"`);
203
+ run("git push");
204
+
205
+ console.log(`✅ Committed and pushed CHANGELOG.md for ${tag}`);
206
+ } catch (error) {
207
+ console.error("⚠️ Failed to commit/push CHANGELOG.md:", error.message);
208
+ console.error(" You may need to commit manually:");
209
+ console.error(" git add CHANGELOG.md && git commit -m 'docs: update CHANGELOG' && git push");
210
+ }
211
+ }
212
+
164
213
  async function main() {
165
214
  console.log("🚀 Creating GitHub release...\n");
166
215
 
@@ -187,12 +236,9 @@ async function main() {
187
236
 
188
237
  updateChangelog(releaseNotes);
189
238
  createGitHubRelease(currentTag, releaseNotes);
239
+ commitAndPushChangelog(currentTag);
190
240
 
191
- console.log("\n🎉 Release complete!");
192
- console.log("\nNext steps:");
193
- console.log(" 1. Review CHANGELOG.md changes");
194
- console.log(" 2. Commit: git add CHANGELOG.md && git commit -m 'docs: update CHANGELOG'");
195
- console.log(" 3. Push: git push");
241
+ console.log("\n🎉 Release complete! All changes pushed to GitHub.");
196
242
  }
197
243
 
198
244
  main().catch(error => {
@@ -133,7 +133,7 @@ def signal_handler(signum: int, frame: Any) -> None:
133
133
  def load_model() -> tuple[Any, Any, Any]:
134
134
  """Load MLX-VLM model."""
135
135
  log(f"Loading model: {MODEL_NAME}")
136
- log("This may take 30-60 seconds on first run or after memory clear...")
136
+ log("This may take 30-120 seconds on first run or after memory clear...")
137
137
  start = time.time()
138
138
 
139
139
  try:
@@ -153,7 +153,31 @@ def load_model() -> tuple[Any, Any, Any]:
153
153
  return model_obj, processor_obj, config_obj
154
154
  except ImportError as e:
155
155
  log(f"Failed to import mlx_vlm: {e}", "error")
156
- log("Install with: pip install mlx-vlm", "error")
156
+ log(f"Python used: {sys.executable}", "error")
157
+ custom_python = os.environ.get("ESCRIBANO_PYTHON_PATH")
158
+ if custom_python:
159
+ log(
160
+ "ESCRIBANO_PYTHON_PATH is set, so Escribano does not auto-install mlx-vlm "
161
+ "into this Python environment.",
162
+ "error",
163
+ )
164
+ log(
165
+ f"Make sure mlx-vlm is installed for that Python "
166
+ f"(e.g. `{custom_python} -m pip install mlx-vlm`), "
167
+ "or unset ESCRIBANO_PYTHON_PATH to let Escribano manage its own Python.",
168
+ "error",
169
+ )
170
+ else:
171
+ log(
172
+ "mlx-vlm is missing from Escribano's managed Python environment. "
173
+ "It is normally installed automatically.",
174
+ "error",
175
+ )
176
+ log(
177
+ "Try restarting Escribano so it can recreate or repair its Python environment. "
178
+ "If the problem persists, install `mlx-vlm` into this Python or report an issue.",
179
+ "error",
180
+ )
157
181
  sys.exit(1)
158
182
  except Exception as e:
159
183
  log(f"Failed to load model: {e}", "error")