escribano 0.4.3 → 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
  });
@@ -3,11 +3,13 @@
3
3
  *
4
4
  * Generates a work session summary from V3 processed TopicBlocks using LLM.
5
5
  */
6
+ import { execSync } from 'node:child_process';
6
7
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
7
8
  import { homedir } from 'node:os';
8
9
  import path, { dirname, resolve } from 'node:path';
9
10
  import { fileURLToPath } from 'node:url';
10
11
  import { log } from '../pipeline/context.js';
12
+ import { groupTopicBlocksIntoSubjects, saveSubjectsToDatabase, } from '../services/subject-grouping.js';
11
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
14
  /**
13
15
  * Generate a work session summary artifact from processed TopicBlocks.
@@ -19,21 +21,54 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
19
21
  * @returns Generated artifact
20
22
  */
21
23
  export async function generateSummaryV3(recordingId, repos, intelligence, options) {
22
- log('info', `[Summary V3] Generating summary for recording ${recordingId}...`);
24
+ log('info', `[Summary V3] Generating narrative for recording ${recordingId}...`);
23
25
  // Get the recording
24
26
  const recording = repos.recordings.findById(recordingId);
25
27
  if (!recording) {
26
28
  throw new Error(`Recording ${recordingId} not found`);
27
29
  }
28
30
  // Get TopicBlocks for this recording
29
- const topicBlocks = repos.topicBlocks.findByRecording(recordingId);
30
- if (topicBlocks.length === 0) {
31
+ const allTopicBlocks = repos.topicBlocks.findByRecording(recordingId);
32
+ if (allTopicBlocks.length === 0) {
31
33
  throw new Error(`No TopicBlocks found for recording ${recordingId}. Run process-v3 first.`);
32
34
  }
33
- log('info', `[Summary V3] Found ${topicBlocks.length} TopicBlocks`);
35
+ log('info', `[Summary V3] Found ${allTopicBlocks.length} TopicBlocks`);
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
+ }
58
+ // Filter TopicBlocks based on personal/work classification
59
+ let topicBlocksToUse = allTopicBlocks;
60
+ if (!options.includePersonal) {
61
+ // Filter out blocks from personal subjects
62
+ const personalSubjectIds = new Set(subjects.filter((s) => s.isPersonal).map((s) => s.id));
63
+ topicBlocksToUse = allTopicBlocks.filter((block) => {
64
+ const subjectForBlock = subjects.find((s) => s.topicBlockIds.includes(block.id));
65
+ // Use the collected personalSubjectIds set for filtering
66
+ return !personalSubjectIds.has(subjectForBlock?.id ?? '');
67
+ });
68
+ }
34
69
  // Build sections from TopicBlocks
35
70
  const sections = [];
36
- for (const block of topicBlocks) {
71
+ for (const block of topicBlocksToUse) {
37
72
  const classification = JSON.parse(block.classification || '{}');
38
73
  sections.push({
39
74
  activity: classification.activity_type || 'unknown',
@@ -65,16 +100,48 @@ export async function generateSummaryV3(recordingId, repos, intelligence, option
65
100
  await mkdir(outputDir, { recursive: true });
66
101
  // Generate filename
67
102
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
68
- const fileName = `${recordingId}-summary-${timestamp}.md`;
103
+ const fileName = `${recordingId}-narrative-${timestamp}.md`;
69
104
  const filePath = path.join(outputDir, fileName);
70
105
  // Write to file
71
106
  await writeFile(filePath, summaryContent, 'utf-8');
72
107
  log('info', `[Summary V3] Summary saved to: ${filePath}`);
108
+ // Save to database
109
+ const artifactId = `artifact-${recordingId}-narrative-${Date.now()}`;
110
+ repos.artifacts.save({
111
+ id: artifactId,
112
+ recording_id: recordingId,
113
+ type: 'narrative',
114
+ content: summaryContent,
115
+ format: 'markdown',
116
+ source_block_ids: JSON.stringify(subjects.flatMap((s) => s.topicBlockIds)),
117
+ source_context_ids: null,
118
+ });
119
+ log('info', `[Summary V3] Saved to database: ${artifactId}`);
120
+ // Link subjects to artifact
121
+ repos.artifacts.linkSubjects(artifactId, subjects.map((s) => s.id));
122
+ log('info', `[Summary V3] Linked ${subjects.length} subjects to artifact`);
123
+ // Handle stdout/clipboard
124
+ if (options.printToStdout) {
125
+ console.log(`\n${summaryContent}\n`);
126
+ }
127
+ if (options.copyToClipboard && process.platform === 'darwin') {
128
+ try {
129
+ execSync('pbcopy', { input: summaryContent, encoding: 'utf-8' });
130
+ log('info', '[Summary V3] Copied to clipboard');
131
+ }
132
+ catch (error) {
133
+ log('warn', `[Summary V3] Failed to copy to clipboard: ${error}`);
134
+ }
135
+ }
73
136
  return {
74
- id: `summary-${recordingId}-${Date.now()}`,
137
+ id: artifactId,
75
138
  recordingId,
139
+ format: 'narrative',
76
140
  content: summaryContent,
77
141
  filePath,
142
+ subjects,
143
+ personalDuration,
144
+ workDuration,
78
145
  createdAt: new Date(),
79
146
  };
80
147
  }
@@ -260,3 +327,31 @@ ${section.transcript}
260
327
  `;
261
328
  return summary;
262
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');
@@ -14,17 +14,18 @@ import { execSync } from 'node:child_process';
14
14
  import { homedir } from 'node:os';
15
15
  import path from 'node:path';
16
16
  import { generateArtifactV3, } from './actions/generate-artifact-v3.js';
17
+ import { generateSummaryV3 } from './actions/generate-summary-v3.js';
17
18
  import { updateGlobalIndex } from './actions/outline-index.js';
18
19
  import { processRecordingV3 } from './actions/process-recording-v3.js';
19
20
  import { hasContentChanged, publishSummaryV3, updateRecordingOutlineMetadata, } from './actions/publish-summary-v3.js';
20
21
  import { createSileroPreprocessor } from './adapters/audio.silero.adapter.js';
21
22
  import { createFilesystemCaptureSource } from './adapters/capture.filesystem.adapter.js';
22
23
  import { cleanupMlxBridge, createMlxIntelligenceService, } from './adapters/intelligence.mlx.adapter.js';
23
- import { createOllamaIntelligenceService } from './adapters/intelligence.ollama.adapter.js';
24
+ import { createOllamaIntelligenceService, unloadOllamaModel, } from './adapters/intelligence.ollama.adapter.js';
24
25
  import { createOutlinePublishingService } from './adapters/publishing.outline.adapter.js';
25
26
  import { createWhisperTranscriptionService } from './adapters/transcription.whisper.adapter.js';
26
27
  import { createFfmpegVideoService } from './adapters/video.ffmpeg.adapter.js';
27
- import { createDefaultConfig } from './config.js';
28
+ import { createDefaultConfig, loadConfig, logConfig } from './config.js';
28
29
  import { getDbPath, getRepositories } from './db/index.js';
29
30
  import { log, setResourceTracker, step, withPipeline, } from './pipeline/context.js';
30
31
  import { ResourceTracker, setupStatsObserver, } from './stats/index.js';
@@ -39,6 +40,10 @@ const MODEL_PATH = path.join(MODELS_DIR, MODEL_FILE);
39
40
  export async function initializeSystem() {
40
41
  // Create default config file if it doesn't exist
41
42
  createDefaultConfig();
43
+ // Load and log unified configuration
44
+ const config = loadConfig();
45
+ logConfig();
46
+ console.log('');
42
47
  console.log('Initializing database...');
43
48
  const repos = getRepositories();
44
49
  console.log(`Database ready: ${getDbPath()}`);
@@ -49,11 +54,11 @@ export async function initializeSystem() {
49
54
  const modelSelection = await selectBestLLMModel();
50
55
  console.log(formatModelSelection(modelSelection));
51
56
  console.log('');
52
- // Initialize adapters ONCE
57
+ // Initialize adapters ONCE (config is now used by adapters)
53
58
  console.log('[VLM] Using MLX-VLM for image processing');
54
- const vlm = createMlxIntelligenceService();
59
+ const vlm = createMlxIntelligenceService(config);
55
60
  console.log('[LLM] Using Ollama for text generation');
56
- const llm = createOllamaIntelligenceService();
61
+ const llm = createOllamaIntelligenceService(config);
57
62
  const video = createFfmpegVideoService();
58
63
  const preprocessor = createSileroPreprocessor();
59
64
  const transcription = createWhisperTranscriptionService({
@@ -101,6 +106,8 @@ export async function processVideo(videoPath, ctx, options = {}) {
101
106
  const { force = false, skipSummary = false, micAudioPath, systemAudioPath, format = 'card', includePersonal = false, copyToClipboard = false, printToStdout = false, } = options;
102
107
  const { repos, adapters, outlineConfig } = ctx;
103
108
  const { vlm, llm, video, preprocessor, transcription } = adapters;
109
+ // Load unified config for lifecycle management
110
+ const config = loadConfig();
104
111
  try {
105
112
  // Create capture source for this specific file
106
113
  // Note: Hardcoded to filesystem source, not Cap recordings
@@ -160,6 +167,9 @@ export async function processVideo(videoPath, ctx, options = {}) {
160
167
  await withPipeline(recording.id, runType, runMetadata, async () => {
161
168
  await processRecordingV3(recording.id, repos, { preprocessor, transcription, video, intelligence: vlm }, { force });
162
169
  });
170
+ // Free VLM memory after processing (good hygiene for all RAM tiers)
171
+ console.log('[VLM] Freeing VLM memory...');
172
+ cleanupMlxBridge();
163
173
  }
164
174
  // Generate artifact and publish (unless skipped), tracked as a pipeline run
165
175
  let artifact = null;
@@ -168,13 +178,28 @@ export async function processVideo(videoPath, ctx, options = {}) {
168
178
  const artifactRunMetadata = collectRunMetadata(ctx.resourceTracker);
169
179
  const pipelineResult = await withPipeline(recording.id, 'artifact', artifactRunMetadata, async () => {
170
180
  console.log(`\nGenerating ${format} artifact...`);
171
- const generatedArtifact = await generateArtifactV3(recording.id, repos, llm, {
172
- recordingId: recording.id,
173
- format,
174
- includePersonal,
175
- copyToClipboard,
176
- printToStdout,
177
- });
181
+ let generatedArtifact;
182
+ if (format === 'narrative') {
183
+ // Route narrative through the corrected path
184
+ generatedArtifact = await generateSummaryV3(recording.id, repos, llm, {
185
+ recordingId: recording.id,
186
+ outputDir: options.outputDir,
187
+ useTemplate: false,
188
+ includePersonal,
189
+ copyToClipboard,
190
+ printToStdout,
191
+ });
192
+ }
193
+ else {
194
+ // Card and standup use the original path
195
+ generatedArtifact = await generateArtifactV3(recording.id, repos, llm, {
196
+ recordingId: recording.id,
197
+ format,
198
+ includePersonal,
199
+ copyToClipboard,
200
+ printToStdout,
201
+ });
202
+ }
178
203
  console.log(`Artifact saved: ${generatedArtifact.filePath}`);
179
204
  if (generatedArtifact.workDuration > 0) {
180
205
  const workMins = Math.round(generatedArtifact.workDuration / 60);
@@ -256,6 +281,26 @@ export async function processVideo(videoPath, ctx, options = {}) {
256
281
  });
257
282
  artifact = pipelineResult.artifact;
258
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
+ }
259
304
  }
260
305
  console.log('\n✓ Complete!');
261
306
  return {