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.
- package/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/0_types.js +279 -0
- package/dist/actions/classify-session.js +77 -0
- package/dist/actions/create-contexts.js +44 -0
- package/dist/actions/create-topic-blocks.js +68 -0
- package/dist/actions/extract-metadata.js +24 -0
- package/dist/actions/generate-artifact-v3.js +296 -0
- package/dist/actions/generate-artifact.js +61 -0
- package/dist/actions/generate-summary-v3.js +260 -0
- package/dist/actions/outline-index.js +204 -0
- package/dist/actions/process-recording-v2.js +494 -0
- package/dist/actions/process-recording-v3.js +412 -0
- package/dist/actions/process-session.js +183 -0
- package/dist/actions/publish-summary-v3.js +303 -0
- package/dist/actions/sync-to-outline.js +196 -0
- package/dist/adapters/audio.silero.adapter.js +69 -0
- package/dist/adapters/cap.adapter.js +94 -0
- package/dist/adapters/capture.cap.adapter.js +107 -0
- package/dist/adapters/capture.filesystem.adapter.js +124 -0
- package/dist/adapters/embedding.ollama.adapter.js +141 -0
- package/dist/adapters/intelligence.adapter.js +202 -0
- package/dist/adapters/intelligence.mlx.adapter.js +395 -0
- package/dist/adapters/intelligence.ollama.adapter.js +741 -0
- package/dist/adapters/publishing.outline.adapter.js +75 -0
- package/dist/adapters/storage.adapter.js +81 -0
- package/dist/adapters/storage.fs.adapter.js +83 -0
- package/dist/adapters/transcription.whisper.adapter.js +206 -0
- package/dist/adapters/video.ffmpeg.adapter.js +405 -0
- package/dist/adapters/whisper.adapter.js +168 -0
- package/dist/batch-context.js +329 -0
- package/dist/db/helpers.js +50 -0
- package/dist/db/index.js +95 -0
- package/dist/db/migrate.js +80 -0
- package/dist/db/repositories/artifact.sqlite.js +77 -0
- package/dist/db/repositories/cluster.sqlite.js +92 -0
- package/dist/db/repositories/context.sqlite.js +75 -0
- package/dist/db/repositories/index.js +10 -0
- package/dist/db/repositories/observation.sqlite.js +70 -0
- package/dist/db/repositories/recording.sqlite.js +56 -0
- package/dist/db/repositories/subject.sqlite.js +64 -0
- package/dist/db/repositories/topic-block.sqlite.js +45 -0
- package/dist/db/types.js +4 -0
- package/dist/domain/classification.js +60 -0
- package/dist/domain/context.js +97 -0
- package/dist/domain/index.js +2 -0
- package/dist/domain/observation.js +17 -0
- package/dist/domain/recording.js +41 -0
- package/dist/domain/segment.js +93 -0
- package/dist/domain/session.js +93 -0
- package/dist/domain/time-range.js +38 -0
- package/dist/domain/transcript.js +79 -0
- package/dist/index.js +173 -0
- package/dist/pipeline/context.js +162 -0
- package/dist/pipeline/events.js +2 -0
- package/dist/prerequisites.js +226 -0
- package/dist/scripts/rebuild-index.js +53 -0
- package/dist/scripts/seed-fixtures.js +290 -0
- package/dist/services/activity-segmentation.js +333 -0
- package/dist/services/activity-segmentation.test.js +191 -0
- package/dist/services/app-normalization.js +212 -0
- package/dist/services/cluster-merge.js +69 -0
- package/dist/services/clustering.js +237 -0
- package/dist/services/debug.js +58 -0
- package/dist/services/frame-sampling.js +318 -0
- package/dist/services/signal-extraction.js +106 -0
- package/dist/services/subject-grouping.js +342 -0
- package/dist/services/temporal-alignment.js +99 -0
- package/dist/services/vlm-enrichment.js +84 -0
- package/dist/services/vlm-service.js +130 -0
- package/dist/stats/index.js +3 -0
- package/dist/stats/observer.js +65 -0
- package/dist/stats/repository.js +36 -0
- package/dist/stats/resource-tracker.js +86 -0
- package/dist/stats/types.js +1 -0
- package/dist/test-classification-prompts.js +181 -0
- package/dist/tests/cap.adapter.test.js +75 -0
- package/dist/tests/capture.cap.adapter.test.js +69 -0
- package/dist/tests/classify-session.test.js +140 -0
- package/dist/tests/db/repositories.test.js +243 -0
- package/dist/tests/domain/time-range.test.js +31 -0
- package/dist/tests/integration.test.js +84 -0
- package/dist/tests/intelligence.adapter.test.js +102 -0
- package/dist/tests/intelligence.ollama.adapter.test.js +178 -0
- package/dist/tests/process-v2.test.js +90 -0
- package/dist/tests/services/clustering.test.js +112 -0
- package/dist/tests/services/frame-sampling.test.js +152 -0
- package/dist/tests/utils/ocr.test.js +76 -0
- package/dist/tests/utils/parallel.test.js +57 -0
- package/dist/tests/visual-observer.test.js +175 -0
- package/dist/utils/id-normalization.js +15 -0
- package/dist/utils/index.js +9 -0
- package/dist/utils/model-detector.js +154 -0
- package/dist/utils/ocr.js +80 -0
- package/dist/utils/parallel.js +32 -0
- package/migrations/001_initial.sql +109 -0
- package/migrations/002_clusters.sql +41 -0
- package/migrations/003_observations_vlm_fields.sql +14 -0
- package/migrations/004_observations_unique.sql +18 -0
- package/migrations/005_processing_stats.sql +29 -0
- package/migrations/006_vlm_raw_response.sql +6 -0
- package/migrations/007_subjects.sql +23 -0
- package/migrations/008_artifacts_recording.sql +6 -0
- package/migrations/009_artifact_subjects.sql +10 -0
- package/package.json +82 -0
- package/prompts/action-items.md +55 -0
- package/prompts/blog-draft.md +54 -0
- package/prompts/blog-research.md +87 -0
- package/prompts/card.md +54 -0
- package/prompts/classify-segment.md +38 -0
- package/prompts/classify.md +37 -0
- package/prompts/code-snippets.md +163 -0
- package/prompts/extract-metadata.md +149 -0
- package/prompts/notes.md +83 -0
- package/prompts/runbook.md +123 -0
- package/prompts/standup.md +50 -0
- package/prompts/step-by-step.md +125 -0
- package/prompts/subject-grouping.md +31 -0
- package/prompts/summary-v3.md +89 -0
- package/prompts/summary.md +77 -0
- package/prompts/topic-classifier.md +24 -0
- package/prompts/topic-extract.md +13 -0
- package/prompts/vlm-batch.md +21 -0
- 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
|
+
}
|