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.
package/dist/config.js ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Configuration Management
3
+ *
4
+ * Loads configuration from multiple sources with priority (highest to lowest):
5
+ * 1. CLI arguments
6
+ * 2. Shell environment variables (export ESCRIBANO_*)
7
+ * 3. ~/.escribano/.env file
8
+ * 4. Default values
9
+ *
10
+ * Note: Project-level .env is NOT loaded by default (only for development).
11
+ */
12
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import path from 'node:path';
15
+ import { config as dotenvConfig } from 'dotenv';
16
+ import { z } from 'zod';
17
+ // =============================================================================
18
+ // CONFIG SCHEMA
19
+ // =============================================================================
20
+ const configSchema = z.object({
21
+ // === PERFORMANCE ===
22
+ frameWidth: z.number().int().min(320).max(3840).default(1024),
23
+ vlmBatchSize: z.number().int().min(1).max(8).default(2),
24
+ sampleInterval: z.number().int().min(1).max(60).default(10),
25
+ // === QUALITY ===
26
+ sceneThreshold: z.number().min(0).max(1).default(0.4),
27
+ vlmMaxTokens: z.number().int().min(500).max(8000).default(2000),
28
+ // === MODELS ===
29
+ llmModel: z.string().optional(),
30
+ vlmModel: z.string().default('mlx-community/Qwen3-VL-2B-Instruct-4bit'),
31
+ subjectGroupingModel: z.string().optional(),
32
+ // === DEBUGGING ===
33
+ verbose: z.boolean().default(false),
34
+ debugOllama: z.boolean().default(false),
35
+ debugVlm: z.boolean().default(false),
36
+ skipLlm: z.boolean().default(false),
37
+ // === ADVANCED ===
38
+ sceneMinInterval: z.number().int().min(1).max(10).default(2),
39
+ sampleGapThreshold: z.number().int().min(5).max(60).default(15),
40
+ sampleGapFill: z.number().int().min(1).max(10).default(3),
41
+ mlxSocketPath: z.string().default('/tmp/escribano-mlx.sock'),
42
+ mlxStartupTimeout: z.number().int().min(10000).default(60000),
43
+ pythonPath: z.string().optional(),
44
+ parallelTranscription: z.boolean().default(false),
45
+ artifactThink: z.boolean().default(false),
46
+ // === OPTIONAL (Outline publishing) ===
47
+ outlineUrl: z.string().optional(),
48
+ outlineToken: z.string().optional(),
49
+ outlineCollection: z.string().default('Escribano Sessions'),
50
+ });
51
+ // =============================================================================
52
+ // DEFAULT CONFIG
53
+ // =============================================================================
54
+ const DEFAULT_CONFIG = {
55
+ frameWidth: 1024,
56
+ vlmBatchSize: 2,
57
+ sampleInterval: 10,
58
+ sceneThreshold: 0.4,
59
+ vlmMaxTokens: 2000,
60
+ vlmModel: 'mlx-community/Qwen3-VL-2B-Instruct-4bit',
61
+ verbose: false,
62
+ debugOllama: false,
63
+ debugVlm: false,
64
+ skipLlm: false,
65
+ sceneMinInterval: 2,
66
+ sampleGapThreshold: 15,
67
+ sampleGapFill: 3,
68
+ mlxSocketPath: '/tmp/escribano-mlx.sock',
69
+ mlxStartupTimeout: 60000,
70
+ parallelTranscription: false,
71
+ artifactThink: false,
72
+ outlineCollection: 'Escribano Sessions',
73
+ };
74
+ // =============================================================================
75
+ // CONFIG FILE TEMPLATE
76
+ // =============================================================================
77
+ const CONFIG_TEMPLATE = `# Escribano Configuration - ~/.escribano/.env
78
+ # Generated by escribano - edit as needed
79
+ # Full reference: https://github.com/eduardosanzb/escribano#configuration
80
+
81
+ # === PERFORMANCE ===
82
+ ESCRIBANO_FRAME_WIDTH=1024 # Lower = faster (1920, 1280, 1024, 640)
83
+ ESCRIBANO_VLM_BATCH_SIZE=2 # 1-4 frames (lower = more reliable)
84
+ ESCRIBANO_SAMPLE_INTERVAL=10 # Base frame sampling (seconds)
85
+
86
+ # === QUALITY ===
87
+ ESCRIBANO_SCENE_THRESHOLD=0.4 # Scene detection sensitivity (0.0-1.0)
88
+ ESCRIBANO_VLM_MAX_TOKENS=2000 # Token budget per batch
89
+
90
+ # === MODELS ===
91
+ # ESCRIBANO_LLM_MODEL=qwen3.5:27b # Summary generation (auto-detected if not set)
92
+ ESCRIBANO_VLM_MODEL=mlx-community/Qwen3-VL-2B-Instruct-4bit
93
+
94
+ # === DEBUGGING ===
95
+ ESCRIBANO_VERBOSE=false # Enable verbose logging
96
+ ESCRIBANO_DEBUG_VLM=false # Debug VLM processing
97
+
98
+ # === ADVANCED ===
99
+ ESCRIBANO_SCENE_MIN_INTERVAL=2
100
+ ESCRIBANO_SAMPLE_GAP_THRESHOLD=15
101
+ ESCRIBANO_SAMPLE_GAP_FILL=3
102
+ ESCRIBANO_MLX_SOCKET_PATH=/tmp/escribano-mlx.sock
103
+ ESCRIBANO_MLX_STARTUP_TIMEOUT=60000
104
+ # ESCRIBANO_PYTHON_PATH= # Auto-detected if not set
105
+ ESCRIBANO_ARTIFACT_THINK=false # Enable thinking for artifacts (slower)
106
+
107
+ # === OPTIONAL (Outline publishing) ===
108
+ # ESCRIBANO_OUTLINE_URL=
109
+ # ESCRIBANO_OUTLINE_TOKEN=
110
+ # ESCRIBANO_OUTLINE_COLLECTION=Escribano Sessions
111
+ `;
112
+ // =============================================================================
113
+ // CONFIG LOADER
114
+ // =============================================================================
115
+ let cachedConfig = null;
116
+ export function getConfigPath() {
117
+ return path.join(homedir(), '.escribano', '.env');
118
+ }
119
+ export function createDefaultConfig() {
120
+ const configDir = path.join(homedir(), '.escribano');
121
+ const configPath = getConfigPath();
122
+ try {
123
+ if (!existsSync(configDir)) {
124
+ mkdirSync(configDir, { recursive: true });
125
+ }
126
+ if (!existsSync(configPath)) {
127
+ writeFileSync(configPath, CONFIG_TEMPLATE, 'utf-8');
128
+ console.log(`Created config file at ${configPath}`);
129
+ console.log('You can customize settings by editing this file.\n');
130
+ }
131
+ }
132
+ catch (error) {
133
+ console.error(`Failed to create config file at ${configPath}: ${error.message}`);
134
+ }
135
+ }
136
+ export function loadConfig() {
137
+ if (cachedConfig) {
138
+ return cachedConfig;
139
+ }
140
+ // 1. Load from config file (if exists)
141
+ const configPath = getConfigPath();
142
+ if (existsSync(configPath)) {
143
+ try {
144
+ const result = dotenvConfig({ path: configPath });
145
+ if (result.error) {
146
+ console.error(`Failed to parse config file ${configPath}: ${result.error.message}`);
147
+ console.error('Using default configuration.');
148
+ }
149
+ else if (result.parsed && Object.keys(result.parsed).length > 0) {
150
+ console.log(`Loaded config from ${configPath}`);
151
+ }
152
+ }
153
+ catch (error) {
154
+ console.error(`Error reading config file ${configPath}: ${error.message}`);
155
+ console.error('Using default configuration.');
156
+ }
157
+ }
158
+ // 2. Build config from environment variables
159
+ const config = {
160
+ // === PERFORMANCE ===
161
+ frameWidth: parseEnvNumber('ESCRIBANO_FRAME_WIDTH', DEFAULT_CONFIG.frameWidth),
162
+ vlmBatchSize: parseEnvNumber('ESCRIBANO_VLM_BATCH_SIZE', DEFAULT_CONFIG.vlmBatchSize),
163
+ sampleInterval: parseEnvNumber('ESCRIBANO_SAMPLE_INTERVAL', DEFAULT_CONFIG.sampleInterval),
164
+ // === QUALITY ===
165
+ sceneThreshold: parseEnvNumber('ESCRIBANO_SCENE_THRESHOLD', DEFAULT_CONFIG.sceneThreshold),
166
+ vlmMaxTokens: parseEnvNumber('ESCRIBANO_VLM_MAX_TOKENS', DEFAULT_CONFIG.vlmMaxTokens),
167
+ // === MODELS ===
168
+ llmModel: process.env.ESCRIBANO_LLM_MODEL,
169
+ vlmModel: process.env.ESCRIBANO_VLM_MODEL || DEFAULT_CONFIG.vlmModel,
170
+ subjectGroupingModel: process.env.ESCRIBANO_SUBJECT_GROUPING_MODEL,
171
+ // === DEBUGGING ===
172
+ verbose: parseEnvBoolean('ESCRIBANO_VERBOSE', DEFAULT_CONFIG.verbose),
173
+ debugOllama: parseEnvBoolean('ESCRIBANO_DEBUG_OLLAMA', DEFAULT_CONFIG.debugOllama),
174
+ debugVlm: parseEnvBoolean('ESCRIBANO_DEBUG_VLM', DEFAULT_CONFIG.debugVlm),
175
+ skipLlm: parseEnvBoolean('ESCRIBANO_SKIP_LLM', DEFAULT_CONFIG.skipLlm),
176
+ // === ADVANCED ===
177
+ sceneMinInterval: parseEnvNumber('ESCRIBANO_SCENE_MIN_INTERVAL', DEFAULT_CONFIG.sceneMinInterval),
178
+ sampleGapThreshold: parseEnvNumber('ESCRIBANO_SAMPLE_GAP_THRESHOLD', DEFAULT_CONFIG.sampleGapThreshold),
179
+ sampleGapFill: parseEnvNumber('ESCRIBANO_SAMPLE_GAP_FILL', DEFAULT_CONFIG.sampleGapFill),
180
+ mlxSocketPath: process.env.ESCRIBANO_MLX_SOCKET_PATH || DEFAULT_CONFIG.mlxSocketPath,
181
+ mlxStartupTimeout: parseEnvNumber('ESCRIBANO_MLX_STARTUP_TIMEOUT', DEFAULT_CONFIG.mlxStartupTimeout),
182
+ pythonPath: process.env.ESCRIBANO_PYTHON_PATH,
183
+ parallelTranscription: parseEnvBoolean('ESCRIBANO_PARALLEL_TRANSCRIPTION', DEFAULT_CONFIG.parallelTranscription),
184
+ artifactThink: parseEnvBoolean('ESCRIBANO_ARTIFACT_THINK', DEFAULT_CONFIG.artifactThink),
185
+ // === OPTIONAL ===
186
+ outlineUrl: process.env.ESCRIBANO_OUTLINE_URL,
187
+ outlineToken: process.env.ESCRIBANO_OUTLINE_TOKEN,
188
+ outlineCollection: process.env.ESCRIBANO_OUTLINE_COLLECTION ||
189
+ DEFAULT_CONFIG.outlineCollection,
190
+ };
191
+ // 3. Validate with Zod
192
+ const validated = configSchema.parse(config);
193
+ cachedConfig = validated;
194
+ return validated;
195
+ }
196
+ // =============================================================================
197
+ // HELPERS
198
+ // =============================================================================
199
+ function parseEnvNumber(key, defaultValue) {
200
+ const value = process.env[key];
201
+ if (!value)
202
+ return defaultValue;
203
+ const parsed = Number(value);
204
+ if (Number.isNaN(parsed)) {
205
+ console.warn(`Invalid ${key}="${value}", using default: ${defaultValue}`);
206
+ return defaultValue;
207
+ }
208
+ return parsed;
209
+ }
210
+ function parseEnvBoolean(key, defaultValue) {
211
+ const value = process.env[key];
212
+ if (!value)
213
+ return defaultValue;
214
+ return value === 'true';
215
+ }
216
+ // =============================================================================
217
+ // CLI UTILITIES
218
+ // =============================================================================
219
+ export function showConfig() {
220
+ const configPath = getConfigPath();
221
+ // Create config file if it doesn't exist
222
+ if (!existsSync(configPath)) {
223
+ createDefaultConfig();
224
+ }
225
+ const config = loadConfig();
226
+ console.log(`Config file: ${configPath}\n`);
227
+ console.log('Current configuration:');
228
+ console.log(JSON.stringify(config, null, 2));
229
+ }
230
+ export function showConfigPath() {
231
+ const configPath = getConfigPath();
232
+ // Create config file if it doesn't exist
233
+ if (!existsSync(configPath)) {
234
+ createDefaultConfig();
235
+ }
236
+ console.log(configPath);
237
+ }
@@ -5,9 +5,7 @@ import { TimeRange } from './time-range.js';
5
5
  import { Transcript } from './transcript.js';
