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,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
|
+
}
|