escribano 0.4.5 → 0.5.0

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/README.md CHANGED
@@ -95,19 +95,49 @@ Good for retrospectives or blog drafts.
95
95
 
96
96
  ## Benchmarks
97
97
 
98
- Ran the full pipeline on 11 real screen recordings:
98
+ ### Architecture Benefits (MLX Migration)
99
+
100
+ | Improvement | Impact |
101
+ |-------------|--------|
102
+ | **Zero dependencies** | No external daemons required |
103
+ | **Unified backend** | VLM + LLM use same MLX infrastructure |
104
+ | **Native Metal** | Optimized for Apple Silicon |
105
+ | **Memory efficient** | Sequential model loading (no OOM) |
106
+ | **Auto-detection** | RAM-based model selection |
107
+
108
+ ### Production Run (March 2026)
109
+
110
+ Processed **17 real screen recordings** with MLX backend:
99
111
 
100
112
  | Metric | Result |
101
113
  |--------|--------|
102
- | Videos processed | 11 |
103
- | Artifacts generated | 33 (3 formats × 11 videos) |
104
- | Success rate | 100% |
105
- | Total time | 1h 41m |
106
- | Avg per video | **~9 min** (pipeline + all 3 formats) |
114
+ | Videos processed | 17 |
115
+ | Successful | 15 (88%) |
116
+ | Total video duration | 25.6 hours |
117
+ | Artifacts generated | 45 (3 formats × 15 videos) |
118
+ | **LLM generation** | **~2.2 min per video** |
119
+ | Subject grouping | 78.7s avg |
120
+ | Artifact generation | 53.6s avg |
121
+ | LLM success rate | 100% (92 calls) |
107
122
  | Hardware | MacBook Pro M4 Max, 128GB |
123
+ | Backend | MLX (Qwen3-VL-2B + Qwen3.5-27B) |
108
124
 
109
125
  Everything runs locally. No API keys. Nothing leaves your machine.
110
126
 
127
+ ### Hardware Tiers (March 2026)
128
+
129
+ Performance varies by hardware:
130
+
131
+ | Hardware | RAM | VLM Speed | LLM Model | LLM Speed | Total (1min video) |
132
+ |----------|-----|-----------|-----------|-----------|-------------------|
133
+ | **M4 Max** | 128GB | 0.7s/frame | Qwen3.5-27B | 53s avg | **~2.2 min** |
134
+ | **M1/M2/M3 Pro** | 16-32GB | 1.5-3s/frame | Qwen3.5-9B | 80-120s | ~5-8 min |
135
+ | **M1/M2 Air** | 16GB | 7-9s/frame | Qwen3.5-9B | 150-250s | ~12-15 min |
136
+
137
+ **Minimum viable**: 16GB unified memory (slower but functional)
138
+
139
+ **Recommended**: 32GB+ for comfortable use, 64GB+ for best quality
140
+
111
141
  ---
112
142
 
113
143
  ## Why this exists
@@ -141,7 +171,7 @@ Screen recording
141
171
  Activity segmentation → temporal audio alignment → TopicBlocks
142
172
 
143
173
 
144
- LLM summary (Ollama, auto-detected) → Markdown artifact
174
+ LLM summary (MLX-LM, auto-detected) → Markdown artifact
145
175
  ```
146
176
 
147
177
  Uses VLM-first visual understanding, not OCR + text clustering. OCR fails for developer work because all code screens produce similar tokens. VLMs understand the *activity*, not just the text.
@@ -154,32 +184,22 @@ Uses VLM-first visual understanding, not OCR + text clustering. OCR fails for de
154
184
 
155
185
  ```bash
156
186
  # macOS (Homebrew)
157
- brew install ollama whisper-cpp ffmpeg
187
+ brew install whisper-cpp ffmpeg
158
188
 
159
- # MLX-VLM for frame analysis (Apple Silicon)
160
- # Using uv (recommended, faster)
161
- uv pip install mlx-vlm
162
-
163
- # Or using pip
164
- pip install mlx-vlm
189
+ # MLX for inference (Apple Silicon) - auto-installed on first run
190
+ # Or pre-install with:
191
+ pip install mlx-vlm mlx-lm
165
192
  ```
166
193
 
167
- ### LLM Model Setup
194
+ That's it. No external daemons required. MLX-VLM and MLX-LM run in-process.
168
195
 
169
- Escribano auto-detects the best model for your hardware:
196
+ ### (Optional) Ollama Backend
170
197
 
171
- | Your RAM | Auto-selected | Install command |
172
- |----------|---------------|-----------------|
173
- | 16GB | `qwen3:8b` | `ollama pull qwen3:8b` |
174
- | 32GB | `qwen3:14b` | `ollama pull qwen3:14b` |
175
- | 64GB+ | `qwen3.5:27b` | `ollama pull qwen3.5:27b` |
198
+ If you prefer Ollama, set `ESCRIBANO_LLM_BACKEND=ollama`:
176
199
 
177
200
  ```bash
