ccsniff 1.0.26 → 1.0.28
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 +241 -0
- package/gui/index.html +50 -0
- package/package.json +7 -2
- package/src/bm25.js +69 -0
- package/src/cli.js +22 -49
- package/src/gui-server.js +315 -0
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.
|
|
3
|
+
"version": "1.0.28",
|
|
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,11 +2,31 @@
|
|
|
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'],
|
|
8
28
|
number: ['limit', 'head', 'tail-n', 'ctx', 'truncate'],
|
|
9
|
-
bool: ['json', 'ndjson', 'tail', 'f', 'full', 'reverse', 'invert', 'no-subagents', 'only-subagents', 'no-meta', 'only-meta', 'list-sessions', 'list-projects', 'list-tools', 'stats', 'count', '
|
|
29
|
+
bool: ['json', 'ndjson', 'tail', 'f', 'full', 'reverse', 'invert', 'no-subagents', 'only-subagents', 'no-meta', 'only-meta', 'list-sessions', 'list-projects', 'list-tools', 'stats', 'count', 'help', 'h'],
|
|
10
30
|
};
|
|
11
31
|
|
|
12
32
|
function parseArgs(argv) {
|
|
@@ -40,7 +60,6 @@ USAGE
|
|
|
40
60
|
ccsniff --list-projects
|
|
41
61
|
ccsniff --list-tools
|
|
42
62
|
ccsniff --stats [filters]
|
|
43
|
-
ccsniff --gm-audit [filters]
|
|
44
63
|
|
|
45
64
|
TIME (any ISO date, epoch ms, or relative Ns/Nm/Nh/Nd/Nw)
|
|
46
65
|
--since <t> include events at/after t (alias: --after)
|
|
@@ -220,53 +239,6 @@ if (opts.help || process.argv.length <= 2) { printHelp(); process.exit(0); }
|
|
|
220
239
|
const since = parseTime(opts.since || opts.after);
|
|
221
240
|
const filter = buildFilter(opts);
|
|
222
241
|
|
|
223
|
-
// ---------- gm-audit (existing, untouched logic, now respects new filters)
|
|
224
|
-
if (opts['gm-audit']) {
|
|
225
|
-
const sessions = new Map();
|
|
226
|
-
const r2 = new JsonlReplayer();
|
|
227
|
-
r2.on('streaming_progress', ev => {
|
|
228
|
-
if (!filter(ev)) return;
|
|
229
|
-
const conv = ev.conversation;
|
|
230
|
-
if (conv.isSubagent) return;
|
|
231
|
-
if (ev.role !== 'user' && ev.role !== 'assistant') return;
|
|
232
|
-
const sid = conv.id;
|
|
233
|
-
if (!sessions.has(sid)) sessions.set(sid, { cwd: conv.cwd, turns: [] });
|
|
234
|
-
const s = sessions.get(sid);
|
|
235
|
-
if (ev.role === 'user' && ev.block?.type === 'text') {
|
|
236
|
-
const t = ev.block.text || '';
|
|
237
|
-
const isContinuation = /^This session is being continued from a previous conversation/.test(t.trimStart());
|
|
238
|
-
const isSystem = ev.block.isMeta || isContinuation || /^<(task-notification|command-name|local-command|system-reminder)\b/.test(t.trimStart()) || t === '[Request interrupted by user]' || t === '[Request interrupted by user for tool use]';
|
|
239
|
-
s.turns.push({ isMeta: isSystem, firstTool: null, text: t.slice(0, 80) });
|
|
240
|
-
} else if (ev.role === 'assistant' && ev.block?.type === 'tool_use' && s.turns.length) {
|
|
241
|
-
const last = s.turns[s.turns.length - 1];
|
|
242
|
-
if (last.firstTool === null) last.firstTool = ev.block.name || '';
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
r2.replay({ since });
|
|
246
|
-
const isTerse = t => t.text.trim().length <= 5 || /^\/\w/.test(t.text.trim());
|
|
247
|
-
let totalReal = 0, totalCompliant = 0, totalTerse = 0;
|
|
248
|
-
for (const [sid, s] of sessions) {
|
|
249
|
-
const real = s.turns.filter(t => !t.isMeta);
|
|
250
|
-
const compliant = real.filter(t => t.firstTool === 'Skill' || t.firstTool === 'mcp__gm__Skill');
|
|
251
|
-
const terse = real.filter(t => isTerse(t) && t.firstTool !== 'Skill' && t.firstTool !== 'mcp__gm__Skill');
|
|
252
|
-
totalReal += real.length;
|
|
253
|
-
totalCompliant += compliant.length;
|
|
254
|
-
totalTerse += terse.length;
|
|
255
|
-
const pct = real.length ? Math.round(100 * compliant.length / real.length) : 0;
|
|
256
|
-
const violations = real.filter(t => t.firstTool !== 'Skill' && t.firstTool !== 'mcp__gm__Skill' && !isTerse(t));
|
|
257
|
-
if (real.length === 0) continue;
|
|
258
|
-
process.stdout.write(`[${pct}%] ${path.basename(s.cwd || sid)} (${compliant.length}/${real.length}, terse-skip:${terse.length}) sid=${sid.slice(0, 8)}\n`);
|
|
259
|
-
for (const v of violations.slice(0, 3)) {
|
|
260
|
-
process.stdout.write(` MISS first=${v.firstTool || 'none'} msg="${v.text.replace(/\s+/g, ' ')}"\n`);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
const total = totalReal ? Math.round(100 * totalCompliant / totalReal) : 0;
|
|
264
|
-
const adjCompliant = totalCompliant + totalTerse;
|
|
265
|
-
const adjTotal = totalReal ? Math.round(100 * adjCompliant / totalReal) : 0;
|
|
266
|
-
process.stderr.write(`# gm-audit: ${totalCompliant}/${totalReal} compliant (${total}%) | adj (terse-skip): ${adjCompliant}/${totalReal} (${adjTotal}%) | ${sessions.size} sessions\n`);
|
|
267
|
-
process.exit(0);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
242
|
// ---------- rollup (filtered)
|
|
271
243
|
if (opts.rollup) {
|
|
272
244
|
const stats = await rollup({ since, out: opts.rollup, format: opts.format || 'ndjson' });
|
|
@@ -390,3 +362,4 @@ for (const ev of rows) process.stdout.write(formatRow(ev, opts));
|
|
|
390
362
|
process.stderr.write(`# ${stats.events} events / ${stats.files} files / ${rows.length} matched\n`);
|
|
391
363
|
|
|
392
364
|
}
|
|
365
|
+
}
|
|
@@ -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
|
+
}
|