@sickr/replay 0.4.5 → 0.5.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.
- package/dist/cli.js +107 -33
- package/dist/render.js +109 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { pathToFileURL } from 'node:url';
|
|
3
3
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
4
|
-
import { homedir } from 'node:os';
|
|
4
|
+
import { homedir, userInfo } from 'node:os';
|
|
5
5
|
import { join, dirname } from 'node:path';
|
|
6
|
-
import { spawn } from 'node:child_process';
|
|
6
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
7
7
|
import { appendEvent, loadRun, runsDir, latestRunId } from './recorder.js';
|
|
8
8
|
import { mergeHooks, removeHooks } from './hookConfig.js';
|
|
9
|
-
import { renderRunHtml } from './render.js';
|
|
9
|
+
import { renderRunHtml, renderCombinedHtml } from './render.js';
|
|
10
10
|
import { buildSharePayload, publish, PublishError } from './share.js';
|
|
11
11
|
const REPLAY_ENDPOINT = process.env.SICKR_REPLAY_ENDPOINT ?? 'https://sickr.ai/api/replay';
|
|
12
12
|
const COMMANDS = ['init', 'record', 'open', 'list', 'share', 'stop', 'clear', 'help'];
|
|
@@ -30,12 +30,13 @@ Commands:
|
|
|
30
30
|
runs to ~/.sickr/runs (secrets redacted).
|
|
31
31
|
--codex install for Codex (.codex/hooks.json) instead of
|
|
32
32
|
Claude Code (.claude/settings.json)
|
|
33
|
-
--
|
|
34
|
-
(default
|
|
35
|
-
open [run] Render a run to a local HTML timeline and open it
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
--no-name label your prompts "Human" instead of your
|
|
34
|
+
login/git name (default is your login name)
|
|
35
|
+
open [run] Render a run to a local HTML timeline and open it. 100% local.
|
|
36
|
+
Defaults to the newest run; pass a run id, or --codex/--claude
|
|
37
|
+
for the newest run of that agent. Combine across agents with a
|
|
38
|
+
window: --today, --since <2h|30m|1d>, or --all (interleaved,
|
|
39
|
+
filterable by agent, sortable by prompt/response time).
|
|
39
40
|
share [run] Redact and publish ONE run to a public sickr.ai/r/<id> link
|
|
40
41
|
(shows a preview and asks first). Links expire after 24h.
|
|
41
42
|
--open also open the published link in your browser
|
|
@@ -71,14 +72,28 @@ const PROVIDERS = {
|
|
|
71
72
|
function configPath() {
|
|
72
73
|
return join(homedir(), '.sickr', 'config.json');
|
|
73
74
|
}
|
|
74
|
-
/**
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
/** The machine's own identity — git user.name, else OS username, else "Human". */
|
|
76
|
+
function loginName() {
|
|
77
|
+
try {
|
|
78
|
+
const n = execFileSync('git', ['config', 'user.name'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
79
|
+
if (n)
|
|
80
|
+
return n;
|
|
81
|
+
}
|
|
82
|
+
catch { /* git not configured */ }
|
|
83
|
+
try {
|
|
84
|
+
const u = userInfo().username;
|
|
85
|
+
if (u)
|
|
86
|
+
return u;
|
|
87
|
+
}
|
|
88
|
+
catch { /* no os user */ }
|
|
89
|
+
return 'Human';
|
|
90
|
+
}
|
|
91
|
+
/** Human label for prompts: the name stored at init (login name, or "Human" if anonymized). */
|
|
92
|
+
function resolveName() {
|
|
78
93
|
try {
|
|
79
94
|
const c = JSON.parse(readFileSync(configPath(), 'utf8'));
|
|
80
|
-
if (c.
|
|
81
|
-
return c.
|
|
95
|
+
if (c.name)
|
|
96
|
+
return c.name;
|
|
82
97
|
}
|
|
83
98
|
catch { /* no config */ }
|
|
84
99
|
return 'Human';
|
|
@@ -87,13 +102,13 @@ function resolveHandle() {
|
|
|
87
102
|
export function handleRecord(input, provider = 'claude') {
|
|
88
103
|
try {
|
|
89
104
|
const cc = JSON.parse(input);
|
|
90
|
-
appendEvent(currentRunId(cc), cc, { human:
|
|
105
|
+
appendEvent(currentRunId(cc), cc, { human: resolveName(), agent: PROVIDERS[provider].label });
|
|
91
106
|
}
|
|
92
107
|
catch {
|
|
93
108
|
/* swallow: recording is best-effort and must not disrupt the session */
|
|
94
109
|
}
|
|
95
110
|
}
|
|
96
|
-
export function handleInit(provider,
|
|
111
|
+
export function handleInit(provider, noName = false) {
|
|
97
112
|
const p = PROVIDERS[provider];
|
|
98
113
|
const settingsPath = p.settingsPath();
|
|
99
114
|
const settings = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, 'utf8')) : {};
|
|
@@ -105,17 +120,11 @@ export function handleInit(provider, handle) {
|
|
|
105
120
|
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
106
121
|
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
|
107
122
|
mkdirSync(runsDir(), { recursive: true });
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
catch { /* none */ }
|
|
114
|
-
writeFileSync(configPath(), JSON.stringify({ ...existing, handle }, null, 2) + '\n');
|
|
115
|
-
}
|
|
116
|
-
const labelLine = handle
|
|
117
|
-
? `Your prompts will be labelled "${handle}".\n`
|
|
118
|
-
: 'Tip: set SICKR_HANDLE or run `init --as "<name>"` to label your prompts.\n';
|
|
123
|
+
// Always (re)write the name so re-running init resets it — no arbitrary names
|
|
124
|
+
// (avoids impersonation on public shares); just the login name, or "Human".
|
|
125
|
+
const name = noName ? 'Human' : loginName();
|
|
126
|
+
writeFileSync(configPath(), JSON.stringify({ name }, null, 2) + '\n');
|
|
127
|
+
const labelLine = `Your prompts will be labelled "${name}"${noName ? '' : ' — run `init --no-name` to anonymize'}.\n`;
|
|
119
128
|
const nextSteps = provider === 'codex'
|
|
120
129
|
? 'Next: in Codex, run `/hooks` to review & trust these hooks (Codex gates new hooks),\nthen use Codex as normal and: npx @sickr/replay open --codex\n'
|
|
121
130
|
: 'Use Claude Code as normal, then: npx @sickr/replay open\n';
|
|
@@ -230,6 +239,69 @@ function handleOpen(runId, provider) {
|
|
|
230
239
|
`→ ${out} (newest run; use \`list\` to see others, \`open <id>\` to pick one)\n`);
|
|
231
240
|
openInBrowser(out);
|
|
232
241
|
}
|
|
242
|
+
function parseDur(s) {
|
|
243
|
+
const m = /^(\d+)(m|h|d)$/.exec(String(s ?? ''));
|
|
244
|
+
if (!m)
|
|
245
|
+
return null;
|
|
246
|
+
return Number(m[1]) * (m[2] === 'm' ? 60_000 : m[2] === 'h' ? 3_600_000 : 86_400_000);
|
|
247
|
+
}
|
|
248
|
+
/** Select runs by window flag (--today/--since <dur>/--all) + optional agent. Null if no window flag. */
|
|
249
|
+
export function selectWindow(rest) {
|
|
250
|
+
let pred = null;
|
|
251
|
+
let label = '';
|
|
252
|
+
const now = Date.now();
|
|
253
|
+
if (rest.includes('--all')) {
|
|
254
|
+
pred = () => true;
|
|
255
|
+
label = 'all runs';
|
|
256
|
+
}
|
|
257
|
+
else if (rest.includes('--today')) {
|
|
258
|
+
const d = new Date();
|
|
259
|
+
d.setHours(0, 0, 0, 0);
|
|
260
|
+
const t = d.getTime();
|
|
261
|
+
pred = (m) => m >= t;
|
|
262
|
+
label = 'today';
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
const i = rest.indexOf('--since');
|
|
266
|
+
if (i >= 0) {
|
|
267
|
+
const ms = parseDur(rest[i + 1]);
|
|
268
|
+
if (ms == null) {
|
|
269
|
+
process.stderr.write('sickr: --since takes a duration like 2h, 30m, or 1d.\n');
|
|
270
|
+
process.exit(1);
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
pred = (m) => m >= now - ms;
|
|
274
|
+
label = `last ${rest[i + 1]}`;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (!pred)
|
|
278
|
+
return null;
|
|
279
|
+
const dir = runsDir();
|
|
280
|
+
const wantAgent = rest.includes('--codex') ? 'Codex' : rest.includes('--claude') ? 'Claude' : null;
|
|
281
|
+
let ids = existsSync(dir)
|
|
282
|
+
? readdirSync(dir).filter((f) => f.endsWith('.ndjson')).filter((f) => pred(statSync(join(dir, f)).mtimeMs)).map((f) => f.replace(/\.ndjson$/, ''))
|
|
283
|
+
: [];
|
|
284
|
+
if (wantAgent) {
|
|
285
|
+
ids = ids.filter((id) => runSummary(id).agent === wantAgent);
|
|
286
|
+
label += ` · ${wantAgent}`;
|
|
287
|
+
}
|
|
288
|
+
return { ids, label };
|
|
289
|
+
}
|
|
290
|
+
function handleOpenCombined(sel) {
|
|
291
|
+
const runs = sel.ids
|
|
292
|
+
.map((id) => ({ id, events: loadRun(id).events }))
|
|
293
|
+
.filter((r) => r.events.length)
|
|
294
|
+
.map((r) => ({ agent: runSummary(r.id).agent, events: r.events }));
|
|
295
|
+
if (runs.length === 0) {
|
|
296
|
+
process.stdout.write(`sickr: no runs in ${sel.label}.\n`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const out = join(homedir(), '.sickr', 'last.html');
|
|
300
|
+
mkdirSync(join(homedir(), '.sickr'), { recursive: true });
|
|
301
|
+
writeFileSync(out, renderCombinedHtml(runs, sel.label));
|
|
302
|
+
process.stdout.write(`sickr: opened combined replay (${sel.label}) · ${runs.length} runs → ${out}\n`);
|
|
303
|
+
openInBrowser(out);
|
|
304
|
+
}
|
|
233
305
|
function handleList(provider) {
|
|
234
306
|
const dir = runsDir();
|
|
235
307
|
let files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.ndjson')) : [];
|
|
@@ -332,13 +404,15 @@ async function main() {
|
|
|
332
404
|
case 'record':
|
|
333
405
|
handleRecord(await readStdin(), provider);
|
|
334
406
|
return;
|
|
335
|
-
case 'init':
|
|
336
|
-
|
|
337
|
-
const handle = asIdx >= 0 ? rest[asIdx + 1] : undefined;
|
|
338
|
-
handleInit(provider, handle);
|
|
407
|
+
case 'init':
|
|
408
|
+
handleInit(provider, rest.includes('--no-name'));
|
|
339
409
|
return;
|
|
340
|
-
}
|
|
341
410
|
case 'open': {
|
|
411
|
+
const sel = selectWindow(rest);
|
|
412
|
+
if (sel) {
|
|
413
|
+
handleOpenCombined(sel);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
342
416
|
const openProvider = rest.includes('--codex') ? 'codex' : rest.includes('--claude') ? 'claude' : undefined;
|
|
343
417
|
handleOpen(rest.find((a) => !a.startsWith('-')), openProvider);
|
|
344
418
|
return;
|
package/dist/render.js
CHANGED
|
@@ -126,6 +126,16 @@ const STYLES = `
|
|
|
126
126
|
details.work .peek{color:#5f6b80;font-size:11px}
|
|
127
127
|
details.work ol.tl{padding:6px 16px 14px}
|
|
128
128
|
details.work ol.tl::before{top:14px;bottom:14px}
|
|
129
|
+
.controls{position:sticky;top:0;z-index:3;display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:10px 0 14px;margin-bottom:4px;background:linear-gradient(var(--ink) 72%,transparent)}
|
|
130
|
+
.chip{font-family:"JetBrains Mono",monospace;font-size:11px;padding:4px 11px;border-radius:999px;border:1px solid #1b2435;background:#04060b;color:#9aa6b6;cursor:pointer}
|
|
131
|
+
.chip.on{border-color:rgba(52,224,255,.5);color:var(--plasma)}
|
|
132
|
+
.sortbtn{margin-left:auto;font-family:"JetBrains Mono",monospace;font-size:11px;padding:4px 11px;border-radius:6px;border:1px solid #1b2435;background:#04060b;color:#9aa6b6;cursor:pointer}
|
|
133
|
+
.sortbtn:hover{border-color:rgba(52,224,255,.5);color:var(--plasma)}
|
|
134
|
+
.turnhead{margin:0 0 4px}
|
|
135
|
+
.agentchip{font-family:"JetBrains Mono",monospace;font-size:10px;text-transform:uppercase;letter-spacing:.1em;padding:2px 8px;border-radius:5px;border:1px solid #2a3850;color:#9aa6b6}
|
|
136
|
+
.agentchip.claude{color:var(--plasma);border-color:rgba(52,224,255,.4);background:rgba(52,224,255,.08)}
|
|
137
|
+
.agentchip.codex{color:#9b8cff;border-color:rgba(155,140,255,.4);background:rgba(155,140,255,.08)}
|
|
138
|
+
.turn.hide{display:none}
|
|
129
139
|
`;
|
|
130
140
|
const FIND_SCRIPT = `<script>
|
|
131
141
|
(function(){
|
|
@@ -417,3 +427,102 @@ export function renderRunHtml(run) {
|
|
|
417
427
|
${FIND_SCRIPT}
|
|
418
428
|
</div></body></html>`;
|
|
419
429
|
}
|
|
430
|
+
// Filter (by agent) + sort (prompt vs response time) + copy. Kept in sync with /r.
|
|
431
|
+
const COMBINED_SCRIPT = `<script>
|
|
432
|
+
(function(){
|
|
433
|
+
var tl=document.getElementById('tl'); if(!tl) return;
|
|
434
|
+
var turns=[].slice.call(tl.getElementsByClassName('turn'));
|
|
435
|
+
var chips=[].slice.call(document.getElementsByClassName('chip'));
|
|
436
|
+
chips.forEach(function(c){c.addEventListener('click',function(){
|
|
437
|
+
chips.forEach(function(x){x.classList.remove('on')}); c.classList.add('on');
|
|
438
|
+
var f=c.getAttribute('data-f');
|
|
439
|
+
turns.forEach(function(t){ t.className='turn'+((f==='All'||t.getAttribute('data-agent')===f)?'':' hide'); });
|
|
440
|
+
});});
|
|
441
|
+
var sb=document.getElementById('sortbtn'), byResp=false;
|
|
442
|
+
if(sb) sb.addEventListener('click',function(){
|
|
443
|
+
byResp=!byResp; sb.textContent=byResp?'sort: response time':'sort: prompt time';
|
|
444
|
+
var key=byResp?'data-rt':'data-pt';
|
|
445
|
+
turns.slice().sort(function(a,b){var x=a.getAttribute(key)||'',y=b.getAttribute(key)||'';return x<y?-1:x>y?1:0;}).forEach(function(t){tl.appendChild(t)});
|
|
446
|
+
});
|
|
447
|
+
document.addEventListener('click',function(e){
|
|
448
|
+
var b=e.target.closest&&e.target.closest('.copy'); if(!b)return;
|
|
449
|
+
var m=b.closest('.msg'),bb=m&&m.querySelector('.bubble');
|
|
450
|
+
if(bb&&navigator.clipboard)navigator.clipboard.writeText(bb.innerText);
|
|
451
|
+
b.classList.add('done'); setTimeout(function(){b.classList.remove('done');},1200);
|
|
452
|
+
});
|
|
453
|
+
})();
|
|
454
|
+
</script>`;
|
|
455
|
+
function combinedSection(agent, t) {
|
|
456
|
+
const pt = t.prompt?.at || t.work[0]?.at || t.response?.at || '';
|
|
457
|
+
const rt = t.response?.at || t.work[t.work.length - 1]?.at || pt;
|
|
458
|
+
const cls = agent === 'Codex' ? 'codex' : agent === 'Claude' ? 'claude' : '';
|
|
459
|
+
return `<section class="turn" data-agent="${esc(agent)}" data-pt="${esc(pt)}" data-rt="${esc(rt)}">
|
|
460
|
+
<div class="turnhead"><span class="agentchip ${cls}">${esc(agent)}</span></div>
|
|
461
|
+
${t.prompt ? bubble('human', speaker(t.prompt.label, 'Prompt', 'Human'), t.prompt) : ''}
|
|
462
|
+
${workBlock(t.work)}
|
|
463
|
+
${t.response && t.response.detail ? bubble('agent', speaker(t.response.label, 'Response', agent), t.response) : ''}
|
|
464
|
+
</section>`;
|
|
465
|
+
}
|
|
466
|
+
/** A combined, interleaved timeline across multiple runs/agents, sorted by prompt time. */
|
|
467
|
+
export function renderCombinedHtml(runs, window) {
|
|
468
|
+
const items = [];
|
|
469
|
+
for (const r of runs) {
|
|
470
|
+
for (const t of groupTurns(r.events).turns) {
|
|
471
|
+
items.push({ agent: r.agent, t, pt: t.prompt?.at || t.work[0]?.at || t.response?.at || '' });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
items.sort((a, b) => (a.pt < b.pt ? -1 : a.pt > b.pt ? 1 : 0));
|
|
475
|
+
const agents = Array.from(new Set(runs.map((r) => r.agent)));
|
|
476
|
+
const chips = ['All', ...agents].map((a) => `<button class="chip${a === 'All' ? ' on' : ''}" type="button" data-f="${esc(a)}">${esc(a)}</button>`).join('');
|
|
477
|
+
const sections = items.length ? items.map((it) => combinedSection(it.agent, it.t)).join('\n') : '<p class="sub">No turns in this window yet.</p>';
|
|
478
|
+
return `<!doctype html><html lang="en"><head><meta charset="UTF-8"/>
|
|
479
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
480
|
+
<title>SICKR Replay — combined (${esc(window)})</title>
|
|
481
|
+
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
|
482
|
+
<link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@600;700&family=Sora:wght@300;400;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
|
483
|
+
<style>${STYLES}</style></head>
|
|
484
|
+
<body>
|
|
485
|
+
<div class="bg"><div class="bg-grid"></div><div class="glow glow-a"></div><div class="glow glow-b"></div></div>
|
|
486
|
+
<div class="wrap">
|
|
487
|
+
<span id="tl-top"></span>
|
|
488
|
+
<header class="bar">
|
|
489
|
+
<a href="https://sickr.ai">${wordmark('lg')}</a>
|
|
490
|
+
<a class="bar-cta" href="https://sickr.ai">sickr.ai →</a>
|
|
491
|
+
</header>
|
|
492
|
+
<div class="layout">
|
|
493
|
+
<aside class="rail">
|
|
494
|
+
<p class="label">Combined replay</p>
|
|
495
|
+
<h3 style="margin-top:10px">All your agents, one timeline</h3>
|
|
496
|
+
<p>Every turn from every agent in this window, interleaved by time.</p>
|
|
497
|
+
<div style="margin-top:14px">
|
|
498
|
+
<div class="meta-row"><span>window</span><b>${esc(window)}</b></div>
|
|
499
|
+
<div class="meta-row"><span>runs</span><b>${runs.length}</b></div>
|
|
500
|
+
<div class="meta-row"><span>turns</span><b>${items.length}</b></div>
|
|
501
|
+
<div class="meta-row"><span>agents</span><b>${esc(agents.join(', ') || '—')}</b></div>
|
|
502
|
+
</div>
|
|
503
|
+
<div class="jumps"><a href="#tl-top">↑ top</a><a href="#tl-bottom">↓ end</a></div>
|
|
504
|
+
</aside>
|
|
505
|
+
<main>
|
|
506
|
+
<h1 class="title">What <span class="hl">all your agents</span> did.</h1>
|
|
507
|
+
<p class="sub">${items.length} turns · ${runs.length} runs · ${esc(window)} · captured locally</p>
|
|
508
|
+
<div class="controls">${chips}<button id="sortbtn" type="button" class="sortbtn">sort: prompt time</button></div>
|
|
509
|
+
<div id="tl" class="turns">${sections}</div>
|
|
510
|
+
</main>
|
|
511
|
+
<aside class="rail panel">
|
|
512
|
+
<p class="label" style="margin-bottom:12px">Beyond your machine</p>
|
|
513
|
+
<h3>Govern your whole team</h3>
|
|
514
|
+
<p>This is your agents on one machine. SICKR adds gates, approvals, multi-agent hand-offs and a full, signed-off audit trail across humans and agents.</p>
|
|
515
|
+
<a class="btn" href="https://sickr.ai">Explore SICKR →</a>
|
|
516
|
+
</aside>
|
|
517
|
+
</div>
|
|
518
|
+
<span id="tl-bottom"></span>
|
|
519
|
+
<div class="bottombar">
|
|
520
|
+
<a href="https://sickr.ai">${wordmark('lg2')}</a>
|
|
521
|
+
<p class="pitch">All your agents, audited in one place. <span style="color:#fff">Govern the whole team</span> — gates, approvals, full audit trail.</p>
|
|
522
|
+
<a class="btn" href="https://sickr.ai">Explore SICKR →</a>
|
|
523
|
+
</div>
|
|
524
|
+
<div class="foot">Captured locally with npx @sickr/replay · <a href="#tl-top">back to top ↑</a> · sickr.ai</div>
|
|
525
|
+
<nav class="jump" aria-label="scroll"><a href="#tl-top" aria-label="Scroll to top" title="Top">↑</a><a href="#tl-bottom" aria-label="Scroll to bottom" title="Bottom">↓</a></nav>
|
|
526
|
+
${COMBINED_SCRIPT}
|
|
527
|
+
</div></body></html>`;
|
|
528
|
+
}
|
package/package.json
CHANGED