hop2 0.11.57 → 0.11.59
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/README.md +26 -1
- package/demo/assemble-video.mjs +222 -0
- package/demo/capture/capture-env.mjs +42 -1
- package/demo/capture/capture.mjs +458 -36
- package/demo/capture/setup-sessions.mjs +4 -4
- package/demo/capture/spawn-aurora.mjs +3 -3
- package/demo/product-video-v3.md +169 -0
- package/demo/storyboards/v3-on-v2.json +157 -0
- package/demo/storyboards/v3.json +169 -0
- package/docs/hero-handoff.svg +138 -0
- package/docs/hero-operations.svg +120 -0
- package/hay/apps/web/package.json +1 -0
- package/hay/apps/web/src/App.tsx +23 -3
- package/hay/apps/web/src/components/SecondaryPane.tsx +3 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +11 -6
- package/hay/apps/web/src/components/ViewsPanel.tsx +162 -81
- package/hay/apps/web/src/styles.css +91 -16
- package/hay/apps/web/src/utils/mathDetect.ts +167 -0
- package/hay/apps/web/src/utils/mathLinkProvider.ts +129 -0
- package/hay/apps/web/src/utils/mathTooltip.ts +132 -0
- package/hay/apps/web/test/ViewsPanel.test.tsx +75 -34
- package/hay/apps/web/test/mathDetect.test.ts +53 -0
- package/hay/package-lock.json +26 -0
- package/hop +17 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -226,6 +226,25 @@ How it works: every hop terminal gets a `HOP_SESSION` env var; a Claude Code `Se
|
|
|
226
226
|
|
|
227
227
|
`hop claude-hook install` adds a `SessionStart` entry to `~/.claude/settings.json` (backed up first); `hop claude-hook remove` reverts it. The hook is a no-op outside hop terminals.
|
|
228
228
|
|
|
229
|
+
### Fork a conversation, or hand it to the other tool
|
|
230
|
+
|
|
231
|
+
<p align="center">
|
|
232
|
+
<img src="docs/hero-handoff.svg" width="980" alt="One conversation, either tool: a wall card for a Claude session with its action menu open on Continue in Codex; an arrow carries the extracted transcript to a new terminal where Codex reads the hand-off and picks the work up; the new session is filed in the same folder, beside its source." />
|
|
233
|
+
</p>
|
|
234
|
+
|
|
235
|
+
Every session card's `⋯` menu offers **Fork** (the same tool resumes the same
|
|
236
|
+
conversation in a new session — Claude and Codex alike) and **Continue in
|
|
237
|
+
Codex… / Continue in Claude…** (the transcript is extracted to a file and the
|
|
238
|
+
other tool reads all of it before it says a word). The new session lands
|
|
239
|
+
beside its source: same folder, same directory, labelled by what it runs.
|
|
240
|
+
The same operation is one call for agents:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
curl -X POST http://127.0.0.1:$PORT/api/sessions/fork \
|
|
244
|
+
-H "Authorization: Bearer $HOP_SECRET" -H 'Content-Type: application/json' \
|
|
245
|
+
-d '{"name":"angler","target":"codex"}' # omit target to fork with the same tool
|
|
246
|
+
```
|
|
247
|
+
|
|
229
248
|
### Render LaTeX math (`hop math`)
|
|
230
249
|
|
|
231
250
|
Glance at a formula without leaving the terminal:
|
|
@@ -238,7 +257,9 @@ echo '\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6}' | hop math
|
|
|
238
257
|
|
|
239
258
|
Agents working inside hop terminals can render formulas for the humans watching by invoking the command themselves — install the bundled skills with `hop agent-skill install` so Claude and Codex sessions know to do this (plus ring the bell when blocked, and keep output phone-friendly).
|
|
240
259
|
|
|
241
|
-
`hop math` renders a **2D Unicode layout** — fractions, roots, sums/limits, sub/superscripts, Greek + operators, and accents — with no dependencies
|
|
260
|
+
`hop math` renders a **2D Unicode layout** — fractions, roots, sums/limits, sub/superscripts, Greek + operators, and accents — with no dependencies, for any terminal.
|
|
261
|
+
|
|
262
|
+
**In the web client, LaTeX renders itself.** Anything an agent prints as `$…$`, `$$…$$`, `\(…\)`, `\[…\]`, or bare `\frac{…}`-style LaTeX is detected in the terminal output — on the wall's tiles, in split panes, and full screen — and underlined on hover, with the formula rendered by KaTeX in a tooltip. Click to pin the tooltip and copy from it; Esc releases it. Prices (`$5`) and Windows paths are left alone. Nothing about the session changes: the source text stays in the terminal, so it round-trips to any client, and the rendering lives in the browser around it.
|
|
242
263
|
|
|
243
264
|
### Show something a terminal cannot draw (`hop view`, `hop port`)
|
|
244
265
|
|
|
@@ -273,6 +294,10 @@ a result in prose.
|
|
|
273
294
|
|
|
274
295
|
## Operations
|
|
275
296
|
|
|
297
|
+
<p align="center">
|
|
298
|
+
<img src="docs/hero-operations.svg" width="980" alt="hop operations, three panels: hop restore after a reboot bringing every session back verified with its conversation, fork and folder; a sign-in card with a passkey button, password plus 2FA, and a list of signed-in devices with revoke; and a signup page where a lab member requests their own subdomain with a university email and gets their own daemon once approved." />
|
|
299
|
+
</p>
|
|
300
|
+
|
|
276
301
|
### Logging and History
|
|
277
302
|
|
|
278
303
|
Hop always writes per-session audit logs.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Assemble the product video from a storyboard: live clips, illustration
|
|
2
|
+
// stills with a slow push-in, title cards, and captions in the house style.
|
|
3
|
+
//
|
|
4
|
+
// node demo/assemble-video.mjs --storyboard demo/storyboards/v3.json \
|
|
5
|
+
// --out demo-output/hop-v3-rough.mp4 [--no-captions] [--only 11-panes]
|
|
6
|
+
//
|
|
7
|
+
// Why this exists: the homebrew ffmpeg has no drawtext, and the March-era
|
|
8
|
+
// stitcher could only concatenate clips. Captions and cards are rendered by
|
|
9
|
+
// the same Chromium the rig records with, so they use real CSS typography —
|
|
10
|
+
// the monospace kicker + sentence caption of the README illustrations — and
|
|
11
|
+
// are composited as transparent PNG overlays. Stills come from the SVGs in
|
|
12
|
+
// docs/, rendered at 2x so the push-in stays crisp.
|
|
13
|
+
//
|
|
14
|
+
// Storyboard shape (JSON):
|
|
15
|
+
// { "width": 1920, "height": 1080, "fps": 30,
|
|
16
|
+
// "segments": [
|
|
17
|
+
// { "id": "00-wall", "type": "clip", "src": "demo-output/footage/live/00-wall.webm",
|
|
18
|
+
// "start": 0, "duration": 12, "speed": 1,
|
|
19
|
+
// "caption": { "kicker": "01 · THE WALL", "text": "Every terminal, one wall." } },
|
|
20
|
+
// { "id": "04-mobile", "type": "still", "src": "docs/hero-mobile.svg", "duration": 8,
|
|
21
|
+
// "push": 1.10, "caption": {...} },
|
|
22
|
+
// { "id": "06-close", "type": "card", "duration": 4,
|
|
23
|
+
// "title": "hop — terminals for humans + agents", "subtitle": "github.com/jzthree/hop" }
|
|
24
|
+
// ] }
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { spawnSync } from "node:child_process";
|
|
29
|
+
import { chromium } from "../hay/node_modules/playwright/index.mjs";
|
|
30
|
+
import { parseArgs } from "./hop-demo-lib.mjs";
|
|
31
|
+
|
|
32
|
+
const HERE = path.dirname(new URL(import.meta.url).pathname);
|
|
33
|
+
const REPO = path.resolve(HERE, "..");
|
|
34
|
+
const FFMPEG = process.env.HOP_CAPTURE_FFMPEG || "/opt/homebrew/bin/ffmpeg";
|
|
35
|
+
const FFPROBE = FFMPEG.replace(/ffmpeg$/, "ffprobe");
|
|
36
|
+
|
|
37
|
+
const args = parseArgs(process.argv.slice(2));
|
|
38
|
+
const storyboardPath = path.resolve(args.storyboard || path.join(HERE, "storyboards", "v3.json"));
|
|
39
|
+
const board = JSON.parse(fs.readFileSync(storyboardPath, "utf8"));
|
|
40
|
+
const W = Number(board.width || 1920);
|
|
41
|
+
const H = Number(board.height || 1080);
|
|
42
|
+
const FPS = Number(board.fps || 30);
|
|
43
|
+
const captions = args["no-captions"] === undefined && args.captions !== "false";
|
|
44
|
+
const only = typeof args.only === "string" ? args.only.split(",") : null;
|
|
45
|
+
const output = path.resolve(args.out || path.join(REPO, "demo-output", `${path.basename(storyboardPath, ".json")}.mp4`));
|
|
46
|
+
const work = path.join(os.tmpdir(), `hop-assemble-${Date.now()}`);
|
|
47
|
+
fs.mkdirSync(work, { recursive: true });
|
|
48
|
+
|
|
49
|
+
const BG = "#101116";
|
|
50
|
+
const MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
|
51
|
+
const SANS = "-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif";
|
|
52
|
+
|
|
53
|
+
const run = (bin, argv, label) => {
|
|
54
|
+
const r = spawnSync(bin, argv, { encoding: "utf8" });
|
|
55
|
+
if (r.status !== 0) {
|
|
56
|
+
throw new Error(`${label} failed (${r.status}):\n${(r.stderr || "").slice(-1500)}`);
|
|
57
|
+
}
|
|
58
|
+
return r;
|
|
59
|
+
};
|
|
60
|
+
const probeDuration = (file) => {
|
|
61
|
+
const r = run(FFPROBE, ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file], "ffprobe");
|
|
62
|
+
return Number(String(r.stdout).trim()) || 0;
|
|
63
|
+
};
|
|
64
|
+
const resolveSrc = (p) => (path.isAbsolute(p) ? p : path.resolve(REPO, p));
|
|
65
|
+
const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
66
|
+
|
|
67
|
+
// ── Chromium renders: stills, cards, caption overlays ──────────────────
|
|
68
|
+
let browser = null;
|
|
69
|
+
const page = async (opts) => {
|
|
70
|
+
if (!browser) browser = await chromium.launch();
|
|
71
|
+
const ctx = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 2, ...opts });
|
|
72
|
+
return { ctx, page: await ctx.newPage() };
|
|
73
|
+
};
|
|
74
|
+
const shoot = async (html, out, { transparent = false } = {}) => {
|
|
75
|
+
const { ctx, page: p } = await page({});
|
|
76
|
+
await p.setContent(html, { waitUntil: "load" });
|
|
77
|
+
await p.waitForTimeout(150);
|
|
78
|
+
await p.screenshot({ path: out, omitBackground: transparent, type: "png" });
|
|
79
|
+
await ctx.close();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// An SVG (or PNG) centred on the dark canvas, filling the frame's height
|
|
83
|
+
// with a little breathing room, rendered at 2x for the push-in.
|
|
84
|
+
const renderStill = async (seg, out) => {
|
|
85
|
+
const src = resolveSrc(seg.src);
|
|
86
|
+
const data = fs.readFileSync(src);
|
|
87
|
+
const mime = src.endsWith(".svg") ? "image/svg+xml" : "image/png";
|
|
88
|
+
const uri = `data:${mime};base64,${data.toString("base64")}`;
|
|
89
|
+
const inset = Number(seg.inset ?? 0.94);
|
|
90
|
+
await shoot(`<!doctype html><html><body style="margin:0;width:${W}px;height:${H}px;background:${BG};display:flex;align-items:center;justify-content:center;">
|
|
91
|
+
<img src="${uri}" style="height:${Math.round(H * inset)}px;max-width:${Math.round(W * inset)}px;width:auto;display:block;"/>
|
|
92
|
+
</body></html>`, out);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Title / end card: the March cut's look — one monospace line, a muted
|
|
96
|
+
// second line, nothing else.
|
|
97
|
+
const renderCard = async (seg, out) => {
|
|
98
|
+
await shoot(`<!doctype html><html><body style="margin:0;width:${W}px;height:${H}px;background:${BG};display:flex;flex-direction:column;align-items:center;justify-content:center;gap:26px;font-family:${MONO};">
|
|
99
|
+
${seg.kicker ? `<div style="font-size:22px;letter-spacing:0.16em;color:#8d909b;">${esc(seg.kicker)}</div>` : ""}
|
|
100
|
+
<div style="font-size:${seg.titleSize || 56}px;color:#e6e6e9;">${esc(seg.title || "")}</div>
|
|
101
|
+
${seg.subtitle ? `<div style="font-size:28px;color:#93939b;">${esc(seg.subtitle)}</div>` : ""}
|
|
102
|
+
</body></html>`, out);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Caption overlay: transparent frame with a kicker (small, purple, spaced
|
|
106
|
+
// capitals — the illustration kicker) over one sentence in a dark pill,
|
|
107
|
+
// bottom-centre, clear of the terminal's own footer.
|
|
108
|
+
const renderCaption = async (cap, out) => {
|
|
109
|
+
// A portrait clip (the phone) sits in the middle of the frame: its caption
|
|
110
|
+
// goes BESIDE it (cap.side = "left" | "right"), not across it.
|
|
111
|
+
const pill = `font-size:${cap.size || 33}px;color:#e6e6e9;background:rgba(23,24,29,0.97);border:1px solid #2e3038;border-radius:12px;padding:14px 26px;box-shadow:0 8px 30px rgba(0,0,0,.45);line-height:1.35;`;
|
|
112
|
+
const kicker = cap.kicker ? `<div style="font-size:17px;letter-spacing:0.18em;color:#a78bfa;text-shadow:0 1px 2px rgba(0,0,0,.8);">${esc(cap.kicker)}</div>` : "";
|
|
113
|
+
const body = cap.side
|
|
114
|
+
? `<div style="position:absolute;top:50%;transform:translateY(-50%);${cap.side === "right" ? "right" : "left"}:${Math.round(W * 0.06)}px;width:${Math.round(W * 0.30)}px;display:flex;flex-direction:column;align-items:flex-start;gap:12px;">
|
|
115
|
+
${kicker}
|
|
116
|
+
<div style="${pill}text-align:left;">${esc(cap.text)}</div>
|
|
117
|
+
</div>`
|
|
118
|
+
: `<div style="position:absolute;left:0;right:0;bottom:${cap.bottom ?? 64}px;display:flex;flex-direction:column;align-items:center;gap:10px;">
|
|
119
|
+
${kicker}
|
|
120
|
+
<div style="${pill}max-width:${Math.round(W * 0.8)}px;text-align:center;">${esc(cap.text)}</div>
|
|
121
|
+
</div>`;
|
|
122
|
+
await shoot(`<!doctype html><html><body style="margin:0;width:${W}px;height:${H}px;background:transparent;position:relative;font-family:${MONO};">${body}</body></html>`, out, { transparent: true });
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// ── Segment normalisation: every piece becomes W×H, FPS, yuv420p, no audio ──
|
|
126
|
+
const filtersFit = `scale=${W}:${H}:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2:0x101116,fps=${FPS},format=yuv420p`;
|
|
127
|
+
|
|
128
|
+
const encodeArgs = ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", "-an", "-movflags", "+faststart"];
|
|
129
|
+
|
|
130
|
+
const withOverlay = (overlayPng, dur) => {
|
|
131
|
+
if (!overlayPng) return { inputs: [], chain: "" };
|
|
132
|
+
return {
|
|
133
|
+
inputs: ["-loop", "1", "-t", dur.toFixed(3), "-i", overlayPng],
|
|
134
|
+
// Fade the caption in over the first 0.35s and out over the last 0.5s.
|
|
135
|
+
chain: `[ov]scale=${W}:${H},format=rgba,fade=t=in:st=0:d=0.35:alpha=1,fade=t=out:st=${Math.max(0, dur - 0.5).toFixed(2)}:d=0.5:alpha=1[ovf];[v][ovf]overlay=0:0:shortest=1`
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const normalizeSegment = async (seg, index) => {
|
|
140
|
+
const id = seg.id || `seg-${index}`;
|
|
141
|
+
const out = path.join(work, `${String(index + 1).padStart(2, "0")}-${id}.mp4`);
|
|
142
|
+
const captionPng = captions && seg.caption?.text ? path.join(work, `${id}-caption.png`) : null;
|
|
143
|
+
if (captionPng) await renderCaption(seg.caption, captionPng);
|
|
144
|
+
|
|
145
|
+
if (seg.type === "clip") {
|
|
146
|
+
const src = resolveSrc(seg.src);
|
|
147
|
+
if (!fs.existsSync(src)) throw new Error(`clip not found: ${src}`);
|
|
148
|
+
const start = Number(seg.start || 0);
|
|
149
|
+
const speed = Number(seg.speed || 1);
|
|
150
|
+
const avail = Math.max(0, (probeDuration(src) - start) / speed);
|
|
151
|
+
const dur = Math.min(Number(seg.duration || avail), avail || Number(seg.duration || 0));
|
|
152
|
+
const ov = withOverlay(captionPng, dur);
|
|
153
|
+
const vchain = `[0:v]${speed !== 1 ? `setpts=PTS/${speed},` : ""}${filtersFit}[v]`;
|
|
154
|
+
const fc = ov.chain ? `${vchain};[1:v]copy[ov];${ov.chain}` : `${vchain}`;
|
|
155
|
+
run(FFMPEG, [
|
|
156
|
+
"-y", "-ss", start.toFixed(3), "-i", src, ...ov.inputs,
|
|
157
|
+
"-filter_complex", fc, ...(ov.chain ? [] : ["-map", "[v]"]),
|
|
158
|
+
"-t", dur.toFixed(3), ...encodeArgs, out
|
|
159
|
+
], `normalize ${id}`);
|
|
160
|
+
return { id, path: out, duration: dur };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (seg.type === "still" || seg.type === "card") {
|
|
164
|
+
const png = path.join(work, `${id}-frame.png`);
|
|
165
|
+
if (seg.type === "still") await renderStill(seg, png);
|
|
166
|
+
else await renderCard(seg, png);
|
|
167
|
+
const dur = Number(seg.duration || 6);
|
|
168
|
+
const frames = Math.round(dur * FPS);
|
|
169
|
+
// The push-in: a slow zoom from 1.0 to `push` (1.0 = static card),
|
|
170
|
+
// centred, evaluated on the 2x render so it never goes soft.
|
|
171
|
+
const push = seg.type === "still" ? Number(seg.push ?? 1.08) : Number(seg.push ?? 1);
|
|
172
|
+
const zoomExpr = push > 1
|
|
173
|
+
? `zoompan=z='1+(${(push - 1).toFixed(4)})*on/${frames}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=${frames}:s=${W}x${H}:fps=${FPS}`
|
|
174
|
+
: `scale=${W}:${H},fps=${FPS}`;
|
|
175
|
+
const ov = withOverlay(captionPng, dur);
|
|
176
|
+
const vchain = `[0:v]${zoomExpr},format=yuv420p[v]`;
|
|
177
|
+
const fc = ov.chain ? `${vchain};[1:v]copy[ov];${ov.chain}` : vchain;
|
|
178
|
+
run(FFMPEG, [
|
|
179
|
+
"-y", "-loop", "1", "-framerate", String(FPS), "-i", png, ...ov.inputs,
|
|
180
|
+
"-filter_complex", fc, ...(ov.chain ? [] : ["-map", "[v]"]),
|
|
181
|
+
"-t", dur.toFixed(3), ...encodeArgs, out
|
|
182
|
+
], `render ${id}`);
|
|
183
|
+
return { id, path: out, duration: dur };
|
|
184
|
+
}
|
|
185
|
+
throw new Error(`unknown segment type for ${id}: ${seg.type}`);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// ── Main ───────────────────────────────────────────────────────────────
|
|
189
|
+
const segments = board.segments.filter((s) => !only || only.includes(s.id));
|
|
190
|
+
if (!segments.length) throw new Error("no segments selected");
|
|
191
|
+
const pieces = [];
|
|
192
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
193
|
+
const seg = segments[i];
|
|
194
|
+
process.stdout.write(`[${i + 1}/${segments.length}] ${seg.id} (${seg.type}) … `);
|
|
195
|
+
const piece = await normalizeSegment(seg, i);
|
|
196
|
+
console.log(`${piece.duration.toFixed(1)}s`);
|
|
197
|
+
pieces.push(piece);
|
|
198
|
+
}
|
|
199
|
+
if (browser) await browser.close();
|
|
200
|
+
|
|
201
|
+
// Concatenate (identical encodes → stream copy), then one pass for the
|
|
202
|
+
// fade-in / fade-out so the cut opens and closes on the canvas colour.
|
|
203
|
+
const concatList = path.join(work, "concat.txt");
|
|
204
|
+
fs.writeFileSync(concatList, pieces.map((p) => `file '${p.path.replace(/'/g, "'\\''")}'`).join("\n") + "\n");
|
|
205
|
+
const joined = path.join(work, "joined.mp4");
|
|
206
|
+
run(FFMPEG, ["-y", "-f", "concat", "-safe", "0", "-i", concatList, "-c", "copy", joined], "concat");
|
|
207
|
+
const total = pieces.reduce((a, p) => a + p.duration, 0);
|
|
208
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
209
|
+
run(FFMPEG, [
|
|
210
|
+
"-y", "-i", joined,
|
|
211
|
+
"-vf", `fade=t=in:st=0:d=0.6,fade=t=out:st=${Math.max(0, total - 0.9).toFixed(2)}:d=0.9`,
|
|
212
|
+
...encodeArgs, output
|
|
213
|
+
], "final");
|
|
214
|
+
|
|
215
|
+
console.log(JSON.stringify({
|
|
216
|
+
ok: true,
|
|
217
|
+
output,
|
|
218
|
+
duration: Number(total.toFixed(2)),
|
|
219
|
+
segments: pieces.map((p) => ({ id: p.id, duration: Number(p.duration.toFixed(2)) })),
|
|
220
|
+
captions,
|
|
221
|
+
work
|
|
222
|
+
}, null, 2));
|
|
@@ -34,7 +34,16 @@ export const AGENT_CMD = process.env.HOP_CAPTURE_AGENT || "claude";
|
|
|
34
34
|
// Demo cast, by INTERNAL session name. The capture driver's DOM rewriter
|
|
35
35
|
// shows the display names (Aurora/Lyra/Nebula/Polaris); Vega appears via the
|
|
36
36
|
// 01-sessions rename clip.
|
|
37
|
-
|
|
37
|
+
// The "2" suffix keeps the cast clear of real sessions on the live daemon.
|
|
38
|
+
// HOP_CAPTURE_CAST_SUFFIX="" drops it on an isolated daemon (the phone
|
|
39
|
+
// recording shows internal names verbatim — there is no DOM rewriter).
|
|
40
|
+
export const CAST_SUFFIX = process.env.HOP_CAPTURE_CAST_SUFFIX ?? "2";
|
|
41
|
+
export const castName = (base) => base + CAST_SUFFIX;
|
|
42
|
+
export const ALLOWED = [castName("Aurora"), castName("Lyra"), castName("Nebula"), castName("Polaris"), "Vega", castName("Aurora") + "-codex"];
|
|
43
|
+
|
|
44
|
+
// A sanitized HTML result for the Views clip: Lyra "publishes" it with
|
|
45
|
+
// `hop view`, and the wall reads it docked. Lives in the demo workspace.
|
|
46
|
+
export const REPORT_HTML = path.join(WORKSPACE, "benchmark-report.html");
|
|
38
47
|
|
|
39
48
|
// Strings that must NEVER be visible in a kept frame (identity leaks). The
|
|
40
49
|
// defaults cover the account-email welcome banner plus the real home
|
|
@@ -105,4 +114,36 @@ process.exit(1);
|
|
|
105
114
|
`
|
|
106
115
|
);
|
|
107
116
|
fs.chmodSync(hopWrapper, 0o755);
|
|
117
|
+
fs.writeFileSync(REPORT_HTML, `<!doctype html><html><head><meta charset="utf-8"><title>Benchmark report — wave 3</title>
|
|
118
|
+
<style>
|
|
119
|
+
body{margin:0;background:#101116;color:#e6e6e9;font:15px/1.5 -apple-system,'Segoe UI',sans-serif;padding:40px 48px}
|
|
120
|
+
h1{font:600 26px/1.2 -apple-system,'Segoe UI',sans-serif;margin:0 0 6px}
|
|
121
|
+
.sub{color:#8d909b;font-family:ui-monospace,Menlo,monospace;font-size:12.5px;letter-spacing:.12em;text-transform:uppercase;margin-bottom:28px}
|
|
122
|
+
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:28px}
|
|
123
|
+
.kpi{background:#17181d;border:1px solid #2e3038;border-radius:10px;padding:16px 18px}
|
|
124
|
+
.kpi b{display:block;font-size:28px;color:#a78bfa;font-family:ui-monospace,Menlo,monospace}
|
|
125
|
+
.kpi span{color:#93939b;font-size:13px}
|
|
126
|
+
table{border-collapse:collapse;width:100%;font-family:ui-monospace,Menlo,monospace;font-size:13px}
|
|
127
|
+
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid #26272f}
|
|
128
|
+
th{color:#8d909b;font-weight:500}
|
|
129
|
+
.up{color:#3fb950}.dn{color:#f0883e}
|
|
130
|
+
.bar{height:10px;border-radius:5px;background:#26272f;overflow:hidden}
|
|
131
|
+
.bar i{display:block;height:100%;background:linear-gradient(90deg,#7c3aed,#a78bfa)}
|
|
132
|
+
</style></head><body>
|
|
133
|
+
<h1>Benchmark report — rollout wave 3</h1>
|
|
134
|
+
<div class="sub">edge-deploy · 12 instances · us-east, eu-west, ap-south</div>
|
|
135
|
+
<div class="grid">
|
|
136
|
+
<div class="kpi"><b>9.1s</b><span>ingest, sample day (was 41s)</span></div>
|
|
137
|
+
<div class="kpi"><b>p99 61ms</b><span>render, wave 3 (−18%)</span></div>
|
|
138
|
+
<div class="kpi"><b>0</b><span>regressions across 47 tests</span></div>
|
|
139
|
+
</div>
|
|
140
|
+
<table>
|
|
141
|
+
<tr><th>metric</th><th>wave 2</th><th>wave 3</th><th>delta</th><th style="width:36%">headroom</th></tr>
|
|
142
|
+
<tr><td>ingest / day</td><td>41.0s</td><td>9.1s</td><td class="up">−78%</td><td><div class="bar"><i style="width:22%"></i></div></td></tr>
|
|
143
|
+
<tr><td>render p50</td><td>25ms</td><td>19ms</td><td class="up">−24%</td><td><div class="bar"><i style="width:38%"></i></div></td></tr>
|
|
144
|
+
<tr><td>render p99</td><td>74ms</td><td>61ms</td><td class="up">−18%</td><td><div class="bar"><i style="width:61%"></i></div></td></tr>
|
|
145
|
+
<tr><td>cdn cache hit</td><td>93.4%</td><td>96.2%</td><td class="up">+2.8</td><td><div class="bar"><i style="width:96%"></i></div></td></tr>
|
|
146
|
+
<tr><td>queue depth (max)</td><td>3</td><td>4</td><td class="dn">+1</td><td><div class="bar"><i style="width:40%"></i></div></td></tr>
|
|
147
|
+
</table>
|
|
148
|
+
</body></html>`);
|
|
108
149
|
}
|