178
- # Minimum (16GB)
179
- ollama pull qwen3:8b
180
-
181
- # Or best quality (64GB+)
182
- ollama pull qwen3.5:27b
201
+ brew install ollama
202
+ ollama pull qwen3:8b # or qwen3.5:27b for 64GB+ RAM
183
203
  ```
184
204
 
185
205
  ### Run
@@ -228,9 +228,11 @@ async function generateLlmArtifact(subjects, groupingResult, format, recording,
228
228
  .replace('{{SUBJECT_COUNT}}', String(subjects.length))
229
229
  .replace('{{SUBJECTS_DATA}}', subjectsData)
230
230
  .replace('{{WORK_SUBJECTS}}', subjectsData);
231
- return intelligence.generateText(prompt, {
232
- expectJson: false,
233
- think: ARTIFACT_THINK,
231
+ return step('llm_artifact_generation', async () => {
232
+ return intelligence.generateText(prompt, {
233
+ expectJson: false,
234
+ think: ARTIFACT_THINK,
235
+ });
234
236
  });
235
237
  }
236
238
  function buildSubjectsDataForPrompt(subjects, allTopicBlocks) {
@@ -8,7 +8,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
8
8
  import { homedir } from 'node:os';
9
9
  import path, { dirname, resolve } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
- import { log } from '../pipeline/context.js';
11
+ import { log, step } from '../pipeline/context.js';
12
12
  import { groupTopicBlocksIntoSubjects, saveSubjectsToDatabase, } from '../services/subject-grouping.js';
13
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
14
  /**
@@ -225,10 +225,35 @@ ${section.transcript ? `**Audio Transcript:**\n${section.transcript}` : '*No aud
225
225
  .replace('{{APPS_LIST}}', appsList)
226
226
  .replace('{{URLS_LIST}}', urlsList);
227
227
  // Call LLM
228
- const result = await intelligence.generateText(prompt, {
229
- expectJson: false,
228
+ const result = await step('llm_artifact_generation', async () => {
229
+ return intelligence.generateText(prompt, {
230
+ expectJson: false,
231
+ debugContext: {
232
+ recordingId: recording.id,
233
+ callType: 'artifact_generation',
234
+ },
235
+ });
230
236
  });
231
- return result;
237
+ // Strip thinking leakage if present
238
+ let cleaned = result.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
239
+ if (cleaned.includes('</think>')) {
240
+ // Handle orphan </think> tag (Qwen3.5 behavior)
241
+ cleaned = cleaned.split('</think>')[1].trim();
242
+ }
243
+ // Strip "Thinking Process:" prose (Qwen3.5-OptiQ format)
244
+ const tpMatch = cleaned.match(/(?:^|\n)Thinking Process:/);
245
+ if (tpMatch !== null) {
246
+ const after = cleaned.slice((tpMatch.index ?? 0) + tpMatch[0].length);
247
+ const heading = after.match(/\n(#\s|\*\*)/);
248
+ cleaned =
249
+ heading?.index !== undefined ? after.slice(heading.index).trim() : '';
250
+ }
251
+ // If cleaning leaves nothing usable, fall back to template
252
+ if (cleaned.length > 50) {
253
+ return cleaned;
254
+ }
255
+ console.warn('[artifact-generation] Thinking leakage detected or response too short — falling back to template');
256
+ return formatSummary(sections, recording.duration, recording.id);
232
257
  }
233
258
  /**
234
259
  * Format sections into a readable markdown summary (template fallback).
@@ -0,0 +1,94 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { capConfigSchema } from '../0_types.js';
5
+ function expandPath(path) {
6
+ if (path.startsWith('~/')) {
7
+ return join(homedir(), path.slice(2));
8
+ }
9
+ return path;
10
+ }
11
+ async function parseCapRecording(capDirPath) {
12
+ try {
13
+ const metaPath = join(capDirPath, 'recording-meta.json');
14
+ const metaContent = await readFile(metaPath, 'utf-8');
15
+ const meta = JSON.parse(metaContent);
16
+ if (!meta.segments ||
17
+ !Array.isArray(meta.segments) ||
18
+ meta.segments.length === 0) {
19
+ throw new Error(`Invalid metadata in ${capDirPath}: missing or empty segments array`);
20
+ }
21
+ const firstSegment = meta.segments[0];
22
+ const videoPath = firstSegment.display?.path
23
+ ? join(capDirPath, firstSegment.display.path)
24
+ : null;
25
+ // we fked up cuz we have mic but also system_audio.ogg
26
+ const micAudio = firstSegment.mic?.path
27
+ ? join(capDirPath, firstSegment.mic.path)
28
+ : null;
29
+ const systemAudio = firstSegment.system_audio?.path
30
+ ? join(capDirPath, firstSegment.system_audio.path)
31
+ : null;
32
+ const audioToStat = micAudio || systemAudio;
33
+ if (!audioToStat) {
34
+ console.log(`Skipping ${capDirPath}: none audio track found`);
35
+ return null;
36
+ }
37
+ const stats = await stat(audioToStat);
38
+ const capturedAt = stats.mtime;
39
+ const recordingId = capDirPath.split('/').pop() || 'unknown';
40
+ return {
41
+ id: recordingId,
42
+ source: {
43
+ type: 'cap',
44
+ originalPath: capDirPath,
45
+ metadata: meta,
46
+ },
47
+ videoPath,
48
+ audioMicPath: micAudio ? micAudio : null,
49
+ audioSystemPath: systemAudio ? systemAudio : null,
50
+ duration: 0,
51
+ capturedAt,
52
+ };
53
+ }
54
+ catch (error) {
55
+ if (error.code === 'ENOENT') {
56
+ throw new Error(`Recording directory or files not found: ${capDirPath}`);
57
+ }
58
+ if (error.name === 'SyntaxError') {
59
+ throw new Error(`Invalid JSON in recording-meta.json at ${capDirPath}`);
60
+ }
61
+ throw new Error(`Failed to parse recording at ${capDirPath}: ${error.message}`);
62
+ }
63
+ }
64
+ export function createCapSource(config = {}) {
65
+ const parsedConfig = capConfigSchema.parse(config);
66
+ const recordingsPath = expandPath(parsedConfig.recordingsPath);
67
+ const innerList = async (limit = 10) => {
68
+ try {
69
+ //
70
+ // 7 directories, 5 files
71
+ const entries = await readdir(recordingsPath, { withFileTypes: true });
72
+ const capDirs = entries.filter((entry) => entry.isDirectory() && entry.name.endsWith('.cap'));
73
+ const recordings = await Promise.allSettled(capDirs.map(async (dir) => parseCapRecording(join(recordingsPath, dir.name))));
74
+ // logging errors
75
+ console.log(recordings
76
+ .filter((p) => p.status === 'rejected')
77
+ .map((p) => p.reason + '\n'));
78
+ return recordings
79
+ .filter((p) => p.status === 'fulfilled')
80
+ .map((x) => x.value)
81
+ .filter((r) => r !== null)
82
+ .sort((a, b) => b.capturedAt.getTime() - a.capturedAt.getTime())
83
+ .slice(0, limit);
84
+ }
85
+ catch (error) {
86
+ console.error('Failed to list Cap recordings:', error);
87
+ return [];
88
+ }
89
+ };
90
+ return {
91
+ getLatestRecording: () => innerList(1).then((recordings) => recordings[0] ?? null),
92
+ listRecordings: innerList,
93
+ };
94
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Escribano - Intelligence Adapter (Ollama)
3
+ *
4
+ * Implements IntelligenceService using Ollama REST API
5
+ */
6
+ import { readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ export function createIntelligenceService(config) {
9
+ return {
10
+ classify: (transcript) => classifyWithOllama(transcript, config),
11
+ extractMetadata: (transcript, classification) => extractMetadata(transcript, classification, config),
12
+ generate: (artifactType, context) => generateArtifact(artifactType, context, config),
13
+ };
14
+ }
15
+ async function checkOllamaHealth() {
16
+ try {
17
+ const response = await fetch('http://localhost:11434/api/tags');
18
+ if (!response.ok) {
19
+ throw new Error('Ollama API not accessible');
20
+ }
21
+ const data = await response.json();
22
+ console.log('✓ Ollama is running and accessible');
23
+ console.log(` Available models: ${data.models?.length || 0}`);
24
+ }
25
+ catch (error) {
26
+ console.error('✗ Ollama is not running or not accessible');
27
+ console.error(' Error:', error.message);
28
+ console.error('');
29
+ console.error('Please start Ollama:');
30
+ console.error(' brew install ollama');
31
+ console.error(' ollama pull qwen3:32b');
32
+ console.error(' ollama serve');
33
+ console.error('');
34
+ throw new Error('Ollama service required for classification');
35
+ }
36
+ }
37
+ async function classifyWithOllama(transcript, config) {
38
+ console.log('Classifying transcript with Ollama...');
39
+ const tick = setInterval(() => {
40
+ process.stdout.write('.');
41
+ }, 1000);
42
+ await checkOllamaHealth();
43
+ const prompt = loadClassifyPrompt(transcript);
44
+ const raw = await callOllama(prompt, config, { expectJson: true });
45
+ clearInterval(tick);
46
+ console.log('\nClassification completed.');
47
+ const classification = {
48
+ meeting: raw.meeting * (raw.meeting <= 1 ? 100 : 1) || 0,
49
+ debugging: raw.debugging * (raw.debugging <= 1 ? 100 : 1) || 0,
50
+ tutorial: raw.tutorial * (raw.tutorial <= 1 ? 100 : 1) || 0,
51
+ learning: raw.learning * (raw.learning <= 1 ? 100 : 1) || 0,
52
+ working: raw.working * (raw.working <= 1 ? 100 : 1) || 0,
53
+ };
54
+ return classification;
55
+ }
56
+ function loadClassifyPrompt(transcript) {
57
+ const promptPath = join(process.cwd(), 'prompts', 'classify.md');
58
+ let prompt = readFileSync(promptPath, 'utf-8');
59
+ const segmentsText = transcript.segments
60
+ .map((seg) => `[seg-${seg.id}] [${seg.start}s - ${seg.end}s] ${seg.text}`)
61
+ .join('\n');
62
+ prompt = prompt.replace('{{TRANSCRIPT_ALL}}', transcript.fullText);
63
+ prompt = prompt.replace('{{TRANSCRIPT_SEGMENTS}}', segmentsText);
64
+ return prompt;
65
+ }
66
+ async function callOllama(prompt, config, options = { expectJson: true }) {
67
+ const { endpoint, model, maxRetries, timeout } = config;
68
+ let lastError = null;
69
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
70
+ try {
71
+ const controller = new AbortController();
72
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
73
+ const response = await fetch(endpoint, {
74
+ method: 'POST',
75
+ headers: {
76
+ 'Content-Type': 'application/json',
77
+ },
78
+ body: JSON.stringify({
79
+ model,
80
+ messages: [
81
+ {
82
+ role: 'system',
83
+ content: options.expectJson
84
+ ? 'You are a JSON-only output system. Output ONLY valid JSON, no other text.'
85
+ : 'You are a helpful assistant that generates high-quality markdown documentation.',
86
+ },
87
+ {
88
+ role: 'user',
89
+ content: prompt,
90
+ },
91
+ ],
92
+ stream: false,
93
+ ...(options.expectJson && { format: 'json' }),
94
+ }),
95
+ signal: controller.signal,
96
+ });
97
+ clearTimeout(timeoutId);
98
+ if (!response.ok) {
99
+ throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
100
+ }
101
+ const data = await response.json();
102
+ if (!data.done || data.done_reason !== 'stop') {
103
+ throw new Error(`Incomplete response: done=${data.done}, reason=${data.done_reason}`);
104
+ }
105
+ const content = data.message.content;
106
+ if (options.expectJson) {
107
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
108
+ if (!jsonMatch)
109
+ throw new Error('No JSON found in response');
110
+ return JSON.parse(jsonMatch[0]);
111
+ }
112
+ return content;
113
+ }
114
+ catch (error) {
115
+ lastError = error;
116
+ if (error instanceof Error && error.name === 'AbortError') {
117
+ console.log(`Attempt ${attempt}/${maxRetries}: Request timed out, retrying...`);
118
+ }
119
+ else {
120
+ console.log(`Attempt ${attempt}/${maxRetries}: Request failed, retrying...`);
121
+ }
122
+ if (attempt < maxRetries) {
123
+ await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
124
+ }
125
+ }
126
+ }
127
+ throw new Error(`Request failed after ${maxRetries} retries: ${lastError?.message}`);
128
+ }
129
+ async function extractMetadata(transcript, classification, config) {
130
+ const prompt = loadMetadataPrompt(transcript, classification);
131
+ const raw = await callOllama(prompt, config, { expectJson: true });
132
+ return {
133
+ speakers: raw.speakers || [],
134
+ keyMoments: raw.keyMoments || [],
135
+ actionItems: raw.actionItems || [],
136
+ technicalTerms: raw.technicalTerms || [],
137
+ codeSnippets: raw.codeSnippets || [],
138
+ };
139
+ }
140
+ function loadMetadataPrompt(transcript, classification) {
141
+ const promptPath = join(process.cwd(), 'prompts', 'extract-metadata.md');
142
+ let prompt = readFileSync(promptPath, 'utf-8');
143
+ const classificationSummary = Object.entries(classification)
144
+ .filter(([_, score]) => score >= 25)
145
+ .map(([type, score]) => `${type}: ${score}%`)
146
+ .join(', ');
147
+ const segmentsText = transcript.segments
148
+ .map((seg) => `[${seg.start}s - ${seg.end}s] ${seg.text}`)
149
+ .join('\n');
150
+ prompt = prompt.replace('{{CLASSIFICATION_SUMMARY}}', classificationSummary);
151
+ prompt = prompt.replace('{{TRANSCRIPT_SEGMENTS}}', segmentsText);
152
+ prompt = prompt.replace('{{TRANSCRIPT_ALL}}', transcript.fullText);
153
+ return prompt;
154
+ }
155
+ function parseMetadataJson(content) {
156
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
157
+ if (!jsonMatch) {
158
+ throw new Error('No JSON object found in metadata extraction response');
159
+ }
160
+ const parsed = JSON.parse(jsonMatch[0]);
161
+ return {
162
+ speakers: parsed.speakers || [],
163
+ keyMoments: parsed.keyMoments || [],
164
+ actionItems: parsed.actionItems || [],
165
+ technicalTerms: parsed.technicalTerms || [],
166
+ codeSnippets: parsed.codeSnippets || [],
167
+ };
168
+ }
169
+ async function generateArtifact(artifactType, context, config) {
170
+ const prompt = loadArtifactPrompt(artifactType, context);
171
+ const response = await callOllama(prompt, config, { expectJson: false });
172
+ return response;
173
+ }
174
+ function loadArtifactPrompt(artifactType, context) {
175
+ const promptPath = join(process.cwd(), 'prompts', `${artifactType}.md`);
176
+ let prompt = readFileSync(promptPath, 'utf-8');
177
+ prompt = prompt.replace('{{TRANSCRIPT_ALL}}', context.transcript.fullText);
178
+ const segmentsText = context.transcript.segments
179
+ .map((seg) => `[${seg.start}s - ${seg.end}s] ${seg.text}`)
180
+ .join('\n');
181
+ prompt = prompt.replace('{{TRANSCRIPT_SEGMENTS}}', segmentsText);
182
+ const classificationSummary = Object.entries(context.classification)
183
+ .filter(([_, score]) => score >= 25)
184
+ .map(([type, score]) => `${type}: ${score}%`)
185
+ .join(', ');
186
+ prompt = prompt.replace('{{CLASSIFICATION_SUMMARY}}', classificationSummary);
187
+ if (context.metadata) {
188
+ prompt = prompt.replace('{{SPEAKERS}}', JSON.stringify(context.metadata.speakers || [], null, 2));
189
+ prompt = prompt.replace('{{KEY_MOMENTS}}', JSON.stringify(context.metadata.keyMoments || [], null, 2));
190
+ prompt = prompt.replace('{{ACTION_ITEMS}}', JSON.stringify(context.metadata.actionItems || [], null, 2));
191
+ prompt = prompt.replace('{{TECHNICAL_TERMS}}', JSON.stringify(context.metadata.technicalTerms || [], null, 2));
192
+ prompt = prompt.replace('{{CODE_SNIPPETS}}', JSON.stringify(context.metadata.codeSnippets || [], null, 2));
193
+ }
194
+ else {
195
+ prompt = prompt.replace('{{SPEAKERS}}', 'N/A');
196
+ prompt = prompt.replace('{{KEY_MOMENTS}}', 'N/A');
197
+ prompt = prompt.replace('{{ACTION_ITEMS}}', 'N/A');
198
+ prompt = prompt.replace('{{TECHNICAL_TERMS}}', 'N/A');
199
+ prompt = prompt.replace('{{CODE_SNIPPETS}}', 'N/A');
200
+ }
201
+ return prompt;
202
+ }