escribano 0.4.4 → 0.4.5

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/0_types.js CHANGED
@@ -262,7 +262,7 @@ export const intelligenceConfigSchema = z.object({
262
262
  similarityThreshold: 0.75,
263
263
  }),
264
264
  // MLX-VLM specific config
265
- vlmBatchSize: z.number().default(4),
265
+ vlmBatchSize: z.number().default(2),
266
266
  vlmMaxTokens: z.number().default(2000),
267
267
  mlxSocketPath: z.string().default('/tmp/escribano-mlx.sock'),
268
268
  });
@@ -33,14 +33,28 @@ export async function generateSummaryV3(recordingId, repos, intelligence, option
33
33
  throw new Error(`No TopicBlocks found for recording ${recordingId}. Run process-v3 first.`);
34
34
  }
35
35
  log('info', `[Summary V3] Found ${allTopicBlocks.length} TopicBlocks`);
36
- // Group TopicBlocks into subjects
37
- log('info', '[Summary V3] Grouping TopicBlocks into subjects...');
38
- const groupingResult = await groupTopicBlocksIntoSubjects(allTopicBlocks, intelligence, recordingId);
39
- const { subjects } = groupingResult;
40
- const { personalDuration, workDuration } = groupingResult;
41
- // Save subjects to database
42
- log('info', `[Summary V3] Saving ${subjects.length} subjects to database...`);
43
- saveSubjectsToDatabase(subjects, recordingId, repos);
36
+ // Check if subjects already exist for this recording
37
+ const existingSubjects = repos.subjects.findByRecording(recordingId);
38
+ let subjects;
39
+ let personalDuration;
40
+ let workDuration;
41
+ if (existingSubjects.length > 0) {
42
+ log('info', `[Summary V3] Reusing ${existingSubjects.length} existing subjects (no re-grouping needed)`);
43
+ const loaded = loadExistingSubjects(existingSubjects, repos);
44
+ subjects = loaded.subjects;
45
+ personalDuration = loaded.personalDuration;
46
+ workDuration = loaded.workDuration;
47
+ }
48
+ else {
49
+ // Group TopicBlocks into subjects
50
+ log('info', '[Summary V3] Grouping TopicBlocks into subjects...');
51
+ const groupingResult = await groupTopicBlocksIntoSubjects(allTopicBlocks, intelligence, recordingId);
52
+ log('info', `[Summary V3] Saving ${groupingResult.subjects.length} subjects to database...`);
53
+ saveSubjectsToDatabase(groupingResult.subjects, recordingId, repos);
54
+ subjects = groupingResult.subjects;
55
+ personalDuration = groupingResult.personalDuration;
56
+ workDuration = groupingResult.workDuration;
57
+ }
44
58
  // Filter TopicBlocks based on personal/work classification
45
59
  let topicBlocksToUse = allTopicBlocks;
