pennyrouter 0.3.51 → 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 +1 -1
- package/standalone/web-demo/claude-meter.mjs +6 -3
- package/standalone/web-demo/compare.html +289 -20
- package/standalone/web-demo/gallery-submit.mjs +24 -0
- package/standalone/web-demo/server.mjs +90 -22
- package/standalone/web-demo/session-transcripts.mjs +37 -0
- package/standalone/remote/rc_echo.py +0 -298
- package/standalone/remote/rc_local.py +0 -110
- package/standalone/remote/rc_penny.py +0 -205
- package/standalone/remote/rc_project.py +0 -167
- package/standalone/remote/rc_restart.py +0 -145
- package/standalone/remote/rc_tools.py +0 -171
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();
|
|
@@ -315,9 +317,9 @@ export class ClaudeMeter {
|
|
|
315
317
|
// Subagent threads sit beside the root and name their parent in session_meta, so the meter
|
|
316
318
|
// follows that lineage (as usage.related_transcripts does) and charges each thread's deltas once.
|
|
317
319
|
export class CodexMeter extends ClaudeMeter {
|
|
318
|
-
constructor(options) {
|
|
320
|
+
constructor({ codexHome = join(homedir(), '.codex'), ...options }) {
|
|
319
321
|
super(options);
|
|
320
|
-
this.root = join(
|
|
322
|
+
this.root = join(codexHome, 'sessions');
|
|
321
323
|
this.transcriptPath = null;
|
|
322
324
|
this.threads = new Map();
|
|
323
325
|
this.metas = new Map();
|
|
@@ -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
|
}
|
|
@@ -458,7 +461,7 @@ export async function saveReport(left, right, reportsDir, environment) {
|
|
|
458
461
|
const path = join(reportsDir, `compare-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
|
|
459
462
|
await writeFile(path, `${JSON.stringify(report, null, 2)}\n`);
|
|
460
463
|
return { path, savings, native: { calls: native.call_count, totals: native.totals },
|
|
461
|
-
penny: { calls: penny.call_count, totals: penny.totals } };
|
|
464
|
+
penny: { calls: penny.call_count, totals: penny.totals }, report };
|
|
462
465
|
}
|
|
463
466
|
|
|
464
467
|
// The report a gallery submission carries (apps/web/lib/gallery.ts validates it and recomputes
|
|
@@ -30,6 +30,13 @@
|
|
|
30
30
|
.bar button:disabled { opacity: .45; cursor: default; }
|
|
31
31
|
.bar .sync-on { color: var(--accent); border-color: #547a50; background: var(--accent-wash); }
|
|
32
32
|
.bar .sync-off { color: #a0a0aa; border-color: #62626d; background: #292930; }
|
|
33
|
+
.menu { position: relative; }
|
|
34
|
+
.bar .menu-toggle::after { content: '▾'; margin-left: 7px; font-size: 10px; }
|
|
35
|
+
.menu-list { position: absolute; top: calc(100% + 6px); left: 0; z-index: 8; min-width: 200px; padding: 4px;
|
|
36
|
+
display: flex; flex-direction: column; border: 1px solid var(--line-strong); border-radius: 8px;
|
|
37
|
+
background: var(--card); box-shadow: 0 8px 24px rgba(0, 0, 0, .45); }
|
|
38
|
+
.bar .menu-list button { border: 0; border-radius: 5px; padding: 7px 10px; text-align: left; white-space: nowrap; }
|
|
39
|
+
.bar .menu-list button:hover:not(:disabled), .bar .menu-list button:focus-visible { background: var(--accent-wash); }
|
|
33
40
|
#delta { color: var(--accent); }
|
|
34
41
|
.pane header #delta { margin-left: auto; white-space: nowrap; }
|
|
35
42
|
#versions { margin-left: auto; color: #92929c; font-size: 12px; }
|
|
@@ -102,8 +109,7 @@
|
|
|
102
109
|
.eyebrow { display: block; margin-bottom: 10px; color: var(--accent); font-size: 10px; font-weight: 600;
|
|
103
110
|
letter-spacing: 1.8px; text-transform: uppercase; }
|
|
104
111
|
.card h2 { margin: 0 0 8px; font-size: 20px; font-weight: 650; letter-spacing: -.4px; }
|
|
105
|
-
.card .lede { margin: 0 0 20px; color:
|
|
106
|
-
color: #b4b4be; font-size: 12px; }
|
|
112
|
+
.card .lede { margin: 0 0 20px; color: #b4b4be; font-size: 12px; }
|
|
107
113
|
.card label { display: block; margin: 14px 0 0; color: #b8b8c1; font-size: 12px; }
|
|
108
114
|
.card input:not([type=checkbox]), .card select { display: block; width: 100%; min-width: 0; margin-top: 8px;
|
|
109
115
|
padding: 10px 12px; border: 1px solid #3a3a44; border-radius: 8px; background: var(--field); color: #eee;
|
|
@@ -158,6 +164,7 @@
|
|
|
158
164
|
@media (max-width: 760px) { .publish-layout { grid-template-columns: 1fr; } }
|
|
159
165
|
.actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
|
|
160
166
|
.actions .primary { width: auto; margin: 0; }
|
|
167
|
+
.actions.split > * { flex: 1; }
|
|
161
168
|
.card .secondary { padding: 11px 16px; border: 1px solid #3a3a44; border-radius: 8px; background: transparent;
|
|
162
169
|
color: var(--text); font-size: 13px; }
|
|
163
170
|
.publish-done { margin: 14px 0 0; color: var(--accent); }
|
|
@@ -212,9 +219,21 @@
|
|
|
212
219
|
<div class="bar"><strong>PENNY COMPARE</strong>
|
|
213
220
|
<button id="sync" class="sync-off" disabled aria-pressed="false"
|
|
214
221
|
title="Type into both panes at once (Ctrl/⌘+Shift+S)">Sync off</button>
|
|
215
|
-
<
|
|
222
|
+
<div class="menu">
|
|
223
|
+
<button id="run-example" class="menu-toggle" disabled aria-haspopup="menu" aria-expanded="false"
|
|
224
|
+
aria-controls="example-menu" title="Type a starter prompt into both panes">Run example</button>
|
|
225
|
+
<div class="menu-list" id="example-menu" role="menu" hidden></div>
|
|
226
|
+
</div>
|
|
227
|
+
<div class="menu">
|
|
228
|
+
<button id="end-session" class="menu-toggle" disabled aria-haspopup="menu" aria-expanded="false"
|
|
229
|
+
aria-controls="end-menu">End session</button>
|
|
230
|
+
<div class="menu-list" id="end-menu" role="menu" hidden>
|
|
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>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
216
236
|
<button id="download-logs" hidden>Download logs</button>
|
|
217
|
-
<button id="add-to-gallery" disabled title="Finish this comparison and share it in the PennyRouter gallery">Add to gallery</button>
|
|
218
237
|
<span id="versions"></span>
|
|
219
238
|
<span id="status">Connecting…</span>
|
|
220
239
|
</div>
|
|
@@ -287,7 +306,11 @@
|
|
|
287
306
|
<div class="sides" id="results-sides"></div>
|
|
288
307
|
<div class="report-path" id="results-path"></div>
|
|
289
308
|
<p class="footnote" id="results-note"></p>
|
|
290
|
-
<
|
|
309
|
+
<div class="actions split">
|
|
310
|
+
<button class="secondary" id="results-back" type="button" hidden>Back to session</button>
|
|
311
|
+
<button class="secondary" id="results-publish" type="button" hidden>Add to gallery</button>
|
|
312
|
+
<button class="primary" id="results-close" type="button">End session</button>
|
|
313
|
+
</div>
|
|
291
314
|
</div>
|
|
292
315
|
</section>
|
|
293
316
|
|
|
@@ -325,6 +348,11 @@
|
|
|
325
348
|
<p class="hint" id="publish-cover-note">Without one, a screenshot is added during review.</p>
|
|
326
349
|
<label>Video (optional, MP4 or WebM, up to 100 MB)
|
|
327
350
|
<input id="publish-video" type="file" accept="video/mp4,video/webm"></label>
|
|
351
|
+
<label class="check-label"><input id="publish-include-transcript" type="checkbox">
|
|
352
|
+
<span id="publish-transcript-text">Include the conversation transcript</span></label>
|
|
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
|
+
<label id="publish-transcript-label" hidden>Transcript file (JSON or JSONL, up to 10 MB)
|
|
355
|
+
<input id="publish-transcript" type="file" accept=".json,.jsonl,application/json,text/plain" disabled></label>
|
|
328
356
|
</div>
|
|
329
357
|
<div class="preview-column">
|
|
330
358
|
<span class="field-label">Preview</span>
|
|
@@ -350,6 +378,26 @@
|
|
|
350
378
|
</form>
|
|
351
379
|
</section>
|
|
352
380
|
|
|
381
|
+
<section class="overlay" id="comment" hidden>
|
|
382
|
+
<form class="card" id="comment-form">
|
|
383
|
+
<span class="eyebrow">Penny Compare</span>
|
|
384
|
+
<h2>Send a comment</h2>
|
|
385
|
+
<p class="lede">A private note to the PennyRouter team. Nothing is published, and the session's cost numbers are attached when available.</p>
|
|
386
|
+
<label>Comment
|
|
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
|
+
<label class="check-label"><input id="comment-include-transcript" type="checkbox">
|
|
389
|
+
<span id="comment-transcript-text">Include the conversation transcript</span></label>
|
|
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
|
+
<label id="comment-transcript-label" hidden>Transcript file (JSON or JSONL, up to 10 MB)
|
|
392
|
+
<input id="comment-transcript" type="file" accept=".json,.jsonl,application/json,text/plain" disabled></label>
|
|
393
|
+
<p class="error" id="comment-error" role="alert"></p>
|
|
394
|
+
<p class="publish-done" id="comment-done" role="status"></p>
|
|
395
|
+
<div class="actions">
|
|
396
|
+
<button class="secondary" id="comment-cancel" type="button">Cancel</button>
|
|
397
|
+
<button class="primary" id="comment-submit">Send comment</button>
|
|
398
|
+
</div>
|
|
399
|
+
</form>
|
|
400
|
+
</section>
|
|
353
401
|
<script src="/xterm.js"></script><script src="/addon-fit.js"></script>
|
|
354
402
|
<script src="/addon-unicode11.js"></script>
|
|
355
403
|
<script src="/terminal-unicode.js"></script>
|
|
@@ -611,6 +659,9 @@
|
|
|
611
659
|
$('sync').disabled = false;
|
|
612
660
|
$('finish').disabled = false;
|
|
613
661
|
$('add-to-gallery').disabled = false;
|
|
662
|
+
$('send-comment').disabled = false;
|
|
663
|
+
$('end-session').disabled = false;
|
|
664
|
+
$('run-example').disabled = false;
|
|
614
665
|
setStatus('Live');
|
|
615
666
|
resize();
|
|
616
667
|
terminals.left.focus();
|
|
@@ -618,19 +669,88 @@
|
|
|
618
669
|
};
|
|
619
670
|
let finishing = false;
|
|
620
671
|
let publishAfterFinish = false;
|
|
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.
|
|
621
677
|
const finishComparison = async () => {
|
|
622
678
|
if (finishing || lastReport || !started) return;
|
|
623
679
|
finishing = true;
|
|
624
|
-
$('finish').disabled = true;
|
|
625
680
|
$('add-to-gallery').disabled = true;
|
|
681
|
+
$('send-comment').disabled = true;
|
|
682
|
+
$('run-example').disabled = true;
|
|
626
683
|
setStatus('Saving files…');
|
|
627
684
|
clearInterval(fileTimer);
|
|
628
685
|
await pollFiles().catch(() => {});
|
|
629
686
|
await snapshotFiles();
|
|
630
|
-
send({ type: '
|
|
687
|
+
send({ type: 'finish' });
|
|
631
688
|
setStatus(hosted ? 'Finishing…' : 'Saving report…');
|
|
632
689
|
};
|
|
633
|
-
|
|
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
|
+
};
|
|
710
|
+
|
|
711
|
+
// Starter prompts typed into both panes, exactly as a visitor would type them.
|
|
712
|
+
const examples = [
|
|
713
|
+
{ label: 'Game of Life (tests)', prompt: 'Using only Node built-ins (no installs): put Game of Life rules in life.mjs on a wrapping grid, write life.test.mjs with node:test covering a blinker, a block, a glider after 4 steps and edge wrapping, run node --test and fix until everything passes, then create life.html as a single self-contained page using the same engine code, with play, pause, step, randomize, a speed slider and click-to-toggle cells.' },
|
|
714
|
+
{ label: 'Sales dashboard (data)', prompt: 'Using only Node built-ins (no installs): write gen.mjs that writes sales.csv with 5000 orders from a seeded random generator (date, store, product, qty, unit_price) across 4 coffee shops over 12 months of 2025, run it, then write analyze.mjs that prints monthly revenue per store, the top 5 products by revenue and the busiest weekday, run it and check the store totals add up to the overall total, then build dashboard.html, a single file with those results embedded and drawn as inline SVG charts.' },
|
|
715
|
+
{ label: 'Maze solver (verify)', prompt: 'Using only Node built-ins (no installs): write maze.mjs with a seeded recursive-backtracker maze generator and a BFS solver, write check.mjs that generates 25x25 mazes for seeds 1 to 50 and verifies every cell is reachable and each solution path only moves through open walls, run it and fix anything that fails, then create maze.html, a single self-contained page that animates the maze being carved and then solved, with a seed box and a new-maze button.' },
|
|
716
|
+
{ label: 'Penalty kick (quick)', prompt: 'Create football-penalty.html: a single-file page with a playable penalty-kick game. Click to aim, a goalkeeper dives, and a score is kept.' },
|
|
717
|
+
];
|
|
718
|
+
const runExample = (prompt) => {
|
|
719
|
+
for (const side of sides) {
|
|
720
|
+
send({ type: 'take-control', side });
|
|
721
|
+
send({ type: 'input', side, data: prompt });
|
|
722
|
+
}
|
|
723
|
+
// Sent after the text so the agent's prompt reads it as typing followed by Enter, not a paste.
|
|
724
|
+
setTimeout(() => { for (const side of sides) send({ type: 'input', side, data: '\r' }); }, 250);
|
|
725
|
+
terminals.left.focus();
|
|
726
|
+
};
|
|
727
|
+
for (const example of examples) {
|
|
728
|
+
const item = document.createElement('button');
|
|
729
|
+
item.type = 'button';
|
|
730
|
+
item.setAttribute('role', 'menuitem');
|
|
731
|
+
item.textContent = example.label;
|
|
732
|
+
item.title = example.prompt;
|
|
733
|
+
item.onclick = () => runExample(example.prompt);
|
|
734
|
+
$('example-menu').append(item);
|
|
735
|
+
}
|
|
736
|
+
const menus = [['run-example', 'example-menu'], ['end-session', 'end-menu']];
|
|
737
|
+
const closeMenus = (except) => {
|
|
738
|
+
for (const [toggle, list] of menus) {
|
|
739
|
+
if (toggle === except) continue;
|
|
740
|
+
$(list).hidden = true;
|
|
741
|
+
$(toggle).setAttribute('aria-expanded', 'false');
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
for (const [toggle, list] of menus) {
|
|
745
|
+
$(toggle).onclick = (event) => {
|
|
746
|
+
event.stopPropagation();
|
|
747
|
+
closeMenus(toggle);
|
|
748
|
+
$(list).hidden = !$(list).hidden;
|
|
749
|
+
$(toggle).setAttribute('aria-expanded', String(!$(list).hidden));
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
document.addEventListener('click', () => closeMenus());
|
|
753
|
+
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeMenus(); });
|
|
634
754
|
|
|
635
755
|
// Files the agents write, previewed in a separate tab
|
|
636
756
|
const previewTabs = new Map();
|
|
@@ -899,9 +1019,12 @@
|
|
|
899
1019
|
`${side.totals.total.toLocaleString()} tokens · $${side.totals.cost.toFixed(3)}`;
|
|
900
1020
|
$('results-sides').textContent = `${line('NATIVE', native)}\n${line('PENNY', penny)}`;
|
|
901
1021
|
$('results-path').textContent = path ? `Report saved: ${path}` : '';
|
|
902
|
-
$('results-note').textContent =
|
|
903
|
-
? 'Your session has ended. Close this page to destroy its sandbox.'
|
|
904
|
-
|
|
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';
|
|
905
1028
|
$('results').hidden = false;
|
|
906
1029
|
setStatus(path ? 'Report saved' : 'Finished');
|
|
907
1030
|
};
|
|
@@ -981,9 +1104,14 @@
|
|
|
981
1104
|
}
|
|
982
1105
|
$('results-publish').hidden = false;
|
|
983
1106
|
if (blocker) $('results-publish').textContent = blocker;
|
|
1107
|
+
$('send-comment').disabled = false;
|
|
984
1108
|
if (publishAfterFinish && !blocker) openPublish();
|
|
1109
|
+
if (commentAfterFinish) openComment();
|
|
985
1110
|
publishAfterFinish = false;
|
|
986
|
-
|
|
1111
|
+
commentAfterFinish = false;
|
|
1112
|
+
if (hosted) {
|
|
1113
|
+
window.parent.postMessage({ type: 'compare-demo-report', report: message.report }, config.parentOrigin);
|
|
1114
|
+
}
|
|
987
1115
|
};
|
|
988
1116
|
const remembered = (key, value) => {
|
|
989
1117
|
try {
|
|
@@ -1064,7 +1192,43 @@
|
|
|
1064
1192
|
$('publish-preview').srcdoc = sandboxed(cover.content);
|
|
1065
1193
|
}
|
|
1066
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
|
+
};
|
|
1067
1230
|
const openPublish = () => {
|
|
1231
|
+
resetTranscript('publish');
|
|
1068
1232
|
for (const [role, id] of [['NATIVE', 'publish-native'], ['PENNY', 'publish-penny']]) {
|
|
1069
1233
|
const select = $(id);
|
|
1070
1234
|
if (!select.options.length) {
|
|
@@ -1085,20 +1249,29 @@
|
|
|
1085
1249
|
return finishComparison();
|
|
1086
1250
|
};
|
|
1087
1251
|
$('results-publish').onclick = openPublish;
|
|
1252
|
+
// A tab the user opened themselves cannot be closed by a page, so say so when it stays open.
|
|
1253
|
+
$('results-close').onclick = () => {
|
|
1254
|
+
endSession();
|
|
1255
|
+
if (hosted) window.parent.postMessage({ type: 'compare-demo-close' }, config.parentOrigin);
|
|
1256
|
+
else window.close();
|
|
1257
|
+
setTimeout(() => { $('results-note').textContent = 'Your browser could not automatically close tab. Please close manually.'; }, 400);
|
|
1258
|
+
};
|
|
1088
1259
|
$('publish-cancel').onclick = () => { $('publish').hidden = true; };
|
|
1089
1260
|
$('publish-form').addEventListener('input', updatePreview);
|
|
1090
1261
|
$('publish-form').addEventListener('change', updatePreview);
|
|
1091
|
-
|
|
1262
|
+
$('publish-include-transcript').onchange = (event) => toggleTranscript('publish', event.target.checked);
|
|
1263
|
+
$('comment-include-transcript').onchange = (event) => toggleTranscript('comment', event.target.checked);
|
|
1264
|
+
const relayToParent = (payload, kind = 'gallery') => new Promise((resolve) => {
|
|
1092
1265
|
const timer = setTimeout(() => { removeEventListener('message', onResult); resolve({ error: 'The website did not respond. Please try again.' }); }, 10 * 60_000);
|
|
1093
1266
|
const onResult = (event) => {
|
|
1094
1267
|
if (event.origin !== config.parentOrigin || event.source !== window.parent ||
|
|
1095
|
-
event.data?.type !==
|
|
1268
|
+
event.data?.type !== `compare-${kind}-result`) return;
|
|
1096
1269
|
clearTimeout(timer);
|
|
1097
1270
|
removeEventListener('message', onResult);
|
|
1098
1271
|
resolve(event.data);
|
|
1099
1272
|
};
|
|
1100
1273
|
addEventListener('message', onResult);
|
|
1101
|
-
window.parent.postMessage({ type:
|
|
1274
|
+
window.parent.postMessage({ type: `compare-${kind}-submit`, ...payload }, config.parentOrigin);
|
|
1102
1275
|
});
|
|
1103
1276
|
$('publish-form').onsubmit = async (event) => {
|
|
1104
1277
|
event.preventDefault();
|
|
@@ -1107,6 +1280,9 @@
|
|
|
1107
1280
|
const penny = chosen('PENNY');
|
|
1108
1281
|
const featured = checkedValue('publish-cover');
|
|
1109
1282
|
const video = $('publish-video').files[0] || null;
|
|
1283
|
+
const includeTranscript = $('publish-include-transcript').checked;
|
|
1284
|
+
const transcript = includeTranscript
|
|
1285
|
+
? (autoTranscript() ? await sessionTranscript() : $('publish-transcript').files[0] || null) : null;
|
|
1110
1286
|
const videoType = video && (video.type || (/\.webm$/i.test(video.name) ? 'video/webm' : /\.mp4$/i.test(video.name) ? 'video/mp4' : ''));
|
|
1111
1287
|
$('publish-error').textContent = '';
|
|
1112
1288
|
if (video && !['video/mp4', 'video/webm'].includes(videoType)) {
|
|
@@ -1117,6 +1293,16 @@
|
|
|
1117
1293
|
$('publish-error').textContent = 'Videos can be at most 100 MB.';
|
|
1118
1294
|
return;
|
|
1119
1295
|
}
|
|
1296
|
+
if (transcript && transcript.size > 10 * 1024 * 1024) {
|
|
1297
|
+
$('publish-error').textContent = 'Transcripts can be at most 10 MB.';
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
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.';
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1120
1306
|
const meta = { title: $('publish-title').value.trim(), comment: $('publish-comment').value.trim(),
|
|
1121
1307
|
tag: checkedValue('publish-tag'), display_name: $('publish-name').value.trim(),
|
|
1122
1308
|
native_file: native?.path ?? null, penny_file: penny?.path ?? null,
|
|
@@ -1131,13 +1317,15 @@
|
|
|
1131
1317
|
if (hosted) {
|
|
1132
1318
|
result = await relayToParent({ report: gallery.report, signature: gallery.signature,
|
|
1133
1319
|
reportNonce: gallery.reportNonce, meta, native: native?.content ?? null, penny: penny?.content ?? null, video,
|
|
1134
|
-
cover: coverImage });
|
|
1320
|
+
cover: coverImage, transcript });
|
|
1135
1321
|
} else {
|
|
1136
1322
|
const form = new FormData();
|
|
1137
1323
|
form.set('meta', JSON.stringify(meta));
|
|
1138
1324
|
if (native) form.set('native', new Blob([native.content], { type: 'text/html' }), 'native.html');
|
|
1139
1325
|
if (penny) form.set('penny', new Blob([penny.content], { type: 'text/html' }), 'penny.html');
|
|
1140
1326
|
if (video) form.set('video', video, video.name);
|
|
1327
|
+
if (transcript) form.set('transcript', transcript, transcript.name);
|
|
1328
|
+
if (includeTranscript && autoTranscript() && !hosted) form.set('include_transcript', '1');
|
|
1141
1329
|
if (coverImage) form.set('cover', coverImage, 'cover');
|
|
1142
1330
|
const response = await fetch('/gallery/submit', { method: 'POST',
|
|
1143
1331
|
headers: { Authorization: `Bearer ${gallery.publishToken}` }, body: form });
|
|
@@ -1167,6 +1355,76 @@
|
|
|
1167
1355
|
$('publish').hidden = true;
|
|
1168
1356
|
};
|
|
1169
1357
|
|
|
1358
|
+
// A private note to the team: ends the session like Submit to Gallery, then asks for the comment.
|
|
1359
|
+
const openComment = () => {
|
|
1360
|
+
resetTranscript('comment');
|
|
1361
|
+
$('comment-done').textContent = '';
|
|
1362
|
+
$('comment-error').textContent = '';
|
|
1363
|
+
$('comment-submit').disabled = false;
|
|
1364
|
+
$('comment-submit').textContent = 'Send comment';
|
|
1365
|
+
$('comment-cancel').textContent = 'Cancel';
|
|
1366
|
+
$('comment').hidden = false;
|
|
1367
|
+
$('comment-text').focus();
|
|
1368
|
+
};
|
|
1369
|
+
$('send-comment').onclick = () => {
|
|
1370
|
+
if (lastReport) return openComment();
|
|
1371
|
+
commentAfterFinish = true;
|
|
1372
|
+
return finishComparison();
|
|
1373
|
+
};
|
|
1374
|
+
$('comment-cancel').onclick = () => { $('comment').hidden = true; };
|
|
1375
|
+
$('comment-form').onsubmit = async (event) => {
|
|
1376
|
+
event.preventDefault();
|
|
1377
|
+
const comment = $('comment-text').value.trim();
|
|
1378
|
+
if (!comment) return;
|
|
1379
|
+
const includeTranscript = $('comment-include-transcript').checked;
|
|
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.';
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
if (transcript && transcript.size > 10 * 1024 * 1024) {
|
|
1389
|
+
$('comment-error').textContent = 'Transcripts can be at most 10 MB.';
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
const submit = $('comment-submit');
|
|
1393
|
+
submit.disabled = true;
|
|
1394
|
+
submit.classList.add('busy');
|
|
1395
|
+
submit.textContent = 'Sending…';
|
|
1396
|
+
$('comment-error').textContent = '';
|
|
1397
|
+
let result;
|
|
1398
|
+
try {
|
|
1399
|
+
if (hosted) {
|
|
1400
|
+
result = await relayToParent({ comment, report: gallery?.report ?? '', transcript }, 'feedback');
|
|
1401
|
+
} else {
|
|
1402
|
+
const form = new FormData();
|
|
1403
|
+
form.set('comment', comment);
|
|
1404
|
+
if (transcript) form.set('transcript', transcript, transcript.name);
|
|
1405
|
+
if (includeTranscript && autoTranscript() && !hosted) form.set('include_transcript', '1');
|
|
1406
|
+
const response = await fetch('/feedback/submit', { method: 'POST',
|
|
1407
|
+
headers: { ...(gallery?.publishToken ? { Authorization: `Bearer ${gallery.publishToken}` } : {}) }, body: form });
|
|
1408
|
+
result = await response.json().catch(() => ({ error: 'Could not send the comment.' }));
|
|
1409
|
+
if (!response.ok && !result.error) result.error = 'Could not send the comment.';
|
|
1410
|
+
}
|
|
1411
|
+
} catch {
|
|
1412
|
+
result = { error: 'Could not send the comment.' };
|
|
1413
|
+
}
|
|
1414
|
+
submit.classList.remove('busy');
|
|
1415
|
+
if (result.error) {
|
|
1416
|
+
$('comment-error').textContent = result.error;
|
|
1417
|
+
submit.disabled = false;
|
|
1418
|
+
submit.textContent = 'Send comment';
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
$('comment-text').value = '';
|
|
1422
|
+
resetTranscript('comment');
|
|
1423
|
+
submit.textContent = 'Sent';
|
|
1424
|
+
$('comment-done').textContent = 'Sent to the PennyRouter team. Thank you.';
|
|
1425
|
+
$('comment-cancel').textContent = 'Close';
|
|
1426
|
+
};
|
|
1427
|
+
|
|
1170
1428
|
// Messages common to both servers
|
|
1171
1429
|
const handleMessage = (message) => {
|
|
1172
1430
|
if (message.type === 'output') terminals[message.side]?.write(message.data);
|
|
@@ -1176,12 +1434,16 @@
|
|
|
1176
1434
|
updateDelta();
|
|
1177
1435
|
} else if (message.type === 'exit' || message.type === 'pane-exit') {
|
|
1178
1436
|
terminals[message.side]?.write(`\r\n[process exited: ${message.code}]\r\n`);
|
|
1437
|
+
} else if (message.type === 'transcripts') {
|
|
1438
|
+
transcriptRequest?.(message.transcript);
|
|
1179
1439
|
} else if (message.type === 'report') {
|
|
1180
|
-
showReport(message);
|
|
1181
1440
|
prepareGallery(message);
|
|
1441
|
+
if (resultsAfterFinish) { resultsAfterFinish = false; showResults(); }
|
|
1442
|
+
else setStatus('Finished · session still open');
|
|
1182
1443
|
}
|
|
1183
1444
|
else if (message.type === 'report-error') {
|
|
1184
1445
|
publishAfterFinish = false;
|
|
1446
|
+
sessionEnded = true;
|
|
1185
1447
|
showReportError(message.error);
|
|
1186
1448
|
}
|
|
1187
1449
|
else return false;
|
|
@@ -1216,7 +1478,7 @@
|
|
|
1216
1478
|
$('download-logs').hidden = !message.captureLogs;
|
|
1217
1479
|
if (message.subscription) {
|
|
1218
1480
|
setStatus('Starting subscription test · costs are API-equivalent estimates');
|
|
1219
|
-
send({ type: 'start', harness: message.subscriptionHarness || 'claude' });
|
|
1481
|
+
send({ type: 'start', harness: message.subscriptionHarness || 'claude', model: message.subscriptionModel || '' });
|
|
1220
1482
|
return;
|
|
1221
1483
|
}
|
|
1222
1484
|
authenticated = true;
|
|
@@ -1239,6 +1501,14 @@
|
|
|
1239
1501
|
$('finish').disabled = true;
|
|
1240
1502
|
if (!lastReport) $('add-to-gallery').disabled = true;
|
|
1241
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
|
+
}
|
|
1242
1512
|
setStatus('Session ended');
|
|
1243
1513
|
toParent({ type: 'compare-demo-ended', error: launchError });
|
|
1244
1514
|
});
|
|
@@ -1350,7 +1620,6 @@
|
|
|
1350
1620
|
} else {
|
|
1351
1621
|
$('left-label').textContent = 'LEFT';
|
|
1352
1622
|
$('right-label').textContent = 'RIGHT';
|
|
1353
|
-
$('finish').textContent = 'End session';
|
|
1354
1623
|
}
|
|
1355
1624
|
let pending = false;
|
|
1356
1625
|
$('local-setup').addEventListener('submit', (event) => {
|
|
@@ -1417,7 +1686,7 @@
|
|
|
1417
1686
|
if (!lastReport) $('add-to-gallery').disabled = true;
|
|
1418
1687
|
if (!$('finish').disabled) {
|
|
1419
1688
|
$('finish').disabled = true;
|
|
1420
|
-
setStatus('Disconnected · reload to reconnect');
|
|
1689
|
+
if (!sessionEnded) setStatus('Disconnected · reload to reconnect');
|
|
1421
1690
|
}
|
|
1422
1691
|
});
|
|
1423
1692
|
}
|
|
@@ -39,6 +39,11 @@ export async function publishToGallery(report, form, { signature = '', reportNon
|
|
|
39
39
|
const page = form.get(side);
|
|
40
40
|
if (page instanceof Blob) outgoing.set(side, page, `${side}.html`);
|
|
41
41
|
}
|
|
42
|
+
const transcript = form.get('transcript');
|
|
43
|
+
if (transcript instanceof Blob && transcript.size) {
|
|
44
|
+
if (transcript.size > 10 * 1024 * 1024) throw new Error('Transcripts can be at most 10 MB.');
|
|
45
|
+
outgoing.set('transcript', transcript, transcript.name || 'transcript.json');
|
|
46
|
+
}
|
|
42
47
|
const cover = form.get('cover');
|
|
43
48
|
if (cover instanceof Blob && cover.size) outgoing.set('cover', cover, 'cover');
|
|
44
49
|
const response = await fetch(`${appUrl}/api/gallery/submissions`, {
|
|
@@ -67,3 +72,22 @@ export async function publishToGallery(report, form, { signature = '', reportNon
|
|
|
67
72
|
return { id: body.id, videoError: 'Your submission was saved, but its video could not be uploaded.' };
|
|
68
73
|
}
|
|
69
74
|
}
|
|
75
|
+
|
|
76
|
+
/** Sends a private comment to the PennyRouter team, with the session's report when there is one. */
|
|
77
|
+
export async function sendFeedback(comment, report = '', transcript = null) {
|
|
78
|
+
const key = await savedPennyKey();
|
|
79
|
+
if (!key) throw new Error('Connect PennyRouter first: run `npx pennyrouter install`, then try again.');
|
|
80
|
+
const form = new FormData();
|
|
81
|
+
form.set('comment', comment);
|
|
82
|
+
form.set('report', report);
|
|
83
|
+
if (transcript instanceof Blob && transcript.size) {
|
|
84
|
+
if (transcript.size > 10 * 1024 * 1024) throw new Error('Transcripts can be at most 10 MB.');
|
|
85
|
+
form.set('transcript', transcript, transcript.name || 'transcript.json');
|
|
86
|
+
}
|
|
87
|
+
const response = await fetch(`${appUrl}/api/compare-feedback`, {
|
|
88
|
+
method: 'POST', headers: { authorization: `Bearer ${key}` }, body: form, signal: AbortSignal.timeout(60_000),
|
|
89
|
+
});
|
|
90
|
+
const body = await response.json().catch(() => ({}));
|
|
91
|
+
if (!response.ok) throw new Error(body.error || `The comment was not accepted (${response.status}).`);
|
|
92
|
+
return { ok: true };
|
|
93
|
+
}
|