Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/script/analyzeEdits.ts
13383 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { promises as fs } from 'fs';
7
import * as path from 'path';
8
import * as readline from 'readline';
9
10
// Edit tool names we're tracking
11
const EDIT_TOOL_NAMES = ['insert_edit_into_file', 'replace_string_in_file', 'multi_replace_string_in_file', 'apply_patch'];
12
13
// Tool names that indicate a continuation/retry attempt
14
const CONTINUATION_TOOL_NAMES = ['read_file'];
15
16
interface ToolCall {
17
tool: string;
18
input_tokens?: number;
19
cached_input_tokens?: number;
20
output_tokens?: number;
21
response: string | string[];
22
edits?: Array<{
23
path: string;
24
edits: {
25
replacements: Array<{
26
replaceRange: { start: number; endExclusive: number };
27
newText: string;
28
}>;
29
};
30
}>;
31
}
32
33
interface EditOperation {
34
toolName: string;
35
timestamp: string;
36
success: boolean;
37
filePath?: string;
38
turnIndex: number;
39
isRetry: boolean;
40
retrySucceeded?: boolean;
41
}
42
43
interface ConversationAnalysis {
44
conversationPath: string;
45
edits: EditOperation[];
46
totalEdits: number;
47
successfulEdits: number;
48
failedEdits: number;
49
successfulEditsWithRetries: number;
50
totalUniqueEdits: number;
51
modelName?: string;
52
}
53
54
interface RunAnalysis {
55
runId: string;
56
conversations: ConversationAnalysis[];
57
totalEdits: number;
58
successRate: number;
59
successRateWithRetries: number;
60
totalUniqueEdits: number;
61
modelName?: string;
62
}
63
64
async function listRuns(amlOutPath: string): Promise<string[]> {
65
const entries = await fs.readdir(amlOutPath, { withFileTypes: true });
66
// Filter directories that are numeric run IDs
67
const runs = entries
68
.filter(e => e.isDirectory() && /^\d+$/.test(e.name))
69
.map(e => e.name)
70
.sort((a, b) => parseInt(b) - parseInt(a)); // Sort descending (newest first)
71
return runs;
72
}
73
74
async function promptUserForRun(runs: string[]): Promise<string> {
75
console.log('\nAvailable test runs (newest first):');
76
runs.slice(0, 10).forEach((run, i) => {
77
console.log(` ${i + 1}. ${run}`);
78
});
79
if (runs.length > 10) {
80
console.log(` ... and ${runs.length - 10} more`);
81
}
82
83
const rl = readline.createInterface({
84
input: process.stdin,
85
output: process.stdout
86
});
87
88
return new Promise((resolve) => {
89
rl.question('\nEnter run number (or press Enter for the most recent): ', (answer) => {
90
rl.close();
91
const choice = answer.trim();
92
if (choice === '') {
93
resolve(runs[0]);
94
} else {
95
const index = parseInt(choice) - 1;
96
if (index >= 0 && index < runs.length) {
97
resolve(runs[index]);
98
} else {
99
console.log('Invalid selection, using most recent run.');
100
resolve(runs[0]);
101
}
102
}
103
});
104
});
105
}
106
107
async function analyzeConversation(conversationPath: string): Promise<ConversationAnalysis> {
108
const trajectoryPath = path.join(conversationPath, 'trajectories', 'trajectory.json');
109
110
let toolCalls: ToolCall[] = [];
111
let modelName: string | undefined;
112
113
try {
114
const content = await fs.readFile(trajectoryPath, 'utf-8');
115
toolCalls = JSON.parse(content);
116
} catch (error) {
117
console.warn(`Could not read trajectory file: ${trajectoryPath}`);
118
return {
119
conversationPath,
120
edits: [],
121
totalEdits: 0,
122
successfulEdits: 0,
123
failedEdits: 0,
124
successfulEditsWithRetries: 0,
125
totalUniqueEdits: 0
126
};
127
}
128
129
const edits: EditOperation[] = [];
130
let turnIndex = 0;
131
132
for (let i = 0; i < toolCalls.length; i++) {
133
const toolCall = toolCalls[i];
134
135
if (!EDIT_TOOL_NAMES.includes(toolCall.tool)) {
136
continue;
137
}
138
139
// Determine success based on response
140
const response = Array.isArray(toolCall.response) ? toolCall.response[0] : toolCall.response;
141
const success = typeof response === 'string' && response.includes('successfully edited');
142
143
// Get file path from edits if available
144
const filePath = toolCall.edits && toolCall.edits.length > 0
145
? toolCall.edits[0].path
146
: undefined;
147
148
// Detect retry pattern: failed edit -> continuation tool -> another edit
149
let isRetry = false;
150
let retrySucceeded: boolean | undefined;
151
152
if (!success) {
153
// Look ahead to see if there's a continuation tool followed by another edit
154
let j = i + 1;
155
let foundContinuationTool = false;
156
while (j < toolCalls.length && j < i + 10) { // Look ahead max 10 calls
157
if (CONTINUATION_TOOL_NAMES.includes(toolCalls[j].tool)) {
158
foundContinuationTool = true;
159
} else if (foundContinuationTool && EDIT_TOOL_NAMES.includes(toolCalls[j].tool)) {
160
// Found a retry!
161
isRetry = true;
162
const retryResponse = Array.isArray(toolCalls[j].response)
163
? toolCalls[j].response[0]
164
: toolCalls[j].response;
165
retrySucceeded = typeof retryResponse === 'string' && retryResponse.includes('successfully edited');
166
break;
167
} else if (EDIT_TOOL_NAMES.includes(toolCalls[j].tool)) {
168
// Another edit without continuation tool in between, not a retry
169
break;
170
}
171
j++;
172
}
173
}
174
175
edits.push({
176
toolName: toolCall.tool,
177
timestamp: new Date().toISOString(), // Trajectory doesn't have timestamps, use current time
178
success,
179
filePath,
180
turnIndex: turnIndex++,
181
isRetry,
182
retrySucceeded
183
});
184
}
185
186
const successfulEdits = edits.filter(e => e.success).length;
187
188
// Calculate success rate accounting for retries (final outcome only)
189
const editsWithRetries = edits.filter(e => !e.success && e.isRetry);
190
const retriedSuccesses = editsWithRetries.filter(e => e.retrySucceeded).length;
191
const successfulEditsWithRetries = successfulEdits + retriedSuccesses;
192
const totalUniqueEdits = edits.length - editsWithRetries.length + editsWithRetries.filter(e => e.retrySucceeded !== undefined).length;
193
194
return {
195
conversationPath,
196
edits,
197
totalEdits: edits.length,
198
successfulEdits,
199
failedEdits: edits.length - successfulEdits,
200
successfulEditsWithRetries,
201
totalUniqueEdits,
202
modelName
203
};
204
}
205
206
async function analyzeRun(runId: string, basePath: string): Promise<RunAnalysis> {
207
const runPath = path.join(basePath, runId);
208
209
const conversations: ConversationAnalysis[] = [];
210
211
try {
212
const entries = await fs.readdir(runPath, { withFileTypes: true });
213
214
for (const entry of entries) {
215
if (entry.isDirectory()) {
216
const conversationPath = path.join(runPath, entry.name);
217
const analysis = await analyzeConversation(conversationPath);
218
if (analysis.totalEdits > 0) {
219
conversations.push(analysis);
220
}
221
}
222
}
223
} catch (error) {
224
console.error(`Error reading run directory: ${error}`);
225
}
226
227
const totalEdits = conversations.reduce((sum, c) => sum + c.totalEdits, 0);
228
const totalSuccessful = conversations.reduce((sum, c) => sum + c.successfulEdits, 0);
229
const totalSuccessfulWithRetries = conversations.reduce((sum, c) => sum + c.successfulEditsWithRetries, 0);
230
const totalUniqueEdits = conversations.reduce((sum, c) => sum + c.totalUniqueEdits, 0);
231
232
// Get model name from first conversation that has one
233
const modelName = conversations.find(c => c.modelName)?.modelName;
234
235
return {
236
runId,
237
conversations,
238
totalEdits,
239
successRate: totalEdits > 0 ? totalSuccessful / totalEdits : 0,
240
successRateWithRetries: totalUniqueEdits > 0 ? totalSuccessfulWithRetries / totalUniqueEdits : 0,
241
totalUniqueEdits,
242
modelName
243
};
244
}
245
246
function generateHTML(analysis: RunAnalysis, outputPath: string, includeRetries: boolean = false): string {
247
// Build Sankey data
248
const sankeyNodes: string[] = [];
249
const sankeyLinks: Array<{ source: number; target: number; value: number }> = [];
250
251
const nodeMap = new Map<string, number>();
252
253
const getNodeIndex = (name: string): number => {
254
if (!nodeMap.has(name)) {
255
nodeMap.set(name, sankeyNodes.length);
256
sankeyNodes.push(name);
257
}
258
return nodeMap.get(name)!;
259
};
260
261
// Track flows
262
const flows = new Map<string, number>();
263
264
for (const conv of analysis.conversations) {
265
for (const edit of conv.edits) {
266
const toolNode = edit.toolName;
267
268
// Check if this is a failed edit with a retry
269
if (includeRetries && !edit.success && edit.isRetry && edit.retrySucceeded !== undefined) {
270
// Show full retry flow: Tool -> Failed -> read_file -> Retry Edit -> Final Result
271
const failedNode = 'Failed (will retry)';
272
const readFileNode = 'read_file';
273
const retryEditNode = `${toolNode} (retry)`;
274
const finalResult = edit.retrySucceeded ? 'Success' : 'Failed';
275
276
flows.set(`${toolNode}->${failedNode}`, (flows.get(`${toolNode}->${failedNode}`) || 0) + 1);
277
flows.set(`${failedNode}->${readFileNode}`, (flows.get(`${failedNode}->${readFileNode}`) || 0) + 1);
278
flows.set(`${readFileNode}->${retryEditNode}`, (flows.get(`${readFileNode}->${retryEditNode}`) || 0) + 1);
279
flows.set(`${retryEditNode}->${finalResult}`, (flows.get(`${retryEditNode}->${finalResult}`) || 0) + 1);
280
continue;
281
}
282
283
// Tool -> Success/Fail
284
const resultNode = edit.success ? 'Success' : 'Failed';
285
const flowKey = `${toolNode}->${resultNode}`;
286
flows.set(flowKey, (flows.get(flowKey) || 0) + 1);
287
}
288
}
289
290
// Convert flows to Sankey links
291
for (const [flowKey, count] of flows.entries()) {
292
const [source, target] = flowKey.split('->');
293
sankeyLinks.push({
294
source: getNodeIndex(source),
295
target: getNodeIndex(target),
296
value: count
297
});
298
}
299
300
// Build table rows
301
const tableRows = analysis.conversations.flatMap(conv =>
302
conv.edits.map(edit => ({
303
conversation: path.basename(conv.conversationPath),
304
toolName: edit.toolName,
305
timestamp: edit.timestamp,
306
success: edit.success,
307
turnIndex: edit.turnIndex,
308
isRetry: edit.isRetry,
309
retrySucceeded: edit.retrySucceeded,
310
filePath: edit.filePath
311
}))
312
);
313
314
const html = `<!DOCTYPE html>
315
<html lang="en">
316
<head>
317
<meta charset="UTF-8">
318
<meta name="viewport" content="width=device-width, initial-scale=1.0">
319
<title>Run ${analysis.runId}${analysis.modelName ? ' - ' + analysis.modelName : ''}</title>
320
<script src="https://unpkg.com/d3@7/dist/d3.min.js"></script>
321
<script src="https://unpkg.com/[email protected]/dist/d3-sankey.min.js"></script>
322
<style>
323
* {
324
box-sizing: border-box;
325
}
326
327
body {
328
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
329
margin: 0;
330
padding: 20px;
331
background: #f5f5f5;
332
color: #333;
333
}
334
335
.container {
336
max-width: 1400px;
337
margin: 0 auto;
338
background: white;
339
padding: 30px;
340
border-radius: 8px;
341
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
342
}
343
344
h1 {
345
margin: 0 0 10px 0;
346
color: #1a1a1a;
347
}
348
349
.stats {
350
display: grid;
351
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
352
gap: 15px;
353
margin: 20px 0;
354
}
355
356
.stat-card {
357
background: #f8f9fa;
358
padding: 15px;
359
border-radius: 6px;
360
border-left: 4px solid #0969da;
361
}
362
363
.stat-label {
364
font-size: 12px;
365
text-transform: uppercase;
366
color: #666;
367
margin-bottom: 5px;
368
}
369
370
.stat-value {
371
font-size: 24px;
372
font-weight: 600;
373
color: #1a1a1a;
374
}
375
376
.controls {
377
margin: 20px 0;
378
padding: 15px;
379
background: #f8f9fa;
380
border-radius: 6px;
381
}
382
383
.controls label {
384
display: inline-flex;
385
align-items: center;
386
cursor: pointer;
387
font-size: 14px;
388
}
389
390
.controls input[type="checkbox"] {
391
margin-right: 8px;
392
width: 18px;
393
height: 18px;
394
cursor: pointer;
395
}
396
397
#sankey-diagram {
398
margin: 30px 0;
399
overflow-x: auto;
400
}
401
402
.table-container {
403
margin-top: 30px;
404
overflow-x: auto;
405
}
406
407
table {
408
width: 100%;
409
border-collapse: collapse;
410
font-size: 14px;
411
}
412
413
thead {
414
background: #f8f9fa;
415
}
416
417
th {
418
text-align: left;
419
padding: 12px;
420
font-weight: 600;
421
color: #1a1a1a;
422
border-bottom: 2px solid #dee2e6;
423
}
424
425
td {
426
padding: 10px 12px;
427
border-bottom: 1px solid #dee2e6;
428
}
429
430
tbody tr:hover {
431
background: #f8f9fa;
432
}
433
434
.badge {
435
display: inline-block;
436
padding: 3px 8px;
437
border-radius: 12px;
438
font-size: 12px;
439
font-weight: 500;
440
}
441
442
.badge-success {
443
background: #d1f4e0;
444
color: #0f6d31;
445
}
446
447
.badge-failed {
448
background: #ffd8d8;
449
color: #d1242f;
450
}
451
452
.sankey-node rect {
453
cursor: pointer;
454
fill-opacity: 0.9;
455
}
456
457
.sankey-node rect:hover {
458
fill-opacity: 1;
459
}
460
461
.sankey-link {
462
fill: none;
463
stroke-opacity: 0.3;
464
}
465
466
.sankey-link:hover {
467
stroke-opacity: 0.5;
468
}
469
470
.sankey-node text {
471
pointer-events: none;
472
font-size: 12px;
473
fill: #1a1a1a;
474
}
475
</style>
476
</head>
477
<body>
478
<div class="container">
479
<h1>🔧 Run ${analysis.runId}${analysis.modelName ? ' - ' + analysis.modelName : ''}</h1>
480
<p style="color: #666; margin: 5px 0 0 0;">Analysis of edit tool operations and success rates</p>
481
482
<div class="stats">
483
<div class="stat-card">
484
<div class="stat-label">Total Edits</div>
485
<div class="stat-value">${analysis.totalEdits}</div>
486
</div>
487
<div class="stat-card" style="border-left-color: #2da44e;">
488
<div class="stat-label">Success Rate</div>
489
<div class="stat-value" id="success-rate-value">${(analysis.successRate * 100).toFixed(1)}%</div>
490
</div>
491
<div class="stat-card" style="border-left-color: #8250df;">
492
<div class="stat-label">Conversations</div>
493
<div class="stat-value">${analysis.conversations.length}</div>
494
</div>
495
</div>
496
497
<div class="controls">
498
<label>
499
<input type="checkbox" id="includeRetries" ${includeRetries ? 'checked' : ''}>
500
Include retries (show re-evaluate → retry flows)
501
</label>
502
</div>
503
504
<div id="sankey-diagram"></div>
505
506
<h2 style="margin-top: 40px;">Edit Operations</h2>
507
<div class="table-container">
508
<table>
509
<thead>
510
<tr>
511
<th>Conversation</th>
512
<th>Tool</th>
513
<th>Turn</th>
514
<th>File</th>
515
<th>Status</th>
516
<th>Retry</th>
517
</tr>
518
</thead>
519
<tbody>
520
${tableRows.map(row => `
521
<tr>
522
<td>${row.conversation}</td>
523
<td><code style="background: #f6f8fa; padding: 2px 6px; border-radius: 3px; font-size: 12px;">${row.toolName}</code></td>
524
<td>${row.turnIndex}</td>
525
<td style="color: #666; font-size: 12px; max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${row.filePath || '-'}</td>
526
<td><span class="badge ${row.success ? 'badge-success' : 'badge-failed'}">${row.success ? '✓ Success' : '✗ Failed'}</span></td>
527
<td>${row.isRetry ? (row.retrySucceeded === true ? '<span class="badge badge-success">✓ Retry Success</span>' : row.retrySucceeded === false ? '<span class="badge badge-failed">✗ Retry Failed</span>' : '<span class="badge" style="background: #e3e3e3; color: #666;">Retry Pending</span>') : '-'}</td>
528
</tr>
529
`).join('')}
530
</tbody>
531
</table>
532
</div>
533
</div>
534
535
<script>
536
const sankeyData = {
537
nodes: ${JSON.stringify(sankeyNodes.map(name => ({ name })))},
538
links: ${JSON.stringify(sankeyLinks)}
539
};
540
const analysisData = {
541
successRate: ${analysis.successRate},
542
successRateWithRetries: ${analysis.successRateWithRetries},
543
totalEdits: ${analysis.totalEdits},
544
totalUniqueEdits: ${analysis.totalUniqueEdits}
545
};
546
547
function drawSankey(includeRetries) {
548
// Clear previous diagram
549
d3.select('#sankey-diagram').html('');
550
551
// Rebuild data based on includeRetries flag
552
const allEdits = ${JSON.stringify(tableRows)};
553
const nodes = [];
554
const links = [];
555
const nodeMap = new Map();
556
557
const getNodeIndex = (name) => {
558
if (!nodeMap.has(name)) {
559
nodeMap.set(name, nodes.length);
560
nodes.push({ name });
561
}
562
return nodeMap.get(name);
563
};
564
565
const flows = new Map();
566
567
for (const edit of allEdits) {
568
const toolNode = edit.toolName;
569
570
// Check if this is a failed edit with a retry
571
if (includeRetries && !edit.success && edit.isRetry && edit.retrySucceeded !== undefined) {
572
// Show full retry flow
573
const failedNode = 'Failed (will retry)';
574
const readFileNode = 'read_file';
575
const retryEditNode = toolNode + ' (retry)';
576
const finalResult = edit.retrySucceeded ? 'Success' : 'Failed';
577
578
flows.set(toolNode + '->' + failedNode, (flows.get(toolNode + '->' + failedNode) || 0) + 1);
579
flows.set(failedNode + '->' + readFileNode, (flows.get(failedNode + '->' + readFileNode) || 0) + 1);
580
flows.set(readFileNode + '->' + retryEditNode, (flows.get(readFileNode + '->' + retryEditNode) || 0) + 1);
581
flows.set(retryEditNode + '->' + finalResult, (flows.get(retryEditNode + '->' + finalResult) || 0) + 1);
582
continue;
583
}
584
585
const resultNode = edit.success ? 'Success' : 'Failed';
586
const flowKey = toolNode + '->' + resultNode;
587
flows.set(flowKey, (flows.get(flowKey) || 0) + 1);
588
}
589
590
for (const [flowKey, count] of flows.entries()) {
591
const [source, target] = flowKey.split('->');
592
links.push({
593
source: getNodeIndex(source),
594
target: getNodeIndex(target),
595
value: count
596
});
597
}
598
599
const width = Math.max(800, document.getElementById('sankey-diagram').offsetWidth);
600
const height = 500;
601
602
const svg = d3.select('#sankey-diagram')
603
.append('svg')
604
.attr('width', width)
605
.attr('height', height);
606
607
const sankey = d3.sankey()
608
.nodeWidth(15)
609
.nodePadding(10)
610
.extent([[1, 1], [width - 1, height - 5]]);
611
612
const graph = sankey({
613
nodes: nodes.map(d => Object.assign({}, d)),
614
links: links.map(d => Object.assign({}, d))
615
});
616
617
const colorScale = d3.scaleOrdinal()
618
.domain(['replace_string_in_file', 'multi_replace_string_in_file', 'read_file', 'Failed (will retry)', 'Success', 'Failed'])
619
.range(['#0969da', '#8250df', '#a855f7', '#ff9800', '#2da44e', '#d1242f']);
620
621
// Links
622
svg.append('g')
623
.attr('class', 'links')
624
.selectAll('path')
625
.data(graph.links)
626
.enter()
627
.append('path')
628
.attr('class', 'sankey-link')
629
.attr('d', d3.sankeyLinkHorizontal())
630
.attr('stroke', d => colorScale(d.source.name))
631
.attr('stroke-width', d => Math.max(1, d.width));
632
633
// Nodes
634
const node = svg.append('g')
635
.attr('class', 'nodes')
636
.selectAll('g')
637
.data(graph.nodes)
638
.enter()
639
.append('g')
640
.attr('class', 'sankey-node');
641
642
node.append('rect')
643
.attr('x', d => d.x0)
644
.attr('y', d => d.y0)
645
.attr('height', d => d.y1 - d.y0)
646
.attr('width', d => d.x1 - d.x0)
647
.attr('fill', d => colorScale(d.name))
648
.append('title')
649
.text(d => d.name + '\\n' + d.value + ' edits');
650
651
node.append('text')
652
.attr('x', d => d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6)
653
.attr('y', d => (d.y1 + d.y0) / 2)
654
.attr('dy', '0.35em')
655
.attr('text-anchor', d => d.x0 < width / 2 ? 'start' : 'end')
656
.text(d => d.name + ' (' + d.value + ')');
657
}
658
659
// Initial draw
660
drawSankey(${includeRetries});
661
662
// Update success rate display
663
function updateSuccessRate(includeRetries) {
664
const rate = includeRetries ? analysisData.successRateWithRetries : analysisData.successRate;
665
document.getElementById('success-rate-value').textContent = (rate * 100).toFixed(1) + '%';
666
}
667
668
// Handle checkbox change
669
document.getElementById('includeRetries').addEventListener('change', (e) => {
670
drawSankey(e.target.checked);
671
updateSuccessRate(e.target.checked);
672
});
673
674
// Redraw on window resize
675
let resizeTimer;
676
window.addEventListener('resize', () => {
677
clearTimeout(resizeTimer);
678
resizeTimer = setTimeout(() => {
679
const includeRetries = document.getElementById('includeRetries').checked;
680
drawSankey(includeRetries);
681
}, 250);
682
});
683
</script>
684
</body>
685
</html>`;
686
687
return html;
688
}
689
690
async function main() {
691
const args = process.argv.slice(2);
692
const runIdArg = args.find(arg => arg.startsWith('--runId='));
693
694
const basePath = path.join('/Users/connor/Github/vscode-copilot-evaluation/.msbenchRun');
695
696
let runId: string;
697
698
if (runIdArg) {
699
runId = runIdArg.split('=')[1];
700
console.log(`Using run ID: ${runId}`);
701
} else {
702
const runs = await listRuns(basePath);
703
if (runs.length === 0) {
704
console.error('No test runs found in', basePath);
705
process.exit(1);
706
}
707
runId = await promptUserForRun(runs);
708
console.log(`Selected run: ${runId}`);
709
}
710
711
console.log('\nAnalyzing run...');
712
const analysis = await analyzeRun(runId, basePath);
713
714
console.log(`\nFound ${analysis.conversations.length} conversations with edits`);
715
console.log(`Total edits: ${analysis.totalEdits}`);
716
console.log(`Success rate: ${(analysis.successRate * 100).toFixed(1)}%`);
717
718
const outputPath = path.join(basePath, runId, 'edit-analysis.html');
719
const html = generateHTML(analysis, outputPath);
720
721
await fs.writeFile(outputPath, html, 'utf-8');
722
console.log(`\n✓ Analysis complete! Generated: ${outputPath}`);
723
}
724
725
main().catch(console.error);
726
727