46
60
  if (!options.includePersonal) {
@@ -48,7 +62,8 @@ export async function generateSummaryV3(recordingId, repos, intelligence, option
48
62
  const personalSubjectIds = new Set(subjects.filter((s) => s.isPersonal).map((s) => s.id));
49
63
  topicBlocksToUse = allTopicBlocks.filter((block) => {
50
64
  const subjectForBlock = subjects.find((s) => s.topicBlockIds.includes(block.id));
51
- return !subjectForBlock?.isPersonal;
65
+ // Use the collected personalSubjectIds set for filtering
66
+ return !personalSubjectIds.has(subjectForBlock?.id ?? '');
52
67
  });
53
68
  }
54
69
  // Build sections from TopicBlocks
@@ -312,3 +327,31 @@ ${section.transcript}
312
327
  `;
313
328
  return summary;
314
329
  }
330
+ function loadExistingSubjects(existingSubjects, repos) {
331
+ const subjects = [];
332
+ for (const dbSubject of existingSubjects) {
333
+ const topicBlocks = repos.subjects.getTopicBlocks(dbSubject.id);
334
+ const activityBreakdown = dbSubject.activity_breakdown
335
+ ? JSON.parse(dbSubject.activity_breakdown)
336
+ : {};
337
+ const metadata = dbSubject.metadata ? JSON.parse(dbSubject.metadata) : {};
338
+ const apps = metadata.apps || [];
339
+ subjects.push({
340
+ id: dbSubject.id,
341
+ recordingId: topicBlocks[0]?.recording_id || '',
342
+ label: dbSubject.label,
343
+ topicBlockIds: topicBlocks.map((b) => b.id),
344
+ totalDuration: dbSubject.duration,
345
+ activityBreakdown,
346
+ apps,
347
+ isPersonal: dbSubject.is_personal === 1,
348
+ });
349
+ }
350
+ const personalDuration = subjects
351
+ .filter((s) => s.isPersonal)
352
+ .reduce((sum, s) => sum + s.totalDuration, 0);
353
+ const workDuration = subjects
354
+ .filter((s) => !s.isPersonal)
355
+ .reduce((sum, s) => sum + s.totalDuration, 0);
356
+ return { subjects, personalDuration, workDuration };
357
+ }
@@ -15,22 +15,14 @@ import { createConnection } from 'node:net';
15
15
  import { dirname, resolve } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ import { loadConfig } from '../config.js';
18
19
  import { ESCRIBANO_HOME, ESCRIBANO_VENV, ESCRIBANO_VENV_PYTHON, getPythonPath, } from '../python-utils.js';
19
- const DEBUG_MLX = process.env.ESCRIBANO_VERBOSE === 'true';
20
20
  function debugLog(...args) {
21
- if (DEBUG_MLX) {
21
+ const config = loadConfig();
22
+ if (config.verbose) {
22
23
  console.log('[VLM] [MLX]', ...args);
23
24
  }
24
25
  }
25
- const DEFAULT_CONFIG = {
26
- model: process.env.ESCRIBANO_VLM_MODEL ??
27
- 'mlx-community/Qwen3-VL-2B-Instruct-bf16',
28
- batchSize: Number(process.env.ESCRIBANO_VLM_BATCH_SIZE) || 4,
29
- maxTokens: Number(process.env.ESCRIBANO_VLM_MAX_TOKENS) || 2000,
30
- socketPath: process.env.ESCRIBANO_MLX_SOCKET_PATH ?? '/tmp/escribano-mlx.sock',
31
- bridgeScript: resolve(__dirname, '../../scripts/mlx_bridge.py'),
32
- startupTimeout: Number(process.env.ESCRIBANO_MLX_STARTUP_TIMEOUT) || 120000,
33
- };
34
26
  /** pip binary inside Escribano's managed venv. */
35
27
  const _ESCRIBANO_VENV_PIP = resolve(ESCRIBANO_VENV, 'bin', 'pip');
36
28
  /**
@@ -129,7 +121,16 @@ export function cleanupMlxBridge() {
129
121
  * Other methods (classify, generate, etc.) are not implemented and will throw.
130
122
  */
131
123
  export function createMlxIntelligenceService(_config = {}) {
132
- const mlxConfig = { ...DEFAULT_CONFIG };
124
+ // Load unified config (respects env vars, config file, and RAM-aware defaults)
125
+ const config = loadConfig();
126
+ const mlxConfig = {
127
+ model: config.vlmModel,
128
+ batchSize: config.vlmBatchSize,
129
+ maxTokens: config.vlmMaxTokens,
130
+ socketPath: config.mlxSocketPath,
131
+ bridgeScript: resolve(__dirname, '../../scripts/mlx_bridge.py'),
132
+ startupTimeout: config.mlxStartupTimeout,
133
+ };
133
134
  const bridge = {
134
135
  process: null,
135
136
  socket: null,
@@ -132,6 +132,43 @@ async function doModelWarmup(modelName, config) {
132
132
  warmedModels.add(modelName); // Mark as warmed to avoid repeated attempts
133
133
  }
134
134
  }
135
+ /**
136
+ * Unload an Ollama model from memory.
137
+ * Uses keep_alive: 0 to tell Ollama to release the model immediately.
138
+ */
139
+ export async function unloadOllamaModel(modelName, config) {
140
+ try {
141
+ debugLog(`Unloading model: ${modelName}...`);
142
+ const response = await fetch(`${config.endpoint.replace('/chat', '').replace('/generate', '')}/generate`, {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify({
146
+ model: modelName,
147
+ prompt: '',
148
+ keep_alive: 0, // Unload immediately
149
+ }),
150
+ });
151
+ if (response.ok) {
152
+ warmedModels.delete(modelName);
153
+ debugLog(`Model ${modelName} unloaded.`);
154
+ }
155
+ else {
156
+ let bodyText = '';
157
+ try {
158
+ bodyText = await response.text();
159
+ }
160
+ catch {
161
+ // Ignore errors while reading response body for logging
162
+ }
163
+ debugLog(`Failed to unload model ${modelName}: HTTP ${response.status} ${response.statusText}` +
164
+ (bodyText ? ` - Response body: ${bodyText}` : ''));
165
+ }
166
+ }
167
+ catch (error) {
168
+ // Unload is best-effort - don't throw
169
+ debugLog(`Failed to unload model ${modelName}: ${error.message}`);
170
+ }
171
+ }
135
172
  async function checkOllamaHealth() {
136
173
  try {
137
174
  const response = await fetch('http://localhost:11434/api/tags');
@@ -21,11 +21,11 @@ import { hasContentChanged, publishSummaryV3, updateRecordingOutlineMetadata, }
21
21
  import { createSileroPreprocessor } from './adapters/audio.silero.adapter.js';
22
22
  import { createFilesystemCaptureSource } from './adapters/capture.filesystem.adapter.js';
23
23
  import { cleanupMlxBridge, createMlxIntelligenceService, } from './adapters/intelligence.mlx.adapter.js';
24
- import { createOllamaIntelligenceService } from './adapters/intelligence.ollama.adapter.js';
24
+ import { createOllamaIntelligenceService, unloadOllamaModel, } from './adapters/intelligence.ollama.adapter.js';
25
25
  import { createOutlinePublishingService } from './adapters/publishing.outline.adapter.js';
26
26
  import { createWhisperTranscriptionService } from './adapters/transcription.whisper.adapter.js';
27
27
  import { createFfmpegVideoService } from './adapters/video.ffmpeg.adapter.js';
28
- import { createDefaultConfig } from './config.js';
28
+ import { createDefaultConfig, loadConfig, logConfig } from './config.js';
29
29
  import { getDbPath, getRepositories } from './db/index.js';
30
30
  import { log, setResourceTracker, step, withPipeline, } from './pipeline/context.js';
31
31
  import { ResourceTracker, setupStatsObserver, } from './stats/index.js';
@@ -40,6 +40,10 @@ const MODEL_PATH = path.join(MODELS_DIR, MODEL_FILE);
40
40
  export async function initializeSystem() {
41
41
  // Create default config file if it doesn't exist
42
42
  createDefaultConfig();
43
+ // Load and log unified configuration
44
+ const config = loadConfig();
45
+ logConfig();
46
+ console.log('');
43
47
  console.log('Initializing database...');
44
48
  const repos = getRepositories();
45
49
  console.log(`Database ready: ${getDbPath()}`);
@@ -50,11 +54,11 @@ export async function initializeSystem() {
50
54
  const modelSelection = await selectBestLLMModel();
51
55
  console.log(formatModelSelection(modelSelection));
52
56
  console.log('');
53
- // Initialize adapters ONCE
57
+ // Initialize adapters ONCE (config is now used by adapters)
54
58
  console.log('[VLM] Using MLX-VLM for image processing');
55
- const vlm = createMlxIntelligenceService();
59
+ const vlm = createMlxIntelligenceService(config);
56
60
  console.log('[LLM] Using Ollama for text generation');
57
- const llm = createOllamaIntelligenceService();
61
+ const llm = createOllamaIntelligenceService(config);
58
62
  const video = createFfmpegVideoService();
59
63
  const preprocessor = createSileroPreprocessor();
60
64
  const transcription = createWhisperTranscriptionService({
@@ -102,6 +106,8 @@ export async function processVideo(videoPath, ctx, options = {}) {
102
106
  const { force = false, skipSummary = false, micAudioPath, systemAudioPath, format = 'card', includePersonal = false, copyToClipboard = false, printToStdout = false, } = options;
103
107
  const { repos, adapters, outlineConfig } = ctx;
104
108
  const { vlm, llm, video, preprocessor, transcription } = adapters;
109
+ // Load unified config for lifecycle management
110
+ const config = loadConfig();
105
111
  try {
106
112
  // Create capture source for this specific file
107
113
  // Note: Hardcoded to filesystem source, not Cap recordings
@@ -161,6 +167,9 @@ export async function processVideo(videoPath, ctx, options = {}) {
161
167
  await withPipeline(recording.id, runType, runMetadata, async () => {
162
168
  await processRecordingV3(recording.id, repos, { preprocessor, transcription, video, intelligence: vlm }, { force });
163
169
  });
170
+ // Free VLM memory after processing (good hygiene for all RAM tiers)
171
+ console.log('[VLM] Freeing VLM memory...');
172
+ cleanupMlxBridge();
164
173
  }
165
174
  // Generate artifact and publish (unless skipped), tracked as a pipeline run
166
175
  let artifact = null;
@@ -272,6 +281,26 @@ export async function processVideo(videoPath, ctx, options = {}) {
272
281
  });
273
282
  artifact = pipelineResult.artifact;
274
283
  outlineUrl = pipelineResult.outlineUrl;
284
+ // Unload LLM after artifact generation to free memory (good hygiene for all RAM tiers)
285
+ if (config.llmModel) {
286
+ console.log('[LLM] Unloading model to free memory...');
287
+ const intelConfig = {
288
+ provider: 'ollama',
289
+ endpoint: 'http://localhost:11434/api/chat',
290
+ model: config.llmModel,
291
+ generationModel: config.llmModel,
292
+ visionModel: config.vlmModel,
293
+ maxRetries: 3,
294
+ timeout: 600000,
295
+ keepAlive: '10m',
296
+ maxContextSize: 131072,
297
+ embedding: { model: 'nomic-embed-text', similarityThreshold: 0.75 },
298
+ vlmBatchSize: config.vlmBatchSize,
299
+ vlmMaxTokens: config.vlmMaxTokens,
300
+ mlxSocketPath: config.mlxSocketPath,
301
+ };
302
+ await unloadOllamaModel(config.llmModel, intelConfig);
303
+ }
275
304
  }
276
305
  console.log('\n✓ Complete!');
277
306
  return {
package/dist/config.js CHANGED
@@ -5,12 +5,12 @@
5
5
  * 1. CLI arguments
6
6
  * 2. Shell environment variables (export ESCRIBANO_*)
7
7
  * 3. ~/.escribano/.env file
8
- * 4. Default values
8
+ * 4. RAM-aware defaults (based on system memory)
9
9
  *
10
10
  * Note: Project-level .env is NOT loaded by default (only for development).
11
11
  */
12
12
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
13
- import { homedir } from 'node:os';
13
+ import { homedir, totalmem } from 'node:os';
14
14
  import path from 'node:path';
15
15
  import { config as dotenvConfig } from 'dotenv';
16
16
  import { z } from 'zod';
@@ -39,7 +39,7 @@ const configSchema = z.object({
39
39
  sampleGapThreshold: z.number().int().min(5).max(60).default(15),
40
40
  sampleGapFill: z.number().int().min(1).max(10).default(3),
41
41
  mlxSocketPath: z.string().default('/tmp/escribano-mlx.sock'),
42
- mlxStartupTimeout: z.number().int().min(10000).default(60000),
42
+ mlxStartupTimeout: z.number().int().min(10000).default(120000),
43
43
  pythonPath: z.string().optional(),
44
44
  parallelTranscription: z.boolean().default(false),
45
45
  artifactThink: z.boolean().default(false),
@@ -49,9 +49,24 @@ const configSchema = z.object({
49
49
  outlineCollection: z.string().default('Escribano Sessions'),
50
50
  });
51
51
  // =============================================================================
52
+ // RAM DETECTION
53
+ // =============================================================================
54
+ function getSystemRamGB() {
55
+ return Math.round(totalmem() / (1024 * 1024 * 1024));
56
+ }
57
+ function getRamTier(ramGB) {
58
+ if (ramGB >= 32) {
59
+ return { tier: 'high', frameWidth: 1024 };
60
+ }
61
+ if (ramGB >= 16) {
62
+ return { tier: 'medium', frameWidth: 1024 };
63
+ }
64
+ return { tier: 'low', frameWidth: 768 };
65
+ }
66
+ // =============================================================================
52
67
  // DEFAULT CONFIG
53
68
  // =============================================================================
54
- const DEFAULT_CONFIG = {
69
+ const BASE_DEFAULTS = {
55
70
  frameWidth: 1024,
56
71
  vlmBatchSize: 2,
57
72
  sampleInterval: 10,
@@ -66,7 +81,7 @@ const DEFAULT_CONFIG = {
66
81
  sampleGapThreshold: 15,
67
82
  sampleGapFill: 3,
68
83
  mlxSocketPath: '/tmp/escribano-mlx.sock',
69
- mlxStartupTimeout: 60000,
84
+ mlxStartupTimeout: 120000,
70
85
  parallelTranscription: false,
71
86
  artifactThink: false,
72
87
  outlineCollection: 'Escribano Sessions',
@@ -79,30 +94,30 @@ const CONFIG_TEMPLATE = `# Escribano Configuration - ~/.escribano/.env
79
94
  # Full reference: https://github.com/eduardosanzb/escribano#configuration
80
95
 
81
96
  # === 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)
97
+ # ESCRIBANO_FRAME_WIDTH=1024 # Auto-adjusted based on RAM (1024 for 16GB+, 768 for <16GB)
98
+ # ESCRIBANO_VLM_BATCH_SIZE=2 # 1-4 frames (lower = more reliable)
99
+ ESCRIBANO_SAMPLE_INTERVAL=10 # Base frame sampling (seconds)
85
100
 
86
101
  # === QUALITY ===
87
- ESCRIBANO_SCENE_THRESHOLD=0.4 # Scene detection sensitivity (0.0-1.0)
88
- ESCRIBANO_VLM_MAX_TOKENS=2000 # Token budget per batch
102
+ ESCRIBANO_SCENE_THRESHOLD=0.4 # Scene detection sensitivity (0.0-1.0)
103
+ ESCRIBANO_VLM_MAX_TOKENS=2000 # Token budget per batch
89
104
 
90
105
  # === MODELS ===
91
- # ESCRIBANO_LLM_MODEL=qwen3.5:27b # Summary generation (auto-detected if not set)
106
+ # ESCRIBANO_LLM_MODEL=qwen3.5:27b # Summary generation (auto-detected if not set)
92
107
  ESCRIBANO_VLM_MODEL=mlx-community/Qwen3-VL-2B-Instruct-4bit
93
108
 
94
109
  # === DEBUGGING ===
95
- ESCRIBANO_VERBOSE=false # Enable verbose logging
96
- ESCRIBANO_DEBUG_VLM=false # Debug VLM processing
110
+ ESCRIBANO_VERBOSE=false # Enable verbose logging
111
+ ESCRIBANO_DEBUG_VLM=false # Debug VLM processing
97
112
 
98
113
  # === ADVANCED ===
99
114
  ESCRIBANO_SCENE_MIN_INTERVAL=2
100
115
  ESCRIBANO_SAMPLE_GAP_THRESHOLD=15
101
116
  ESCRIBANO_SAMPLE_GAP_FILL=3
102
117
  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)
