escribano 0.1.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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -0
  3. package/dist/0_types.js +279 -0
  4. package/dist/actions/classify-session.js +77 -0
  5. package/dist/actions/create-contexts.js +44 -0
  6. package/dist/actions/create-topic-blocks.js +68 -0
  7. package/dist/actions/extract-metadata.js +24 -0
  8. package/dist/actions/generate-artifact-v3.js +296 -0
  9. package/dist/actions/generate-artifact.js +61 -0
  10. package/dist/actions/generate-summary-v3.js +260 -0
  11. package/dist/actions/outline-index.js +204 -0
  12. package/dist/actions/process-recording-v2.js +494 -0
  13. package/dist/actions/process-recording-v3.js +412 -0
  14. package/dist/actions/process-session.js +183 -0
  15. package/dist/actions/publish-summary-v3.js +303 -0
  16. package/dist/actions/sync-to-outline.js +196 -0
  17. package/dist/adapters/audio.silero.adapter.js +69 -0
  18. package/dist/adapters/cap.adapter.js +94 -0
  19. package/dist/adapters/capture.cap.adapter.js +107 -0
  20. package/dist/adapters/capture.filesystem.adapter.js +124 -0
  21. package/dist/adapters/embedding.ollama.adapter.js +141 -0
  22. package/dist/adapters/intelligence.adapter.js +202 -0
  23. package/dist/adapters/intelligence.mlx.adapter.js +395 -0
  24. package/dist/adapters/intelligence.ollama.adapter.js +741 -0
  25. package/dist/adapters/publishing.outline.adapter.js +75 -0
  26. package/dist/adapters/storage.adapter.js +81 -0
  27. package/dist/adapters/storage.fs.adapter.js +83 -0
  28. package/dist/adapters/transcription.whisper.adapter.js +206 -0
  29. package/dist/adapters/video.ffmpeg.adapter.js +405 -0
  30. package/dist/adapters/whisper.adapter.js +168 -0
  31. package/dist/batch-context.js +329 -0
  32. package/dist/db/helpers.js +50 -0
  33. package/dist/db/index.js +95 -0
  34. package/dist/db/migrate.js +80 -0
  35. package/dist/db/repositories/artifact.sqlite.js +77 -0
  36. package/dist/db/repositories/cluster.sqlite.js +92 -0
  37. package/dist/db/repositories/context.sqlite.js +75 -0
  38. package/dist/db/repositories/index.js +10 -0
  39. package/dist/db/repositories/observation.sqlite.js +70 -0
  40. package/dist/db/repositories/recording.sqlite.js +56 -0
  41. package/dist/db/repositories/subject.sqlite.js +64 -0
  42. package/dist/db/repositories/topic-block.sqlite.js +45 -0
  43. package/dist/db/types.js +4 -0
  44. package/dist/domain/classification.js +60 -0
  45. package/dist/domain/context.js +97 -0
  46. package/dist/domain/index.js +2 -0
  47. package/dist/domain/observation.js +17 -0
  48. package/dist/domain/recording.js +41 -0
  49. package/dist/domain/segment.js +93 -0
  50. package/dist/domain/session.js +93 -0
  51. package/dist/domain/time-range.js +38 -0
  52. package/dist/domain/transcript.js +79 -0
  53. package/dist/index.js +173 -0
  54. package/dist/pipeline/context.js +162 -0
  55. package/dist/pipeline/events.js +2 -0
  56. package/dist/prerequisites.js +226 -0
  57. package/dist/scripts/rebuild-index.js +53 -0
  58. package/dist/scripts/seed-fixtures.js +290 -0
  59. package/dist/services/activity-segmentation.js +333 -0
  60. package/dist/services/activity-segmentation.test.js +191 -0
  61. package/dist/services/app-normalization.js +212 -0
  62. package/dist/services/cluster-merge.js +69 -0
  63. package/dist/services/clustering.js +237 -0
  64. package/dist/services/debug.js +58 -0
  65. package/dist/services/frame-sampling.js +318 -0
  66. package/dist/services/signal-extraction.js +106 -0
  67. package/dist/services/subject-grouping.js +342 -0
  68. package/dist/services/temporal-alignment.js +99 -0
  69. package/dist/services/vlm-enrichment.js +84 -0
  70. package/dist/services/vlm-service.js +130 -0
  71. package/dist/stats/index.js +3 -0
  72. package/dist/stats/observer.js +65 -0
  73. package/dist/stats/repository.js +36 -0
  74. package/dist/stats/resource-tracker.js +86 -0
  75. package/dist/stats/types.js +1 -0
  76. package/dist/test-classification-prompts.js +181 -0
  77. package/dist/tests/cap.adapter.test.js +75 -0
  78. package/dist/tests/capture.cap.adapter.test.js +69 -0
  79. package/dist/tests/classify-session.test.js +140 -0
  80. package/dist/tests/db/repositories.test.js +243 -0
  81. package/dist/tests/domain/time-range.test.js +31 -0
  82. package/dist/tests/integration.test.js +84 -0
  83. package/dist/tests/intelligence.adapter.test.js +102 -0
  84. package/dist/tests/intelligence.ollama.adapter.test.js +178 -0
  85. package/dist/tests/process-v2.test.js +90 -0
  86. package/dist/tests/services/clustering.test.js +112 -0
  87. package/dist/tests/services/frame-sampling.test.js +152 -0
  88. package/dist/tests/utils/ocr.test.js +76 -0
  89. package/dist/tests/utils/parallel.test.js +57 -0
  90. package/dist/tests/visual-observer.test.js +175 -0
  91. package/dist/utils/id-normalization.js +15 -0
  92. package/dist/utils/index.js +9 -0
  93. package/dist/utils/model-detector.js +154 -0
  94. package/dist/utils/ocr.js +80 -0
  95. package/dist/utils/parallel.js +32 -0
  96. package/migrations/001_initial.sql +109 -0
  97. package/migrations/002_clusters.sql +41 -0
  98. package/migrations/003_observations_vlm_fields.sql +14 -0
  99. package/migrations/004_observations_unique.sql +18 -0
  100. package/migrations/005_processing_stats.sql +29 -0
  101. package/migrations/006_vlm_raw_response.sql +6 -0
  102. package/migrations/007_subjects.sql +23 -0
  103. package/migrations/008_artifacts_recording.sql +6 -0
  104. package/migrations/009_artifact_subjects.sql +10 -0
  105. package/package.json +82 -0
  106. package/prompts/action-items.md +55 -0
  107. package/prompts/blog-draft.md +54 -0
  108. package/prompts/blog-research.md +87 -0
  109. package/prompts/card.md +54 -0
  110. package/prompts/classify-segment.md +38 -0
  111. package/prompts/classify.md +37 -0
  112. package/prompts/code-snippets.md +163 -0
  113. package/prompts/extract-metadata.md +149 -0
  114. package/prompts/notes.md +83 -0
  115. package/prompts/runbook.md +123 -0
  116. package/prompts/standup.md +50 -0
  117. package/prompts/step-by-step.md +125 -0
  118. package/prompts/subject-grouping.md +31 -0
  119. package/prompts/summary-v3.md +89 -0
  120. package/prompts/summary.md +77 -0
  121. package/prompts/topic-classifier.md +24 -0
  122. package/prompts/topic-extract.md +13 -0
  123. package/prompts/vlm-batch.md +21 -0
  124. package/prompts/vlm-single.md +19 -0
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Escribano - Generate Summary V3
3
+ *
4
+ * Generates a work session summary from V3 processed TopicBlocks using LLM.
5
+ */
6
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
7
+ import { homedir } from 'node:os';
8
+ import path from 'node:path';
9
+ import { log } from '../pipeline/context.js';
10
+ /**
11
+ * Generate a work session summary artifact from processed TopicBlocks.
12
+ *
13
+ * @param recordingId - Recording ID to generate summary for
14
+ * @param repos - Database repositories
15
+ * @param intelligence - Intelligence service for LLM generation
16
+ * @param options - Generation options
17
+ * @returns Generated artifact
18
+ */
19
+ export async function generateSummaryV3(recordingId, repos, intelligence, options) {
20
+ log('info', `[Summary V3] Generating summary for recording ${recordingId}...`);
21
+ // Get the recording
22
+ const recording = repos.recordings.findById(recordingId);
23
+ if (!recording) {
24
+ throw new Error(`Recording ${recordingId} not found`);
25
+ }
26
+ // Get TopicBlocks for this recording
27
+ const topicBlocks = repos.topicBlocks.findByRecording(recordingId);
28
+ if (topicBlocks.length === 0) {
29
+ throw new Error(`No TopicBlocks found for recording ${recordingId}. Run process-v3 first.`);
30
+ }
31
+ log('info', `[Summary V3] Found ${topicBlocks.length} TopicBlocks`);
32
+ // Build sections from TopicBlocks
33
+ const sections = [];
34
+ for (const block of topicBlocks) {
35
+ const classification = JSON.parse(block.classification || '{}');
36
+ sections.push({
37
+ activity: classification.activity_type || 'unknown',
38
+ duration: block.duration || classification.duration || 0,
39
+ description: classification.key_description || '',
40
+ transcript: classification.combined_transcript || '',
41
+ apps: classification.apps || [],
42
+ topics: classification.topics || [],
43
+ startTime: classification.start_time || 0,
44
+ endTime: classification.end_time || 0,
45
+ });
46
+ }
47
+ // Sort by start time
48
+ sections.sort((a, b) => a.startTime - b.startTime);
49
+ log('info', `[Summary V3] Building summary from ${sections.length} sections...`);
50
+ // Generate summary using LLM or template
51
+ let summaryContent;
52
+ const skipLlm = options.useTemplate || process.env.ESCRIBANO_SKIP_LLM === 'true';
53
+ if (skipLlm) {
54
+ log('info', '[Summary V3] Using template fallback (LLM skipped)');
55
+ summaryContent = formatSummary(sections, recording.duration, recording.id);
56
+ }
57
+ else {
58
+ log('info', '[Summary V3] Generating with LLM...');
59
+ summaryContent = await generateLlmSummary(sections, recording, intelligence);
60
+ }
61
+ // Ensure output directory exists
62
+ const outputDir = options.outputDir || path.join(homedir(), '.escribano', 'artifacts');
63
+ await mkdir(outputDir, { recursive: true });
64
+ // Generate filename
65
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
66
+ const fileName = `${recordingId}-summary-${timestamp}.md`;
67
+ const filePath = path.join(outputDir, fileName);
68
+ // Write to file
69
+ await writeFile(filePath, summaryContent, 'utf-8');
70
+ log('info', `[Summary V3] Summary saved to: ${filePath}`);
71
+ return {
72
+ id: `summary-${recordingId}-${Date.now()}`,
73
+ recordingId,
74
+ content: summaryContent,
75
+ filePath,
76
+ createdAt: new Date(),
77
+ };
78
+ }
79
+ /**
80
+ * Generate summary using LLM.
81
+ */
82
+ async function generateLlmSummary(sections, recording, intelligence) {
83
+ // Read prompt template
84
+ const promptPath = path.join(process.cwd(), 'prompts', 'summary-v3.md');
85
+ let promptTemplate;
86
+ try {
87
+ promptTemplate = await readFile(promptPath, 'utf-8');
88
+ }
89
+ catch {
90
+ // Fallback if prompt file not found
91
+ log('warn', '[Summary V3] Prompt template not found, using default');
92
+ promptTemplate = `Generate a summary of this work session.\n\nSession Duration: {{SESSION_DURATION}} minutes\nActivities: {{ACTIVITY_COUNT}}\n\n{{ACTIVITY_TIMELINE}}`;
93
+ }
94
+ // Extract unique apps from all sections
95
+ const allApps = new Set();
96
+ for (const section of sections) {
97
+ for (const app of section.apps) {
98
+ allApps.add(app);
99
+ }
100
+ }
101
+ const appsList = [...allApps].sort().join(', ') || 'None detected';
102
+ // Extract URLs from all descriptions
103
+ const urlPattern = /https?:\/\/[^\s<>"{}|\\^`[\]]+/gi;
104
+ const allUrls = new Set();
105
+ for (const section of sections) {
106
+ const matches = section.description.match(urlPattern);
107
+ if (matches) {
108
+ for (const url of matches) {
109
+ // Clean up trailing punctuation
110
+ const cleanUrl = url.replace(/[.,;:!?)\]]+$/, '');
111
+ allUrls.add(cleanUrl);
112
+ }
113
+ }
114
+ // Also check transcripts for URLs
115
+ const transcriptMatches = section.transcript.match(urlPattern);
116
+ if (transcriptMatches) {
117
+ for (const url of transcriptMatches) {
118
+ const cleanUrl = url.replace(/[.,;:!?)\]]+$/, '');
119
+ allUrls.add(cleanUrl);
120
+ }
121
+ }
122
+ }
123
+ const urlsList = [...allUrls]
124
+ .sort()
125
+ .map((url) => `- ${url}`)
126
+ .join('\n') || 'None detected';
127
+ // Build activity timeline
128
+ const activityTimeline = sections
129
+ .map((section, i) => {
130
+ const startMin = Math.round(section.startTime / 60);
131
+ const durationMin = Math.round(section.duration / 60);
132
+ const startTimeStr = `${Math.floor(section.startTime / 60)}:${Math.floor(section.startTime % 60)
133
+ .toString()
134
+ .padStart(2, '0')}`;
135
+ const endTimeStr = `${Math.floor(section.endTime / 60)}:${Math.floor(section.endTime % 60)
136
+ .toString()
137
+ .padStart(2, '0')}`;
138
+ return `### Segment ${i + 1}: ${section.activity} (${startTimeStr} - ${endTimeStr}, ${durationMin} minutes)
139
+
140
+ **Description:**
141
+ ${section.description || 'No description available'}
142
+
143
+ **Apps:** ${section.apps.join(', ') || 'None detected'}
144
+ **Topics:** ${section.topics.join(', ') || 'None detected'}
145
+
146
+ ${section.transcript ? `**Audio Transcript:**\n${section.transcript}` : '*No audio transcript*'}
147
+ `;
148
+ })
149
+ .join('\n---\n\n');
150
+ // Replace template variables
151
+ const prompt = promptTemplate
152
+ .replace('{{SESSION_DURATION}}', String(Math.round(recording.duration / 60)))
153
+ .replace('{{SESSION_DATE}}', new Date(recording.captured_at).toLocaleDateString())
154
+ .replace('{{ACTIVITY_COUNT}}', String(sections.length))
155
+ .replace('{{ACTIVITY_TIMELINE}}', activityTimeline)
156
+ .replace('{{APPS_LIST}}', appsList)
157
+ .replace('{{URLS_LIST}}', urlsList);
158
+ // Call LLM
159
+ const result = await intelligence.generateText(prompt, {
160
+ expectJson: false,
161
+ });
162
+ return result;
163
+ }
164
+ /**
165
+ * Format sections into a readable markdown summary (template fallback).
166
+ */
167
+ function formatSummary(sections, totalDuration, recordingId) {
168
+ const durationMinutes = Math.round(totalDuration / 60);
169
+ const now = new Date().toLocaleString();
170
+ // Extract unique apps from all sections
171
+ const allApps = new Set();
172
+ for (const section of sections) {
173
+ for (const app of section.apps) {
174
+ allApps.add(app);
175
+ }
176
+ }
177
+ const appsList = [...allApps].sort().join(', ') || 'None detected';
178
+ // Extract URLs from all descriptions
179
+ const urlPattern = /https?:\/\/[^\s<>"{}|\\^`[\]]+/gi;
180
+ const allUrls = new Set();
181
+ for (const section of sections) {
182
+ const matches = section.description.match(urlPattern);
183
+ if (matches) {
184
+ for (const url of matches) {
185
+ const cleanUrl = url.replace(/[.,;:!?)\]]+$/, '');
186
+ allUrls.add(cleanUrl);
187
+ }
188
+ }
189
+ const transcriptMatches = section.transcript.match(urlPattern);
190
+ if (transcriptMatches) {
191
+ for (const url of transcriptMatches) {
192
+ const cleanUrl = url.replace(/[.,;:!?)\]]+$/, '');
193
+ allUrls.add(cleanUrl);
194
+ }
195
+ }
196
+ }
197
+ const urlsList = [...allUrls]
198
+ .sort()
199
+ .map((url) => `- ${url}`)
200
+ .join('\n') || 'None detected';
201
+ let summary = `# Work Session Summary
202
+
203
+ **Generated:** ${now}
204
+ **Recording ID:** ${recordingId}
205
+ **Session Duration:** ${durationMinutes} minutes
206
+ **Activities Identified:** ${sections.length}
207
+
208
+ ## Overview
209
+
210
+ This work session consisted of ${sections.length} distinct activities over ${durationMinutes} minutes.
211
+
212
+ ## Apps & Pages Used
213
+
214
+ ### Applications
215
+ ${appsList}
216
+
217
+ ### Websites Visited
218
+ ${urlsList}
219
+
220
+ `;
221
+ // Activity breakdown
222
+ summary += `## Activities
223
+
224
+ `;
225
+ for (let i = 0; i < sections.length; i++) {
226
+ const section = sections[i];
227
+ const startMin = Math.round(section.startTime / 60);
228
+ const durationMin = Math.round(section.duration / 60);
229
+ summary += `### ${i + 1}. ${section.activity.charAt(0).toUpperCase() + section.activity.slice(1)}
230
+
231
+ - **Time:** ${startMin} minutes into session
232
+ - **Duration:** ${durationMin} minutes
233
+ - **Apps:** ${section.apps.join(', ') || 'None detected'}
234
+ - **Topics:** ${section.topics.join(', ') || 'None detected'}
235
+
236
+ **What was happening:**
237
+ ${section.description || '*No visual description available*'}
238
+
239
+ `;
240
+ if (section.transcript.trim()) {
241
+ summary += `**Audio transcript:**
242
+ \`\`\`
243
+ ${section.transcript}
244
+ \`\`\`
245
+
246
+ `;
247
+ }
248
+ summary += `---
249
+
250
+ `;
251
+ }
252
+ summary += `## Summary Statistics
253
+
254
+ - Total activities: ${sections.length}
255
+ - Total duration: ${durationMinutes} minutes
256
+ - Activities with audio: ${sections.filter((s) => s.transcript.trim()).length}
257
+
258
+ `;
259
+ return summary;
260
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Escribano - Outline Index Management
3
+ *
4
+ * Maintains a global session index document in Outline.
5
+ */
6
+ import { log } from '../pipeline/context.js';
7
+ /**
8
+ * Update the global session index in Outline.
9
+ *
10
+ * Creates or updates a master index document listing all published
11
+ * recording summaries with links to their respective documents.
12
+ *
13
+ * @param repos - Database repositories
14
+ * @param publishing - Outline publishing service
15
+ * @param options - Index options
16
+ * @returns URL of the index document
17
+ */
18
+ export async function updateGlobalIndex(repos, publishing, options = {}) {
19
+ const collectionName = options.collectionName ?? 'Escribano Sessions';
20
+ const indexTitle = options.indexTitle ?? '📋 Session Summaries Index';
21
+ log('info', `[Index] Updating global index...`);
22
+ // 1. Ensure collection exists
23
+ const collection = await publishing.ensureCollection(collectionName);
24
+ // 2. Get all published recordings from DB
25
+ const recordings = repos.recordings.findByStatus('published');
26
+ // 3. Get topic blocks for all recordings
27
+ const recordingsWithBlocks = recordings.map((recording) => ({
28
+ recording,
29
+ blocks: repos.topicBlocks.findByRecording(recording.id),
30
+ }));
31
+ // 4. Build index content
32
+ const content = buildIndexContent(recordingsWithBlocks, indexTitle);
33
+ // 5. Check for existing index document
34
+ const existing = await publishing.findDocumentByTitle(collection.id, indexTitle);
35
+ // 6. Create or update index
36
+ let document;
37
+ if (existing) {
38
+ log('info', `[Index] Updating existing index`);
39
+ await publishing.updateDocument(existing.id, {
40
+ title: indexTitle,
41
+ content,
42
+ });
43
+ document = existing;
44
+ }
45
+ else {
46
+ log('info', `[Index] Creating new index`);
47
+ document = await publishing.createDocument({
48
+ collectionId: collection.id,
49
+ title: indexTitle,
50
+ content,
51
+ publish: true,
52
+ });
53
+ }
54
+ log('info', `[Index] Index updated: ${document.url}`);
55
+ return { url: document.url, documentId: document.id };
56
+ }
57
+ /**
58
+ * Build the index document content.
59
+ */
60
+ function buildIndexContent(recordings, title) {
61
+ const now = new Date();
62
+ let content = `# ${title}\n\n`;
63
+ content += `*Last updated: ${now.toLocaleString()}*\n\n`;
64
+ // Group by month
65
+ const grouped = groupByMonth(recordings);
66
+ for (const [month, monthRecordings] of Object.entries(grouped)) {
67
+ content += `## ${month}\n\n`;
68
+ content += buildMonthTable(monthRecordings);
69
+ content += `\n`;
70
+ }
71
+ // Add summary stats
72
+ content += `\n---\n\n`;
73
+ content += `## Statistics\n\n`;
74
+ content += `- **Total sessions:** ${recordings.length}\n`;
75
+ content += `- **Total duration:** ${formatTotalDuration(recordings)}\n`;
76
+ content += `- **Last updated:** ${now.toLocaleString()}\n`;
77
+ return content;
78
+ }
79
+ /**
80
+ * Group recordings by month.
81
+ */
82
+ function groupByMonth(recordings) {
83
+ const grouped = {};
84
+ for (const item of recordings) {
85
+ const date = new Date(item.recording.captured_at);
86
+ const month = date.toLocaleString('default', {
87
+ month: 'long',
88
+ year: 'numeric',
89
+ });
90
+ if (!grouped[month]) {
91
+ grouped[month] = [];
92
+ }
93
+ grouped[month].push(item);
94
+ }
95
+ // Sort months descending (newest first)
96
+ const sortedMonths = Object.keys(grouped).sort((a, b) => {
97
+ const dateA = new Date(grouped[a][0].recording.captured_at);
98
+ const dateB = new Date(grouped[b][0].recording.captured_at);
99
+ return dateB.getTime() - dateA.getTime();
100
+ });
101
+ const sorted = {};
102
+ for (const month of sortedMonths) {
103
+ sorted[month] = grouped[month];
104
+ }
105
+ return sorted;
106
+ }
107
+ /**
108
+ * Build a markdown table for a month's recordings.
109
+ */
110
+ function buildMonthTable(recordings) {
111
+ // Sort by date descending
112
+ const sorted = [...recordings].sort((a, b) => {
113
+ const dateA = new Date(a.recording.captured_at).getTime();
114
+ const dateB = new Date(b.recording.captured_at).getTime();
115
+ return dateB - dateA;
116
+ });
117
+ let table = `| Date | Activities | Duration | Links |\n`;
118
+ table += `|------|------------|----------|-------|\n`;
119
+ for (const { recording, blocks } of sorted) {
120
+ const date = new Date(recording.captured_at);
121
+ const dateStr = date.toLocaleDateString();
122
+ const activities = extractActivities(blocks).slice(0, 3).join(', ') || 'Unknown';
123
+ const duration = formatDuration(recording.duration);
124
+ // Get all format variant links
125
+ const outlineUrls = extractOutlineUrls(recording);
126
+ let links = '—';
127
+ if (outlineUrls.length > 0) {
128
+ links = outlineUrls
129
+ .map((item) => `[${item.format}](${item.url})`)
130
+ .join(' · ');
131
+ }
132
+ table += `| ${dateStr} | ${activities} | ${duration} | ${links} |\n`;
133
+ }
134
+ return table;
135
+ }
136
+ /**
137
+ * Extract activities from topic blocks.
138
+ */
139
+ function extractActivities(blocks) {
140
+ const activityCounts = new Map();
141
+ for (const block of blocks) {
142
+ try {
143
+ const classification = JSON.parse(block.classification || '{}');
144
+ const activity = classification.activity_type;
145
+ if (activity) {
146
+ activityCounts.set(activity, (activityCounts.get(activity) ?? 0) + 1);
147
+ }
148
+ }
149
+ catch {
150
+ // Ignore invalid JSON
151
+ }
152
+ }
153
+ return Array.from(activityCounts.entries())
154
+ .sort((a, b) => b[1] - a[1])
155
+ .map(([activity]) => activity.charAt(0).toUpperCase() + activity.slice(1));
156
+ }
157
+ /**
158
+ * Format duration in human-readable form.
159
+ */
160
+ function formatDuration(seconds) {
161
+ const minutes = Math.round(seconds / 60);
162
+ if (minutes < 60) {
163
+ return `${minutes}m`;
164
+ }
165
+ const hours = Math.floor(minutes / 60);
166
+ const remainingMinutes = minutes % 60;
167
+ if (remainingMinutes === 0) {
168
+ return `${hours}h`;
169
+ }
170
+ return `${hours}h ${remainingMinutes}m`;
171
+ }
172
+ /**
173
+ * Calculate total duration of all recordings.
174
+ */
175
+ function formatTotalDuration(recordings) {
176
+ const totalSeconds = recordings.reduce((sum, r) => sum + r.recording.duration, 0);
177
+ return formatDuration(totalSeconds);
178
+ }
179
+ /**
180
+ * Extract Outline URLs from recording metadata.
181
+ * Returns all format variants if available, otherwise the single outline URL.
182
+ */
183
+ function extractOutlineUrls(recording) {
184
+ try {
185
+ const metadata = recording.source_metadata
186
+ ? JSON.parse(recording.source_metadata)
187
+ : {};
188
+ // Check for multiple format variants (new structure)
189
+ if (metadata.outline_formats && Array.isArray(metadata.outline_formats)) {
190
+ return metadata.outline_formats.map((item) => ({
191
+ format: item.format || 'unknown',
192
+ url: item.url || '',
193
+ }));
194
+ }
195
+ // Fallback to single outline URL (backward compatibility)
196
+ if (metadata.outline?.url) {
197
+ return [{ format: 'default', url: metadata.outline.url }];
198
+ }
199
+ return [];
200
+ }
201
+ catch {
202
+ return [];
203
+ }
204
+ }