pennyrouter 0.3.52 → 0.3.53
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/package.json
CHANGED
|
@@ -105,6 +105,7 @@ export class ClaudeMeter {
|
|
|
105
105
|
this.totals = emptyTotals();
|
|
106
106
|
this.model = '';
|
|
107
107
|
this.transcriptPath = null;
|
|
108
|
+
this.transcriptFiles = [];
|
|
108
109
|
this.started = Date.now() / 1000;
|
|
109
110
|
this.requestIds = new Set();
|
|
110
111
|
this.transcriptPrints = new Map();
|
|
@@ -217,6 +218,7 @@ export class ClaudeMeter {
|
|
|
217
218
|
}
|
|
218
219
|
};
|
|
219
220
|
await collect(join(transcript.slice(0, -6), 'subagents'));
|
|
221
|
+
this.transcriptFiles = paths;
|
|
220
222
|
for (const path of paths) await this.readAppended(path);
|
|
221
223
|
this.chargeSideCalls({ final });
|
|
222
224
|
return this.snapshot();
|
|
@@ -372,6 +374,7 @@ export class CodexMeter extends ClaudeMeter {
|
|
|
372
374
|
added = true;
|
|
373
375
|
}
|
|
374
376
|
}
|
|
377
|
+
this.transcriptFiles = paths;
|
|
375
378
|
for (const path of paths) await this.readRollout(path, path === root);
|
|
376
379
|
return this.snapshot();
|
|
377
380
|
}
|
|
@@ -109,8 +109,7 @@
|
|
|
109
109
|
.eyebrow { display: block; margin-bottom: 10px; color: var(--accent); font-size: 10px; font-weight: 600;
|
|
110
110
|
letter-spacing: 1.8px; text-transform: uppercase; }
|
|
111
111
|
.card h2 { margin: 0 0 8px; font-size: 20px; font-weight: 650; letter-spacing: -.4px; }
|
|
112
|
-
.card .lede { margin: 0 0 20px; color:
|
|
113
|
-
color: #b4b4be; font-size: 12px; }
|
|
112
|
+
.card .lede { margin: 0 0 20px; color: #b4b4be; font-size: 12px; }
|
|
114
113
|
.card label { display: block; margin: 14px 0 0; color: #b8b8c1; font-size: 12px; }
|
|
115
114
|
.card input:not([type=checkbox]), .card select { display: block; width: 100%; min-width: 0; margin-top: 8px;
|
|
116
115
|
padding: 10px 12px; border: 1px solid #3a3a44; border-radius: 8px; background: var(--field); color: #eee;
|
|
@@ -229,9 +228,9 @@
|
|
|
229
228
|
<button id="end-session" class="menu-toggle" disabled aria-haspopup="menu" aria-expanded="false"
|
|
230
229
|
aria-controls="end-menu">End session</button>
|
|
231
230
|
<div class="menu-list" id="end-menu" role="menu" hidden>
|
|
232
|
-
<button id="add-to-gallery" role="menuitem" disabled title="
|
|
233
|
-
<button id="send-comment" role="menuitem" disabled title="
|
|
234
|
-
<button id="finish" role="menuitem" disabled>
|
|
231
|
+
<button id="add-to-gallery" role="menuitem" disabled title="Share the comparison in the PennyRouter gallery. The session stays open until you exit it">Submit to Gallery</button>
|
|
232
|
+
<button id="send-comment" role="menuitem" disabled title="Send a private note to the PennyRouter team. The session stays open until you exit it">Send Comment</button>
|
|
233
|
+
<button id="finish" role="menuitem" disabled title="Freeze the comparison and see its results. The session stays open until you end it">Finish Session</button>
|
|
235
234
|
</div>
|
|
236
235
|
</div>
|
|
237
236
|
<button id="download-logs" hidden>Download logs</button>
|
|
@@ -308,8 +307,9 @@
|
|
|
308
307
|
<div class="report-path" id="results-path"></div>
|
|
309
308
|
<p class="footnote" id="results-note"></p>
|
|
310
309
|
<div class="actions split">
|
|
310
|
+
<button class="secondary" id="results-back" type="button" hidden>Back to session</button>
|
|
311
311
|
<button class="secondary" id="results-publish" type="button" hidden>Add to gallery</button>
|
|
312
|
-
<button class="primary" id="results-close" type="button">
|
|
312
|
+
<button class="primary" id="results-close" type="button">End session</button>
|
|
313
313
|
</div>
|
|
314
314
|
</div>
|
|
315
315
|
</section>
|
|
@@ -349,7 +349,7 @@
|
|
|
349
349
|
<label>Video (optional, MP4 or WebM, up to 100 MB)
|
|
350
350
|
<input id="publish-video" type="file" accept="video/mp4,video/webm"></label>
|
|
351
351
|
<label class="check-label"><input id="publish-include-transcript" type="checkbox">
|
|
352
|
-
Include the conversation transcript</label>
|
|
352
|
+
<span id="publish-transcript-text">Include the conversation transcript</span></label>
|
|
353
353
|
<p class="hint">PennyRouter has a zero-retention policy. The transcript is deleted when this session ends unless you choose to include it. Included transcripts are private to Gallery reviewers and can help verify Artifact links.</p>
|
|
354
354
|
<label id="publish-transcript-label" hidden>Transcript file (JSON or JSONL, up to 10 MB)
|
|
355
355
|
<input id="publish-transcript" type="file" accept=".json,.jsonl,application/json,text/plain" disabled></label>
|
|
@@ -386,7 +386,7 @@
|
|
|
386
386
|
<label>Comment
|
|
387
387
|
<textarea id="comment-text" maxlength="4000" rows="6" required placeholder="What worked, what didn't, what you'd like us to look at?"></textarea></label>
|
|
388
388
|
<label class="check-label"><input id="comment-include-transcript" type="checkbox">
|
|
389
|
-
Include the conversation transcript</label>
|
|
389
|
+
<span id="comment-transcript-text">Include the conversation transcript</span></label>
|
|
390
390
|
<p class="hint">PennyRouter has a zero-retention policy. The transcript is deleted when this session ends unless you choose to include it. Included transcripts are sent privately to the PennyRouter team.</p>
|
|
391
391
|
<label id="comment-transcript-label" hidden>Transcript file (JSON or JSONL, up to 10 MB)
|
|
392
392
|
<input id="comment-transcript" type="file" accept=".json,.jsonl,application/json,text/plain" disabled></label>
|
|
@@ -670,10 +670,13 @@
|
|
|
670
670
|
let finishing = false;
|
|
671
671
|
let publishAfterFinish = false;
|
|
672
672
|
let commentAfterFinish = false;
|
|
673
|
+
let resultsAfterFinish = false;
|
|
674
|
+
let sessionEnded = false;
|
|
675
|
+
// Finishing freezes the comparison and produces its report; the session itself stays open, so a
|
|
676
|
+
// Gallery submission or comment can still read its transcripts, until it is exited.
|
|
673
677
|
const finishComparison = async () => {
|
|
674
678
|
if (finishing || lastReport || !started) return;
|
|
675
679
|
finishing = true;
|
|
676
|
-
$('finish').disabled = true;
|
|
677
680
|
$('add-to-gallery').disabled = true;
|
|
678
681
|
$('send-comment').disabled = true;
|
|
679
682
|
$('run-example').disabled = true;
|
|
@@ -681,10 +684,29 @@
|
|
|
681
684
|
clearInterval(fileTimer);
|
|
682
685
|
await pollFiles().catch(() => {});
|
|
683
686
|
await snapshotFiles();
|
|
684
|
-
send({ type: '
|
|
687
|
+
send({ type: 'finish' });
|
|
685
688
|
setStatus(hosted ? 'Finishing…' : 'Saving report…');
|
|
686
689
|
};
|
|
687
|
-
|
|
690
|
+
// Shows the results while the session stays open, so Add to gallery and Send Comment can still
|
|
691
|
+
// read its transcripts. The panes are already stopped, so Back to session only shows them frozen.
|
|
692
|
+
const showResults = () => {
|
|
693
|
+
if (!lastReport) { resultsAfterFinish = true; return finishComparison(); }
|
|
694
|
+
$('publish').hidden = true;
|
|
695
|
+
$('comment').hidden = true;
|
|
696
|
+
showReport(lastReport);
|
|
697
|
+
};
|
|
698
|
+
// Ends the session for good: stops the server side (a hosted sandbox is destroyed).
|
|
699
|
+
const endSession = () => {
|
|
700
|
+
if (sessionEnded) return;
|
|
701
|
+
sessionEnded = true;
|
|
702
|
+
send({ type: 'exit' });
|
|
703
|
+
if (hosted) window.parent.postMessage({ type: 'compare-demo-finished' }, config.parentOrigin);
|
|
704
|
+
};
|
|
705
|
+
$('finish').onclick = showResults;
|
|
706
|
+
$('results-back').onclick = () => {
|
|
707
|
+
$('results').hidden = true;
|
|
708
|
+
setStatus(sessionEnded ? 'Session ended' : 'Finished · session still open');
|
|
709
|
+
};
|
|
688
710
|
|
|
689
711
|
// Starter prompts typed into both panes, exactly as a visitor would type them.
|
|
690
712
|
const examples = [
|
|
@@ -997,9 +1019,12 @@
|
|
|
997
1019
|
`${side.totals.total.toLocaleString()} tokens · $${side.totals.cost.toFixed(3)}`;
|
|
998
1020
|
$('results-sides').textContent = `${line('NATIVE', native)}\n${line('PENNY', penny)}`;
|
|
999
1021
|
$('results-path').textContent = path ? `Report saved: ${path}` : '';
|
|
1000
|
-
$('results-note').textContent =
|
|
1001
|
-
? 'Your session has ended. Close this page to destroy its sandbox.'
|
|
1002
|
-
|
|
1022
|
+
$('results-note').textContent = sessionEnded
|
|
1023
|
+
? (hosted ? 'Your session has ended. Close this page to destroy its sandbox.'
|
|
1024
|
+
: 'Comparison complete. You can close this tab.')
|
|
1025
|
+
: 'Your session is still open. Add it to the gallery if you like, then end the session.';
|
|
1026
|
+
$('results-back').hidden = sessionEnded;
|
|
1027
|
+
$('results-close').textContent = sessionEnded ? 'Close tab' : 'End session';
|
|
1003
1028
|
$('results').hidden = false;
|
|
1004
1029
|
setStatus(path ? 'Report saved' : 'Finished');
|
|
1005
1030
|
};
|
|
@@ -1086,7 +1111,6 @@
|
|
|
1086
1111
|
commentAfterFinish = false;
|
|
1087
1112
|
if (hosted) {
|
|
1088
1113
|
window.parent.postMessage({ type: 'compare-demo-report', report: message.report }, config.parentOrigin);
|
|
1089
|
-
window.parent.postMessage({ type: 'compare-demo-finished' }, config.parentOrigin);
|
|
1090
1114
|
}
|
|
1091
1115
|
};
|
|
1092
1116
|
const remembered = (key, value) => {
|
|
@@ -1168,11 +1192,43 @@
|
|
|
1168
1192
|
$('publish-preview').srcdoc = sandboxed(cover.content);
|
|
1169
1193
|
}
|
|
1170
1194
|
};
|
|
1195
|
+
// The sessions' transcripts never need uploading by hand: a local server bundles them when the
|
|
1196
|
+
// form is submitted, and a hosted sandbox sends them with its report before it is destroyed.
|
|
1197
|
+
// The file picker remains only for a comparison that produced none.
|
|
1198
|
+
const autoTranscript = () => gallery?.transcriptBytes > 0;
|
|
1199
|
+
const transcriptTooLarge = () => autoTranscript() && gallery.transcriptBytes > 10 * 1024 * 1024;
|
|
1200
|
+
// What a submission sends for the "include transcripts" box: a hosted run asks its sandbox for the
|
|
1201
|
+
// bundle (it is still up), a local run asks its server to attach one when the form is posted.
|
|
1202
|
+
let transcriptRequest = null;
|
|
1203
|
+
const sessionTranscript = () => {
|
|
1204
|
+
if (!hosted) return Promise.resolve(null);
|
|
1205
|
+
return new Promise((resolve) => {
|
|
1206
|
+
const timer = setTimeout(() => { transcriptRequest = null; resolve(null); }, 30_000);
|
|
1207
|
+
transcriptRequest = (text) => {
|
|
1208
|
+
clearTimeout(timer);
|
|
1209
|
+
transcriptRequest = null;
|
|
1210
|
+
resolve(text ? new File([text], 'transcripts.jsonl', { type: 'application/x-ndjson' }) : null);
|
|
1211
|
+
};
|
|
1212
|
+
send({ type: 'transcripts' });
|
|
1213
|
+
});
|
|
1214
|
+
};
|
|
1215
|
+
const resetTranscript = (prefix) => {
|
|
1216
|
+
const box = $(`${prefix}-include-transcript`);
|
|
1217
|
+
box.checked = false;
|
|
1218
|
+
box.disabled = transcriptTooLarge();
|
|
1219
|
+
$(`${prefix}-transcript-label`).hidden = true;
|
|
1220
|
+
$(`${prefix}-transcript`).disabled = true;
|
|
1221
|
+
$(`${prefix}-transcript`).value = '';
|
|
1222
|
+
$(`${prefix}-transcript-text`).textContent = transcriptTooLarge()
|
|
1223
|
+
? 'Transcripts are over 10 MB and cannot be included'
|
|
1224
|
+
: autoTranscript() ? 'Include both session transcripts' : 'Include the conversation transcript';
|
|
1225
|
+
};
|
|
1226
|
+
const toggleTranscript = (prefix, checked) => {
|
|
1227
|
+
$(`${prefix}-transcript-label`).hidden = !checked || autoTranscript();
|
|
1228
|
+
$(`${prefix}-transcript`).disabled = !checked || autoTranscript();
|
|
1229
|
+
};
|
|
1171
1230
|
const openPublish = () => {
|
|
1172
|
-
|
|
1173
|
-
$('publish-transcript-label').hidden = true;
|
|
1174
|
-
$('publish-transcript').disabled = true;
|
|
1175
|
-
$('publish-transcript').value = '';
|
|
1231
|
+
resetTranscript('publish');
|
|
1176
1232
|
for (const [role, id] of [['NATIVE', 'publish-native'], ['PENNY', 'publish-penny']]) {
|
|
1177
1233
|
const select = $(id);
|
|
1178
1234
|
if (!select.options.length) {
|
|
@@ -1195,6 +1251,7 @@
|
|
|
1195
1251
|
$('results-publish').onclick = openPublish;
|
|
1196
1252
|
// A tab the user opened themselves cannot be closed by a page, so say so when it stays open.
|
|
1197
1253
|
$('results-close').onclick = () => {
|
|
1254
|
+
endSession();
|
|
1198
1255
|
if (hosted) window.parent.postMessage({ type: 'compare-demo-close' }, config.parentOrigin);
|
|
1199
1256
|
else window.close();
|
|
1200
1257
|
setTimeout(() => { $('results-note').textContent = 'Your browser could not automatically close tab. Please close manually.'; }, 400);
|
|
@@ -1202,14 +1259,8 @@
|
|
|
1202
1259
|
$('publish-cancel').onclick = () => { $('publish').hidden = true; };
|
|
1203
1260
|
$('publish-form').addEventListener('input', updatePreview);
|
|
1204
1261
|
$('publish-form').addEventListener('change', updatePreview);
|
|
1205
|
-
$('publish-include-transcript').onchange = (event) =>
|
|
1206
|
-
|
|
1207
|
-
$('publish-transcript').disabled = !event.target.checked;
|
|
1208
|
-
};
|
|
1209
|
-
$('comment-include-transcript').onchange = (event) => {
|
|
1210
|
-
$('comment-transcript-label').hidden = !event.target.checked;
|
|
1211
|
-
$('comment-transcript').disabled = !event.target.checked;
|
|
1212
|
-
};
|
|
1262
|
+
$('publish-include-transcript').onchange = (event) => toggleTranscript('publish', event.target.checked);
|
|
1263
|
+
$('comment-include-transcript').onchange = (event) => toggleTranscript('comment', event.target.checked);
|
|
1213
1264
|
const relayToParent = (payload, kind = 'gallery') => new Promise((resolve) => {
|
|
1214
1265
|
const timer = setTimeout(() => { removeEventListener('message', onResult); resolve({ error: 'The website did not respond. Please try again.' }); }, 10 * 60_000);
|
|
1215
1266
|
const onResult = (event) => {
|
|
@@ -1230,7 +1281,8 @@
|
|
|
1230
1281
|
const featured = checkedValue('publish-cover');
|
|
1231
1282
|
const video = $('publish-video').files[0] || null;
|
|
1232
1283
|
const includeTranscript = $('publish-include-transcript').checked;
|
|
1233
|
-
const transcript = includeTranscript
|
|
1284
|
+
const transcript = includeTranscript
|
|
1285
|
+
? (autoTranscript() ? await sessionTranscript() : $('publish-transcript').files[0] || null) : null;
|
|
1234
1286
|
const videoType = video && (video.type || (/\.webm$/i.test(video.name) ? 'video/webm' : /\.mp4$/i.test(video.name) ? 'video/mp4' : ''));
|
|
1235
1287
|
$('publish-error').textContent = '';
|
|
1236
1288
|
if (video && !['video/mp4', 'video/webm'].includes(videoType)) {
|
|
@@ -1245,8 +1297,10 @@
|
|
|
1245
1297
|
$('publish-error').textContent = 'Transcripts can be at most 10 MB.';
|
|
1246
1298
|
return;
|
|
1247
1299
|
}
|
|
1248
|
-
if (includeTranscript && !transcript) {
|
|
1249
|
-
$('publish-error').textContent =
|
|
1300
|
+
if (includeTranscript && !transcript && (!autoTranscript() || hosted)) {
|
|
1301
|
+
$('publish-error').textContent = autoTranscript()
|
|
1302
|
+
? 'The session transcripts are no longer available. Clear the include transcripts option to submit without them.'
|
|
1303
|
+
: 'Choose a transcript file or clear the include transcript selection.';
|
|
1250
1304
|
return;
|
|
1251
1305
|
}
|
|
1252
1306
|
const meta = { title: $('publish-title').value.trim(), comment: $('publish-comment').value.trim(),
|
|
@@ -1271,6 +1325,7 @@
|
|
|
1271
1325
|
if (penny) form.set('penny', new Blob([penny.content], { type: 'text/html' }), 'penny.html');
|
|
1272
1326
|
if (video) form.set('video', video, video.name);
|
|
1273
1327
|
if (transcript) form.set('transcript', transcript, transcript.name);
|
|
1328
|
+
if (includeTranscript && autoTranscript() && !hosted) form.set('include_transcript', '1');
|
|
1274
1329
|
if (coverImage) form.set('cover', coverImage, 'cover');
|
|
1275
1330
|
const response = await fetch('/gallery/submit', { method: 'POST',
|
|
1276
1331
|
headers: { Authorization: `Bearer ${gallery.publishToken}` }, body: form });
|
|
@@ -1302,10 +1357,7 @@
|
|
|
1302
1357
|
|
|
1303
1358
|
// A private note to the team: ends the session like Submit to Gallery, then asks for the comment.
|
|
1304
1359
|
const openComment = () => {
|
|
1305
|
-
|
|
1306
|
-
$('comment-transcript-label').hidden = true;
|
|
1307
|
-
$('comment-transcript').disabled = true;
|
|
1308
|
-
$('comment-transcript').value = '';
|
|
1360
|
+
resetTranscript('comment');
|
|
1309
1361
|
$('comment-done').textContent = '';
|
|
1310
1362
|
$('comment-error').textContent = '';
|
|
1311
1363
|
$('comment-submit').disabled = false;
|
|
@@ -1325,9 +1377,12 @@
|
|
|
1325
1377
|
const comment = $('comment-text').value.trim();
|
|
1326
1378
|
if (!comment) return;
|
|
1327
1379
|
const includeTranscript = $('comment-include-transcript').checked;
|
|
1328
|
-
const transcript = includeTranscript
|
|
1329
|
-
|
|
1330
|
-
|
|
1380
|
+
const transcript = includeTranscript
|
|
1381
|
+
? (autoTranscript() ? await sessionTranscript() : $('comment-transcript').files[0] || null) : null;
|
|
1382
|
+
if (includeTranscript && !transcript && (!autoTranscript() || hosted)) {
|
|
1383
|
+
$('comment-error').textContent = autoTranscript()
|
|
1384
|
+
? 'The session transcripts are no longer available. Clear the include transcripts option to send without them.'
|
|
1385
|
+
: 'Choose a transcript file or clear the include transcript selection.';
|
|
1331
1386
|
return;
|
|
1332
1387
|
}
|
|
1333
1388
|
if (transcript && transcript.size > 10 * 1024 * 1024) {
|
|
@@ -1347,6 +1402,7 @@
|
|
|
1347
1402
|
const form = new FormData();
|
|
1348
1403
|
form.set('comment', comment);
|
|
1349
1404
|
if (transcript) form.set('transcript', transcript, transcript.name);
|
|
1405
|
+
if (includeTranscript && autoTranscript() && !hosted) form.set('include_transcript', '1');
|
|
1350
1406
|
const response = await fetch('/feedback/submit', { method: 'POST',
|
|
1351
1407
|
headers: { ...(gallery?.publishToken ? { Authorization: `Bearer ${gallery.publishToken}` } : {}) }, body: form });
|
|
1352
1408
|
result = await response.json().catch(() => ({ error: 'Could not send the comment.' }));
|
|
@@ -1363,10 +1419,7 @@
|
|
|
1363
1419
|
return;
|
|
1364
1420
|
}
|
|
1365
1421
|
$('comment-text').value = '';
|
|
1366
|
-
|
|
1367
|
-
$('comment-transcript-label').hidden = true;
|
|
1368
|
-
$('comment-transcript').disabled = true;
|
|
1369
|
-
$('comment-transcript').value = '';
|
|
1422
|
+
resetTranscript('comment');
|
|
1370
1423
|
submit.textContent = 'Sent';
|
|
1371
1424
|
$('comment-done').textContent = 'Sent to the PennyRouter team. Thank you.';
|
|
1372
1425
|
$('comment-cancel').textContent = 'Close';
|
|
@@ -1381,12 +1434,16 @@
|
|
|
1381
1434
|
updateDelta();
|
|
1382
1435
|
} else if (message.type === 'exit' || message.type === 'pane-exit') {
|
|
1383
1436
|
terminals[message.side]?.write(`\r\n[process exited: ${message.code}]\r\n`);
|
|
1437
|
+
} else if (message.type === 'transcripts') {
|
|
1438
|
+
transcriptRequest?.(message.transcript);
|
|
1384
1439
|
} else if (message.type === 'report') {
|
|
1385
|
-
showReport(message);
|
|
1386
1440
|
prepareGallery(message);
|
|
1441
|
+
if (resultsAfterFinish) { resultsAfterFinish = false; showResults(); }
|
|
1442
|
+
else setStatus('Finished · session still open');
|
|
1387
1443
|
}
|
|
1388
1444
|
else if (message.type === 'report-error') {
|
|
1389
1445
|
publishAfterFinish = false;
|
|
1446
|
+
sessionEnded = true;
|
|
1390
1447
|
showReportError(message.error);
|
|
1391
1448
|
}
|
|
1392
1449
|
else return false;
|
|
@@ -1444,6 +1501,14 @@
|
|
|
1444
1501
|
$('finish').disabled = true;
|
|
1445
1502
|
if (!lastReport) $('add-to-gallery').disabled = true;
|
|
1446
1503
|
if (!$('results').hidden) return;
|
|
1504
|
+
if (lastReport && !sessionEnded) {
|
|
1505
|
+
// The sandbox ended on its own after the report; a form being filled in stays open.
|
|
1506
|
+
sessionEnded = true;
|
|
1507
|
+
if ($('publish').hidden && $('comment').hidden) showReport(lastReport);
|
|
1508
|
+
else setStatus('Session ended');
|
|
1509
|
+
toParent({ type: 'compare-demo-finished' });
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1447
1512
|
setStatus('Session ended');
|
|
1448
1513
|
toParent({ type: 'compare-demo-ended', error: launchError });
|
|
1449
1514
|
});
|
|
@@ -1621,7 +1686,7 @@
|
|
|
1621
1686
|
if (!lastReport) $('add-to-gallery').disabled = true;
|
|
1622
1687
|
if (!$('finish').disabled) {
|
|
1623
1688
|
$('finish').disabled = true;
|
|
1624
|
-
setStatus('Disconnected · reload to reconnect');
|
|
1689
|
+
if (!sessionEnded) setStatus('Disconnected · reload to reconnect');
|
|
1625
1690
|
}
|
|
1626
1691
|
});
|
|
1627
1692
|
}
|
|
@@ -14,6 +14,7 @@ import { applyPaneOptions, describePaneOptions, normalizePaneOptions } from './p
|
|
|
14
14
|
import { listArtifacts, readArtifact } from './artifacts.mjs';
|
|
15
15
|
import { readTelemetry, telemetryEnv, telemetryRoute } from './telemetry.mjs';
|
|
16
16
|
import { Readable } from 'node:stream';
|
|
17
|
+
import { bundleTranscripts, maxTranscriptBytes, transcriptBytes } from './session-transcripts.mjs';
|
|
17
18
|
|
|
18
19
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
const compareDir = dirname(here);
|
|
@@ -46,6 +47,8 @@ const artifactSessions = new Map();
|
|
|
46
47
|
const telemetryMeters = new Map();
|
|
47
48
|
// Finished comparisons that may still be published, by one-time publish token.
|
|
48
49
|
const publishable = new Map();
|
|
50
|
+
// Each finished comparison's session transcript files, by the same token as its report.
|
|
51
|
+
const sessionTranscripts = new Map();
|
|
49
52
|
const publishWindowMs = 60 * 60_000;
|
|
50
53
|
// `npm start` runs from this package's folder; INIT_CWD is where it was invoked.
|
|
51
54
|
const launchDirectory = process.env.INIT_CWD || process.cwd();
|
|
@@ -132,14 +135,26 @@ async function submitToGallery(request, response) {
|
|
|
132
135
|
const form = await new Request('http://localhost/gallery/submit', { method: 'POST',
|
|
133
136
|
headers: { 'content-type': request.headers['content-type'] || '' },
|
|
134
137
|
body: Readable.toWeb(request), duplex: 'half' }).formData();
|
|
138
|
+
await attachSessionTranscripts(form, token);
|
|
135
139
|
const result = await publishToGallery(report, form);
|
|
136
140
|
publishable.delete(token);
|
|
141
|
+
sessionTranscripts.delete(token);
|
|
137
142
|
reply(201, result);
|
|
138
143
|
} catch (error) {
|
|
139
144
|
reply(400, { error: error.message || 'Could not publish to the gallery.' });
|
|
140
145
|
}
|
|
141
146
|
}
|
|
142
147
|
|
|
148
|
+
// When the submitter ticked "include transcripts", the page sends no file: the server bundles the
|
|
149
|
+
// two sessions it launched.
|
|
150
|
+
async function attachSessionTranscripts(form, token) {
|
|
151
|
+
if (form.get('include_transcript') !== '1') return;
|
|
152
|
+
const bundle = await bundleTranscripts(sessionTranscripts.get(token) ?? []);
|
|
153
|
+
if (!bundle) throw new Error('No session transcripts were found to include.');
|
|
154
|
+
if (bundle.size > maxTranscriptBytes) throw new Error('The session transcripts are over 10 MB.');
|
|
155
|
+
form.set('transcript', bundle, 'transcripts.jsonl');
|
|
156
|
+
}
|
|
157
|
+
|
|
143
158
|
async function submitFeedback(request, response) {
|
|
144
159
|
const reply = (status, body) => response.writeHead(status, { 'Content-Type': 'application/json',
|
|
145
160
|
'Cache-Control': 'no-store' }).end(JSON.stringify(body));
|
|
@@ -163,6 +178,7 @@ async function submitFeedback(request, response) {
|
|
|
163
178
|
if (typeof comment !== 'string' || !comment.trim()) { reply(400, { error: 'Write a comment first.' }); return; }
|
|
164
179
|
const token = request.headers.authorization?.replace(/^Bearer /, '');
|
|
165
180
|
const report = publishable.get(token) ?? '';
|
|
181
|
+
await attachSessionTranscripts(form, token);
|
|
166
182
|
reply(201, await sendFeedback(comment.trim(), report, form.get('transcript')));
|
|
167
183
|
} catch (error) {
|
|
168
184
|
reply(400, { error: error.message || 'Could not send the comment.' });
|
|
@@ -300,6 +316,39 @@ wss.on('connection', (ws) => {
|
|
|
300
316
|
sessions.clear();
|
|
301
317
|
if (meterTimer) clearInterval(meterTimer);
|
|
302
318
|
};
|
|
319
|
+
let finishing = null;
|
|
320
|
+
// Reports the comparison once and stops its panes; resolves false when no report could be made.
|
|
321
|
+
const finishSession = () => finishing ??= (async () => {
|
|
322
|
+
try {
|
|
323
|
+
// Telemetry is exported every second; let the last batch land before closing the books.
|
|
324
|
+
if (telemetryRoutesBySide.size) await new Promise((done) => setTimeout(done, 1500));
|
|
325
|
+
await pollMeters();
|
|
326
|
+
for (const meter of meters.values()) meter.chargeSideCalls({ final: true });
|
|
327
|
+
const left = meters.get('left');
|
|
328
|
+
const right = meters.get('right');
|
|
329
|
+
if (!left || !right) throw new Error('Both compare panes must start before a report can be saved.');
|
|
330
|
+
const report = galleryReport(left, right, { environment, harnessVersions: await harnessVersions });
|
|
331
|
+
const publishToken = report ? randomUUID() : null;
|
|
332
|
+
if (publishToken) {
|
|
333
|
+
publishable.set(publishToken, report);
|
|
334
|
+
const transcripts = [left, right].map((meter) => ({ role: meter.role, harness: meter.snapshot().harness,
|
|
335
|
+
files: meter.transcriptFiles }));
|
|
336
|
+
sessionTranscripts.set(publishToken, transcripts);
|
|
337
|
+
setTimeout(() => { publishable.delete(publishToken); sessionTranscripts.delete(publishToken); },
|
|
338
|
+
publishWindowMs).unref();
|
|
339
|
+
}
|
|
340
|
+
send({ type: 'report', ...await saveReport(left, right, join(compareDir, 'reports'), environment),
|
|
341
|
+
gallery: publishToken ? { publishToken, transcriptBytes: await transcriptBytes(sessionTranscripts.get(publishToken)) } : null });
|
|
342
|
+
if (meterTimer) clearInterval(meterTimer);
|
|
343
|
+
for (const session of sessions.values()) {
|
|
344
|
+
try { session.kill(); } catch { /* already exited */ }
|
|
345
|
+
}
|
|
346
|
+
return true;
|
|
347
|
+
} catch (error) {
|
|
348
|
+
send({ type: 'report-error', error: error.message });
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
})();
|
|
303
352
|
const handle = async (raw) => {
|
|
304
353
|
let message;
|
|
305
354
|
try { message = JSON.parse(raw); } catch { return; }
|
|
@@ -327,30 +376,16 @@ wss.on('connection', (ws) => {
|
|
|
327
376
|
}
|
|
328
377
|
return;
|
|
329
378
|
}
|
|
379
|
+
// "finish" freezes the comparison and reports it while the panes' transcripts stay on disk, so a
|
|
380
|
+
// Gallery submission or comment can still read them; "exit" then ends the server-side session.
|
|
381
|
+
if (message.type === 'finish') {
|
|
382
|
+
if (compare && !await finishSession()) { dispose(); ws.close(); }
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
330
385
|
if (message.type === 'exit') {
|
|
331
386
|
if (exiting) return;
|
|
332
387
|
exiting = true;
|
|
333
|
-
if (compare) {
|
|
334
|
-
try {
|
|
335
|
-
// Telemetry is exported every second; let the last batch land before closing the books.
|
|
336
|
-
if (telemetryRoutesBySide.size) await new Promise((done) => setTimeout(done, 1500));
|
|
337
|
-
await pollMeters();
|
|
338
|
-
for (const meter of meters.values()) meter.chargeSideCalls({ final: true });
|
|
339
|
-
const left = meters.get('left');
|
|
340
|
-
const right = meters.get('right');
|
|
341
|
-
if (!left || !right) throw new Error('Both compare panes must start before a report can be saved.');
|
|
342
|
-
const report = galleryReport(left, right, { environment, harnessVersions: await harnessVersions });
|
|
343
|
-
const publishToken = report ? randomUUID() : null;
|
|
344
|
-
if (publishToken) {
|
|
345
|
-
publishable.set(publishToken, report);
|
|
346
|
-
setTimeout(() => publishable.delete(publishToken), publishWindowMs).unref();
|
|
347
|
-
}
|
|
348
|
-
send({ type: 'report', ...await saveReport(left, right, join(compareDir, 'reports'), environment),
|
|
349
|
-
gallery: publishToken ? { publishToken } : null });
|
|
350
|
-
} catch (error) {
|
|
351
|
-
send({ type: 'report-error', error: error.message });
|
|
352
|
-
}
|
|
353
|
-
}
|
|
388
|
+
if (compare && !await finishSession()) { /* the error was already reported */ }
|
|
354
389
|
dispose();
|
|
355
390
|
ws.close();
|
|
356
391
|
return;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
// The Gallery stores one transcript per submission. A finished comparison has two sessions, so
|
|
4
|
+
// they travel as a single JSONL file: a header line naming each session, then that session's
|
|
5
|
+
// own lines unchanged (the root transcript first, then any subagent or child transcripts).
|
|
6
|
+
export const maxTranscriptBytes = 10 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
const header = (role, harness, file) => `${JSON.stringify({ penny_compare_session: role, harness, file })}\n`;
|
|
9
|
+
|
|
10
|
+
async function sessionParts({ role, harness, files }) {
|
|
11
|
+
const parts = [];
|
|
12
|
+
for (const [index, path] of files.entries()) {
|
|
13
|
+
let text;
|
|
14
|
+
try { text = await readFile(path, 'utf8'); } catch { continue; }
|
|
15
|
+
if (!text) continue;
|
|
16
|
+
parts.push(header(role, harness, index === 0 ? 'root' : 'child'), text.endsWith('\n') ? text : `${text}\n`);
|
|
17
|
+
}
|
|
18
|
+
return parts;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The two sessions' transcripts as one JSONL blob, or null when neither left a readable file. */
|
|
22
|
+
export async function bundleTranscripts(sessions) {
|
|
23
|
+
const parts = (await Promise.all(sessions.map(sessionParts))).flat();
|
|
24
|
+
return parts.length ? new Blob(parts, { type: 'application/x-ndjson' }) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Bytes the bundle will occupy, without reading the files (a file with no final newline gains one). */
|
|
28
|
+
export async function transcriptBytes(sessions) {
|
|
29
|
+
let total = 0;
|
|
30
|
+
for (const { role, harness, files } of sessions) {
|
|
31
|
+
for (const [index, path] of files.entries()) {
|
|
32
|
+
const info = await stat(path).catch(() => null);
|
|
33
|
+
if (info?.size) total += info.size + Buffer.byteLength(header(role, harness, index === 0 ? 'root' : 'child'));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return total;
|
|
37
|
+
}
|