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,44 @@
1
+ /**
2
+ * Escribano - Context Creation Action
3
+ *
4
+ * Creates Context entities from extracted signals and links observations.
5
+ */
6
+ import { generateId } from '../db/helpers.js';
7
+ export function createContextsFromSignals(signals, observations, contextRepo) {
8
+ const contextIds = [];
9
+ const observationLinks = [];
10
+ // Helper: get or create context
11
+ const getOrCreateContext = (type, name) => {
12
+ const existing = contextRepo.findByTypeAndName(type, name);
13
+ if (existing)
14
+ return existing.id;
15
+ const id = generateId();
16
+ contextRepo.save({ id, type, name, metadata: null });
17
+ return id;
18
+ };
19
+ // Create contexts for each signal type
20
+ for (const app of signals.apps) {
21
+ const contextId = getOrCreateContext('app', app);
22
+ contextIds.push(contextId);
23
+ }
24
+ for (const url of signals.urls) {
25
+ const contextId = getOrCreateContext('url', url);
26
+ contextIds.push(contextId);
27
+ }
28
+ for (const project of signals.projects) {
29
+ const contextId = getOrCreateContext('project', project);
30
+ contextIds.push(contextId);
31
+ }
32
+ for (const topic of signals.topics) {
33
+ const contextId = getOrCreateContext('topic', topic);
34
+ contextIds.push(contextId);
35
+ }
36
+ // Link all observations to all contexts (cluster-level association)
37
+ const uniqueContextIds = [...new Set(contextIds)];
38
+ for (const obs of observations) {
39
+ for (const contextId of uniqueContextIds) {
40
+ observationLinks.push({ observationId: obs.id, contextId });
41
+ }
42
+ }
43
+ return { contextIds: uniqueContextIds, observationLinks };
44
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Escribano - TopicBlock Creation Action
3
+ *
4
+ * Creates TopicBlocks from clusters with their associated contexts.
5
+ */
6
+ import { generateId } from '../db/helpers.js';
7
+ export function createTopicBlockFromCluster(input, topicBlockRepo) {
8
+ const { cluster, contextIds, signals, mergedAudioClusterIds } = input;
9
+ const id = generateId();
10
+ // Build classification from signals
11
+ const classification = buildClassification(signals);
12
+ // Calculate duration
13
+ const duration = cluster.end_timestamp - cluster.start_timestamp;
14
+ topicBlockRepo.save({
15
+ id,
16
+ recording_id: cluster.recording_id,
17
+ context_ids: JSON.stringify(contextIds),
18
+ classification: JSON.stringify({
19
+ ...classification,
20
+ mergedAudioClusterIds: mergedAudioClusterIds || [],
21
+ }),
22
+ duration,
23
+ });
24
+ return id;
25
+ }
26
+ function buildClassification(signals) {
27
+ // Map topics to classification scores
28
+ const scores = {
29
+ meeting: 0,
30
+ debugging: 0,
31
+ tutorial: 0,
32
+ learning: 0,
33
+ working: 0,
34
+ };
35
+ for (const topic of signals.topics) {
36
+ const lower = topic.toLowerCase();
37
+ if (lower.includes('debug') ||
38
+ lower.includes('fix') ||
39
+ lower.includes('error')) {
40
+ scores.debugging = Math.max(scores.debugging, 80);
41
+ }
42
+ if (lower.includes('learn') ||
43
+ lower.includes('understand') ||
44
+ lower.includes('research')) {
45
+ scores.learning = Math.max(scores.learning, 80);
46
+ }
47
+ if (lower.includes('tutorial') ||
48
+ lower.includes('watch') ||
49
+ lower.includes('video')) {
50
+ scores.tutorial = Math.max(scores.tutorial, 80);
51
+ }
52
+ if (lower.includes('meeting') ||
53
+ lower.includes('call') ||
54
+ lower.includes('discuss')) {
55
+ scores.meeting = Math.max(scores.meeting, 80);
56
+ }
57
+ if (lower.includes('implement') ||
58
+ lower.includes('build') ||
59
+ lower.includes('code')) {
60
+ scores.working = Math.max(scores.working, 80);
61
+ }
62
+ }
63
+ // Default to working if no specific signals
64
+ if (Object.values(scores).every((s) => s === 0)) {
65
+ scores.working = 50;
66
+ }
67
+ return scores;
68
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Escribano - Extract Metadata Action
3
+ *
4
+ * Extracts structured metadata from session transcripts using LLM
5
+ */
6
+ export async function extractMetadata(session, intelligence) {
7
+ if (!session.classification) {
8
+ throw new Error('Session must be classified before metadata extraction');
9
+ }
10
+ console.log('Extracting metadata from transcript...');
11
+ const metadata = await intelligence.extractMetadata(session.transcripts[0]?.transcript || {
12
+ fullText: '',
13
+ segments: [],
14
+ duration: session.recording.duration,
15
+ language: 'en',
16
+ }, session.classification, session.visualLogs);
17
+ console.log('✓ Metadata extraction complete');
18
+ return {
19
+ ...session,
20
+ metadata,
21
+ status: 'metadata-extracted',
22
+ updatedAt: new Date(),
23
+ };
24
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Escribano - Generate Artifact V3.1
3
+ *
4
+ * Generates structured artifacts from Subjects using format templates.
5
+ * Supports: card (default), standup, narrative
6
+ */
7
+ import { execSync } from 'node:child_process';
8
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
9
+ import { homedir } from 'node:os';
10
+ import path from 'node:path';
11
+ import { log, step } from '../pipeline/context.js';
12
+ import { normalizeAppNames } from '../services/app-normalization.js';
13
+ import { groupTopicBlocksIntoSubjects, saveSubjectsToDatabase, } from '../services/subject-grouping.js';
14
+ export async function generateArtifactV3(recordingId, repos, intelligence, options) {
15
+ const format = options.format || 'card';
16
+ log('info', `[Artifact V3.1] Generating ${format} artifact for recording ${recordingId}...`);
17
+ const recording = repos.recordings.findById(recordingId);
18
+ if (!recording) {
19
+ throw new Error(`Recording ${recordingId} not found`);
20
+ }
21
+ const topicBlocks = repos.topicBlocks.findByRecording(recordingId);
22
+ if (topicBlocks.length === 0) {
23
+ throw new Error(`No TopicBlocks found for recording ${recordingId}. Run process-v3 first.`);
24
+ }
25
+ log('info', `[Artifact V3.1] Found ${topicBlocks.length} TopicBlocks`);
26
+ const existingSubjects = repos.subjects.findByRecording(recordingId);
27
+ let subjects;
28
+ let personalDuration;
29
+ let workDuration;
30
+ if (existingSubjects.length > 0) {
31
+ log('info', `[Artifact V3.1] Reusing ${existingSubjects.length} existing subjects (no re-grouping needed)`);
32
+ const loaded = await loadExistingSubjects(existingSubjects, repos);
33
+ subjects = loaded.subjects;
34
+ personalDuration = loaded.personalDuration;
35
+ workDuration = loaded.workDuration;
36
+ }
37
+ else {
38
+ log('info', '[Artifact V3.1] Grouping TopicBlocks into subjects...');
39
+ const groupingResult = await step('subject grouping', () => groupTopicBlocksIntoSubjects(topicBlocks, intelligence, recordingId));
40
+ log('info', `[Artifact V3.1] Saving ${groupingResult.subjects.length} subjects to database...`);
41
+ saveSubjectsToDatabase(groupingResult.subjects, recordingId, repos);
42
+ subjects = groupingResult.subjects;
43
+ personalDuration = groupingResult.personalDuration;
44
+ workDuration = groupingResult.workDuration;
45
+ }
46
+ for (const subject of subjects) {
47
+ subject.apps = normalizeAppNames(subject.apps);
48
+ }
49
+ const filteredSubjects = options.includePersonal
50
+ ? subjects
51
+ : subjects.filter((s) => !s.isPersonal);
52
+ log('info', `[Artifact V3.1] Generating ${format} with LLM...`);
53
+ const content = await step('artifact generation', () => generateLlmArtifact(subjects, { subjects, personalDuration, workDuration }, format, recording, intelligence, repos, topicBlocks));
54
+ const outputDir = options.outputDir || path.join(homedir(), '.escribano', 'artifacts');
55
+ await mkdir(outputDir, { recursive: true });
56
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
57
+ const fileName = `${recordingId}-${format}-${timestamp}.md`;
58
+ const filePath = path.join(outputDir, fileName);
59
+ await writeFile(filePath, content, 'utf-8');
60
+ log('info', `[Artifact V3.1] Artifact saved to: ${filePath}`);
61
+ const artifactId = `artifact-${recordingId}-${format}-${Date.now()}`;
62
+ repos.artifacts.save({
63
+ id: artifactId,
64
+ recording_id: recordingId,
65
+ type: format,
66
+ content,
67
+ format: 'markdown',
68
+ source_block_ids: JSON.stringify(subjects.flatMap((s) => s.topicBlockIds)),
69
+ source_context_ids: null,
70
+ });
71
+ log('info', `[Artifact V3.1] Saved to database: ${artifactId}`);
72
+ // Link subjects to artifact
73
+ repos.artifacts.linkSubjects(artifactId, subjects.map((s) => s.id));
74
+ log('info', `[Artifact V3.1] Linked ${subjects.length} subjects to artifact`);
75
+ if (options.printToStdout) {
76
+ console.log(`\n${content}\n`);
77
+ }
78
+ if (options.copyToClipboard && process.platform === 'darwin') {
79
+ try {
80
+ execSync('pbcopy', { input: content, encoding: 'utf-8' });
81
+ log('info', '[Artifact V3.1] Copied to clipboard');
82
+ }
83
+ catch (error) {
84
+ log('warn', `[Artifact V3.1] Failed to copy to clipboard: ${error}`);
85
+ }
86
+ }
87
+ return {
88
+ id: artifactId,
89
+ recordingId,
90
+ format,
91
+ content,
92
+ filePath,
93
+ subjects,
94
+ personalDuration,
95
+ workDuration,
96
+ createdAt: new Date(),
97
+ };
98
+ }
99
+ async function loadExistingSubjects(existingSubjects, repos) {
100
+ const subjects = [];
101
+ for (const dbSubject of existingSubjects) {
102
+ const topicBlocks = repos.subjects.getTopicBlocks(dbSubject.id);
103
+ const activityBreakdown = dbSubject.activity_breakdown
104
+ ? JSON.parse(dbSubject.activity_breakdown)
105
+ : {};
106
+ const metadata = dbSubject.metadata ? JSON.parse(dbSubject.metadata) : {};
107
+ const apps = metadata.apps || [];
108
+ subjects.push({
109
+ id: dbSubject.id,
110
+ recordingId: topicBlocks[0]?.recording_id || '',
111
+ label: dbSubject.label,
112
+ topicBlockIds: topicBlocks.map((b) => b.id),
113
+ totalDuration: dbSubject.duration,
114
+ activityBreakdown,
115
+ apps,
116
+ isPersonal: dbSubject.is_personal === 1,
117
+ });
118
+ }
119
+ const personalDuration = subjects
120
+ .filter((s) => s.isPersonal)
121
+ .reduce((sum, s) => sum + s.totalDuration, 0);
122
+ const workDuration = subjects
123
+ .filter((s) => !s.isPersonal)
124
+ .reduce((sum, s) => sum + s.totalDuration, 0);
125
+ return { subjects, personalDuration, workDuration };
126
+ }
127
+ function generateTemplateArtifact(subjects, groupingResult, format, recording) {
128
+ const sessionDate = new Date(recording.captured_at).toLocaleDateString('en-US', {
129
+ weekday: 'long',
130
+ year: 'numeric',
131
+ month: 'long',
132
+ day: 'numeric',
133
+ });
134
+ const sessionDuration = formatDuration(recording.duration);
135
+ switch (format) {
136
+ case 'standup':
137
+ return generateStandupTemplate(subjects, sessionDate, sessionDuration);
138
+ case 'narrative':
139
+ return generateNarrativeTemplate(subjects, sessionDate, sessionDuration);
140
+ default:
141
+ return generateCardTemplate(subjects, groupingResult, sessionDate, sessionDuration);
142
+ }
143
+ }
144
+ function generateCardTemplate(subjects, groupingResult, sessionDate, sessionDuration) {
145
+ let content = `# Session Card - ${sessionDate}\n\n`;
146
+ content += `**Total Duration:** ${sessionDuration}\n`;
147
+ content += `**Subjects:** ${subjects.length}\n\n`;
148
+ for (const subject of subjects) {
149
+ const activityStr = Object.entries(subject.activityBreakdown)
150
+ .sort((a, b) => b[1] - a[1])
151
+ .slice(0, 3)
152
+ .map(([activity, duration]) => `${activity} ${formatDuration(duration)}`)
153
+ .join(', ');
154
+ content += `## ${subject.label}\n`;
155
+ content += `**${formatDuration(subject.totalDuration)}** | ${activityStr || 'various'}\n\n`;
156
+ const bullets = extractAccomplishmentBullets(subject);
157
+ for (const bullet of bullets.slice(0, 4)) {
158
+ content += `- ${bullet}\n`;
159
+ }
160
+ content += '\n';
161
+ }
162
+ if (groupingResult.personalDuration > 0) {
163
+ content += `---\n*Personal time: ${formatDuration(groupingResult.personalDuration)} (filtered)*\n`;
164
+ }
165
+ return content;
166
+ }
167
+ function generateStandupTemplate(subjects, sessionDate, sessionDuration) {
168
+ let content = `## Standup - ${sessionDate}\n\n`;
169
+ content += `**What I did:**\n`;
170
+ const allActivities = [];
171
+ for (const subject of subjects) {
172
+ allActivities.push(...extractAccomplishmentBullets(subject));
173
+ }
174
+ for (const activity of allActivities.slice(0, 5)) {
175
+ content += `- ${activity}\n`;
176
+ }
177
+ content += '\n**Key outcomes:**\n';
178
+ content += '- [Add key outcomes from session]\n';
179
+ content += '- [Add key outcomes from session]\n';
180
+ content += '\n**Next:**\n';
181
+ content += '- [Add next steps]\n';
182
+ return content;
183
+ }
184
+ function generateNarrativeTemplate(subjects, sessionDate, sessionDuration) {
185
+ let content = `# Session Summary - ${sessionDate}\n\n`;
186
+ content += `**Duration:** ${sessionDuration}\n\n`;
187
+ content += `## Overview\n\n`;
188
+ content += `This session covered ${subjects.length} main subjects.\n\n`;
189
+ for (const subject of subjects) {
190
+ content += `## ${subject.label}\n\n`;
191
+ content += `Spent ${formatDuration(subject.totalDuration)} on ${subject.label.toLowerCase()}.\n\n`;
192
+ const bullets = extractAccomplishmentBullets(subject);
193
+ for (const bullet of bullets.slice(0, 3)) {
194
+ content += `- ${bullet}\n`;
195
+ }
196
+ content += '\n';
197
+ }
198
+ return content;
199
+ }
200
+ async function generateLlmArtifact(subjects, groupingResult, format, recording, intelligence, repos, allTopicBlocks) {
201
+ const ARTIFACT_THINK = process.env.ESCRIBANO_ARTIFACT_THINK === 'true';
202
+ const promptFileName = format === 'card'
203
+ ? 'card.md'
204
+ : format === 'standup'
205
+ ? 'standup.md'
206
+ : 'summary-v3.md';
207
+ const promptPath = path.join(process.cwd(), 'prompts', promptFileName);
208
+ let promptTemplate;
209
+ try {
210
+ promptTemplate = await readFile(promptPath, 'utf-8');
211
+ }
212
+ catch {
213
+ log('warn', `[Artifact V3.1] Prompt template not found: ${promptPath}, using fallback`);
214
+ return generateTemplateArtifact(subjects, groupingResult, format, recording);
215
+ }
216
+ const sessionDate = new Date(recording.captured_at).toLocaleDateString('en-US', {
217
+ weekday: 'long',
218
+ year: 'numeric',
219
+ month: 'long',
220
+ day: 'numeric',
221
+ });
222
+ const subjectsData = buildSubjectsDataForPrompt(subjects, allTopicBlocks);
223
+ const prompt = promptTemplate
224
+ .replace('{{SESSION_DURATION}}', formatDuration(recording.duration))
225
+ .replace('{{SESSION_DATE}}', sessionDate)
226
+ .replace('{{SUBJECT_COUNT}}', String(subjects.length))
227
+ .replace('{{SUBJECTS_DATA}}', subjectsData)
228
+ .replace('{{WORK_SUBJECTS}}', subjectsData);
229
+ return intelligence.generateText(prompt, {
230
+ expectJson: false,
231
+ think: ARTIFACT_THINK,
232
+ });
233
+ }
234
+ function buildSubjectsDataForPrompt(subjects, allTopicBlocks) {
235
+ // Build a map of TopicBlocks by subject ID for quick lookup
236
+ const blocksBySubjectId = new Map();
237
+ for (const subject of subjects) {
238
+ const subjectBlocks = allTopicBlocks.filter((block) => subject.topicBlockIds.includes(block.id));
239
+ blocksBySubjectId.set(subject.id, subjectBlocks);
240
+ }
241
+ return subjects
242
+ .map((subject) => {
243
+ const activityStr = Object.entries(subject.activityBreakdown)
244
+ .sort((a, b) => b[1] - a[1])
245
+ .map(([activity, duration]) => `${activity}: ${formatDuration(duration)}`)
246
+ .join(', ');
247
+ const subjectBlocks = blocksBySubjectId.get(subject.id) || [];
248
+ const blockDescriptions = subjectBlocks
249
+ .map((block, index) => {
250
+ try {
251
+ const classification = JSON.parse(block.classification || '{}');
252
+ const desc = classification.key_description || '';
253
+ const duration = block.duration
254
+ ? formatDuration(block.duration)
255
+ : 'unknown';
256
+ return `- Block ${index + 1} (${duration}): ${desc}`;
257
+ }
258
+ catch {
259
+ return `- Block ${index + 1}: [Unable to parse description]`;
260
+ }
261
+ })
262
+ .join('\n');
263
+ return `### Subject: ${subject.label}
264
+ **Duration:** ${formatDuration(subject.totalDuration)}
265
+ **Activities:** ${activityStr || 'various'}
266
+ **Apps:** ${subject.apps.join(', ') || 'none'}
267
+ **isPersonal:** ${subject.isPersonal}
268
+
269
+ **Block Descriptions:**
270
+ ${blockDescriptions || '(no blocks)'}
271
+ `;
272
+ })
273
+ .join('\n---\n\n');
274
+ }
275
+ function extractAccomplishmentBullets(subject) {
276
+ const bullets = [];
277
+ if (subject.apps.length > 0) {
278
+ bullets.push(`Worked with ${subject.apps.slice(0, 3).join(', ')}`);
279
+ }
280
+ const dominantActivity = Object.entries(subject.activityBreakdown).sort((a, b) => b[1] - a[1])[0];
281
+ if (dominantActivity) {
282
+ bullets.push(`Primary activity: ${dominantActivity[0]} (${formatDuration(dominantActivity[1])})`);
283
+ }
284
+ return bullets;
285
+ }
286
+ function formatDuration(seconds) {
287
+ const hours = Math.floor(seconds / 3600);
288
+ const mins = Math.floor((seconds % 3600) / 60);
289
+ if (hours > 0) {
290
+ return `${hours}h ${mins}m`;
291
+ }
292
+ if (mins > 0) {
293
+ return `${mins}m`;
294
+ }
295
+ return `${Math.floor(seconds)}s`;
296
+ }
@@ -0,0 +1,61 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { Session } from '../domain/session.js';
4
+ /**
5
+ * Generates a specific artifact for a session, including on-demand screenshot extraction
6
+ */
7
+ export async function generateArtifact(session, intelligence, artifactType, videoService) {
8
+ if (!session.classification) {
9
+ throw new Error('Session must be classified before generating artifacts');
10
+ }
11
+ // Combine transcripts for context
12
+ const fullText = session.transcripts
13
+ .map((t) => `[${t.source.toUpperCase()}]\n${t.transcript.fullText}`)
14
+ .join('\n\n');
15
+ const context = {
16
+ transcript: {
17
+ ...session.transcripts[0].transcript,
18
+ fullText,
19
+ },
20
+ classification: session.classification,
21
+ metadata: session.metadata,
22
+ visualLogs: session.visualLogs,
23
+ };
24
+ let content = await intelligence.generate(artifactType, context);
25
+ // Post-process [SCREENSHOT: timestamp] tags for on-demand extraction
26
+ const screenshotRegex = /\[SCREENSHOT:\s*([\d.]+)\]/g;
27
+ const matches = [...content.matchAll(screenshotRegex)];
28
+ if (matches.length > 0 && session.recording.videoPath) {
29
+ console.log(`Found ${matches.length} screenshot requests in artifact. Extracting...`);
30
+ const timestamps = matches.map((m) => Number.parseFloat(m[1]));
31
+ const screenshotDir = path.join(os.homedir(), '.escribano', 'sessions', session.id, 'artifacts', 'screenshots');
32
+ try {
33
+ const paths = await videoService.extractFramesAtTimestamps(session.recording.videoPath, timestamps, screenshotDir);
34
+ // Replace tags with markdown images using relative paths
35
+ for (let i = 0; i < matches.length; i++) {
36
+ const match = matches[i];
37
+ if (paths[i]) {
38
+ const fileName = path.basename(paths[i]);
39
+ const markdownImage = `![Screenshot at ${match[1]}s](./screenshots/${fileName})`;
40
+ content = content.replace(match[0], markdownImage);
41
+ }
42
+ }
43
+ }
44
+ catch (error) {
45
+ console.error('Failed to extract screenshots for artifact:', error);
46
+ }
47
+ }
48
+ return {
49
+ id: `${session.id}-${artifactType}-${Date.now()}`,
50
+ type: artifactType,
51
+ content,
52
+ format: 'markdown',
53
+ createdAt: new Date(),
54
+ };
55
+ }
56
+ /**
57
+ * Returns a list of recommended artifact types based on session classification
58
+ */
59
+ export function getRecommendedArtifacts(session) {
60
+ return Session.getRecommendedArtifacts(session);
61
+ }