118
+ ESCRIBANO_MLX_STARTUP_TIMEOUT=120000
119
+ # ESCRIBANO_PYTHON_PATH= # Auto-detected if not set
120
+ ESCRIBANO_ARTIFACT_THINK=false # Enable thinking for artifacts (slower)
106
121
 
107
122
  # === OPTIONAL (Outline publishing) ===
108
123
  # ESCRIBANO_OUTLINE_URL=
@@ -113,6 +128,7 @@ ESCRIBANO_ARTIFACT_THINK=false # Enable thinking for artifacts (slower)
113
128
  // CONFIG LOADER
114
129
  // =============================================================================
115
130
  let cachedConfig = null;
131
+ let cachedSources = [];
116
132
  export function getConfigPath() {
117
133
  return path.join(homedir(), '.escribano', '.env');
118
134
  }
@@ -133,97 +149,176 @@ export function createDefaultConfig() {
133
149
  console.error(`Failed to create config file at ${configPath}: ${error.message}`);
134
150
  }
135
151
  }
152
+ /**
153
+ * Check if running in development mode.
154
+ * Development mode = running via tsx from source (src/index.ts)
155
+ * Production mode = running compiled code (dist/index.js)
156
+ */
157
+ function isDevelopmentMode() {
158
+ // Check if running from src directory via tsx
159
+ const currentFile = import.meta.url;
160
+ return currentFile.includes('/src/');
161
+ }
136
162
  export function loadConfig() {
137
163
  if (cachedConfig) {
138
164
  return cachedConfig;
139
165
  }
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.');
166
+ const sources = [];
167
+ // 1. Load from user config file (PRODUCTION MODE ONLY)
168
+ // In development mode, we use project .env via tsx --env-file flag
169
+ if (!isDevelopmentMode()) {
170
+ const configPath = getConfigPath();
171
+ if (existsSync(configPath)) {
172
+ try {
173
+ const result = dotenvConfig({ path: configPath });
174
+ if (result.error) {
175
+ console.error(`Failed to parse config file ${configPath}: ${result.error.message}`);
176
+ console.error('Using default configuration.');
177
+ }
178
+ else if (result.parsed && Object.keys(result.parsed).length > 0) {
179
+ console.log(`Loaded config from ${configPath}`);
180
+ }
148
181
  }
149
- else if (result.parsed && Object.keys(result.parsed).length > 0) {
150
- console.log(`Loaded config from ${configPath}`);
182
+ catch (error) {
183
+ console.error(`Error reading config file ${configPath}: ${error.message}`);
184
+ console.error('Using default configuration.');
151
185
  }
152
186
  }
153
- catch (error) {
154
- console.error(`Error reading config file ${configPath}: ${error.message}`);
155
- console.error('Using default configuration.');
156
- }
157
187
  }
158
- // 2. Build config from environment variables
188
+ // 2. Get RAM-aware defaults
189
+ const ramGB = getSystemRamGB();
190
+ const ramTier = getRamTier(ramGB);
191
+ // 3. Build config with source tracking
159
192
  const config = {
160
193
  // === 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),
194
+ frameWidth: parseEnvNumberWithSource('ESCRIBANO_FRAME_WIDTH', ramTier.frameWidth, sources, 'frameWidth'),
195
+ vlmBatchSize: parseEnvNumberWithSource('ESCRIBANO_VLM_BATCH_SIZE', BASE_DEFAULTS.vlmBatchSize, sources, 'vlmBatchSize'),
196
+ sampleInterval: parseEnvNumberWithSource('ESCRIBANO_SAMPLE_INTERVAL', BASE_DEFAULTS.sampleInterval, sources, 'sampleInterval'),
164
197
  // === QUALITY ===
165
- sceneThreshold: parseEnvNumber('ESCRIBANO_SCENE_THRESHOLD', DEFAULT_CONFIG.sceneThreshold),
166
- vlmMaxTokens: parseEnvNumber('ESCRIBANO_VLM_MAX_TOKENS', DEFAULT_CONFIG.vlmMaxTokens),
198
+ sceneThreshold: parseEnvNumberWithSource('ESCRIBANO_SCENE_THRESHOLD', BASE_DEFAULTS.sceneThreshold, sources, 'sceneThreshold'),
199
+ vlmMaxTokens: parseEnvNumberWithSource('ESCRIBANO_VLM_MAX_TOKENS', BASE_DEFAULTS.vlmMaxTokens, sources, 'vlmMaxTokens'),
167
200
  // === 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,
201
+ llmModel: parseEnvStringWithSource('ESCRIBANO_LLM_MODEL', undefined, sources, 'llmModel'),
202
+ vlmModel: parseEnvStringWithSource('ESCRIBANO_VLM_MODEL', BASE_DEFAULTS.vlmModel, sources, 'vlmModel'),
203
+ subjectGroupingModel: parseEnvStringWithSource('ESCRIBANO_SUBJECT_GROUPING_MODEL', undefined, sources, 'subjectGroupingModel'),
171
204
  // === 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),
205
+ verbose: parseEnvBooleanWithSource('ESCRIBANO_VERBOSE', BASE_DEFAULTS.verbose, sources, 'verbose'),
206
+ debugOllama: parseEnvBooleanWithSource('ESCRIBANO_DEBUG_OLLAMA', BASE_DEFAULTS.debugOllama, sources, 'debugOllama'),
207
+ debugVlm: parseEnvBooleanWithSource('ESCRIBANO_DEBUG_VLM', BASE_DEFAULTS.debugVlm, sources, 'debugVlm'),
208
+ skipLlm: parseEnvBooleanWithSource('ESCRIBANO_SKIP_LLM', BASE_DEFAULTS.skipLlm, sources, 'skipLlm'),
176
209
  // === 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),
210
+ sceneMinInterval: parseEnvNumberWithSource('ESCRIBANO_SCENE_MIN_INTERVAL', BASE_DEFAULTS.sceneMinInterval, sources, 'sceneMinInterval'),
211
+ sampleGapThreshold: parseEnvNumberWithSource('ESCRIBANO_SAMPLE_GAP_THRESHOLD', BASE_DEFAULTS.sampleGapThreshold, sources, 'sampleGapThreshold'),
212
+ sampleGapFill: parseEnvNumberWithSource('ESCRIBANO_SAMPLE_GAP_FILL', BASE_DEFAULTS.sampleGapFill, sources, 'sampleGapFill'),
213
+ mlxSocketPath: parseEnvStringWithSource('ESCRIBANO_MLX_SOCKET_PATH', BASE_DEFAULTS.mlxSocketPath, sources, 'mlxSocketPath'),
214
+ mlxStartupTimeout: parseEnvNumberWithSource('ESCRIBANO_MLX_STARTUP_TIMEOUT', BASE_DEFAULTS.mlxStartupTimeout, sources, 'mlxStartupTimeout'),
215
+ pythonPath: parseEnvStringWithSource('ESCRIBANO_PYTHON_PATH', undefined, sources, 'pythonPath'),
216
+ parallelTranscription: parseEnvBooleanWithSource('ESCRIBANO_PARALLEL_TRANSCRIPTION', BASE_DEFAULTS.parallelTranscription, sources, 'parallelTranscription'),
217
+ artifactThink: parseEnvBooleanWithSource('ESCRIBANO_ARTIFACT_THINK', BASE_DEFAULTS.artifactThink, sources, 'artifactThink'),
185
218
  // === 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,
219
+ outlineUrl: parseEnvStringWithSource('ESCRIBANO_OUTLINE_URL', undefined, sources, 'outlineUrl'),
220
+ outlineToken: parseEnvStringWithSource('ESCRIBANO_OUTLINE_TOKEN', undefined, sources, 'outlineToken'),
221
+ outlineCollection: parseEnvStringWithSource('ESCRIBANO_OUTLINE_COLLECTION', BASE_DEFAULTS.outlineCollection, sources, 'outlineCollection'),
190
222
  };
191
- // 3. Validate with Zod
223
+ // 4. Validate with Zod
192
224
  const validated = configSchema.parse(config);
193
225
  cachedConfig = validated;
226
+ cachedSources = sources;
194
227
  return validated;
195
228
  }
229
+ export function getConfigSources() {
230
+ return cachedSources;
231
+ }
232
+ export function getRamInfo() {
233
+ const ramGB = getSystemRamGB();
234
+ const ramTier = getRamTier(ramGB);
235
+ return { ramGB, tier: ramTier.tier };
236
+ }
196
237
  // =============================================================================
197
238
  // HELPERS
198
239
  // =============================================================================
199
- function parseEnvNumber(key, defaultValue) {
240
+ function parseEnvNumberWithSource(key, defaultValue, sources, configKey) {
200
241
  const value = process.env[key];
201
- if (!value)
242
+ if (value === undefined) {
243
+ const isRamAware = configKey === 'frameWidth';
244
+ sources.push({
245
+ key: configKey,
246
+ source: isRamAware ? 'ram-aware' : 'default',
247
+ });
202
248
  return defaultValue;
249
+ }
203
250
  const parsed = Number(value);
204
251
  if (Number.isNaN(parsed)) {
205
252
  console.warn(`Invalid ${key}="${value}", using default: ${defaultValue}`);
253
+ sources.push({ key: configKey, source: 'default' });
206
254
  return defaultValue;
207
255
  }
256
+ sources.push({ key: configKey, source: 'env' });
208
257
  return parsed;
209
258
  }
210
- function parseEnvBoolean(key, defaultValue) {
259
+ function parseEnvStringWithSource(key, defaultValue, sources, configKey) {
211
260
  const value = process.env[key];
212
- if (!value)
261
+ if (value === undefined) {
262
+ sources.push({ key: configKey, source: 'default' });
213
263
  return defaultValue;
264
+ }
265
+ sources.push({ key: configKey, source: 'env' });
266
+ return value;
267
+ }
268
+ function parseEnvBooleanWithSource(key, defaultValue, sources, configKey) {
269
+ const value = process.env[key];
270
+ if (value === undefined) {
271
+ sources.push({ key: configKey, source: 'default' });
272
+ return defaultValue;
273
+ }
274
+ sources.push({ key: configKey, source: 'env' });
214
275
  return value === 'true';
215
276
  }
216
277
  // =============================================================================
278
+ // LOGGING
279
+ // =============================================================================
280
+ export function logConfig() {
281
+ const config = loadConfig();
282
+ const { ramGB, tier } = getRamInfo();
283
+ const sources = getConfigSources();
284
+ const userSetKeys = sources.filter((s) => s.source === 'env');
285
+ // Compact one-liner per category
286
+ const perf = `frameWidth=${config.frameWidth} vlmBatchSize=${config.vlmBatchSize} sampleInterval=${config.sampleInterval}`;
287
+ const quality = `sceneThreshold=${config.sceneThreshold} vlmMaxTokens=${config.vlmMaxTokens}`;
288
+ const models = `vlmModel=${config.vlmModel.split('/').pop()} llmModel=${config.llmModel || 'auto'}`;
289
+ // Show dev mode indicator if applicable
290
+ if (isDevelopmentMode()) {
291
+ console.log('[Config] Mode: development (using project .env)');
292
+ }
293
+ console.log(`[Config] RAM: ${ramGB}GB (${tier})`);
294
+ console.log(`[Config] Performance: ${perf}`);
295
+ console.log(`[Config] Quality: ${quality}`);
296
+ console.log(`[Config] Models: ${models}`);
297
+ if (userSetKeys.length > 0) {
298
+ console.log(`[Config] User overrides: ${userSetKeys.map((s) => s.key).join(', ')}`);
299
+ }
300
+ }
301
+ // =============================================================================
217
302
  // CLI UTILITIES
218
303
  // =============================================================================
219
304
  export function showConfig() {
220
305
  const configPath = getConfigPath();
221
- // Create config file if it doesn't exist
306
+ // In dev mode, show that we're using project .env instead
307
+ if (isDevelopmentMode()) {
308
+ console.log('Development mode: Using project .env (not ~/.escribano/.env)\n');
309
+ console.log('Current configuration:');
310
+ const config = loadConfig();
311
+ console.log(JSON.stringify(config, null, 2));
312
+ return;
313
+ }
314
+ // Create config file if it doesn't exist (production mode)
222
315
  if (!existsSync(configPath)) {
223
316
  createDefaultConfig();
224
317
  }
225
318
  const config = loadConfig();
226
- console.log(`Config file: ${configPath}\n`);
319
+ const { ramGB, tier } = getRamInfo();
320
+ console.log(`Config file: ${configPath}`);
321
+ console.log(`System RAM: ${ramGB}GB (${tier} tier)\n`);
227
322
  console.log('Current configuration:');
228
323
  console.log(JSON.stringify(config, null, 2));
229
324
  }