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 +1 -1
- package/dist/actions/generate-summary-v3.js +52 -9
- package/dist/adapters/intelligence.mlx.adapter.js +13 -12
- package/dist/adapters/intelligence.ollama.adapter.js +37 -0
- package/dist/batch-context.js +34 -5
- package/dist/config.js +157 -62
- package/dist/tests/index.test.js +25 -12
- package/dist/tests/utils/env-logger.test.js +6 -6
- package/package.json +1 -1
- package/scripts/mlx_bridge.py +5 -5
- package/dist/adapters/cap.adapter.js +0 -94
- package/dist/adapters/intelligence.adapter.js +0 -202
- package/dist/adapters/storage.adapter.js +0 -81
- package/dist/adapters/whisper.adapter.js +0 -168
- package/dist/domain/context.js +0 -97
- package/dist/domain/index.js +0 -2
- package/dist/domain/observation.js +0 -17
- package/dist/test-classification-prompts.js +0 -181
- package/dist/tests/cap.adapter.test.js +0 -75
- package/dist/tests/intelligence.adapter.test.js +0 -102
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(
|
|
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
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|
package/dist/batch-context.js
CHANGED
|
@@ -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.
|
|
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(
|
|
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
|
|
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:
|
|
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 #
|
|
83
|
-
ESCRIBANO_VLM_BATCH_SIZE=2 # 1-4 frames (lower = more reliable)
|
|
84
|
-
ESCRIBANO_SAMPLE_INTERVAL=10
|
|
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
|
|
88
|
-
ESCRIBANO_VLM_MAX_TOKENS=2000
|
|
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
|
|
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
|
|
96
|
-
ESCRIBANO_DEBUG_VLM=false
|
|
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=
|
|
104
|
-
# ESCRIBANO_PYTHON_PATH=
|
|
105
|
-
ESCRIBANO_ARTIFACT_THINK=false
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
console.
|
|
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.
|
|
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:
|
|
162
|
-
vlmBatchSize:
|
|
163
|
-
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:
|
|
166
|
-
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:
|
|
169
|
-
vlmModel:
|
|
170
|
-
subjectGroupingModel:
|
|
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:
|
|
173
|
-
debugOllama:
|
|
174
|
-
debugVlm:
|
|
175
|
-
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:
|
|
178
|
-
sampleGapThreshold:
|
|
179
|
-
sampleGapFill:
|
|
180
|
-
mlxSocketPath:
|
|
181
|
-
mlxStartupTimeout:
|
|
182
|
-
pythonPath:
|
|
183
|
-
parallelTranscription:
|
|
184
|
-
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:
|
|
187
|
-
outlineToken:
|
|
188
|
-
outlineCollection:
|
|
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
|
-
//
|
|
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
|
|
240
|
+
function parseEnvNumberWithSource(key, defaultValue, sources, configKey) {
|
|
200
241
|
const value = process.env[key];
|
|
201
|
-
if (
|
|
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
|
|
259
|
+
function parseEnvStringWithSource(key, defaultValue, sources, configKey) {
|
|
211
260
|
const value = process.env[key];
|
|
212
|
-
if (
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
}
|