@sickr/replay 0.3.0 → 0.4.3
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/dist/cli.js +127 -38
- package/dist/recorder.js +11 -7
- package/dist/render.js +113 -4
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
3
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
5
|
+
import { join, dirname } from 'node:path';
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import { appendEvent, loadRun, runsDir, latestRunId } from './recorder.js';
|
|
8
8
|
import { mergeHooks, removeHooks } from './hookConfig.js';
|
|
@@ -16,8 +16,8 @@ export function parseCommand(argv) {
|
|
|
16
16
|
}
|
|
17
17
|
export const HELP = `SICKR Replay — audit & replay what your AI coding agent did.
|
|
18
18
|
|
|
19
|
-
Records your Claude Code session (prompts, edits, commands) to a local,
|
|
20
|
-
timeline you can replay — and optionally share as a public link.
|
|
19
|
+
Records your Claude Code or Codex session (prompts, edits, commands) to a local,
|
|
20
|
+
redacted timeline you can replay — and optionally share as a public link.
|
|
21
21
|
|
|
22
22
|
Why: a durable record of every agent action — a dashcam for your coding agent.
|
|
23
23
|
If your agent (Claude or Codex) loses context or can't reload a past chat, the
|
|
@@ -26,10 +26,16 @@ replay log helps you — and it — recall exactly what was just done.
|
|
|
26
26
|
Usage: npx @sickr/replay <command> [options]
|
|
27
27
|
|
|
28
28
|
Commands:
|
|
29
|
-
init Install the
|
|
30
|
-
|
|
29
|
+
init Install the recording hooks in this project and start capturing
|
|
30
|
+
runs to ~/.sickr/runs (secrets redacted).
|
|
31
|
+
--codex install for Codex (.codex/hooks.json) instead of
|
|
32
|
+
Claude Code (.claude/settings.json)
|
|
33
|
+
--as "<name>" label your prompts with <name> on replays
|
|
34
|
+
(default "Human"; or set SICKR_HANDLE)
|
|
31
35
|
open [run] Render a run to a local HTML timeline and open it in your
|
|
32
|
-
browser. 100% local — nothing is uploaded.
|
|
36
|
+
browser. 100% local — nothing is uploaded. Defaults to the
|
|
37
|
+
newest run; pass a run id, or --codex / --claude to open the
|
|
38
|
+
newest run for that agent.
|
|
33
39
|
share [run] Redact and publish ONE run to a public sickr.ai/r/<id> link
|
|
34
40
|
(shows a preview and asks first). Links expire after 24h.
|
|
35
41
|
--open also open the published link in your browser
|
|
@@ -58,44 +64,83 @@ audit, accountability, productivity and confidence.
|
|
|
58
64
|
export function currentRunId(cc) {
|
|
59
65
|
return String(cc.session_id ?? 'session');
|
|
60
66
|
}
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
const PROVIDERS = {
|
|
68
|
+
claude: { name: 'Claude Code', label: 'Claude', settingsPath: () => join(process.cwd(), '.claude', 'settings.json') },
|
|
69
|
+
codex: { name: 'Codex', label: 'Codex', settingsPath: () => join(process.cwd(), '.codex', 'hooks.json') },
|
|
70
|
+
};
|
|
71
|
+
function configPath() {
|
|
72
|
+
return join(homedir(), '.sickr', 'config.json');
|
|
73
|
+
}
|
|
74
|
+
/** Display name for the human: SICKR_HANDLE env, else ~/.sickr/config.json handle, else "Human". */
|
|
75
|
+
function resolveHandle() {
|
|
76
|
+
if (process.env.SICKR_HANDLE)
|
|
77
|
+
return process.env.SICKR_HANDLE;
|
|
78
|
+
try {
|
|
79
|
+
const c = JSON.parse(readFileSync(configPath(), 'utf8'));
|
|
80
|
+
if (c.handle)
|
|
81
|
+
return c.handle;
|
|
82
|
+
}
|
|
83
|
+
catch { /* no config */ }
|
|
84
|
+
return 'Human';
|
|
85
|
+
}
|
|
86
|
+
/** Ingest one hook payload (Claude Code or Codex). Must never throw. */
|
|
87
|
+
export function handleRecord(input, provider = 'claude') {
|
|
63
88
|
try {
|
|
64
89
|
const cc = JSON.parse(input);
|
|
65
|
-
appendEvent(currentRunId(cc), cc);
|
|
90
|
+
appendEvent(currentRunId(cc), cc, { human: resolveHandle(), agent: PROVIDERS[provider].label });
|
|
66
91
|
}
|
|
67
92
|
catch {
|
|
68
93
|
/* swallow: recording is best-effort and must not disrupt the session */
|
|
69
94
|
}
|
|
70
95
|
}
|
|
71
|
-
function handleInit() {
|
|
72
|
-
const
|
|
96
|
+
export function handleInit(provider, handle) {
|
|
97
|
+
const p = PROVIDERS[provider];
|
|
98
|
+
const settingsPath = p.settingsPath();
|
|
73
99
|
const settings = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, 'utf8')) : {};
|
|
74
|
-
const
|
|
75
|
-
|
|
100
|
+
const command = `npx @sickr/replay record${provider === 'codex' ? ' --codex' : ''}`;
|
|
101
|
+
// Remove any prior SICKR hook first, then install the current command — so
|
|
102
|
+
// re-running init (or a CLI upgrade that changes the command) self-heals
|
|
103
|
+
// instead of leaving a stale hook. Scoped to this provider's file.
|
|
104
|
+
const merged = mergeHooks(removeHooks(settings), command);
|
|
105
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
76
106
|
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
|
77
107
|
mkdirSync(runsDir(), { recursive: true });
|
|
78
|
-
|
|
108
|
+
if (handle) {
|
|
109
|
+
let existing = {};
|
|
110
|
+
try {
|
|
111
|
+
existing = JSON.parse(readFileSync(configPath(), 'utf8'));
|
|
112
|
+
}
|
|
113
|
+
catch { /* none */ }
|
|
114
|
+
writeFileSync(configPath(), JSON.stringify({ ...existing, handle }, null, 2) + '\n');
|
|
115
|
+
}
|
|
116
|
+
process.stdout.write(`sickr: installed ${p.name} recording hooks in ${settingsPath}\n` +
|
|
79
117
|
`Runs are recorded locally to ${runsDir()} (secrets redacted).\n` +
|
|
80
|
-
`
|
|
118
|
+
(handle ? `Your prompts will be labelled "${handle}".\n` : 'Tip: set SICKR_HANDLE or run `init --as "<name>"` to label your prompts.\n') +
|
|
119
|
+
`Use ${p.name} as normal, then: npx @sickr/replay open\n`);
|
|
81
120
|
}
|
|
82
|
-
/** Stop recording: remove SICKR's hooks from this project
|
|
121
|
+
/** Stop recording: remove SICKR's hooks from this project (both providers), keep runs. */
|
|
83
122
|
export function handleStop() {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
123
|
+
const targets = [PROVIDERS.claude.settingsPath(), PROVIDERS.codex.settingsPath()];
|
|
124
|
+
const cleaned = [];
|
|
125
|
+
for (const settingsPath of targets) {
|
|
126
|
+
if (!existsSync(settingsPath))
|
|
127
|
+
continue;
|
|
128
|
+
let settings;
|
|
129
|
+
try {
|
|
130
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
process.stderr.write(`sickr: could not parse ${settingsPath}; left it unchanged.\n`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
writeFileSync(settingsPath, JSON.stringify(removeHooks(settings), null, 2) + '\n');
|
|
137
|
+
cleaned.push(settingsPath);
|
|
92
138
|
}
|
|
93
|
-
|
|
94
|
-
process.
|
|
139
|
+
if (cleaned.length === 0) {
|
|
140
|
+
process.stdout.write('sickr: no SICKR hooks found here — not recording in this project.\n');
|
|
95
141
|
return;
|
|
96
142
|
}
|
|
97
|
-
|
|
98
|
-
process.stdout.write('sickr: recording stopped — removed SICKR hooks from .claude/settings.json.\n' +
|
|
143
|
+
process.stdout.write(`sickr: recording stopped — removed SICKR hooks from ${cleaned.join(', ')}.\n` +
|
|
99
144
|
'Your recorded runs are kept. Run `npx @sickr/replay init` to start again.\n');
|
|
100
145
|
}
|
|
101
146
|
/** Delete all local runs. Destructive — confirms unless `yes` is set. */
|
|
@@ -131,17 +176,49 @@ function openInBrowser(file) {
|
|
|
131
176
|
}
|
|
132
177
|
catch { /* ignore */ }
|
|
133
178
|
}
|
|
134
|
-
|
|
135
|
-
|
|
179
|
+
/** A short, human-readable summary of a run: agent + first prompt + event count. */
|
|
180
|
+
function runSummary(id) {
|
|
181
|
+
const run = loadRun(id);
|
|
182
|
+
const agent = run.events.find((e) => e.kind === 'response')?.label || '—';
|
|
183
|
+
const prompt = (run.events.find((e) => e.kind === 'prompt')?.detail || '').replace(/\s+/g, ' ').trim();
|
|
184
|
+
return { agent, prompt, events: run.events.length };
|
|
185
|
+
}
|
|
186
|
+
/** Newest run whose agent (response label) matches `agent`, or null. */
|
|
187
|
+
export function latestRunIdFor(agent) {
|
|
188
|
+
const dir = runsDir();
|
|
189
|
+
if (!existsSync(dir))
|
|
190
|
+
return null;
|
|
191
|
+
const files = readdirSync(dir)
|
|
192
|
+
.filter((f) => f.endsWith('.ndjson'))
|
|
193
|
+
.sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs);
|
|
194
|
+
for (const f of files) {
|
|
195
|
+
const id = f.replace(/\.ndjson$/, '');
|
|
196
|
+
if (loadRun(id).events.some((e) => e.kind === 'response' && e.label === agent))
|
|
197
|
+
return id;
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
function handleOpen(runId, provider) {
|
|
202
|
+
let id = runId;
|
|
203
|
+
if (!id && provider) {
|
|
204
|
+
id = latestRunIdFor(PROVIDERS[provider].label) ?? undefined;
|
|
205
|
+
if (!id) {
|
|
206
|
+
process.stdout.write(`sickr: no ${PROVIDERS[provider].label} runs yet — use ${PROVIDERS[provider].name} with the hooks installed, then try again.\n`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
id = id ?? latestRunId() ?? undefined;
|
|
136
211
|
if (!id) {
|
|
137
|
-
process.stdout.write('sickr: no runs recorded yet. Run `npx @sickr/replay init`, then use Claude Code.\n');
|
|
212
|
+
process.stdout.write('sickr: no runs recorded yet. Run `npx @sickr/replay init`, then use Claude Code or Codex.\n');
|
|
138
213
|
return;
|
|
139
214
|
}
|
|
140
215
|
const html = renderRunHtml(loadRun(id));
|
|
141
216
|
const out = join(homedir(), '.sickr', 'last.html');
|
|
142
217
|
mkdirSync(join(homedir(), '.sickr'), { recursive: true });
|
|
143
218
|
writeFileSync(out, html);
|
|
144
|
-
|
|
219
|
+
const s = runSummary(id);
|
|
220
|
+
process.stdout.write(`sickr: opened ${s.agent} run ${id} · ${s.events} events${s.prompt ? ` · "${s.prompt.slice(0, 60)}"` : ''}\n` +
|
|
221
|
+
`→ ${out} (newest run; use \`list\` to see others, \`open <id>\` to pick one)\n`);
|
|
145
222
|
openInBrowser(out);
|
|
146
223
|
}
|
|
147
224
|
function handleList() {
|
|
@@ -153,7 +230,13 @@ function handleList() {
|
|
|
153
230
|
}
|
|
154
231
|
files
|
|
155
232
|
.sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs)
|
|
156
|
-
.forEach((f) =>
|
|
233
|
+
.forEach((f) => {
|
|
234
|
+
const id = f.replace(/\.ndjson$/, '');
|
|
235
|
+
const s = runSummary(id);
|
|
236
|
+
const when = statSync(join(dir, f)).mtime.toISOString().replace('T', ' ').slice(0, 16);
|
|
237
|
+
const snippet = s.prompt ? ` "${s.prompt.slice(0, 48)}"` : '';
|
|
238
|
+
process.stdout.write(`${id} ${s.agent.padEnd(7)} ${String(s.events).padStart(4)} ev ${when}${snippet}\n`);
|
|
239
|
+
});
|
|
157
240
|
}
|
|
158
241
|
async function handleShare(runId, yes, open) {
|
|
159
242
|
const id = runId ?? latestRunId();
|
|
@@ -231,16 +314,22 @@ async function main() {
|
|
|
231
314
|
}
|
|
232
315
|
const cmd = parseCommand(argv);
|
|
233
316
|
const rest = argv.slice(1);
|
|
317
|
+
const provider = rest.includes('--codex') ? 'codex' : 'claude';
|
|
234
318
|
switch (cmd) {
|
|
235
319
|
case 'record':
|
|
236
|
-
handleRecord(await readStdin());
|
|
320
|
+
handleRecord(await readStdin(), provider);
|
|
237
321
|
return;
|
|
238
|
-
case 'init':
|
|
239
|
-
|
|
322
|
+
case 'init': {
|
|
323
|
+
const asIdx = rest.indexOf('--as');
|
|
324
|
+
const handle = asIdx >= 0 ? rest[asIdx + 1] : undefined;
|
|
325
|
+
handleInit(provider, handle);
|
|
240
326
|
return;
|
|
241
|
-
|
|
242
|
-
|
|
327
|
+
}
|
|
328
|
+
case 'open': {
|
|
329
|
+
const openProvider = rest.includes('--codex') ? 'codex' : rest.includes('--claude') ? 'claude' : undefined;
|
|
330
|
+
handleOpen(rest.find((a) => !a.startsWith('-')), openProvider);
|
|
243
331
|
return;
|
|
332
|
+
}
|
|
244
333
|
case 'list':
|
|
245
334
|
handleList();
|
|
246
335
|
return;
|
package/dist/recorder.js
CHANGED
|
@@ -80,18 +80,22 @@ function formatQuestions(questions) {
|
|
|
80
80
|
})
|
|
81
81
|
.join('\n\n');
|
|
82
82
|
}
|
|
83
|
-
/**
|
|
84
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Map one hook payload (Claude Code OR Codex — same field names) to a redacted
|
|
85
|
+
* run event. `ctx` supplies the human/agent display labels.
|
|
86
|
+
*/
|
|
87
|
+
export function mapEvent(cc, now = new Date(), ctx = {}) {
|
|
85
88
|
const at = now.toISOString();
|
|
86
89
|
const name = String(cc.hook_event_name ?? '');
|
|
87
90
|
switch (name) {
|
|
88
91
|
case 'SessionStart':
|
|
89
92
|
return { kind: 'start', label: 'Session', detail: redact(String(cc.cwd ?? '')), at };
|
|
90
93
|
case 'UserPromptSubmit':
|
|
91
|
-
return { kind: 'prompt', label: '
|
|
94
|
+
return { kind: 'prompt', label: (ctx.human || 'Human').slice(0, 40), detail: redact(String(cc.prompt ?? '')).slice(0, 400), at };
|
|
92
95
|
case 'Stop': {
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
// Codex hands us the reply directly; Claude Code we read from the transcript.
|
|
97
|
+
const text = String(cc.last_assistant_message ?? '') || (cc.transcript_path ? extractLastAssistantText(String(cc.transcript_path)) : '');
|
|
98
|
+
return { kind: 'response', label: (ctx.agent || 'Agent').slice(0, 40), detail: redact(text).slice(0, 2000), at };
|
|
95
99
|
}
|
|
96
100
|
case 'PreToolUse':
|
|
97
101
|
case 'PostToolUse': {
|
|
@@ -113,10 +117,10 @@ export function mapEvent(cc, now = new Date()) {
|
|
|
113
117
|
return { kind: 'tool', label: name || 'event', detail: '', at };
|
|
114
118
|
}
|
|
115
119
|
}
|
|
116
|
-
export function appendEvent(runId, cc) {
|
|
120
|
+
export function appendEvent(runId, cc, ctx = {}) {
|
|
117
121
|
const dir = runsDir();
|
|
118
122
|
mkdirSync(dir, { recursive: true });
|
|
119
|
-
appendFileSync(join(dir, `${runId}.ndjson`), JSON.stringify(mapEvent(cc)) + '\n');
|
|
123
|
+
appendFileSync(join(dir, `${runId}.ndjson`), JSON.stringify(mapEvent(cc, new Date(), ctx)) + '\n');
|
|
120
124
|
}
|
|
121
125
|
export function loadRun(runId) {
|
|
122
126
|
const file = join(runsDir(), `${runId}.ndjson`);
|
package/dist/render.js
CHANGED
|
@@ -108,6 +108,16 @@ const STYLES = `
|
|
|
108
108
|
.msg .bubble{padding:10px 14px;border-radius:0 8px 8px 0;white-space:pre-wrap;word-break:break-word;font-size:14px;line-height:1.6}
|
|
109
109
|
.msg.human .bubble{border-left:2px solid rgba(255,207,107,.55);background:rgba(255,207,107,.05);color:#f3ead7}
|
|
110
110
|
.msg.agent .bubble{border-left:2px solid rgba(52,224,255,.55);background:rgba(52,224,255,.05);color:#d7e6ee}
|
|
111
|
+
.bubble.md{white-space:normal}
|
|
112
|
+
.bubble.md p{margin:0 0 8px}.bubble.md p:last-child{margin-bottom:0}
|
|
113
|
+
.bubble.md code{font-family:"JetBrains Mono",monospace;font-size:.92em;background:rgba(255,255,255,.07);padding:1px 5px;border-radius:4px}
|
|
114
|
+
.bubble.md pre{background:#04060b;border:1px solid #1b2435;border-radius:8px;padding:10px 12px;overflow:auto;margin:8px 0}
|
|
115
|
+
.bubble.md pre code{background:none;padding:0;font-size:12.5px;line-height:1.5}
|
|
116
|
+
.bubble.md ul,.bubble.md ol{margin:6px 0;padding-left:20px}.bubble.md li{margin:3px 0}
|
|
117
|
+
.bubble.md a{color:var(--plasma);text-decoration:underline}
|
|
118
|
+
.bubble.md blockquote{margin:8px 0;padding:2px 12px;border-left:2px solid #2a3850;color:#9aa6b6}
|
|
119
|
+
.bubble.md .md-h{font-family:"Chakra Petch","Sora",sans-serif;font-weight:700;color:#fff;font-size:15px;margin:10px 0 6px}
|
|
120
|
+
.bubble.md strong{color:#fff;font-weight:700}
|
|
111
121
|
details.work{margin:10px 0;border:1px solid #16202f;border-radius:10px;background:rgba(8,12,20,.4)}
|
|
112
122
|
details.work>summary{cursor:pointer;list-style:none;padding:10px 14px;display:flex;align-items:center;gap:10px;font-family:"JetBrains Mono",monospace;font-size:12px;color:#9aa6b6}
|
|
113
123
|
details.work>summary::-webkit-details-marker{display:none}
|
|
@@ -210,10 +220,109 @@ function fmtTime(at) {
|
|
|
210
220
|
return at ? esc(at.replace('T', ' ').slice(0, 19) + 'Z') : '';
|
|
211
221
|
}
|
|
212
222
|
const COPY_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>';
|
|
223
|
+
// Inline markdown on an ALREADY-ESCAPED string. Code spans are protected from
|
|
224
|
+
// emphasis. Only http(s) links are emitted. No raw HTML can survive (input is
|
|
225
|
+
// pre-escaped), so this is XSS-safe — it only adds a fixed set of safe tags.
|
|
226
|
+
function inlineMd(s) {
|
|
227
|
+
return s.split(/(`[^`]+`)/).map((p) => {
|
|
228
|
+
if (p.length >= 2 && p[0] === '`' && p[p.length - 1] === '`')
|
|
229
|
+
return `<code>${p.slice(1, -1)}</code>`;
|
|
230
|
+
return p
|
|
231
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" rel="noopener noreferrer" target="_blank">$1</a>')
|
|
232
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
233
|
+
.replace(/__([^_]+)__/g, '<strong>$1</strong>')
|
|
234
|
+
.replace(/\*([^*\s][^*]*)\*/g, '<em>$1</em>')
|
|
235
|
+
.replace(/(^|[^a-zA-Z0-9_])_([^_\s][^_]*)_/g, '$1<em>$2</em>');
|
|
236
|
+
}).join('');
|
|
237
|
+
}
|
|
238
|
+
/** Minimal, XSS-safe markdown → HTML for chat bubbles (prompt/response). */
|
|
239
|
+
export function mdToHtml(raw) {
|
|
240
|
+
const lines = esc(raw).split('\n');
|
|
241
|
+
const out = [];
|
|
242
|
+
let para = [];
|
|
243
|
+
let code = [];
|
|
244
|
+
let inCode = false;
|
|
245
|
+
let list = null;
|
|
246
|
+
const flushPara = () => { if (para.length) {
|
|
247
|
+
out.push(`<p>${inlineMd(para.join('<br>'))}</p>`);
|
|
248
|
+
para = [];
|
|
249
|
+
} };
|
|
250
|
+
const flushList = () => { if (list) {
|
|
251
|
+
out.push(`</${list}>`);
|
|
252
|
+
list = null;
|
|
253
|
+
} };
|
|
254
|
+
for (const line of lines) {
|
|
255
|
+
const fence = /^```/.test(line.trim());
|
|
256
|
+
if (inCode) {
|
|
257
|
+
if (fence) {
|
|
258
|
+
out.push(`<pre><code>${code.join('\n')}</code></pre>`);
|
|
259
|
+
code = [];
|
|
260
|
+
inCode = false;
|
|
261
|
+
}
|
|
262
|
+
else
|
|
263
|
+
code.push(line);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (fence) {
|
|
267
|
+
flushPara();
|
|
268
|
+
flushList();
|
|
269
|
+
inCode = true;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (/^\s*$/.test(line)) {
|
|
273
|
+
flushPara();
|
|
274
|
+
flushList();
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
let m;
|
|
278
|
+
if ((m = line.match(/^#{1,6}\s+(.*)$/))) {
|
|
279
|
+
flushPara();
|
|
280
|
+
flushList();
|
|
281
|
+
out.push(`<div class="md-h">${inlineMd(m[1])}</div>`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if ((m = line.match(/^\s*[-*]\s+(.*)$/))) {
|
|
285
|
+
flushPara();
|
|
286
|
+
if (list !== 'ul') {
|
|
287
|
+
flushList();
|
|
288
|
+
out.push('<ul>');
|
|
289
|
+
list = 'ul';
|
|
290
|
+
}
|
|
291
|
+
out.push(`<li>${inlineMd(m[1])}</li>`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if ((m = line.match(/^\s*\d+\.\s+(.*)$/))) {
|
|
295
|
+
flushPara();
|
|
296
|
+
if (list !== 'ol') {
|
|
297
|
+
flushList();
|
|
298
|
+
out.push('<ol>');
|
|
299
|
+
list = 'ol';
|
|
300
|
+
}
|
|
301
|
+
out.push(`<li>${inlineMd(m[1])}</li>`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if ((m = line.match(/^>\s?(.*)$/))) {
|
|
305
|
+
flushPara();
|
|
306
|
+
flushList();
|
|
307
|
+
out.push(`<blockquote>${inlineMd(m[1])}</blockquote>`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
para.push(line);
|
|
311
|
+
}
|
|
312
|
+
if (inCode)
|
|
313
|
+
out.push(`<pre><code>${code.join('\n')}</code></pre>`);
|
|
314
|
+
flushPara();
|
|
315
|
+
flushList();
|
|
316
|
+
return out.join('\n');
|
|
317
|
+
}
|
|
318
|
+
// The speaker name travels on the event label; fall back for legacy generic labels.
|
|
319
|
+
function speaker(label, generic, fallback) {
|
|
320
|
+
return label && label !== generic ? label : fallback;
|
|
321
|
+
}
|
|
213
322
|
function bubble(kind, who, e) {
|
|
214
323
|
return `<div class="msg ${kind}">
|
|
215
|
-
<div class="who"><span class="badge ${kind === 'human' ? 'h' : 'a'}">${who}</span><span class="ts">${fmtTime(e.at)}</span><button class="copy" type="button" aria-label="Copy text" title="Copy">${COPY_ICON}</button></div>
|
|
216
|
-
<div class="bubble">${
|
|
324
|
+
<div class="who"><span class="badge ${kind === 'human' ? 'h' : 'a'}">${esc(who)}</span><span class="ts">${fmtTime(e.at)}</span><button class="copy" type="button" aria-label="Copy text" title="Copy">${COPY_ICON}</button></div>
|
|
325
|
+
<div class="bubble md">${mdToHtml(e.detail)}</div>
|
|
217
326
|
</div>`;
|
|
218
327
|
}
|
|
219
328
|
function workBlock(work) {
|
|
@@ -238,9 +347,9 @@ function renderTimeline(events) {
|
|
|
238
347
|
: '';
|
|
239
348
|
const preTools = workBlock(pre.filter((e) => e.kind === 'tool'));
|
|
240
349
|
const body = turns.map((t) => `<section class="turn">
|
|
241
|
-
${t.prompt ? bubble('human', '
|
|
350
|
+
${t.prompt ? bubble('human', speaker(t.prompt.label, 'Prompt', 'Human'), t.prompt) : ''}
|
|
242
351
|
${workBlock(t.work)}
|
|
243
|
-
${t.response && t.response.detail ? bubble('agent', '
|
|
352
|
+
${t.response && t.response.detail ? bubble('agent', speaker(t.response.label, 'Response', 'Agent'), t.response) : ''}
|
|
244
353
|
</section>`).join('\n');
|
|
245
354
|
return preamble + preTools + body;
|
|
246
355
|
}
|
package/package.json
CHANGED