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 +1 -1
- package/dist/actions/generate-summary-v3.js +102 -7
- package/dist/adapters/intelligence.mlx.adapter.js +13 -12
- package/dist/adapters/intelligence.ollama.adapter.js +37 -0
- package/dist/batch-context.js +57 -12
- 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
|
});
|
|
@@ -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
|
|
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
|
|
30
|
-
if (
|
|
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 ${
|
|
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
|
|
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}-
|
|
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:
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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 {
|