ccsniff 1.0.25 → 1.0.27

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/gui/app.js ADDED
@@ -0,0 +1,241 @@
1
+ import { mount, components as C, h } from 'anentrypoint-design';
2
+
3
+ const TABS = ['overview', 'sessions', 'projects', 'tools', 'timeline', 'errors', 'subagents', 'live', 'search'];
4
+ const state = {
5
+ tab: 'overview',
6
+ data: { snapshot: null, sessions: [], projects: [], tools: [], timeline: [], stats: null, errors: [], subagents: [] },
7
+ query: '',
8
+ searchResults: [],
9
+ searching: false,
10
+ liveLog: [],
11
+ };
12
+
13
+ const api = (p) => fetch(p).then(r => r.json());
14
+
15
+ async function loadAll() {
16
+ const [snapshot, sessions, projects, tools, timeline, stats, errors, subagents] = await Promise.all([
17
+ api('/api/snapshot'), api('/api/sessions'), api('/api/projects'), api('/api/tools'),
18
+ api('/api/timeline'), api('/api/stats'), api('/api/errors'), api('/api/subagents'),
19
+ ]);
20
+ state.data = { snapshot, sessions, projects, tools, timeline, stats, errors, subagents };
21
+ render();
22
+ }
23
+
24
+ function ts(t) { return new Date(t).toISOString().slice(0, 19).replace('T', ' '); }
25
+ function n(v) { return (v || 0).toLocaleString(); }
26
+ function dur(ms) { const s = Math.round((ms || 0) / 1000); return s < 60 ? s + 's' : Math.round(s / 60) + 'm'; }
27
+
28
+ function Stat(lbl, val, sub) {
29
+ return h('div', { class: 'stat' }, h('div', { class: 'lbl' }, lbl), h('div', { class: 'val' }, val), sub ? h('div', { class: 'lbl', style: 'margin-top:4px' }, sub) : null);
30
+ }
31
+
32
+ function Overview() {
33
+ const s = state.data.snapshot || {};
34
+ const st = state.data.stats || { role: {}, type: {}, model: {} };
35
+ return h('div', { class: 'grid' },
36
+ h('div', { class: 'grid cols-4' },
37
+ Stat('events', n(s.events)),
38
+ Stat('sessions', n(s.sessions)),
39
+ Stat('projects', n(s.projects)),
40
+ Stat('tools', n(s.tools)),
41
+ Stat('errors', n(s.errors)),
42
+ Stat('files', n(s.files)),
43
+ Stat('total cost', '$' + (st.totalCostUsd || 0).toFixed(4)),
44
+ Stat('total duration', dur(st.totalDurationMs)),
45
+ ),
46
+ C.Panel({ head: 'roles', children: dist(st.role) }),
47
+ C.Panel({ head: 'block types', children: dist(st.type) }),
48
+ C.Panel({ head: 'models', children: dist(st.model) }),
49
+ C.Panel({ head: 'window', children: h('div', { class: 'row-grid', style: 'grid-template-columns: 140px 1fr' },
50
+ h('span', {}, 'earliest'), h('span', {}, s.earliest ? ts(s.earliest) : '—'),
51
+ h('span', {}, 'latest'), h('span', {}, s.latest ? ts(s.latest) : '—'),
52
+ h('span', {}, 'indexed'), h('span', {}, s.indexedAt ? ts(s.indexedAt) : '—'),
53
+ h('span', {}, 'bytes (text)'), h('span', {}, n(s.bytes)),
54
+ ) }),
55
+ );
56
+ }
57
+
58
+ function dist(map) {
59
+ const entries = Object.entries(map || {}).sort((a, b) => b[1] - a[1]);
60
+ const max = entries[0]?.[1] || 1;
61
+ return h('div', {}, ...entries.map(([k, v]) => h('div', { class: 'row-grid', style: 'grid-template-columns: 200px 80px 1fr' },
62
+ h('span', {}, k), h('span', {}, n(v)),
63
+ h('span', { class: 'bar' }, h('i', { style: `width:${(v / max) * 100}%` })),
64
+ )));
65
+ }
66
+
67
+ function SessionsView() {
68
+ const list = state.data.sessions.slice(0, 200);
69
+ return C.Panel({ head: `sessions · ${list.length}`, children: h('div', {},
70
+ h('div', { class: 'row-grid sessions', style: 'opacity:.55' },
71
+ h('span', {}, 'last'), h('span', {}, 'turns'), h('span', {}, 'tools'), h('span', {}, 'ev'), h('span', {}, 'err'), h('span', {}, 'project · sid')),
72
+ ...list.map(s => h('div', { class: 'row-grid sessions' },
73
+ h('span', {}, ts(s.last).slice(5)),
74
+ h('span', {}, n(s.userTurns)),
75
+ h('span', {}, n(s.tools)),
76
+ h('span', {}, n(s.events)),
77
+ h('span', { class: s.errors ? 'err' : '' }, n(s.errors)),
78
+ h('span', { class: 'truncate' }, (s.isSubagent ? '★ ' : '') + (s.project || '—') + ' ', h('span', { class: 'accent' }, s.sid.slice(0, 8))),
79
+ )),
80
+ ) });
81
+ }
82
+
83
+ function ProjectsView() {
84
+ return C.Panel({ head: `projects · ${state.data.projects.length}`, children: h('div', {},
85
+ h('div', { class: 'row-grid projects', style: 'opacity:.55' },
86
+ h('span', {}, 'project'), h('span', {}, 'sessions'), h('span', {}, 'events'), h('span', {}, 'tools'), h('span', {}, 'last')),
87
+ ...state.data.projects.map(p => h('div', { class: 'row-grid projects' },
88
+ h('span', { class: 'truncate accent' }, p.project),
89
+ h('span', {}, n(p.sessions)),
90
+ h('span', {}, n(p.events)),
91
+ h('span', {}, n(p.tools)),
92
+ h('span', {}, ts(p.last).slice(5)),
93
+ )),
94
+ ) });
95
+ }
96
+
97
+ function ToolsView() {
98
+ const max = state.data.tools[0]?.count || 1;
99
+ return C.Panel({ head: `tools · ${state.data.tools.length}`, children: h('div', {},
100
+ h('div', { class: 'row-grid tools', style: 'opacity:.55' },
101
+ h('span', {}, 'tool'), h('span', {}, 'count'), h('span', {}, 'sessions'), h('span', {}, 'errors'), h('span', {}, 'bar')),
102
+ ...state.data.tools.map(t => h('div', { class: 'row-grid tools' },
103
+ h('span', { class: 'accent' }, t.tool),
104
+ h('span', {}, n(t.count)),
105
+ h('span', {}, n(t.sessions)),
106
+ h('span', { class: t.errors ? 'err' : '' }, n(t.errors)),
107
+ h('span', { class: 'bar' }, h('i', { style: `width:${(t.count / max) * 100}%` })),
108
+ )),
109
+ ) });
110
+ }
111
+
112
+ function TimelineView() {
113
+ const buckets = state.data.timeline;
114
+ const max = buckets.reduce((m, b) => Math.max(m, b.events), 1);
115
+ return C.Panel({ head: `timeline · 1h buckets · ${buckets.length}`, children: h('div', {},
116
+ h('div', { class: 'spark', style: 'padding:12px' }, ...buckets.map(b => h('i', { style: `height:${(b.events / max) * 100}%`, title: `${ts(b.t)} ev:${b.events} tools:${b.tools} err:${b.errors}` }))),
117
+ h('div', { class: 'row-grid', style: 'grid-template-columns:160px 80px 80px 80px 80px;opacity:.55' },
118
+ h('span', {}, 'bucket'), h('span', {}, 'events'), h('span', {}, 'tools'), h('span', {}, 'sess'), h('span', {}, 'err')),
119
+ ...buckets.slice(-40).reverse().map(b => h('div', { class: 'row-grid', style: 'grid-template-columns:160px 80px 80px 80px 80px' },
120
+ h('span', {}, ts(b.t).slice(5)), h('span', {}, n(b.events)), h('span', {}, n(b.tools)), h('span', {}, n(b.sessions)), h('span', { class: b.errors ? 'err' : '' }, n(b.errors)),
121
+ )),
122
+ ) });
123
+ }
124
+
125
+ function ErrorsView() {
126
+ return C.Panel({ head: `errors · ${state.data.errors.length}`, children: h('div', {},
127
+ h('div', { class: 'row-grid errors', style: 'opacity:.55' }, h('span', {}, 'when'), h('span', {}, 'session'), h('span', {}, 'message')),
128
+ ...state.data.errors.map(e => h('div', { class: 'row-grid errors err' },
129
+ h('span', {}, ts(e.ts).slice(5)),
130
+ h('span', {}, (e.sid || '').slice(0, 8)),
131
+ h('span', { class: 'truncate' }, e.error + (e.recoverable ? ' (recoverable)' : '')),
132
+ )),
133
+ ) });
134
+ }
135
+
136
+ function SubagentsView() {
137
+ return C.Panel({ head: `subagents · ${state.data.subagents.length} parents`, children: h('div', {},
138
+ ...state.data.subagents.map(p => h('div', { style: 'padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.05)' },
139
+ h('div', { class: 'accent' }, 'parent ', p.parent.slice(0, 8), ' · ', n(p.children.length), ' children'),
140
+ ...p.children.map(c => h('div', { class: 'row-grid', style: 'grid-template-columns:90px 100px 1fr' },
141
+ h('span', {}, c.sid.slice(0, 8)), h('span', {}, n(c.events) + ' ev'), h('span', { class: 'truncate' }, c.project || '—'),
142
+ )),
143
+ )),
144
+ ) });
145
+ }
146
+
147
+ function LiveView() {
148
+ return C.Panel({ head: `live · ${state.liveLog.length} events (last 200)`, children: h('div', { class: 'live' },
149
+ ...state.liveLog.slice().reverse().map((e, i) => h('div', { key: i, class: 'e' },
150
+ h('span', { class: 'ts' }, ts(e.ts || Date.now()).slice(11)),
151
+ h('span', { class: 'k' }, e._kind || 'event'),
152
+ h('span', {}, fmtLive(e)),
153
+ )),
154
+ ) });
155
+ }
156
+
157
+ function fmtLive(e) {
158
+ if (e._kind === 'conversation') return 'new ' + (e.conv?.title || e.conv?.id?.slice(0, 8));
159
+ if (e._kind === 'start' || e._kind === 'complete') return (e.sid || '').slice(0, 8);
160
+ if (e._kind === 'error') return e.error;
161
+ const tag = `${e.role || ''}/${e.type || ''}` + (e.tool ? ':' + e.tool : '');
162
+ return tag + ' ' + (e.text || '').slice(0, 200);
163
+ }
164
+
165
+ function SearchView() {
166
+ return C.Panel({ head: `bm25 search · ${state.searchResults.length} hits`, children: h('div', {},
167
+ h('div', { style: 'padding:12px' },
168
+ h('input', {
169
+ class: 'search', placeholder: 'query (BM25 over all event text)…', value: state.query,
170
+ oninput: (e) => { state.query = e.target.value; },
171
+ onkeydown: (e) => { if (e.key === 'Enter') doSearch(); },
172
+ }),
173
+ h('div', { style: 'margin-top:8px;font-size:11px;opacity:.6' }, state.searching ? 'searching…' : 'enter to search · zero deps · idf-weighted'),
174
+ ),
175
+ h('div', { class: 'row-grid search', style: 'opacity:.55' },
176
+ h('span', {}, 'score'), h('span', {}, 'when'), h('span', {}, 'role'), h('span', {}, 'tool'), h('span', {}, 'snippet')),
177
+ ...state.searchResults.map(r => h('div', { class: 'row-grid search' },
178
+ h('span', { class: 'accent' }, r.score.toFixed(2)),
179
+ h('span', {}, ts(r.ts).slice(5)),
180
+ h('span', {}, r.role + '/' + (r.type || '?')),
181
+ h('span', {}, r.tool || '—'),
182
+ h('span', { class: 'truncate' }, h('span', { class: 'pill' }, r.project || '—'), r.snippet),
183
+ )),
184
+ ) });
185
+ }
186
+
187
+ async function doSearch() {
188
+ if (!state.query.trim()) return;
189
+ state.searching = true; render();
190
+ const res = await api('/api/search?q=' + encodeURIComponent(state.query) + '&limit=200');
191
+ state.searchResults = res.results || [];
192
+ state.searching = false; render();
193
+ }
194
+
195
+ const VIEWS = { overview: Overview, sessions: SessionsView, projects: ProjectsView, tools: ToolsView, timeline: TimelineView, errors: ErrorsView, subagents: SubagentsView, live: LiveView, search: SearchView };
196
+
197
+ function App() {
198
+ const s = state.data.snapshot || {};
199
+ return C.AppShell({
200
+ topbar: C.Topbar({
201
+ brand: '247420', leaf: 'ccsniff',
202
+ items: TABS.map(t => [t, '#/' + t]),
203
+ active: state.tab,
204
+ onNav: (t) => { state.tab = t; location.hash = '#/' + t; render(); },
205
+ }),
206
+ main: h('div', { style: 'padding:16px;display:flex;flex-direction:column;gap:16px' },
207
+ C.Crumb({ trail: ['247420', 'ccsniff'], leaf: state.tab, right: h('span', { class: 'pill' }, n(s.events) + ' events') }),
208
+ (VIEWS[state.tab] || Overview)(),
209
+ ),
210
+ status: C.Status({
211
+ left: ['ccsniff', '·', n(s.sessions) + ' sessions', '·', n(s.events) + ' events'],
212
+ right: [state.searching ? 'searching' : 'live', '·', s.indexedAt ? ts(s.indexedAt).slice(11) : '—'],
213
+ }),
214
+ });
215
+ }
216
+
217
+ const root = document.getElementById('app');
218
+ function render() { mount(root, App); window.__ccsniff = { state, render, doSearch }; }
219
+ render();
220
+
221
+ (function initRoute() {
222
+ const h0 = (location.hash || '').replace(/^#\//, '');
223
+ if (TABS.includes(h0)) state.tab = h0;
224
+ })();
225
+
226
+ (async function init() {
227
+ await loadAll();
228
+ setInterval(loadAll, 15_000);
229
+ const sse = new EventSource('/api/stream');
230
+ const push = (kind, data) => {
231
+ const item = { ...data, _kind: kind, ts: data?.ts || Date.now() };
232
+ state.liveLog.push(item);
233
+ if (state.liveLog.length > 200) state.liveLog = state.liveLog.slice(-200);
234
+ if (state.tab === 'live') render();
235
+ };
236
+ sse.addEventListener('event', e => push('event', JSON.parse(e.data)));
237
+ sse.addEventListener('error', e => { try { push('error', JSON.parse(e.data)); } catch {} });
238
+ sse.addEventListener('start', e => push('start', JSON.parse(e.data)));
239
+ sse.addEventListener('complete', e => push('complete', JSON.parse(e.data)));
240
+ sse.addEventListener('conversation', e => push('conversation', JSON.parse(e.data)));
241
+ })();
package/gui/index.html ADDED
@@ -0,0 +1,50 @@
1
+ <!doctype html>
2
+ <html lang="en" class="ds-247420" data-theme="dark">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>ccsniff — observatory</title>
7
+ <link rel="stylesheet" href="https://unpkg.com/anentrypoint-design@latest/dist/247420.css">
8
+ <script type="importmap">
9
+ { "imports": {
10
+ "anentrypoint-design": "https://unpkg.com/anentrypoint-design@latest/dist/247420.js"
11
+ } }
12
+ </script>
13
+ <style>
14
+ body { margin: 0; }
15
+ .grid { display: grid; gap: 12px; }
16
+ .grid.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
17
+ .grid.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
18
+ .grid.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
19
+ .stat { padding: 14px 16px; border-radius: 10px; background: var(--panel-bg, #111); }
20
+ .stat .lbl { font-size: 11px; opacity: .65; text-transform: uppercase; letter-spacing: .08em; }
21
+ .stat .val { font-size: 22px; font-weight: 600; margin-top: 4px; }
22
+ .row-grid { display: grid; gap: 4px 12px; align-items: start; padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,.05); font-family: var(--ff-mono, ui-monospace, monospace); font-size: 12px; }
23
+ .row-grid.sessions { grid-template-columns: 110px 80px 60px 60px 60px 1fr; }
24
+ .row-grid.projects { grid-template-columns: 1fr 90px 90px 90px 110px; }
25
+ .row-grid.tools { grid-template-columns: 1fr 80px 80px 80px 110px; }
26
+ .row-grid.events { grid-template-columns: 110px 90px 90px 110px 1fr; }
27
+ .row-grid.search { grid-template-columns: 60px 100px 90px 90px 1fr; }
28
+ .row-grid.errors { grid-template-columns: 110px 100px 1fr; }
29
+ .row-grid .truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
30
+ .row-grid .accent { color: var(--panel-accent, #6cf); }
31
+ .row-grid .err { color: #f66; }
32
+ .bar { height: 6px; background: rgba(255,255,255,.08); border-radius: 3px; overflow: hidden; }
33
+ .bar > i { display: block; height: 100%; background: var(--panel-accent, #6cf); }
34
+ .spark { display: flex; align-items: flex-end; gap: 2px; height: 60px; }
35
+ .spark > i { display: block; flex: 1; background: var(--panel-accent, #6cf); min-height: 1px; opacity: .8; }
36
+ .live { font-family: var(--ff-mono, ui-monospace, monospace); font-size: 12px; max-height: 360px; overflow: auto; padding: 6px 12px; }
37
+ .live .e { padding: 2px 0; border-bottom: 1px dashed rgba(255,255,255,.05); }
38
+ .live .e .ts { opacity: .5; margin-right: 8px; }
39
+ .live .e .k { color: var(--panel-accent, #6cf); margin-right: 8px; }
40
+ input.search { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(255,255,255,.04); color: inherit; font: inherit; }
41
+ .pill { display: inline-block; padding: 1px 7px; border-radius: 999px; background: rgba(255,255,255,.08); font-size: 10px; margin-right: 4px; }
42
+ .pill.err { background: rgba(255,80,80,.18); color: #f88; }
43
+ .tab-active { background: rgba(255,255,255,.08); border-radius: 6px; }
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <div id="app"></div>
48
+ <script type="module" src="./app.js"></script>
49
+ </body>
50
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccsniff",
3
- "version": "1.0.25",
3
+ "version": "1.0.27",
4
4
  "description": "Watch Claude Code JSONL output files and emit structured events as a Node.js EventEmitter",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -14,8 +14,13 @@
14
14
  "ccsniff": "./src/cli.js"
15
15
  },
16
16
  "files": [
17
- "src/"
17
+ "src/",
18
+ "gui/"
18
19
  ],
20
+ "scripts": {
21
+ "test": "node test.js",
22
+ "gui": "node src/cli.js gui"
23
+ },
19
24
  "keywords": [
20
25
  "claude",
21
26
  "claude-code",
package/src/bm25.js ADDED
@@ -0,0 +1,69 @@
1
+ const STOP = new Set(['the','a','an','and','or','but','of','to','in','on','for','is','it','this','that','with','as','by','at','be','from']);
2
+
3
+ export function tokenize(s) {
4
+ if (!s) return [];
5
+ const out = [];
6
+ const re = /[A-Za-z0-9_]{2,}/g;
7
+ let m;
8
+ while ((m = re.exec(String(s).toLowerCase())) !== null) {
9
+ if (!STOP.has(m[0])) out.push(m[0]);
10
+ }
11
+ return out;
12
+ }
13
+
14
+ export function buildIndex(docs, getText) {
15
+ const N = docs.length;
16
+ const df = new Map();
17
+ const lens = new Array(N);
18
+ const tfs = new Array(N);
19
+ let total = 0;
20
+ for (let i = 0; i < N; i++) {
21
+ const toks = tokenize(getText ? getText(docs[i]) : docs[i]);
22
+ const tf = new Map();
23
+ for (const t of toks) tf.set(t, (tf.get(t) || 0) + 1);
24
+ for (const t of tf.keys()) df.set(t, (df.get(t) || 0) + 1);
25
+ tfs[i] = tf;
26
+ lens[i] = toks.length;
27
+ total += toks.length;
28
+ }
29
+ const avgdl = total / Math.max(1, N);
30
+ const idf = new Map();
31
+ for (const [t, n] of df) idf.set(t, Math.log(1 + (N - n + 0.5) / (n + 0.5)));
32
+ return { N, avgdl, idf, tfs, lens, docs };
33
+ }
34
+
35
+ export function search(idx, query, { k1 = 1.5, b = 0.75, limit = 50 } = {}) {
36
+ const qToks = [...new Set(tokenize(query))];
37
+ if (!qToks.length) return [];
38
+ const scores = new Float64Array(idx.N);
39
+ const matched = new Array(idx.N);
40
+ for (const t of qToks) {
41
+ const w = idx.idf.get(t);
42
+ if (w === undefined) continue;
43
+ for (let i = 0; i < idx.N; i++) {
44
+ const f = idx.tfs[i].get(t);
45
+ if (!f) continue;
46
+ const dl = idx.lens[i];
47
+ const norm = 1 - b + b * (dl / (idx.avgdl || 1));
48
+ const s = w * ((f * (k1 + 1)) / (f + k1 * norm));
49
+ scores[i] += s;
50
+ if (!matched[i]) matched[i] = [];
51
+ matched[i].push(t);
52
+ }
53
+ }
54
+ const ranked = [];
55
+ for (let i = 0; i < idx.N; i++) if (scores[i] > 0) ranked.push({ i, score: scores[i], terms: matched[i] });
56
+ ranked.sort((a, b) => b.score - a.score);
57
+ return ranked.slice(0, limit);
58
+ }
59
+
60
+ export function snippet(text, terms, max = 240) {
61
+ if (!text) return '';
62
+ const lc = text.toLowerCase();
63
+ let pos = -1;
64
+ for (const t of terms || []) { const p = lc.indexOf(t); if (p >= 0 && (pos < 0 || p < pos)) pos = p; }
65
+ if (pos < 0) return text.slice(0, max);
66
+ const start = Math.max(0, pos - 60);
67
+ const end = Math.min(text.length, start + max);
68
+ return (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '');
69
+ }
package/src/cli.js CHANGED
@@ -2,6 +2,26 @@
2
2
  import { JsonlReplayer, rollup } from './index.js';
3
3
  import path from 'path';
4
4
 
5
+ if (process.argv[2] === 'gui') {
6
+ const { createServer } = await import('./gui-server.js');
7
+ const args = process.argv.slice(3);
8
+ let port = 0, host = '127.0.0.1', open = false;
9
+ for (let i = 0; i < args.length; i++) {
10
+ const a = args[i];
11
+ if (a === '--port') port = parseInt(args[++i], 10) || 0;
12
+ else if (a === '--host') host = args[++i];
13
+ else if (a === '--open') open = true;
14
+ }
15
+ if (!port) port = 4791;
16
+ const { url } = await createServer({ port, host });
17
+ process.stdout.write(`ccsniff gui · ${url}\n`);
18
+ if (open) {
19
+ const cmd = process.platform === 'win32' ? `start "" "${url}"` : process.platform === 'darwin' ? `open "${url}"` : `xdg-open "${url}"`;
20
+ try { (await import('child_process')).exec(cmd); } catch {}
21
+ }
22
+ process.stdin.resume();
23
+ } else {
24
+
5
25
  const FLAGS = {
6
26
  string: ['since', 'until', 'before', 'after', 'grep', 'igrep', 'cwd', 'project', 'role', 'type', 'tool', 'session', 'sid', 'parent', 'rollup', 'format', 'sort'],
7
27
  multi: ['grep', 'igrep', 'role', 'type', 'tool', 'session', 'sid', 'project', 'cwd'],
@@ -390,3 +410,4 @@ for (const ev of rows) process.stdout.write(formatRow(ev, opts));
390
410
  process.stderr.write(`# ${stats.events} events / ${stats.files} files / ${rows.length} matched\n`);
391
411
 
392
412
  }
413
+ }
@@ -0,0 +1,315 @@
1
+ import http from 'http';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { JsonlReplayer, JsonlWatcher } from './index.js';
6
+ import { buildIndex, search, snippet, tokenize } from './bm25.js';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const GUI_DIR = path.join(__dirname, '..', 'gui');
10
+ const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml' };
11
+
12
+ function blockText(b) {
13
+ if (!b) return '';
14
+ if (typeof b.text === 'string') return b.text;
15
+ if (typeof b.content === 'string') return b.content;
16
+ if (Array.isArray(b.content)) return b.content.map(c => c?.text || '').join('');
17
+ if (b.input) { try { return JSON.stringify(b.input); } catch { return ''; } }
18
+ return '';
19
+ }
20
+
21
+ function flattenEvent(ev, idx) {
22
+ const c = ev.conversation || {};
23
+ const b = ev.block || {};
24
+ return {
25
+ i: idx,
26
+ ts: ev.timestamp || 0,
27
+ sid: c.id || '',
28
+ parent: c.parentSid || null,
29
+ cwd: c.cwd || '',
30
+ project: path.basename(c.cwd || ''),
31
+ isSubagent: !!c.isSubagent,
32
+ role: ev.role,
33
+ type: b.type || null,
34
+ tool: b.name || null,
35
+ text: blockText(b),
36
+ isError: !!b.is_error || ev.role === 'streaming_error',
37
+ cost: b.total_cost_usd || null,
38
+ duration: b.duration_ms || null,
39
+ subtype: b.subtype || null,
40
+ model: b.model || null,
41
+ };
42
+ }
43
+
44
+ class Store {
45
+ constructor(projectsDir) {
46
+ this.projectsDir = projectsDir;
47
+ this.events = [];
48
+ this.errors = [];
49
+ this.fileBytes = 0;
50
+ this.fileCount = 0;
51
+ this.index = null;
52
+ this.lastBuilt = 0;
53
+ this.watcher = null;
54
+ this.sseClients = new Set();
55
+ }
56
+
57
+ loadOnce() {
58
+ const r = new JsonlReplayer(this.projectsDir);
59
+ let i = 0;
60
+ r.on('streaming_progress', ev => { this.events.push(flattenEvent(ev, i++)); });
61
+ r.on('streaming_error', ev => { this.errors.push({ ts: ev.timestamp, sid: ev.conversationId, error: ev.error, recoverable: ev.recoverable }); });
62
+ const stats = r.replay({});
63
+ this.fileCount = stats.files;
64
+ this.rebuildIndex();
65
+ return stats;
66
+ }
67
+
68
+ rebuildIndex() {
69
+ this.index = buildIndex(this.events, e => e.text);
70
+ this.lastBuilt = Date.now();
71
+ }
72
+
73
+ startLive() {
74
+ if (this.watcher) return;
75
+ this.watcher = new JsonlWatcher(this.projectsDir);
76
+ this.watcher.on('streaming_progress', ev => {
77
+ const fl = flattenEvent(ev, this.events.length);
78
+ this.events.push(fl);
79
+ this.broadcast('event', fl);
80
+ });
81
+ this.watcher.on('streaming_error', ev => {
82
+ const e = { ts: ev.timestamp, sid: ev.conversationId, error: ev.error, recoverable: ev.recoverable };
83
+ this.errors.push(e);
84
+ this.broadcast('error', e);
85
+ });
86
+ this.watcher.on('streaming_start', ev => this.broadcast('start', { sid: ev.conversationId, ts: ev.timestamp }));
87
+ this.watcher.on('streaming_complete', ev => this.broadcast('complete', { sid: ev.conversationId, ts: ev.timestamp }));
88
+ this.watcher.on('conversation_created', ev => this.broadcast('conversation', { conv: ev.conversation, ts: ev.timestamp }));
89
+ this.watcher.start();
90
+ }
91
+
92
+ stop() {
93
+ if (this.watcher) this.watcher.stop();
94
+ for (const r of this.sseClients) try { r.end(); } catch {}
95
+ this.sseClients.clear();
96
+ }
97
+
98
+ broadcast(kind, data) {
99
+ const payload = `event: ${kind}\ndata: ${JSON.stringify(data)}\n\n`;
100
+ for (const res of this.sseClients) { try { res.write(payload); } catch {} }
101
+ }
102
+
103
+ snapshot() {
104
+ const sids = new Set(), projects = new Set(), tools = new Map();
105
+ let earliest = Infinity, latest = 0, bytes = 0;
106
+ for (const e of this.events) {
107
+ sids.add(e.sid); if (e.project) projects.add(e.project);
108
+ if (e.tool) tools.set(e.tool, (tools.get(e.tool) || 0) + 1);
109
+ if (e.ts < earliest) earliest = e.ts;
110
+ if (e.ts > latest) latest = e.ts;
111
+ bytes += (e.text || '').length;
112
+ }
113
+ return {
114
+ events: this.events.length, sessions: sids.size, projects: projects.size,
115
+ tools: tools.size, errors: this.errors.length, files: this.fileCount,
116
+ bytes, earliest: earliest === Infinity ? 0 : earliest, latest, indexedAt: this.lastBuilt,
117
+ };
118
+ }
119
+
120
+ sessions() {
121
+ const map = new Map();
122
+ for (const e of this.events) {
123
+ let s = map.get(e.sid);
124
+ if (!s) { s = { sid: e.sid, project: e.project, cwd: e.cwd, parent: e.parent, isSubagent: e.isSubagent, first: e.ts, last: e.ts, events: 0, tools: 0, userTurns: 0, cost: 0, errors: 0 }; map.set(e.sid, s); }
125
+ s.events++;
126
+ if (e.ts < s.first) s.first = e.ts;
127
+ if (e.ts > s.last) s.last = e.ts;
128
+ if (e.type === 'tool_use') s.tools++;
129
+ if (e.role === 'user' && e.type === 'text') s.userTurns++;
130
+ if (e.cost) s.cost += e.cost;
131
+ if (e.isError) s.errors++;
132
+ }
133
+ return [...map.values()].sort((a, b) => b.last - a.last);
134
+ }
135
+
136
+ projects() {
137
+ const map = new Map();
138
+ for (const e of this.events) {
139
+ if (!e.project) continue;
140
+ let p = map.get(e.project);
141
+ if (!p) { p = { project: e.project, sessions: new Set(), events: 0, tools: 0, last: 0, errors: 0, cost: 0 }; map.set(e.project, p); }
142
+ p.events++;
143
+ p.sessions.add(e.sid);
144
+ if (e.type === 'tool_use') p.tools++;
145
+ if (e.ts > p.last) p.last = e.ts;
146
+ if (e.cost) p.cost += e.cost;
147
+ if (e.isError) p.errors++;
148
+ }
149
+ return [...map.values()].map(p => ({ ...p, sessions: p.sessions.size })).sort((a, b) => b.last - a.last);
150
+ }
151
+
152
+ tools() {
153
+ const map = new Map();
154
+ for (const e of this.events) {
155
+ if (!e.tool) continue;
156
+ let t = map.get(e.tool);
157
+ if (!t) { t = { tool: e.tool, count: 0, sessions: new Set(), errors: 0, last: 0 }; map.set(e.tool, t); }
158
+ t.count++;
159
+ t.sessions.add(e.sid);
160
+ if (e.isError) t.errors++;
161
+ if (e.ts > t.last) t.last = e.ts;
162
+ }
163
+ return [...map.values()].map(t => ({ ...t, sessions: t.sessions.size })).sort((a, b) => b.count - a.count);
164
+ }
165
+
166
+ timeline(bucketMs = 3600_000) {
167
+ const buckets = new Map();
168
+ for (const e of this.events) {
169
+ const k = Math.floor(e.ts / bucketMs) * bucketMs;
170
+ let b = buckets.get(k);
171
+ if (!b) { b = { t: k, events: 0, tools: 0, errors: 0, sessions: new Set() }; buckets.set(k, b); }
172
+ b.events++;
173
+ if (e.type === 'tool_use') b.tools++;
174
+ if (e.isError) b.errors++;
175
+ b.sessions.add(e.sid);
176
+ }
177
+ return [...buckets.values()].map(b => ({ ...b, sessions: b.sessions.size })).sort((a, b) => a.t - b.t);
178
+ }
179
+
180
+ stats() {
181
+ const role = {}, type = {}, model = {};
182
+ let cost = 0, dur = 0, results = 0;
183
+ for (const e of this.events) {
184
+ role[e.role || '?'] = (role[e.role || '?'] || 0) + 1;
185
+ type[e.type || '?'] = (type[e.type || '?'] || 0) + 1;
186
+ if (e.model) model[e.model] = (model[e.model] || 0) + 1;
187
+ if (e.cost) { cost += e.cost; results++; }
188
+ if (e.duration) dur += e.duration;
189
+ }
190
+ return { role, type, model, totalCostUsd: cost, totalDurationMs: dur, results };
191
+ }
192
+
193
+ errorsList() { return this.errors.slice(-200).reverse(); }
194
+
195
+ subagents() {
196
+ const tree = new Map();
197
+ for (const e of this.events) {
198
+ if (!e.isSubagent) continue;
199
+ const parent = e.parent || 'orphan';
200
+ let p = tree.get(parent);
201
+ if (!p) { p = { parent, children: new Map() }; tree.set(parent, p); }
202
+ let c = p.children.get(e.sid);
203
+ if (!c) { c = { sid: e.sid, project: e.project, events: 0, last: 0 }; p.children.set(e.sid, c); }
204
+ c.events++;
205
+ if (e.ts > c.last) c.last = e.ts;
206
+ }
207
+ return [...tree.values()].map(p => ({ parent: p.parent, children: [...p.children.values()] }));
208
+ }
209
+
210
+ search(q, { limit = 50, role, type, project, sid } = {}) {
211
+ if (!this.index) this.rebuildIndex();
212
+ const hits = search(this.index, q, { limit: limit * 4 });
213
+ const out = [];
214
+ for (const h of hits) {
215
+ const e = this.events[h.i];
216
+ if (role && e.role !== role) continue;
217
+ if (type && e.type !== type) continue;
218
+ if (project && e.project !== project) continue;
219
+ if (sid && !e.sid.startsWith(sid)) continue;
220
+ out.push({ ...e, score: h.score, terms: h.terms, snippet: snippet(e.text, h.terms) });
221
+ if (out.length >= limit) break;
222
+ }
223
+ return out;
224
+ }
225
+
226
+ events_filtered({ role, type, project, sid, tool, since, until, limit = 200, offset = 0, q } = {}) {
227
+ let arr = this.events;
228
+ if (q) {
229
+ const tokens = new Set(tokenize(q));
230
+ arr = arr.filter(e => { const t = tokenize(e.text); return [...tokens].every(x => t.includes(x)); });
231
+ }
232
+ arr = arr.filter(e => {
233
+ if (role && e.role !== role) return false;
234
+ if (type && e.type !== type) return false;
235
+ if (project && e.project !== project) return false;
236
+ if (sid && !e.sid.startsWith(sid)) return false;
237
+ if (tool && e.tool !== tool) return false;
238
+ if (since && e.ts < since) return false;
239
+ if (until && e.ts > until) return false;
240
+ return true;
241
+ });
242
+ return { total: arr.length, rows: arr.slice(offset, offset + limit) };
243
+ }
244
+ }
245
+
246
+ function send(res, code, body, type = 'application/json') {
247
+ res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-cache', 'Access-Control-Allow-Origin': '*' });
248
+ res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body));
249
+ }
250
+
251
+ function serveStatic(req, res) {
252
+ const url = new URL(req.url, 'http://x');
253
+ let p = url.pathname === '/' ? '/index.html' : url.pathname;
254
+ const file = path.join(GUI_DIR, p);
255
+ if (!file.startsWith(GUI_DIR)) return send(res, 403, 'forbidden', 'text/plain');
256
+ fs.readFile(file, (err, buf) => {
257
+ if (err) return send(res, 404, 'not found', 'text/plain');
258
+ const ext = path.extname(file);
259
+ send(res, 200, buf, MIME[ext] || 'application/octet-stream');
260
+ });
261
+ }
262
+
263
+ function parseQuery(u) {
264
+ const q = {};
265
+ for (const [k, v] of u.searchParams) q[k] = v;
266
+ if (q.limit) q.limit = parseInt(q.limit, 10);
267
+ if (q.offset) q.offset = parseInt(q.offset, 10);
268
+ if (q.since) q.since = parseInt(q.since, 10);
269
+ if (q.until) q.until = parseInt(q.until, 10);
270
+ if (q.bucket) q.bucket = parseInt(q.bucket, 10);
271
+ return q;
272
+ }
273
+
274
+ export function createServer({ projectsDir, port = 0, host = '127.0.0.1' } = {}) {
275
+ const store = new Store(projectsDir);
276
+ store.loadOnce();
277
+ store.startLive();
278
+
279
+ const server = http.createServer((req, res) => {
280
+ const u = new URL(req.url, 'http://x');
281
+ const q = parseQuery(u);
282
+ const path = u.pathname;
283
+ if (!path.startsWith('/api/')) return serveStatic(req, res);
284
+ try {
285
+ if (path === '/api/snapshot') return send(res, 200, store.snapshot());
286
+ if (path === '/api/sessions') return send(res, 200, store.sessions());
287
+ if (path === '/api/projects') return send(res, 200, store.projects());
288
+ if (path === '/api/tools') return send(res, 200, store.tools());
289
+ if (path === '/api/timeline') return send(res, 200, store.timeline(q.bucket || 3600_000));
290
+ if (path === '/api/stats') return send(res, 200, store.stats());
291
+ if (path === '/api/errors') return send(res, 200, store.errorsList());
292
+ if (path === '/api/subagents') return send(res, 200, store.subagents());
293
+ if (path === '/api/events') return send(res, 200, store.events_filtered(q));
294
+ if (path === '/api/search') return send(res, 200, { query: q.q || '', results: q.q ? store.search(q.q, q) : [] });
295
+ if (path === '/api/reindex') { store.rebuildIndex(); return send(res, 200, { ok: true, at: store.lastBuilt }); }
296
+ if (path === '/api/stream') {
297
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*' });
298
+ res.write('event: hello\ndata: {}\n\n');
299
+ store.sseClients.add(res);
300
+ req.on('close', () => store.sseClients.delete(res));
301
+ return;
302
+ }
303
+ send(res, 404, { error: 'not found' });
304
+ } catch (e) {
305
+ send(res, 500, { error: String(e?.message || e) });
306
+ }
307
+ });
308
+
309
+ return new Promise(resolve => {
310
+ server.listen(port, host, () => {
311
+ const addr = server.address();
312
+ resolve({ server, store, url: `http://${host}:${addr.port}`, port: addr.port, close: () => { store.stop(); return new Promise(r => server.close(r)); } });
313
+ });
314
+ });
315
+ }