@supersuit/session-player 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,311 @@
1
+ // Drafts a scenario from a Claude Code session transcript (~/.claude/projects/<dir>/<id>.jsonl).
2
+ //
3
+ // The hard part is telling what the PERSON typed from everything else that arrives as a
4
+ // "user" line: tool results, skill bodies, task notifications, peer-session messages and
5
+ // interrupt markers. Get that wrong and the replay shows the agent talking to itself.
6
+ //
7
+ // This drafts; it does not publish. Anonymizing people is the promote skill's job. What the
8
+ // converter guarantees is a floor: no credential-shaped string and no home path survives,
9
+ // and every edit it makes is written to `cuts`, so nothing is staged silently.
10
+
11
+ import { FORMAT } from './scenario.mjs';
12
+ import { deviceFromSession } from './device.mjs';
13
+
14
+ const LONG_PASTE = 600;
15
+ // Idle time is not work. A session's clock keeps running while a command hangs, the laptop
16
+ // sleeps, or the person walks away, and an album release once read as six hours because one
17
+ // folder check hung overnight (Gary, 2026-10-10: "There's no way that releasing the album
18
+ // took 6 hours"). So: the agent's thinking before a step counts at most THINK_CAP, any one
19
+ // step counts at most STEP_CAP, a command that timed out counts as no work, and a question
20
+ // counts at most ASK_CAP of the person deciding. Everything removed is kept as `idleMs`.
21
+ const THINK_CAP = 3 * 60_000;
22
+ const STEP_CAP = 10 * 60_000;
23
+ const ASK_CAP = 5 * 60_000;
24
+ const timedOut = (data, text) => Boolean(data?.timedOutAfterMs) || /did not complete within its \d+s timeout/.test(text ?? '');
25
+ const PASTE_KEEP = 280;
26
+
27
+ export function parseSession(text) {
28
+ const out = [];
29
+ for (const line of text.split('\n')) {
30
+ if (!line.trim()) continue;
31
+ try { out.push(JSON.parse(line)); } catch { /* a torn line is skipped, never fatal */ }
32
+ }
33
+ return out;
34
+ }
35
+
36
+ const blocks = (m) => (typeof m?.content === 'string' ? [{ type: 'text', text: m.content }] : Array.isArray(m?.content) ? m.content : []);
37
+ const textOf = (m) => blocks(m).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
38
+ const isInterrupt = (o) => o.type === 'user' && /^\[Request interrupted by user/.test(textOf(o.message));
39
+
40
+ function isHuman(o) {
41
+ if (o.type !== 'user' || o.isSidechain || o.isMeta || o.toolUseResult !== undefined) return false;
42
+ if (blocks(o.message).some((b) => b.type === 'tool_result')) return false;
43
+ const text = textOf(o.message);
44
+ if (!text) return false;
45
+ if (o.origin) return o.origin.kind === 'human';
46
+ // Sessions recorded before Claude Code wrote `origin`: anything machine-made starts with a tag or a bracket.
47
+ return !/^[<[]/.test(text);
48
+ }
49
+
50
+ // Credential shapes, and env-style assignments whose value looks like a secret.
51
+ const SECRETS = [
52
+ /\b(sk-[A-Za-z0-9_-]{20,})/g,
53
+ /\b(gh[pousr]_[A-Za-z0-9]{30,})/g,
54
+ /\b(AIza[0-9A-Za-z_-]{30,})/g,
55
+ /\b(xox[abprs]-[A-Za-z0-9-]{10,})/g,
56
+ /\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Z0-9_]*=(["']?)([A-Za-z0-9_\-./+]{16,})\1/g,
57
+ ];
58
+
59
+ // Contact details: a scenario is shown to strangers, and an agent session is full of them
60
+ // (every text-message approval names a number). Dates (2026-10-10), versions (4.259.1) and
61
+ // counts (1,000) do not match: a phone needs 10 digits in 3-3-4 groups.
62
+ const CONTACTS = [
63
+ [/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g, '[email removed]'],
64
+ [/(?<![\w.-])(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}(?![\w-]|\.\d)/g, '[phone removed]'],
65
+ [/(?<![\w.-])\+\d{11,15}(?![\w-])/g, '[phone removed]'],
66
+ ];
67
+
68
+ function cleaner(home, cuts) {
69
+ const homeRe = home ? new RegExp(home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') : null;
70
+ return (text, turn, where) => {
71
+ let s = String(text ?? '');
72
+ if (homeRe) s = s.replace(homeRe, '~');
73
+ for (const [re, mark] of CONTACTS) {
74
+ s = s.replace(re, () => {
75
+ cuts.push({ turn, what: where, reason: 'contact detail removed' });
76
+ return mark;
77
+ });
78
+ }
79
+ for (const re of SECRETS) {
80
+ s = s.replace(re, (m) => {
81
+ cuts.push({ turn, what: where, reason: 'credential removed' });
82
+ const eq = m.indexOf('=');
83
+ return eq > 0 && /[A-Z]/.test(m[0]) ? `${m.slice(0, eq + 1)}[removed]` : '[removed]';
84
+ });
85
+ }
86
+ return s;
87
+ };
88
+ }
89
+
90
+ const base = (p) => String(p).split('/').filter(Boolean).pop() ?? String(p);
91
+
92
+ function stepFor(name, input = {}) {
93
+ switch (name) {
94
+ case 'Read': return { kind: 'read', label: `read ${base(input.file_path)}` };
95
+ case 'Write': return { kind: 'write', label: `wrote ${base(input.file_path)}` };
96
+ case 'Edit': case 'MultiEdit': case 'NotebookEdit': return { kind: 'edit', label: `edited ${base(input.file_path ?? input.notebook_path)}` };
97
+ case 'Grep': case 'Glob': return { kind: 'search', label: 'searched the files' };
98
+ case 'Skill': return { kind: `skill:${input.skill}`, label: `used ${input.skill}` };
99
+ case 'Agent': case 'Task': return { kind: `agent:${input.description}`, label: input.description || 'ran an agent' };
100
+ case 'Bash': return { kind: `bash:${input.description ?? input.command}`, label: input.description || 'ran a command' };
101
+ case 'WebFetch': case 'WebSearch': return { kind: 'web', label: 'read the web' };
102
+ case 'AskUserQuestion': return { kind: 'ask', label: 'asked a question' };
103
+ default: return { kind: `tool:${name}`, label: name.replace(/^mcp__[^_]+__/, '').replace(/_/g, ' ') };
104
+ }
105
+ }
106
+
107
+ const PLURAL = { read: (n) => `read ${n} files`, write: (n) => `wrote ${n} files`, edit: (n) => `edited ${n} files`, search: (n) => `searched the files ${n} times`, web: (n) => `read ${n} web pages` };
108
+
109
+ function merge(steps) {
110
+ const out = [];
111
+ for (const s of steps) {
112
+ const last = out[out.length - 1];
113
+ if (last && last.kind === s.kind) {
114
+ last.count += 1; last.ms += s.ms; last.at = s.at;
115
+ if (s.idleMs) last.idleMs = (last.idleMs ?? 0) + s.idleMs;
116
+ continue;
117
+ }
118
+ out.push({ ...s, count: 1 });
119
+ }
120
+ return out.map(({ kind, ...s }) => (s.count > 1 && PLURAL[kind] ? { ...s, label: PLURAL[kind](s.count) } : s));
121
+ }
122
+
123
+ const HARNESS_NOTICE = /^\s*(You've hit your (weekly |session |daily )?limit|API Error:|Claude AI usage limit reached|Request timed out)/i;
124
+ const URL_RE = /https:\/\/[^\s)>\]"'`]+/g;
125
+ // A path the agent names in backticks is something it is pointing the person at.
126
+ const PATH_RE = /`((?:~|\/)[^`\s]*\/[^`\s]+)`/g;
127
+ // What a viewer counts as "made": a page, a picture, a sound, a document. Code, config and
128
+ // folders are how the work got done, and they already show as steps. Counting every file
129
+ // written put a session's tooling in the "made" list (Gary, 2026-10-10: "This session
130
+ // definitely didn't make all these files").
131
+ const DELIVERABLE = /\.(md|pdf|docx?|pptx?|key|xlsx?|csv|html?|png|jpe?g|gif|webp|svg|heic|wav|mp3|m4a|aac|flac|mp4|mov|webm|epub)$/i;
132
+
133
+ // Tools that are harness plumbing rather than work a viewer would recognize. They are not
134
+ // shown; their time is carried into the next real step so the clock still adds up.
135
+ const PLUMBING = new Set(['ToolSearch', 'EnterWorktree', 'ExitWorktree', 'TaskOutput', 'TaskStop', 'TodoWrite', 'ListAgents', 'ScheduleWakeup']);
136
+
137
+ export function convert(lines, opts = {}) {
138
+ const home = opts.home ?? process.env.HOME ?? '';
139
+ const cuts = [];
140
+ const clean = cleaner(home, cuts);
141
+
142
+ const results = new Map();
143
+ const resultText = new Map();
144
+ const resultData = new Map();
145
+ for (const o of lines) {
146
+ if (o.type !== 'user') continue;
147
+ for (const b of blocks(o.message)) {
148
+ if (b.type !== 'tool_result') continue;
149
+ results.set(b.tool_use_id, Date.parse(o.timestamp));
150
+ resultText.set(b.tool_use_id, typeof b.content === 'string' ? b.content : (b.content ?? []).map((c) => c.text ?? '').join('\n'));
151
+ resultData.set(b.tool_use_id, o.toolUseResult);
152
+ }
153
+ }
154
+
155
+ const turns = [];
156
+ const chapters = [];
157
+ const artifacts = [];
158
+ const seenArtifact = new Map();
159
+ // Claude Code writes two title streams that alternate line by line: the session's name
160
+ // (custom-title) and an automatic one (ai-title). Chapters follow the name when the
161
+ // session has one, else the automatic title; mixing them flips the chapter every line.
162
+ const titleType = lines.some((o) => o.type === 'custom-title') ? 'custom-title' : 'ai-title';
163
+ let title = null;
164
+ let pendingTitle = null;
165
+ let cursor = null;
166
+ let carry = { ms: 0, idle: 0 };
167
+ let interrupted = false;
168
+ let start = null;
169
+ let raw = [];
170
+ let sessionId = null;
171
+
172
+ const addArtifact = (turn, a, at) => {
173
+ const key = a.path ?? a.url;
174
+ if (seenArtifact.has(key)) return;
175
+ const id = `a${artifacts.length + 1}`;
176
+ seenArtifact.set(key, id);
177
+ artifacts.push({ id, ...a, turn: turn.id, at });
178
+ turn.artifacts.push(id);
179
+ };
180
+ const flush = () => {
181
+ const t = turns[turns.length - 1];
182
+ if (carry.ms && raw.length) raw[raw.length - 1].ms += carry.ms;
183
+ if (t) t.steps = merge(raw);
184
+ raw = [];
185
+ carry = { ms: 0, idle: 0 };
186
+ };
187
+
188
+ for (const o of lines) {
189
+ if (o.isSidechain) continue;
190
+ sessionId ??= o.sessionId ?? null;
191
+ if (o.type === 'custom-title' || o.type === 'ai-title') {
192
+ if (o.type !== titleType) continue;
193
+ const t = o.customTitle ?? o.aiTitle;
194
+ if (t && t !== title) { title = t; pendingTitle = t; }
195
+ continue;
196
+ }
197
+ if (isInterrupt(o)) { interrupted = true; continue; }
198
+
199
+ if (isHuman(o)) {
200
+ flush();
201
+ const ts = Date.parse(o.timestamp);
202
+ start ??= ts;
203
+ cursor = ts;
204
+ const id = `t${turns.length + 1}`;
205
+ // A slash command arrives wrapped in tags; show it as the person typed it.
206
+ let raw = textOf(o.message);
207
+ const cmd = raw.match(/<command-name>([^<]+)<\/command-name>/);
208
+ if (cmd) raw = [cmd[1].trim(), raw.match(/<command-args>([^<]*)<\/command-args>/)?.[1].trim()].filter(Boolean).join(' ');
209
+ let text = clean(raw, id, 'human');
210
+ if (text.length > LONG_PASTE) {
211
+ cuts.push({ turn: id, what: 'human', reason: 'long paste collapsed' });
212
+ text = `${text.slice(0, PASTE_KEEP).trimEnd()} [pasted ${text.length} characters]`;
213
+ }
214
+ const pasted = blocks(o.message).filter((b) => b.type === 'image' && b.source?.type === 'base64' && b.source.data)
215
+ .map((b, n) => ({ id: `pasted-${id}-${n + 1}`, src: `data:${b.source.media_type ?? 'image/png'};base64,${b.source.data}`, caption: 'pasted by the operator' }));
216
+ turns.push({ id, at: ts - start, human: { text, via: o.promptSource === 'voice' ? 'spoken' : 'typed', choices: [], ...(pasted.length ? { images: pasted } : {}) }, intervention: interrupted ? 'redirect' : null, steps: [], replies: [], artifacts: [] });
217
+ if (pendingTitle) { chapters.push({ title: pendingTitle, turn: id }); pendingTitle = null; }
218
+ interrupted = false;
219
+ continue;
220
+ }
221
+
222
+ const turn = turns[turns.length - 1];
223
+ if (!turn || o.type !== 'assistant') continue;
224
+ // Usage limits and API errors are the harness talking, not the agent; they also carry the
225
+ // operator's plan and time zone.
226
+ if (o.isApiErrorMessage || blocks(o.message).some((b) => b.type === 'text' && HARNESS_NOTICE.test(b.text))) {
227
+ cuts.push({ turn: turn.id, what: 'reply', reason: 'harness notice dropped' });
228
+ continue;
229
+ }
230
+ interrupted = false;
231
+ const ts = Date.parse(o.timestamp);
232
+ for (const b of blocks(o.message)) {
233
+ if (b.type === 'text' && b.text.trim()) {
234
+ const text = clean(b.text.trim(), turn.id, 'reply');
235
+ turn.replies.push({ text, at: ts - start });
236
+ cursor = Math.max(cursor, ts);
237
+ for (const url of text.match(URL_RE) ?? []) addArtifact(turn, { kind: 'link', title: url.replace(/^https:\/\//, '').replace(/[.,;:]+$/, ''), url: url.replace(/[.,;:]+$/, '') }, ts - start);
238
+ for (const [, path] of text.matchAll(PATH_RE)) if (DELIVERABLE.test(path)) addArtifact(turn, { kind: 'file', title: base(path), path }, ts - start);
239
+ } else if (b.type === 'tool_use') {
240
+ // Real tools return in well under a second; the time goes to the agent thinking
241
+ // before it calls them. So a step owns the whole gap since the previous step ended.
242
+ const end = Math.max(results.get(b.id) ?? ts, ts);
243
+ const think = Math.max(0, ts - cursor);
244
+ const runFor = end - ts;
245
+ const stalled = timedOut(resultData.get(b.id), resultText.get(b.id));
246
+ const cap = b.name === 'AskUserQuestion' ? ASK_CAP : STEP_CAP;
247
+ const counted = stalled ? 0 : Math.min(Math.min(think, THINK_CAP) + runFor + carry.ms, cap);
248
+ const idleMs = think + runFor + carry.ms + carry.idle - counted;
249
+ const ms = counted;
250
+ cursor = end;
251
+ carry = { ms: 0, idle: 0 };
252
+ if (PLUMBING.has(b.name)) { carry = { ms, idle: idleMs }; continue; }
253
+ const { kind, label } = stepFor(b.name, b.input);
254
+ const step = { kind, label: clean(label, turn.id, 'step'), tool: b.name, ms, at: end - start };
255
+ if (idleMs > 1000) { step.idleMs = idleMs; cuts.push({ turn: turn.id, what: 'step', reason: 'idle time removed', ms: idleMs }); }
256
+ if (stalled) step.stalled = true;
257
+ if (b.name === 'AskUserQuestion') {
258
+ // The agent stopped and the person decided: the clearest human-in-the-loop moment a
259
+ // session has. The whole board is kept, as the person saw it, so a replay can draw it.
260
+ const ask = askBoard(b.input, resultData.get(b.id), resultText.get(b.id) ?? '', (t) => clean(t, turn.id, 'step'));
261
+ Object.assign(step, { kind: `ask:${b.id}`, label: ask.questions[0]?.question ? `asked: ${ask.questions[0].question}` : 'asked a question', ask });
262
+ }
263
+ raw.push(step);
264
+ if (b.name === 'Write' && DELIVERABLE.test(b.input?.file_path ?? '')) addArtifact(turn, { kind: 'file', title: base(b.input.file_path), path: clean(b.input.file_path, turn.id, 'artifact') }, ts - start);
265
+ }
266
+ }
267
+ }
268
+ flush();
269
+ if (turns.length === 0) throw new Error('no human turns in this session: nothing to draft');
270
+
271
+ const first = lines.find((o) => o.timestamp && !o.isSidechain);
272
+ // Where it ran, when the transcript can tell (or the caller knows); left out otherwise.
273
+ const device = opts.device ?? deviceFromSession(lines);
274
+ return {
275
+ format: FORMAT,
276
+ id: opts.id ?? `session-${(sessionId ?? 'unknown').slice(0, 8)}`,
277
+ title: opts.title ?? title ?? turns[0].human.text.slice(0, 60),
278
+ persona: { name: 'the operator', role: '', blurb: '', ...opts.persona },
279
+ source: { kind: 'session', sessionId, date: first ? first.timestamp.slice(0, 10) : null, illustrative: false },
280
+ ...(device ? { device } : {}),
281
+ chapters,
282
+ turns,
283
+ artifacts,
284
+ cuts,
285
+ };
286
+ }
287
+
288
+ // One AskUserQuestion call as the board the person saw: every question, every option, the
289
+ // answer (the option's label, or what they typed in Other), and whether they closed it.
290
+ function askBoard(input, data, text, clean) {
291
+ const dismissed = typeof data === 'string' ? /rejected/i.test(data) : /doesn't want to proceed|rejected/i.test(text) && !data?.answers;
292
+ const answers = data && typeof data === 'object' && data.answers ? data.answers : {};
293
+ const questions = (input?.questions ?? []).map((q) => {
294
+ let answer = dismissed ? null : answers[q.question] ?? null;
295
+ if (answer == null && !dismissed) {
296
+ const esc = q.question.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
297
+ answer = text.match(new RegExp(`"${esc}"="([^"]*)"`))?.[1] ?? null;
298
+ }
299
+ const options = (q.options ?? []).map((o) => ({ label: clean(o.label ?? ''), description: clean(o.description ?? '') }));
300
+ const chosen = answer == null ? null : (q.options ?? []).findIndex((o) => o.label === answer);
301
+ return {
302
+ header: clean(q.header ?? ''),
303
+ question: clean(q.question ?? ''),
304
+ multiSelect: Boolean(q.multiSelect),
305
+ options,
306
+ answer: answer == null ? null : clean(answer),
307
+ chosen: chosen == null || chosen < 0 ? null : chosen,
308
+ };
309
+ });
310
+ return { questions, dismissed };
311
+ }
package/src/device.mjs ADDED
@@ -0,0 +1,105 @@
1
+ // Which device the demo is drawn in. Three inputs decide it: the mode (auto, or forced by the
2
+ // embedder, the URL or the viewer's own switch), what the viewer is holding (a phone, or a
3
+ // desktop or tablet), and where the real session ran (the scenario's optional `device`).
4
+ //
5
+ // On a phone the demo IS the phone, so nothing is drawn around it. On a desktop it sits in an
6
+ // iPhone, or a MacBook when the session ran on a Mac or the MacBook is forced. A MacBook forced
7
+ // on a phone is never a laptop shrunk into a phone: it is a desktop-style window at phone
8
+ // width, with a label that stays on screen saying what is simulated.
9
+
10
+ export const MODES = ['auto', 'iphone', 'macbook'];
11
+ export const DEVICES = ['iphone', 'macbook'];
12
+ export const LABELS = { iphone: 'Simulating an iPhone', macbook: 'Simulating a MacBook' };
13
+
14
+ export function normalizeMode(v) {
15
+ const m = String(v ?? '').trim().toLowerCase();
16
+ return MODES.includes(m) ? m : 'auto';
17
+ }
18
+
19
+ // frame: 'none' | 'iphone' | 'macbook' | 'macbook-compact'; label: what is simulated, or null.
20
+ export function resolveDevice({ mode, viewer, device } = {}) {
21
+ const m = normalizeMode(mode);
22
+ const recorded = DEVICES.includes(device) ? device : 'iphone';
23
+ if (viewer === 'phone') return m === 'macbook' ? { frame: 'macbook-compact', label: LABELS.macbook } : { frame: 'none', label: null };
24
+ const d = m === 'auto' ? recorded : m;
25
+ return { frame: d, label: LABELS[d] };
26
+ }
27
+
28
+ // What the viewer is holding, from capability signals rather than width alone: a phone is a
29
+ // touch device whose primary pointer is coarse (or that cannot hover) AND whose shorter side
30
+ // is phone-sized. A narrow desktop window still has a mouse; an iPad (including iPadOS, which
31
+ // reports itself as a Mac with touch points) is wide enough to be treated as a desktop, and is
32
+ // held like a phone only in a narrow split view.
33
+ export const PHONE_MAX = 700;
34
+ export function classifyViewer({ width, height, coarse = false, hoverNone = false, maxTouchPoints = 0 } = {}) {
35
+ const touchFirst = coarse || (maxTouchPoints > 0 && hoverNone);
36
+ if (!touchFirst || !Number.isFinite(width)) return 'desktop';
37
+ const shorter = Number.isFinite(height) ? Math.min(width, height) : width;
38
+ return shorter < PHONE_MAX && width < 1000 ? 'phone' : 'desktop';
39
+ }
40
+
41
+ // The browser's signals, read now. Safe to call anywhere; outside a browser it is a desktop.
42
+ export function readViewer(w = typeof window === 'undefined' ? null : window) {
43
+ if (!w) return 'desktop';
44
+ const mq = (q) => Boolean(w.matchMedia?.(q).matches);
45
+ return classifyViewer({
46
+ width: w.innerWidth, height: w.innerHeight,
47
+ coarse: mq('(pointer: coarse)'), hoverNone: mq('(hover: none)'),
48
+ maxTouchPoints: w.navigator?.maxTouchPoints ?? 0,
49
+ });
50
+ }
51
+
52
+ // Calls back whenever the answer may have changed: resize, rotation, a pointer plugged in.
53
+ export function watchViewer(onChange, w = typeof window === 'undefined' ? null : window) {
54
+ if (!w) return () => {};
55
+ const fire = () => onChange(readViewer(w));
56
+ const queries = ['(pointer: coarse)', '(hover: none)'].map((q) => w.matchMedia?.(q)).filter(Boolean);
57
+ w.addEventListener('resize', fire);
58
+ w.addEventListener('orientationchange', fire);
59
+ for (const q of queries) q.addEventListener?.('change', fire);
60
+ return () => {
61
+ w.removeEventListener('resize', fire);
62
+ w.removeEventListener('orientationchange', fire);
63
+ for (const q of queries) q.removeEventListener?.('change', fire);
64
+ };
65
+ }
66
+
67
+ // Where a real session ran, when its transcript can say. Typed into an interactive terminal,
68
+ // or the desktop app, on a Mac: a MacBook. Remote Control and SDK sessions could be a phone or
69
+ // a browser, so they stay unset (the player then shows an iPhone).
70
+ export function deviceFromSession(lines) {
71
+ const human = lines.filter((o) => o?.type === 'user' && o.origin?.kind === 'human');
72
+ if (!human.length) return undefined;
73
+ const onMac = (o) => /^\/Users\//.test(o.cwd ?? '');
74
+ const desk = human.filter((o) => onMac(o) && ((o.entrypoint === 'cli' && ['typed', 'queued'].includes(o.promptSource)) || o.entrypoint === 'claude-desktop'));
75
+ return desk.length * 2 > human.length ? 'macbook' : undefined;
76
+ }
77
+
78
+ // What Auto resolved to, for the hint beside it on the switch.
79
+ export function autoHint({ viewer, device } = {}) {
80
+ const { frame } = resolveDevice({ mode: 'auto', viewer, device });
81
+ return frame === 'macbook' ? 'MacBook' : frame === 'iphone' ? 'iPhone' : 'this phone';
82
+ }
83
+
84
+ // The toast after a tap on the device switch: what is shown now, and why.
85
+ export function deviceToast({ mode, viewer, device } = {}) {
86
+ const m = normalizeMode(mode);
87
+ const mac = device === 'macbook';
88
+ const onMac = 'this session ran in Claude Code on a Mac';
89
+ if (viewer === 'phone') {
90
+ if (m === 'macbook') return 'Now simulating a MacBook, in a window at phone width.';
91
+ return `${m === 'auto' ? 'Auto' : 'iPhone'}: you're on a phone, so the demo is the phone.`;
92
+ }
93
+ if (m === 'auto') return mac ? `Auto: showing a MacBook because ${onMac}.` : "Auto: showing an iPhone because you're on a desktop.";
94
+ if (m === 'macbook') return `Now simulating a MacBook: ${mac ? onMac : 'this session actually ran on a phone'}.`;
95
+ return mac ? 'Now simulating an iPhone: this session actually ran on a Mac.' : 'Now simulating an iPhone.';
96
+ }
97
+
98
+ export function autoplayToast(on) {
99
+ return on ? 'Autoplay: the session plays itself.' : "You're the human now: tap to send each message and make each call.";
100
+ }
101
+
102
+ // After the viewer picks on a board: the real person's choice, which is where the replay goes.
103
+ export function choiceToast(person, verdict) {
104
+ return verdict.same ? `Same call as ${person}.` : `In the real session, ${person} chose ${verdict.real}.`;
105
+ }
package/src/embed.mjs ADDED
@@ -0,0 +1,45 @@
1
+ // <supersuit-simulator>: the simulator as a tag, for a page or a book that wants it inline
2
+ // rather than in an iframe. The page supplies React through an import map (or a bundler).
3
+ //
4
+ // <supersuit-simulator src="/simulator/scenarios" scenario="low-tide-tapes"
5
+ // artifacts="off" timeline="off" autoplay="off" height="620px" device="auto"
6
+ // speed="3" highlights moments steps="1"></supersuit-simulator>
7
+ // <script type="module">import { defineSimulatorElement } from '@supersuit/session-player/embed'; defineSimulatorElement();</script>
8
+ //
9
+ // `src` returns a JSON array of scenarios (or one scenario). Image paths in it resolve against
10
+ // the page, so serve the scenario's assets/ folder beside it. Any page that cannot run modules
11
+ // can use the iframe instead: <iframe src="<served>/embed?scenario=<id>&timeline=off">.
12
+
13
+ export function propsFromAttributes(a) {
14
+ return {
15
+ src: a.src,
16
+ scenarioId: a.scenario || undefined,
17
+ artifacts: a.artifacts !== 'off',
18
+ timeline: a.timeline !== 'off',
19
+ autoplay: a.autoplay !== 'off',
20
+ height: a.height || undefined,
21
+ device: a.device || undefined,
22
+ start: a.start || undefined,
23
+ // The highlights cut (Wilson, 0.3.0): speed="3" highlights moments steps="1".
24
+ speed: Number(a.speed) || undefined,
25
+ highlights: a.highlights != null && a.highlights !== 'off',
26
+ moments: a.moments != null && a.moments !== 'off',
27
+ maxSteps: Number(a.steps) || undefined,
28
+ };
29
+ }
30
+
31
+ export function defineSimulatorElement(name = 'supersuit-simulator') {
32
+ if (typeof customElements === 'undefined' || customElements.get(name)) return;
33
+ customElements.define(name, class extends HTMLElement {
34
+ async connectedCallback() {
35
+ const attrs = Object.fromEntries([...this.attributes].map((x) => [x.name, x.value]));
36
+ const { src, ...props } = propsFromAttributes(attrs);
37
+ const [{ createElement }, { createRoot }, { SimulatorPlayer }] = await Promise.all([import('react'), import('react-dom/client'), import('./player.mjs')]);
38
+ const data = await (await fetch(src, { credentials: 'same-origin' })).json();
39
+ const scenarios = Array.isArray(data) ? data : [data];
40
+ this.root = createRoot(this);
41
+ this.root.render(createElement(SimulatorPlayer, { scenarios, ...props }));
42
+ }
43
+ disconnectedCallback() { this.root?.unmount(); }
44
+ });
45
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,7 @@
1
+ export { FORMAT, validate, INTERVENTIONS, VIA, ARTIFACT_KINDS, SOURCE_KINDS } from './scenario.mjs';
2
+ export { convert, parseSession } from './convert.mjs';
3
+ export { tidy } from './tidy.mjs';
4
+ export { recast } from './recast.mjs';
5
+ export { bundle } from './bundle.mjs';
6
+ export { allImages } from './scenario.mjs';
7
+ export { resolveDevice, classifyViewer, normalizeMode, deviceFromSession, MODES, DEVICES } from './device.mjs';
@@ -0,0 +1,126 @@
1
+ // Turns a scenario into something watchable. A real session runs for hours and hundreds of
2
+ // steps; a viewer gives it minutes. Nothing here changes what happened: condensed steps keep
3
+ // their total time and count, and every decision the person made stays visible.
4
+
5
+ const isAsk = (s) => Boolean(s.ask);
6
+ // Decisions and anything the person was shown always stay visible.
7
+ const isKept = (s) => isAsk(s) || Boolean(s.images?.length);
8
+
9
+ export function condense(steps, max = 5) {
10
+ if (steps.length <= max + 1) return steps;
11
+ const keep = new Set(
12
+ steps.map((s, i) => [s, i]).filter(([s]) => !isKept(s)).sort((a, b) => b[0].ms - a[0].ms).slice(0, max).map(([, i]) => i),
13
+ );
14
+ const out = [];
15
+ let group = null;
16
+ steps.forEach((s, i) => {
17
+ if (keep.has(i) || isKept(s)) {
18
+ if (group) out.push(group);
19
+ group = null;
20
+ out.push(s);
21
+ return;
22
+ }
23
+ group ??= { label: '', tool: 'group', ms: 0, count: 0, group: true, at: s.at };
24
+ group.ms += s.ms;
25
+ group.count += s.count ?? 1;
26
+ group.at = s.at;
27
+ });
28
+ if (group) out.push(group);
29
+ for (const s of out) if (s.group) s.label = `${s.count} quick step${s.count === 1 ? '' : 's'}`;
30
+ return out;
31
+ }
32
+
33
+ // Playback time for a step, from its real time: log-scaled so a seven-minute download reads
34
+ // as longer than a two-second read without making anyone wait seven minutes.
35
+ export function pace(ms) {
36
+ return Math.round(Math.min(1600, 280 + 300 * Math.log10(1 + ms / 1000)));
37
+ }
38
+
39
+ export function duration(ms) {
40
+ if (ms < 1000) return 'under a second';
41
+ const s = Math.round(ms / 1000);
42
+ if (s < 60) return `${s} s`;
43
+ const m = Math.round(ms / 60000);
44
+ if (m < 60) return `${m} min`;
45
+ const h = Math.floor(ms / 3600000);
46
+ const rest = Math.round((ms - h * 3600000) / 60000);
47
+ return rest ? `${h} h ${rest} min` : `${h} h`;
48
+ }
49
+
50
+ export function summarize(sc) {
51
+ const all = sc.turns.flatMap((t) => t.steps);
52
+ return {
53
+ commands: sc.turns.length,
54
+ steps: all.filter((s) => !isAsk(s)).reduce((n, s) => n + (s.count ?? 1), 0),
55
+ agentMs: all.filter((s) => !isAsk(s)).reduce((n, s) => n + s.ms, 0),
56
+ decisions: all.filter(isAsk).length,
57
+ redirects: sc.turns.filter((t) => t.intervention).length,
58
+ artifacts: (sc.artifacts ?? []).length,
59
+ idleMs: all.reduce((n, s) => n + (s.idleMs ?? 0), 0),
60
+ decisionMs: all.filter(isAsk).reduce((n, s) => n + s.ms, 0),
61
+ };
62
+ }
63
+
64
+ // One entry per turn: the command the viewer sends, then the events that follow it in the
65
+ // order they really happened, each with a playback offset `t`. `length` is the turn's
66
+ // playback time.
67
+ // A reply's first paragraph is its answer (the house style leads with it), so a highlights
68
+ // cut can show only that and give the viewer time to read it rather than a fixed beat.
69
+ export function lead(text) {
70
+ return String(text).split(/\n\s*\n/)[0].trim();
71
+ }
72
+ export function readMs(text) {
73
+ const words = String(text).trim().split(/\s+/).filter(Boolean).length;
74
+ return Math.max(900, Math.min(2800, 600 + 50 * words));
75
+ }
76
+
77
+ export function script(sc, { maxSteps = 5, speed = 1, leadOnly = false } = {}) {
78
+ const byId = new Map((sc.artifacts ?? []).map((a) => [a.id, a]));
79
+ return sc.turns.map((turn) => {
80
+ const steps = condense(turn.steps, maxSteps);
81
+ const items = [
82
+ ...steps.map((s) => ({ kind: 'step', real: s.at ?? turn.at, step: s })),
83
+ // A highlight shows where the turn landed: its last answer, not the progress notes before it.
84
+ ...(leadOnly ? [turn.replies[turn.highlight ?? turn.replies.length - 1]].filter(Boolean) : turn.replies).map((r) => ({ kind: 'reply', real: r.at, reply: leadOnly ? { ...r, text: lead(r.text) } : r })),
85
+ ...turn.artifacts.map((id) => byId.get(id)).filter(Boolean).map((a) => ({ kind: 'artifact', real: a.at ?? turn.at, artifact: a })),
86
+ ].map((e, i) => ({ ...e, i }));
87
+ // Stable by real time; within one instant, steps before replies before artifacts.
88
+ const rank = { step: 0, reply: 1, artifact: 2 };
89
+ items.sort((a, b) => a.real - b.real || rank[a.kind] - rank[b.kind] || a.i - b.i);
90
+ let t = 0;
91
+ const events = items.map(({ i, ...e }) => {
92
+ const at = t;
93
+ // speed shortens the work; reading time in a highlights cut is never sped up.
94
+ t += e.kind === 'step' ? Math.round(pace(e.step.ms) / speed) : e.kind === 'reply' ? (leadOnly ? readMs(e.reply.text) : Math.round(900 / speed)) : Math.round(500 / speed);
95
+ return { ...e, t: at };
96
+ });
97
+ return { id: turn.id, command: { text: turn.human.text, intervention: turn.intervention, images: turn.human.images }, events, length: t };
98
+ });
99
+ }
100
+
101
+ // What the person really did on one question of a board.
102
+ export function realPick(q, dismissed) {
103
+ if (dismissed) return { kind: 'dismissed', index: null, label: 'closed it and typed instead' };
104
+ if (q.chosen != null) return { kind: 'option', index: q.chosen, label: q.options[q.chosen]?.label ?? q.answer };
105
+ return { kind: 'other', index: null, label: q.answer ?? '' };
106
+ }
107
+
108
+ // The viewer's pick against the real one. A recording has one path, so a different pick is
109
+ // answered with what really happened, never with an invented branch.
110
+ export function compare(q, dismissed, pick) {
111
+ const real = realPick(q, dismissed);
112
+ const same = pick.kind === real.kind && (real.kind !== 'option' || pick.index === real.index);
113
+ return { same, real: real.label };
114
+ }
115
+
116
+ // Frames for "saying" a command: words appear the way dictation lands, ending on the whole
117
+ // command. Long pastes advance in larger jumps so no command takes more than ~40 frames.
118
+ export function dictate(text) {
119
+ const words = String(text).trim().split(/\s+/).filter(Boolean);
120
+ if (!words.length) return [''];
121
+ const step = Math.max(1, Math.ceil(words.length / 40));
122
+ const frames = [];
123
+ for (let n = step; n < words.length; n += step) frames.push(words.slice(0, n).join(' '));
124
+ frames.push(String(text).trim());
125
+ return frames;
126
+ }