6
6
  // Minimal Context logic previously in context.ts
7
7
  const Context = {
8
- extractFromOCR: (text) => {
9
- // This is a stub to keep V1 compiling.
10
- // V2 uses signal-extraction.ts instead.
8
+ extractFromOCR: (_text) => {
11
9
  return [];
12
10
  },
13
11
  unique: (contexts) => contexts,
package/dist/index.js CHANGED
@@ -5,18 +5,87 @@
5
5
  * Single command: process latest recording and generate summary
6
6
  * Refactored to use batch-context for shared initialization logic
7
7
  */
8
+ import { access, constants, readdir, stat } from 'node:fs/promises';
8
9
  import { homedir } from 'node:os';
9
10
  import path from 'node:path';
10
11
  import pkg from '../package.json' with { type: 'json' };
11
12
  import { createCapCaptureSource } from './adapters/capture.cap.adapter.js';
12
13
  import { createFilesystemCaptureSource } from './adapters/capture.filesystem.adapter.js';
13
14
  import { cleanupMlxBridge, initializeSystem, processVideo, } from './batch-context.js';
15
+ import { loadConfig, showConfig, showConfigPath } from './config.js';
14
16
  import { getDbPath } from './db/index.js';
15
17
  import { checkPrerequisites, hasMissingPrerequisites, printDoctorResults, } from './prerequisites.js';
16
18
  import { logEnvironmentVariables } from './utils/env-logger.js';
17
19
  const MODELS_DIR = path.join(homedir(), '.escribano', 'models');
18
20
  const MODEL_FILE = 'ggml-large-v3.bin';
19
- const MODEL_PATH = path.join(MODELS_DIR, MODEL_FILE);
21
+ const _MODEL_PATH = path.join(MODELS_DIR, MODEL_FILE);
22
+ const VIDEO_EXTENSIONS = ['.mov', '.mp4', '.mkv', '.avi', '.webm'];
23
+ function expandPath(inputPath) {
24
+ if (!inputPath.startsWith('~')) {
25
+ return inputPath;
26
+ }
27
+ const homeDir = homedir();
28
+ if (!homeDir) {
29
+ return inputPath;
30
+ }
31
+ if (inputPath === '~' || inputPath === '~/') {
32
+ return homeDir;
33
+ }
34
+ if (inputPath.startsWith('~/')) {
35
+ return path.join(homeDir, inputPath.slice(2));
36
+ }
37
+ return inputPath;
38
+ }
39
+ async function findLatestVideo(dirPath) {
40
+ const resolvedPath = expandPath(dirPath);
41
+ let entries;
42
+ try {
43
+ entries = await readdir(resolvedPath, { withFileTypes: true });
44
+ }
45
+ catch (error) {
46
+ const err = error;
47
+ if (err && typeof err === 'object') {
48
+ switch (err.code) {
49
+ case 'ENOENT':
50
+ throw new Error(`Directory not found: ${resolvedPath}`);
51
+ case 'ENOTDIR':
52
+ throw new Error(`Not a directory: ${resolvedPath}`);
53
+ case 'EACCES':
54
+ case 'EPERM':
55
+ throw new Error(`Permission denied reading directory: ${resolvedPath}`);
56
+ default:
57
+ break;
58
+ }
59
+ }
60
+ throw error;
61
+ }
62
+ const videoFiles = entries.filter((entry) => entry.isFile() &&
63
+ VIDEO_EXTENSIONS.some((ext) => entry.name.toLowerCase().endsWith(ext)));
64
+ if (videoFiles.length === 0) {
65
+ throw new Error(`No video files found in: ${resolvedPath}`);
66
+ }
67
+ let latestFilePath = null;
68
+ let latestMtime = -Infinity;
69
+ for (const entry of videoFiles) {
70
+ const fullPath = path.join(resolvedPath, entry.name);
71
+ try {
72
+ await access(fullPath, constants.R_OK);
73
+ const fileStat = await stat(fullPath);
74
+ const mtimeMs = fileStat.mtime.getTime();
75
+ if (mtimeMs > latestMtime) {
76
+ latestMtime = mtimeMs;
77
+ latestFilePath = fullPath;
78
+ }
79
+ }
80
+ catch {
81
+ // Skip files that are inaccessible (permission denied, broken symlink, etc.)
82
+ }
83
+ }
84
+ if (!latestFilePath) {
85
+ throw new Error(`No accessible video files found in: ${resolvedPath}`);
86
+ }
87
+ return latestFilePath;
88
+ }
20
89
  function main() {
21
90
  const args = parseArgs(process.argv.slice(2));
22
91
  if (args.version) {
@@ -34,6 +103,17 @@ function main() {
34
103
  });
35
104
  return;
36
105
  }
106
+ if (args.config) {
107
+ if (args.configPath) {
108
+ showConfigPath();
109
+ }
110
+ else {
111
+ showConfig();
112
+ }
113
+ return;
114
+ }
115
+ // Load user config file before any adapter reads process.env
116
+ loadConfig();
37
117
  run(args).catch((error) => {
38
118
  console.error('Error:', error.message);
39
119
  console.error(error.stack);
@@ -50,6 +130,20 @@ async function runDoctor() {
50
130
  function parseArgs(argsArray) {
51
131
  const fileIndex = argsArray.indexOf('--file');
52
132
  const filePath = fileIndex !== -1 ? argsArray[fileIndex + 1] || null : null;
133
+ const latestIndex = argsArray.indexOf('--latest');
134
+ const latestPath = latestIndex !== -1 ? argsArray[latestIndex + 1] || null : null;
135
+ if (filePath && latestPath) {
136
+ console.error('Error: Cannot use both --latest and --file');
137
+ process.exit(1);
138
+ }
139
+ if (latestIndex !== -1 && !latestPath) {
140
+ console.error('Error: --latest requires a directory argument');
141
+ process.exit(1);
142
+ }
143
+ if (latestIndex !== -1 && latestPath?.startsWith('-')) {
144
+ console.error('Error: --latest requires a directory argument');
145
+ process.exit(1);
146
+ }
53
147
  const micIndex = argsArray.indexOf('--mic-audio');
54
148
  const micAudio = micIndex !== -1 ? argsArray[micIndex + 1] || null : null;
55
149
  const sysIndex = argsArray.indexOf('--system-audio');
@@ -61,7 +155,11 @@ function parseArgs(argsArray) {
61
155
  help: argsArray.includes('--help') || argsArray.includes('-h'),
62
156
  version: argsArray.includes('--version') || argsArray.includes('-v'),
63
157
  doctor: argsArray[0] === 'doctor',
158
+ config: argsArray[0] === 'config',
159
+ configShow: argsArray.includes('--show'),
160
+ configPath: argsArray.includes('--path'),
64
161
  file: filePath,
162
+ latest: latestPath,
65
163
  skipSummary: argsArray.includes('--skip-summary'),
66
164
  micAudio,
67
165
  systemAudio,
@@ -80,7 +178,10 @@ Escribano - Session Intelligence Tool
80
178
  Usage:
81
179
  npx escribano Process latest Cap recording
82
180
  npx escribano doctor Check prerequisites
181
+ npx escribano config Show current configuration
182
+ npx escribano config --path Show config file path
83
183
  npx escribano --file <path> Process video from filesystem
184
+ npx escribano --latest <dir> Process latest video in directory
84
185
  npx escribano --file <path> --mic-audio <wav> Use external mic audio
85
186
  npx escribano --file <path> --system-audio <wav> Provide system audio
86
187
  npx escribano --force Reprocess from scratch
@@ -94,22 +195,35 @@ Usage:
94
195
 
95
196
  Examples:
96
197
  npx escribano --file "~/Desktop/Screen Recording.mov"
198
+ npx escribano --latest "~/Desktop"
97
199
  npx escribano --file "/path/to/video.mp4" --mic-audio "/path/to/mic.wav"
98
200
  npx escribano --file "/path/to/video.mp4" --system-audio "/path/to/system.wav"
99
201
  npx escribano --format standup --stdout
100
202
  npx escribano --format narrative --include-personal
101
203
 
204
+ Configuration:
205
+ Config file: ~/.escribano/.env
206
+ View config: escribano config
207
+ Edit manually: vim ~/.escribano/.env
208
+
102
209
  Output: Markdown summary saved to ~/.escribano/artifacts/
103
210
  `);
104
211
  }
105
212
  async function run(args) {
106
- const { force, file: filePath, skipSummary, micAudio, systemAudio, format, includePersonal, copyToClipboard, printToStdout, } = args;
213
+ const { force, file: filePath, latest, skipSummary, micAudio, systemAudio, format, includePersonal, copyToClipboard, printToStdout, } = args;
214
+ // Resolve --latest to a file path
215
+ let resolvedFilePath = filePath;
216
+ if (latest) {
217
+ resolvedFilePath = await findLatestVideo(latest);
218
+ console.log(`Found latest video: ${resolvedFilePath}`);
219
+ }
107
220
  // Log environment variables if verbose mode is enabled
108
221
  logEnvironmentVariables();
109
222
  // Initialize system (reuses batch-context for consistency)
110
223
  console.log('Initializing database...');
111
224
  const ctx = await initializeSystem();
112
- const { repos } = ctx;
225
+ // Note: repos unused in CLI mode (only needed for batch processing)
226
+ void ctx;
113
227
  console.log(`Database ready: ${getDbPath()}`);
114
228
  console.log('');
115
229
  // SIGINT handler for graceful cancellation
@@ -121,14 +235,14 @@ async function run(args) {
121
235
  process.on('SIGINT', sigintHandler);
122
236
  // Create appropriate capture source
123
237
  let captureSource;
124
- if (filePath) {
125
- console.log(`Using filesystem source: ${filePath}`);
238
+ if (resolvedFilePath) {
239
+ console.log(`Using filesystem source: ${resolvedFilePath}`);
126
240
  if (micAudio)
127
241
  console.log(` Mic audio: ${micAudio}`);
128
242
  if (systemAudio)
129
243
  console.log(` System audio: ${systemAudio}`);
130
244
  captureSource = createFilesystemCaptureSource({
131
- videoPath: filePath,
245
+ videoPath: resolvedFilePath,
132
246
  micAudioPath: micAudio ?? undefined,
133
247
  systemAudioPath: systemAudio ?? undefined,
134
248
  }, ctx.adapters.video);
@@ -140,8 +254,8 @@ async function run(args) {
140
254
  // Get recording
141
255
  const recording = await captureSource.getLatestRecording();
142
256
  if (!recording) {
143
- if (filePath) {
144
- console.log(`Failed to load video file: ${filePath}`);
257
+ if (resolvedFilePath) {
258
+ console.log(`Failed to load video file: ${resolvedFilePath}`);
145
259
  }
146
260
  else {
147
261
  console.log('No Cap recordings found.');
@@ -5,8 +5,7 @@
5
5
  */
6
6
  import { execSync } from 'node:child_process';
7
7
  import { existsSync } from 'node:fs';
8
- import { homedir } from 'node:os';
9
- import { resolve } from 'node:path';
8
+ import { ESCRIBANO_VENV_PYTHON, getEffectivePythonPathSync, } from './python-utils.js';
10
9
  const LLM_MODEL_TIERS = [
11
10
  { model: 'qwen3.5:27b', tier: 4, minRamGB: 32, label: 'best' },
12
11
  { model: 'qwen3:14b', tier: 3, minRamGB: 20, label: 'very good' },
@@ -59,8 +58,8 @@ const PREREQUISITES = [
59
58
  {
60
59
  name: 'mlx-vlm',
61
60
  found: false,
62
- installCommand: 'pip install mlx-vlm',
63
- notes: 'VLM library for frame analysis (Apple Silicon)',
61
+ installCommand: 'pip install mlx-vlm torch torchvision',
62
+ notes: 'VLM library for frame analysis (Apple Silicon) — auto-installed by escribano on first run',
64
63
  },
65
64
  ];
66
65
  function checkCommand(command, args = ['--version']) {
@@ -128,21 +127,8 @@ function checkLLMModels() {
128
127
  installCommand: 'ollama pull qwen3:8b',
129
128
  };
130
129
  }
131
- function getPythonPath() {
132
- if (process.env.ESCRIBANO_PYTHON_PATH) {
133
- return process.env.ESCRIBANO_PYTHON_PATH;
134
- }
135
- if (process.env.VIRTUAL_ENV) {
136
- return resolve(process.env.VIRTUAL_ENV, 'bin', 'python3');
137
- }
138
- const uvHomeVenv = resolve(homedir(), '.venv', 'bin', 'python3');
139
- if (existsSync(uvHomeVenv)) {
140
- return uvHomeVenv;
141
- }
142
- return 'python3';
143
- }
144
130
  function checkPythonPackage(packageName) {
145
- const pythonPath = getPythonPath();
131
+ const pythonPath = getEffectivePythonPathSync();
146
132
  try {
147
133
  execSync(`"${pythonPath}" -c "import ${packageName}"`, {
148
134
  encoding: 'utf-8',
@@ -243,6 +229,10 @@ export function checkPrerequisites() {
243
229
  if (check.found && check.pythonPath) {
244
230
  result.notes = `via ${check.pythonPath}`;
245
231
  }
232
+ else if (existsSync(ESCRIBANO_VENV_PYTHON)) {
233
+ // Managed venv exists but mlx-vlm is missing — auto-install will fix it on next run
234
+ result.installCommand = `${ESCRIBANO_VENV_PYTHON} -m pip install mlx-vlm torch torchvision`;
235
+ }
246
236
  else {
247
237
  result.installCommand = detectPipCommand();
248
238
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Shared Python path resolution utilities for Escribano.
3
+ *
4
+ * Used by both the MLX adapter (runtime) and the prerequisites checker (doctor).
5
+ */
6
+ import { existsSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { resolve } from 'node:path';
9
+ /** Escribano's managed Python environment — created automatically on first use. */
10
+ export const ESCRIBANO_HOME = resolve(homedir(), '.escribano');
11
+ export const ESCRIBANO_VENV = resolve(ESCRIBANO_HOME, 'venv');
12
+ export const ESCRIBANO_VENV_PYTHON = resolve(ESCRIBANO_VENV, 'bin', 'python3');
13
+ /**
14
+ * Get explicitly configured Python path.
15
+ * Returns null when nothing is explicitly configured or found via well-known
16
+ * conventions (active venv, uv project environment, local/home .venv directory).
17
+ * Callers receiving null should fall through to ensureEscribanoVenv() for
18
+ * zero-config auto-setup.
19
+ *
20
+ * Priority:
21
+ * 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
27
+ */
28
+ export function getPythonPath() {
29
+ if (process.env.ESCRIBANO_PYTHON_PATH) {
30
+ return process.env.ESCRIBANO_PYTHON_PATH;
31
+ }
32
+ if (process.env.VIRTUAL_ENV) {
33
+ return resolve(process.env.VIRTUAL_ENV, 'bin', 'python3');
34
+ }
35
+ // UV_PROJECT_ENVIRONMENT: set by uv when running inside a project with `uv sync`
36
+ if (process.env.UV_PROJECT_ENVIRONMENT) {
37
+ return resolve(process.env.UV_PROJECT_ENVIRONMENT, 'bin', 'python3');
38
+ }
39
+ // Check project-local .venv (created by `uv venv` in the current working directory)
40
+ const localVenv = resolve(process.cwd(), '.venv', 'bin', 'python3');
41
+ if (existsSync(localVenv)) {
42
+ return localVenv;
43
+ }
44
+ // Check common home-level venv (e.g., `uv venv ~/.venv`)
45
+ const uvHomeVenv = resolve(homedir(), '.venv', 'bin', 'python3');
46
+ if (existsSync(uvHomeVenv)) {
47
+ return uvHomeVenv;
48
+ }
49
+ return null;
50
+ }
51
+ /**
52
+ * Get the best Python path for synchronous prerequisite checks (e.g., `escribano doctor`).
53
+ * Checks the managed venv if it already exists. Does NOT create or install anything.
54
+ *
55
+ * Priority: explicit config → managed ~/.escribano/venv (if it exists) → system python3
56
+ */
57
+ export function getEffectivePythonPathSync() {
58
+ const explicit = getPythonPath();
59
+ if (explicit)
60
+ return explicit;
61
+ if (existsSync(ESCRIBANO_VENV_PYTHON))
62
+ return ESCRIBANO_VENV_PYTHON;
63
+ return 'python3';
64
+ }
@@ -102,7 +102,7 @@ function extractActivityType(vlmDescription) {
102
102
  * Parse VLM description to extract apps and topics.
103
103
  * Expects format like: "Debugging Python error in VSCode, working on escribano project"
104
104
  */
105
- function extractContext(vlmDescription) {
105
+ function _extractContext(vlmDescription) {
106
106
  if (!vlmDescription)
107
107
  return { apps: [], topics: [] };
108
108
  const apps = [];
@@ -120,7 +120,7 @@ function extractContext(vlmDescription) {
120
120
  ];
121
121
  for (const pattern of appPatterns) {
122
122
  const match = text.match(pattern);
123
- if (match && match[1]) {
123
+ if (match?.[1]) {
124
124
  apps.push(match[1].toLowerCase().replace(' ', '_'));
125
125
  }
126
126
  }
@@ -132,7 +132,7 @@ function extractContext(vlmDescription) {
132
132
  ];
133
133
  for (const pattern of topicPatterns) {
134
134
  const match = text.match(pattern);
135
- if (match && match[1]) {
135
+ if (match?.[1]) {
136
136
  topics.push(match[1].toLowerCase());
137
137
  }
138
138
  }
@@ -68,7 +68,7 @@ export function extractProjects(texts) {
68
68
  for (const text of texts) {
69
69
  for (const pattern of PROJECT_PATTERNS) {
70
70
  const match = text.match(pattern);
71
- if (match && match[1]) {
71
+ if (match?.[1]) {
72
72
  const name = match[1].toLowerCase();
73
73
  // Filter out common non-project names
74
74
  if (!['src', 'lib', 'dist', 'build', 'node_modules', 'packages'].includes(name)) {
@@ -217,7 +217,7 @@ function parseGroupingResponse(response, topicBlocks) {
217
217
  }
218
218
  return { groups };
219
219
  }
220
- function detectPersonalSubject(apps, activityBreakdown) {
220
+ function detectPersonalSubject(apps, _activityBreakdown) {
221
221
  let personalAppCount = 0;
222
222
  let totalAppCount = 0;
223
223
  for (const app of apps) {
@@ -322,7 +322,7 @@ function formatDuration(seconds) {
322
322
  }
323
323
  return `${Math.floor(seconds)}s`;
324
324
  }
325
- export function saveSubjectsToDatabase(subjects, recordingId, repos) {
325
+ export function saveSubjectsToDatabase(subjects, _recordingId, repos) {
326
326
  const subjectInserts = [];
327
327
  const links = [];
328
328
  for (const subject of subjects) {
@@ -49,7 +49,7 @@ export function alignAudioToSegments(segments, audioObservations, config = {}) {
49
49
  const cfg = { ...DEFAULT_CONFIG, ...config };
50
50
  // Filter to audio observations with transcripts
51
51
  const audioTranscripts = audioObservations
52
- .filter((o) => o.type === 'audio' && o.text && o.text.trim().length > 0)
52
+ .filter((o) => o.type === 'audio' && o.text !== null && o.text.trim().length > 0)
53
53
  .map((o) => ({
54
54
  source: o.audio_source,
55
55
  text: o.text,
@@ -68,13 +68,16 @@ export async function describeFrames(frames, intelligence) {
68
68
  const results = new Map();
69
69
  if (frames.length === 0)
70
70
  return results;
71
- const images = frames.map((f) => ({
71
+ const validFrames = frames.filter((f) => f.observation.image_path !== null);
72
+ if (validFrames.length === 0)
73
+ return results;
74
+ const images = validFrames.map((f) => ({
72
75
  imagePath: f.observation.image_path,
73
76
  clusterId: 0, // Not used in our case
74
77
  timestamp: f.observation.timestamp,
75
78
  }));
76
79
  const descriptions = await intelligence.describeImages(images);
77
- for (const [index, frame] of frames.entries()) {
80
+ for (const [index, frame] of validFrames.entries()) {
78
81
  const desc = descriptions[index];
79
82
  if (desc?.description) {
80
83
  results.set(frame.observation.id, desc.description);
@@ -17,7 +17,7 @@ export function setupStatsObserver(repo) {
17
17
  pipelineEvents.on('run:end', (data) => {
18
18
  if (!currentRunId)
19
19
  return;
20
- const startedAt = data.timestamp;
20
+ const _startedAt = data.timestamp;
21
21
  repo.updateRun(currentRunId, {
22
22
  status: data.status,
23
23
  completed_at: new Date(data.timestamp).toISOString(),