envseed 0.1.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/Dockerfile.simulation +18 -0
- package/README.md +498 -0
- package/bin/dashboard.mjs +706 -0
- package/bin/propensity-monitor.mjs +897 -0
- package/commands/log-incident.md +20 -0
- package/entrypoint.sh +93 -0
- package/lib/background-analyzer.mjs +113 -0
- package/lib/container-replicator.mjs +690 -0
- package/lib/hook-handler.mjs +109 -0
- package/lib/llm-analyzer.mjs +247 -0
- package/lib/log-incident.mjs +320 -0
- package/lib/logger.mjs +42 -0
- package/lib/personas.mjs +176 -0
- package/lib/redaction-review.mjs +255 -0
- package/lib/risk-analyzer.mjs +477 -0
- package/lib/s3.mjs +191 -0
- package/lib/session-tracker.mjs +132 -0
- package/lib/simulation-orchestrator.mjs +492 -0
- package/lib/utils.mjs +33 -0
- package/package.json +28 -0
- package/postinstall.mjs +165 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Propensity Monitor Dashboard — zero-dependency web UI.
|
|
5
|
+
* Usage: node dashboard.mjs [--port 3456]
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { execSync } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
const DATA_DIR = path.join(process.env.HOME, '.propensity-monitor', 'data');
|
|
14
|
+
const INSTALL_DIR = path.join(process.env.HOME, '.propensity-monitor');
|
|
15
|
+
const INCIDENTS_DIR = path.join(DATA_DIR, 'incidents');
|
|
16
|
+
|
|
17
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function readJson(p) {
|
|
20
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readJsonl(p, limit = 0) {
|
|
24
|
+
try {
|
|
25
|
+
if (!fs.existsSync(p)) return [];
|
|
26
|
+
const lines = fs.readFileSync(p, 'utf8').split('\n').filter(l => l.trim());
|
|
27
|
+
const parsed = [];
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
try { parsed.push(JSON.parse(line)); } catch {}
|
|
30
|
+
}
|
|
31
|
+
return limit > 0 ? parsed.slice(-limit) : parsed;
|
|
32
|
+
} catch { return []; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function json(res, data, status = 200) {
|
|
36
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
37
|
+
res.end(JSON.stringify(data));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function matchRoute(url, pattern) {
|
|
41
|
+
const urlParts = url.split('/').filter(Boolean);
|
|
42
|
+
const patParts = pattern.split('/').filter(Boolean);
|
|
43
|
+
if (urlParts.length !== patParts.length) return null;
|
|
44
|
+
const params = {};
|
|
45
|
+
for (let i = 0; i < patParts.length; i++) {
|
|
46
|
+
if (patParts[i].startsWith(':')) {
|
|
47
|
+
params[patParts[i].slice(1)] = decodeURIComponent(urlParts[i]);
|
|
48
|
+
} else if (patParts[i] !== urlParts[i]) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return params;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── API Routes ──────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function apiIncidents(req, res) {
|
|
58
|
+
if (!fs.existsSync(INCIDENTS_DIR)) return json(res, []);
|
|
59
|
+
const dirs = fs.readdirSync(INCIDENTS_DIR)
|
|
60
|
+
.filter(d => { try { return fs.statSync(path.join(INCIDENTS_DIR, d)).isDirectory(); } catch { return false; } })
|
|
61
|
+
.sort().reverse();
|
|
62
|
+
|
|
63
|
+
const incidents = dirs.map(id => {
|
|
64
|
+
const meta = readJson(path.join(INCIDENTS_DIR, id, 'metadata.json'));
|
|
65
|
+
const status = readJson(path.join(INCIDENTS_DIR, id, 'status.json'));
|
|
66
|
+
return { id, meta, status };
|
|
67
|
+
});
|
|
68
|
+
json(res, incidents);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function apiIncident(req, res, params) {
|
|
72
|
+
const dir = path.join(INCIDENTS_DIR, params.id);
|
|
73
|
+
if (!fs.existsSync(dir)) return json(res, { error: 'Not found' }, 404);
|
|
74
|
+
const meta = readJson(path.join(dir, 'metadata.json'));
|
|
75
|
+
const status = readJson(path.join(dir, 'status.json'));
|
|
76
|
+
|
|
77
|
+
// Count assessments by rating
|
|
78
|
+
const assessments = readJsonl(path.join(dir, 'assessments.jsonl'));
|
|
79
|
+
const ratingCounts = { LOW: 0, INTERESTING: 0, 'GOOD OPPORTUNITY': 0, ERROR: 0 };
|
|
80
|
+
for (const a of assessments) {
|
|
81
|
+
if (a.llm_error) ratingCounts.ERROR++;
|
|
82
|
+
else if (/\[GOOD OPPORTUNITY\]/i.test(a.llm_assessment)) ratingCounts['GOOD OPPORTUNITY']++;
|
|
83
|
+
else if (/\[INTERESTING\]/i.test(a.llm_assessment)) ratingCounts.INTERESTING++;
|
|
84
|
+
else ratingCounts.LOW++;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
json(res, { id: params.id, meta, status, ratingCounts, assessmentCount: assessments.length });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function apiAssessments(req, res, params) {
|
|
91
|
+
const p = path.join(INCIDENTS_DIR, params.id, 'assessments.jsonl');
|
|
92
|
+
const url = new URL(req.url, 'http://localhost');
|
|
93
|
+
const limit = parseInt(url.searchParams.get('limit') || '200');
|
|
94
|
+
const offset = parseInt(url.searchParams.get('offset') || '0');
|
|
95
|
+
const filter = url.searchParams.get('filter') || '';
|
|
96
|
+
|
|
97
|
+
let assessments = readJsonl(p);
|
|
98
|
+
if (filter) {
|
|
99
|
+
assessments = assessments.filter(a => {
|
|
100
|
+
const text = a.llm_assessment || '';
|
|
101
|
+
if (filter === 'GOOD OPPORTUNITY') return /\[GOOD OPPORTUNITY\]/i.test(text);
|
|
102
|
+
if (filter === 'INTERESTING') return /\[INTERESTING\]/i.test(text);
|
|
103
|
+
if (filter === 'LOW') return /\[LOW\]/i.test(text);
|
|
104
|
+
if (filter === 'ERROR') return !!a.llm_error;
|
|
105
|
+
return true;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const total = assessments.length;
|
|
109
|
+
const page = assessments.slice(offset, offset + limit);
|
|
110
|
+
json(res, { total, offset, limit, assessments: page });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function apiTranscript(req, res, params) {
|
|
114
|
+
const transcriptDir = path.join(INCIDENTS_DIR, params.id, 'transcript');
|
|
115
|
+
const sessionFile = path.join(transcriptDir, 'session.jsonl');
|
|
116
|
+
const messages = readJsonl(sessionFile);
|
|
117
|
+
json(res, messages);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function apiSimulations(req, res, params) {
|
|
121
|
+
const status = readJson(path.join(INCIDENTS_DIR, params.id, 'status.json'));
|
|
122
|
+
const simsDir = path.join(INCIDENTS_DIR, params.id, 'simulations');
|
|
123
|
+
const sims = (status?.simulations || []).map(s => {
|
|
124
|
+
const result = readJson(path.join(simsDir, s.id, 'result.json'));
|
|
125
|
+
const config = readJson(path.join(simsDir, s.id, 'simulation-config.json'));
|
|
126
|
+
return { ...s, result, config };
|
|
127
|
+
});
|
|
128
|
+
json(res, sims);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function apiSimulation(req, res, params) {
|
|
132
|
+
const simDir = path.join(INCIDENTS_DIR, params.id, 'simulations', params.simId);
|
|
133
|
+
if (!fs.existsSync(simDir)) return json(res, { error: 'Not found' }, 404);
|
|
134
|
+
const result = readJson(path.join(simDir, 'result.json'));
|
|
135
|
+
const config = readJson(path.join(simDir, 'simulation-config.json'));
|
|
136
|
+
const transcript = readJson(path.join(simDir, 'transcript.json'));
|
|
137
|
+
const prompt = (() => { try { return fs.readFileSync(path.join(simDir, 'prompt.txt'), 'utf8'); } catch { return null; } })();
|
|
138
|
+
const changedFiles = (() => { try { return fs.readFileSync(path.join(simDir, 'changed-files.txt'), 'utf8').trim().split('\n').filter(Boolean); } catch { return []; } })();
|
|
139
|
+
const stderr = (() => { try { return fs.readFileSync(path.join(simDir, 'stderr.log'), 'utf8'); } catch { return ''; } })();
|
|
140
|
+
json(res, { simId: params.simId, result, config, transcript, prompt, changedFiles, stderr });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function apiEvents(req, res) {
|
|
144
|
+
const eventsDir = path.join(DATA_DIR, 'events');
|
|
145
|
+
if (!fs.existsSync(eventsDir)) return json(res, []);
|
|
146
|
+
const files = fs.readdirSync(eventsDir).filter(f => f.endsWith('.jsonl')).sort().reverse().slice(0, 7);
|
|
147
|
+
let events = [];
|
|
148
|
+
for (const f of files) {
|
|
149
|
+
events.push(...readJsonl(path.join(eventsDir, f)));
|
|
150
|
+
}
|
|
151
|
+
events.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
|
|
152
|
+
json(res, events.slice(0, 500));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function apiConfig(req, res) {
|
|
156
|
+
const config = readJson(path.join(INSTALL_DIR, 'config.json'));
|
|
157
|
+
json(res, config || {});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── HTML SPA ────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
function serveHtml(req, res) {
|
|
163
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
164
|
+
res.end(HTML);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const HTML = `<!DOCTYPE html>
|
|
168
|
+
<html lang="en">
|
|
169
|
+
<head>
|
|
170
|
+
<meta charset="utf-8">
|
|
171
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
172
|
+
<title>Propensity Monitor</title>
|
|
173
|
+
<style>
|
|
174
|
+
:root {
|
|
175
|
+
--bg: #0d1117; --bg2: #161b22; --bg3: #21262d; --border: #30363d;
|
|
176
|
+
--fg: #c9d1d9; --fg2: #8b949e; --fg3: #484f58;
|
|
177
|
+
--green: #3fb950; --yellow: #d29922; --red: #f85149; --blue: #58a6ff; --purple: #bc8cff;
|
|
178
|
+
--font: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
|
179
|
+
}
|
|
180
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
181
|
+
body { background: var(--bg); color: var(--fg); font-family: var(--font); font-size: 13px; line-height: 1.5; }
|
|
182
|
+
a { color: var(--blue); text-decoration: none; }
|
|
183
|
+
a:hover { text-decoration: underline; }
|
|
184
|
+
|
|
185
|
+
.container { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
|
186
|
+
header { background: var(--bg2); border-bottom: 1px solid var(--border); padding: 12px 16px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 100; }
|
|
187
|
+
header h1 { font-size: 14px; color: var(--fg); font-weight: 600; }
|
|
188
|
+
header .status { font-size: 11px; padding: 2px 8px; border-radius: 12px; }
|
|
189
|
+
header .status.on { background: #0d2818; color: var(--green); }
|
|
190
|
+
header .status.off { background: #2d1215; color: var(--red); }
|
|
191
|
+
header nav { margin-left: auto; display: flex; gap: 12px; }
|
|
192
|
+
header nav a { color: var(--fg2); font-size: 12px; }
|
|
193
|
+
header nav a:hover, header nav a.active { color: var(--fg); }
|
|
194
|
+
|
|
195
|
+
.card { background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; padding: 16px; margin-bottom: 12px; }
|
|
196
|
+
.card:hover { border-color: var(--fg3); }
|
|
197
|
+
.card h3 { font-size: 13px; margin-bottom: 8px; }
|
|
198
|
+
.card .meta { color: var(--fg2); font-size: 11px; }
|
|
199
|
+
.card .meta span { margin-right: 16px; }
|
|
200
|
+
|
|
201
|
+
.badge { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 11px; font-weight: 500; }
|
|
202
|
+
.badge.done { background: #0d2818; color: var(--green); }
|
|
203
|
+
.badge.running { background: #0c2d6b; color: var(--blue); }
|
|
204
|
+
.badge.error { background: #2d1215; color: var(--red); }
|
|
205
|
+
.badge.archived { background: var(--bg3); color: var(--fg2); }
|
|
206
|
+
.badge.low { background: var(--bg3); color: var(--fg2); }
|
|
207
|
+
.badge.interesting { background: #2d2200; color: var(--yellow); }
|
|
208
|
+
.badge.good { background: #2d1215; color: var(--red); }
|
|
209
|
+
|
|
210
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; margin: 12px 0; }
|
|
211
|
+
.stat { background: var(--bg3); border-radius: 4px; padding: 8px 12px; text-align: center; }
|
|
212
|
+
.stat .val { font-size: 20px; font-weight: 700; }
|
|
213
|
+
.stat .label { font-size: 10px; color: var(--fg2); text-transform: uppercase; }
|
|
214
|
+
|
|
215
|
+
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
|
|
216
|
+
.tabs button { background: none; border: none; color: var(--fg2); font-family: var(--font); font-size: 12px; padding: 8px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
|
|
217
|
+
.tabs button:hover { color: var(--fg); }
|
|
218
|
+
.tabs button.active { color: var(--blue); border-bottom-color: var(--blue); }
|
|
219
|
+
|
|
220
|
+
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
221
|
+
th { text-align: left; color: var(--fg2); font-weight: 500; padding: 6px 8px; border-bottom: 1px solid var(--border); }
|
|
222
|
+
td { padding: 6px 8px; border-bottom: 1px solid var(--bg3); }
|
|
223
|
+
tr:hover td { background: var(--bg3); }
|
|
224
|
+
tr { cursor: pointer; }
|
|
225
|
+
|
|
226
|
+
.assessment { padding: 8px 12px; border-left: 3px solid var(--fg3); margin-bottom: 6px; cursor: pointer; }
|
|
227
|
+
.assessment:hover { background: var(--bg3); }
|
|
228
|
+
.assessment.low { border-color: var(--fg3); }
|
|
229
|
+
.assessment.interesting { border-color: var(--yellow); }
|
|
230
|
+
.assessment.good { border-color: var(--red); }
|
|
231
|
+
.assessment .time { color: var(--fg2); font-size: 11px; }
|
|
232
|
+
.assessment .tool { color: var(--blue); }
|
|
233
|
+
.assessment .text { margin-top: 4px; white-space: pre-wrap; word-break: break-word; display: none; }
|
|
234
|
+
.assessment.expanded .text { display: block; }
|
|
235
|
+
|
|
236
|
+
.msg { padding: 10px 14px; margin-bottom: 8px; border-radius: 6px; }
|
|
237
|
+
.msg.user { background: #0c2d6b30; border-left: 3px solid var(--blue); }
|
|
238
|
+
.msg.assistant { background: var(--bg2); border-left: 3px solid var(--purple); }
|
|
239
|
+
.msg.tool { background: var(--bg3); border-left: 3px solid var(--fg3); font-size: 11px; }
|
|
240
|
+
.msg .role { font-size: 10px; text-transform: uppercase; color: var(--fg2); margin-bottom: 4px; font-weight: 600; }
|
|
241
|
+
.msg .content { white-space: pre-wrap; word-break: break-word; }
|
|
242
|
+
|
|
243
|
+
.sim-result { background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; padding: 16px; margin-bottom: 16px; }
|
|
244
|
+
.sim-result .content { white-space: pre-wrap; word-break: break-word; max-height: 600px; overflow-y: auto; margin-top: 12px; padding: 12px; background: var(--bg); border-radius: 4px; }
|
|
245
|
+
|
|
246
|
+
.back { color: var(--fg2); font-size: 12px; margin-bottom: 12px; display: inline-block; }
|
|
247
|
+
.back:hover { color: var(--fg); }
|
|
248
|
+
|
|
249
|
+
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
|
250
|
+
.filter-bar button { background: var(--bg3); border: 1px solid var(--border); color: var(--fg2); font-family: var(--font); font-size: 11px; padding: 4px 10px; border-radius: 4px; cursor: pointer; }
|
|
251
|
+
.filter-bar button:hover, .filter-bar button.active { border-color: var(--blue); color: var(--blue); }
|
|
252
|
+
|
|
253
|
+
.empty { color: var(--fg2); text-align: center; padding: 40px; }
|
|
254
|
+
#app { min-height: calc(100vh - 48px); }
|
|
255
|
+
.loading { color: var(--fg2); padding: 20px; text-align: center; }
|
|
256
|
+
</style>
|
|
257
|
+
</head>
|
|
258
|
+
<body>
|
|
259
|
+
<header>
|
|
260
|
+
<h1>propensity-monitor</h1>
|
|
261
|
+
<span class="status" id="status-badge">...</span>
|
|
262
|
+
<nav>
|
|
263
|
+
<a href="#/" id="nav-incidents">incidents</a>
|
|
264
|
+
<a href="#/events" id="nav-events">events</a>
|
|
265
|
+
</nav>
|
|
266
|
+
</header>
|
|
267
|
+
<div class="container">
|
|
268
|
+
<div id="app"><div class="loading">Loading...</div></div>
|
|
269
|
+
</div>
|
|
270
|
+
<script>
|
|
271
|
+
const API = '';
|
|
272
|
+
|
|
273
|
+
async function fetchJson(url) {
|
|
274
|
+
const r = await fetch(API + url);
|
|
275
|
+
return r.json();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function el(tag, attrs, ...children) {
|
|
279
|
+
const e = document.createElement(tag);
|
|
280
|
+
if (attrs) for (const [k, v] of Object.entries(attrs)) {
|
|
281
|
+
if (k === 'onclick' || k === 'className' || k === 'innerHTML' || k === 'textContent') e[k] = v;
|
|
282
|
+
else e.setAttribute(k, v);
|
|
283
|
+
}
|
|
284
|
+
for (const c of children) {
|
|
285
|
+
if (typeof c === 'string') e.appendChild(document.createTextNode(c));
|
|
286
|
+
else if (c) e.appendChild(c);
|
|
287
|
+
}
|
|
288
|
+
return e;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function timeAgo(ts) {
|
|
292
|
+
const d = Date.now() - new Date(ts).getTime();
|
|
293
|
+
if (d < 60000) return 'just now';
|
|
294
|
+
if (d < 3600000) return Math.floor(d/60000) + 'm ago';
|
|
295
|
+
if (d < 86400000) return Math.floor(d/3600000) + 'h ago';
|
|
296
|
+
return Math.floor(d/86400000) + 'd ago';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function shortTime(ts) {
|
|
300
|
+
return ts ? ts.replace('T', ' ').replace(/\\.\\d+Z$/, '').slice(0, 19) : '';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function ratingClass(text) {
|
|
304
|
+
if (!text) return 'low';
|
|
305
|
+
if (/\\[GOOD OPPORTUNITY\\]/i.test(text)) return 'good';
|
|
306
|
+
if (/\\[INTERESTING\\]/i.test(text)) return 'interesting';
|
|
307
|
+
return 'low';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function statusBadge(status) {
|
|
311
|
+
if (!status) return el('span', { className: 'badge archived' }, 'archived');
|
|
312
|
+
if (status.finished) return el('span', { className: 'badge done' }, 'done');
|
|
313
|
+
if (status.error) return el('span', { className: 'badge error' }, 'error');
|
|
314
|
+
if (status.simulationsStarted) return el('span', { className: 'badge running' }, 'running');
|
|
315
|
+
return el('span', { className: 'badge archived' }, 'archived');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── Pages ──────────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
async function pageIncidents() {
|
|
321
|
+
const incidents = await fetchJson('/api/incidents');
|
|
322
|
+
const app = document.getElementById('app');
|
|
323
|
+
app.innerHTML = '';
|
|
324
|
+
|
|
325
|
+
if (incidents.length === 0) {
|
|
326
|
+
app.innerHTML = '<div class="empty">No incidents logged yet.<br>Use /log-incident in Claude Code to create one.</div>';
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
for (const inc of incidents) {
|
|
331
|
+
const m = inc.meta || {};
|
|
332
|
+
const s = inc.status || {};
|
|
333
|
+
const completed = s.completed || 0;
|
|
334
|
+
const total = s.totalPlanned || 0;
|
|
335
|
+
const failed = s.failed || 0;
|
|
336
|
+
|
|
337
|
+
const card = el('div', { className: 'card', onclick: () => location.hash = '/incident/' + inc.id });
|
|
338
|
+
card.appendChild(el('h3', null, inc.id));
|
|
339
|
+
|
|
340
|
+
const meta = el('div', { className: 'meta' });
|
|
341
|
+
meta.appendChild(el('span', null, shortTime(m.timestamp)));
|
|
342
|
+
meta.appendChild(el('span', null, (m.cwd || '').replace(/^\\/Users\\/[^\\/]+/, '~')));
|
|
343
|
+
if (m.assessmentCount) meta.appendChild(el('span', null, m.assessmentCount + ' assessments'));
|
|
344
|
+
meta.appendChild(statusBadge(s));
|
|
345
|
+
if (total > 0) meta.appendChild(el('span', null, ' ' + completed + '/' + total + ' sims' + (failed > 0 ? ' (' + failed + ' failed)' : '')));
|
|
346
|
+
card.appendChild(meta);
|
|
347
|
+
|
|
348
|
+
if (m.userNotes) card.appendChild(el('div', { className: 'meta', style: 'margin-top:6px;color:var(--fg)' }, m.userNotes));
|
|
349
|
+
app.appendChild(card);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function pageIncident(id) {
|
|
354
|
+
const data = await fetchJson('/api/incidents/' + id);
|
|
355
|
+
if (data.error) { document.getElementById('app').innerHTML = '<div class="empty">Incident not found</div>'; return; }
|
|
356
|
+
|
|
357
|
+
const app = document.getElementById('app');
|
|
358
|
+
app.innerHTML = '';
|
|
359
|
+
app.appendChild(el('a', { className: 'back', href: '#/' }, '< back to incidents'));
|
|
360
|
+
|
|
361
|
+
const m = data.meta || {};
|
|
362
|
+
const s = data.status || {};
|
|
363
|
+
const rc = data.ratingCounts || {};
|
|
364
|
+
|
|
365
|
+
// Header
|
|
366
|
+
const header = el('div', { className: 'card' });
|
|
367
|
+
header.appendChild(el('h3', null, data.id));
|
|
368
|
+
const info = el('div', { className: 'meta' });
|
|
369
|
+
info.innerHTML = [
|
|
370
|
+
'<span>Session: ' + (m.sessionId || '?').slice(0,12) + '</span>',
|
|
371
|
+
'<span>' + shortTime(m.timestamp) + '</span>',
|
|
372
|
+
'<span>' + (m.cwd || '') + '</span>',
|
|
373
|
+
m.userNotes ? '<br><span style="color:var(--fg);margin-top:4px;display:inline-block">' + m.userNotes + '</span>' : '',
|
|
374
|
+
].join('');
|
|
375
|
+
header.appendChild(info);
|
|
376
|
+
app.appendChild(header);
|
|
377
|
+
|
|
378
|
+
// Stats
|
|
379
|
+
const grid = el('div', { className: 'grid' });
|
|
380
|
+
const stats = [
|
|
381
|
+
{ val: data.assessmentCount || 0, label: 'Assessments' },
|
|
382
|
+
{ val: rc['GOOD OPPORTUNITY'] || 0, label: 'Good Opps', color: 'var(--red)' },
|
|
383
|
+
{ val: rc.INTERESTING || 0, label: 'Interesting', color: 'var(--yellow)' },
|
|
384
|
+
{ val: s.completed || 0, label: 'Sims Done', color: 'var(--green)' },
|
|
385
|
+
{ val: s.failed || 0, label: 'Sims Failed', color: (s.failed || 0) > 0 ? 'var(--red)' : 'var(--fg2)' },
|
|
386
|
+
];
|
|
387
|
+
for (const st of stats) {
|
|
388
|
+
const d = el('div', { className: 'stat' });
|
|
389
|
+
d.appendChild(el('div', { className: 'val', style: st.color ? 'color:' + st.color : '' }, String(st.val)));
|
|
390
|
+
d.appendChild(el('div', { className: 'label' }, st.label));
|
|
391
|
+
grid.appendChild(d);
|
|
392
|
+
}
|
|
393
|
+
app.appendChild(grid);
|
|
394
|
+
|
|
395
|
+
// Tabs
|
|
396
|
+
const tabs = el('div', { className: 'tabs' });
|
|
397
|
+
const content = el('div');
|
|
398
|
+
const tabDefs = [
|
|
399
|
+
{ label: 'Assessments', fn: () => loadAssessments(id, content) },
|
|
400
|
+
{ label: 'Transcript', fn: () => loadTranscript(id, content) },
|
|
401
|
+
{ label: 'Simulations', fn: () => loadSimulations(id, content) },
|
|
402
|
+
];
|
|
403
|
+
for (const t of tabDefs) {
|
|
404
|
+
const btn = el('button', { onclick: () => {
|
|
405
|
+
tabs.querySelectorAll('button').forEach(b => b.classList.remove('active'));
|
|
406
|
+
btn.classList.add('active');
|
|
407
|
+
t.fn();
|
|
408
|
+
}}, t.label);
|
|
409
|
+
tabs.appendChild(btn);
|
|
410
|
+
}
|
|
411
|
+
app.appendChild(tabs);
|
|
412
|
+
app.appendChild(content);
|
|
413
|
+
|
|
414
|
+
// Default tab
|
|
415
|
+
tabs.children[0].click();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function loadAssessments(id, container) {
|
|
419
|
+
container.innerHTML = '<div class="loading">Loading assessments...</div>';
|
|
420
|
+
|
|
421
|
+
const filterBar = el('div', { className: 'filter-bar' });
|
|
422
|
+
const filters = ['All', 'GOOD OPPORTUNITY', 'INTERESTING', 'LOW', 'ERROR'];
|
|
423
|
+
let activeFilter = '';
|
|
424
|
+
|
|
425
|
+
async function load(filter) {
|
|
426
|
+
activeFilter = filter;
|
|
427
|
+
filterBar.querySelectorAll('button').forEach(b => b.classList.remove('active'));
|
|
428
|
+
filterBar.querySelector('[data-f="' + (filter || 'All') + '"]')?.classList.add('active');
|
|
429
|
+
|
|
430
|
+
const url = '/api/incidents/' + id + '/assessments?limit=300' + (filter ? '&filter=' + encodeURIComponent(filter) : '');
|
|
431
|
+
const data = await fetchJson(url);
|
|
432
|
+
|
|
433
|
+
// Clear and rebuild
|
|
434
|
+
while (container.children.length > 1) container.removeChild(container.lastChild);
|
|
435
|
+
|
|
436
|
+
const countInfo = el('div', { className: 'meta', style: 'margin-bottom:8px' }, data.total + ' assessments' + (filter ? ' (' + filter + ')' : ''));
|
|
437
|
+
container.appendChild(countInfo);
|
|
438
|
+
|
|
439
|
+
for (const a of data.assessments) {
|
|
440
|
+
const cls = ratingClass(a.llm_assessment);
|
|
441
|
+
const div = el('div', { className: 'assessment ' + cls, onclick: function() { this.classList.toggle('expanded'); } });
|
|
442
|
+
const header = el('div');
|
|
443
|
+
header.appendChild(el('span', { className: 'time' }, shortTime(a.timestamp) + ' '));
|
|
444
|
+
if (a.tool_name) header.appendChild(el('span', { className: 'tool' }, a.tool_name + ' '));
|
|
445
|
+
if (a.hook_event_name) header.appendChild(el('span', { className: 'time' }, a.hook_event_name));
|
|
446
|
+
div.appendChild(header);
|
|
447
|
+
div.appendChild(el('div', { className: 'text' }, a.llm_assessment || a.llm_error || '(no assessment)'));
|
|
448
|
+
container.appendChild(div);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (data.assessments.length === 0) {
|
|
452
|
+
container.appendChild(el('div', { className: 'empty' }, 'No assessments match this filter.'));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
container.innerHTML = '';
|
|
457
|
+
for (const f of filters) {
|
|
458
|
+
const btn = el('button', { 'data-f': f, onclick: () => load(f === 'All' ? '' : f) }, f);
|
|
459
|
+
filterBar.appendChild(btn);
|
|
460
|
+
}
|
|
461
|
+
container.appendChild(filterBar);
|
|
462
|
+
load('');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function loadTranscript(id, container) {
|
|
466
|
+
container.innerHTML = '<div class="loading">Loading transcript...</div>';
|
|
467
|
+
const messages = await fetchJson('/api/incidents/' + id + '/transcript');
|
|
468
|
+
container.innerHTML = '';
|
|
469
|
+
|
|
470
|
+
if (messages.length === 0) {
|
|
471
|
+
container.innerHTML = '<div class="empty">No transcript found.</div>';
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
for (const msg of messages) {
|
|
476
|
+
const type = msg.type || '';
|
|
477
|
+
if (type === 'file-history-snapshot') continue;
|
|
478
|
+
|
|
479
|
+
let role = type;
|
|
480
|
+
let content = '';
|
|
481
|
+
let cls = 'tool';
|
|
482
|
+
|
|
483
|
+
if (type === 'user') {
|
|
484
|
+
role = 'user';
|
|
485
|
+
cls = 'user';
|
|
486
|
+
const c = msg.message?.content;
|
|
487
|
+
content = typeof c === 'string' ? c : Array.isArray(c) ? c.filter(x => x.type === 'text').map(x => x.text).join('\\n') : JSON.stringify(c, null, 2);
|
|
488
|
+
} else if (type === 'assistant') {
|
|
489
|
+
role = 'assistant';
|
|
490
|
+
cls = 'assistant';
|
|
491
|
+
const c = msg.message?.content;
|
|
492
|
+
if (typeof c === 'string') content = c;
|
|
493
|
+
else if (Array.isArray(c)) {
|
|
494
|
+
content = c.map(x => {
|
|
495
|
+
if (x.type === 'text') return x.text;
|
|
496
|
+
if (x.type === 'tool_use') return '[tool_use: ' + x.name + ']\\n' + JSON.stringify(x.input, null, 2).slice(0, 500);
|
|
497
|
+
return JSON.stringify(x);
|
|
498
|
+
}).join('\\n');
|
|
499
|
+
} else content = JSON.stringify(c, null, 2);
|
|
500
|
+
} else if (type === 'tool_result') {
|
|
501
|
+
role = 'tool_result';
|
|
502
|
+
content = JSON.stringify(msg.message?.content || msg.content, null, 2)?.slice(0, 1000) || '';
|
|
503
|
+
} else {
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (!content?.trim()) continue;
|
|
508
|
+
const div = el('div', { className: 'msg ' + cls });
|
|
509
|
+
div.appendChild(el('div', { className: 'role' }, role + (msg.timestamp ? ' - ' + shortTime(msg.timestamp) : '')));
|
|
510
|
+
div.appendChild(el('div', { className: 'content' }, content.slice(0, 3000) + (content.length > 3000 ? '\\n... (truncated)' : '')));
|
|
511
|
+
container.appendChild(div);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function loadSimulations(id, container) {
|
|
516
|
+
container.innerHTML = '<div class="loading">Loading simulations...</div>';
|
|
517
|
+
const sims = await fetchJson('/api/incidents/' + id + '/simulations');
|
|
518
|
+
container.innerHTML = '';
|
|
519
|
+
|
|
520
|
+
if (sims.length === 0) {
|
|
521
|
+
container.innerHTML = '<div class="empty">No simulations recorded.</div>';
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const table = el('table');
|
|
526
|
+
const thead = el('tr');
|
|
527
|
+
for (const h of ['ID', 'Persona', 'Status', 'Turns', 'Duration', 'Model']) {
|
|
528
|
+
thead.appendChild(el('th', null, h));
|
|
529
|
+
}
|
|
530
|
+
table.appendChild(thead);
|
|
531
|
+
|
|
532
|
+
for (const sim of sims) {
|
|
533
|
+
const tr = el('tr', { onclick: () => location.hash = '/incident/' + id + '/sim/' + sim.id });
|
|
534
|
+
const statusCls = sim.status === 'completed' ? 'done' : sim.status === 'running' ? 'running' : sim.status === 'failed' ? 'error' : 'archived';
|
|
535
|
+
tr.appendChild(el('td', null, sim.id || ''));
|
|
536
|
+
tr.appendChild(el('td', null, sim.persona || ''));
|
|
537
|
+
tr.appendChild(el('td', null, el('span', { className: 'badge ' + statusCls }, sim.status || 'pending')));
|
|
538
|
+
tr.appendChild(el('td', null, String(sim.turns || sim.result?.turnCount || 0)));
|
|
539
|
+
tr.appendChild(el('td', null, (sim.duration || sim.result?.durationSeconds || 0) + 's'));
|
|
540
|
+
tr.appendChild(el('td', null, sim.result?.model || sim.config?.model || ''));
|
|
541
|
+
table.appendChild(tr);
|
|
542
|
+
}
|
|
543
|
+
container.appendChild(table);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function pageSimulation(incidentId, simId) {
|
|
547
|
+
const data = await fetchJson('/api/incidents/' + incidentId + '/simulations/' + simId);
|
|
548
|
+
if (data.error) { document.getElementById('app').innerHTML = '<div class="empty">Simulation not found</div>'; return; }
|
|
549
|
+
|
|
550
|
+
const app = document.getElementById('app');
|
|
551
|
+
app.innerHTML = '';
|
|
552
|
+
app.appendChild(el('a', { className: 'back', href: '#/incident/' + incidentId }, '< back to incident'));
|
|
553
|
+
|
|
554
|
+
const r = data.result || {};
|
|
555
|
+
const c = data.config || {};
|
|
556
|
+
const persona = c.persona || {};
|
|
557
|
+
|
|
558
|
+
// Header
|
|
559
|
+
const header = el('div', { className: 'card' });
|
|
560
|
+
header.appendChild(el('h3', null, simId + ' - ' + (persona.name || persona.id || 'unknown')));
|
|
561
|
+
const info = el('div', { className: 'meta' });
|
|
562
|
+
info.innerHTML = [
|
|
563
|
+
'<span>Model: ' + (r.model || c.model || '?') + '</span>',
|
|
564
|
+
'<span>Turns: ' + (r.turnCount || 0) + '</span>',
|
|
565
|
+
'<span>Duration: ' + (r.durationSeconds || 0) + 's</span>',
|
|
566
|
+
'<span>Exit: ' + (r.exitReason || '?') + '</span>',
|
|
567
|
+
'<span>Seed: ' + (c.seed || '?') + '</span>',
|
|
568
|
+
].join('');
|
|
569
|
+
header.appendChild(info);
|
|
570
|
+
if (persona.description) header.appendChild(el('div', { className: 'meta', style: 'margin-top:8px;color:var(--fg)' }, persona.description));
|
|
571
|
+
app.appendChild(header);
|
|
572
|
+
|
|
573
|
+
// Prompt
|
|
574
|
+
if (data.prompt) {
|
|
575
|
+
const promptCard = el('div', { className: 'sim-result' });
|
|
576
|
+
promptCard.appendChild(el('h3', null, 'System Prompt'));
|
|
577
|
+
promptCard.appendChild(el('div', { className: 'content' }, data.prompt));
|
|
578
|
+
app.appendChild(promptCard);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Transcript
|
|
582
|
+
if (data.transcript) {
|
|
583
|
+
const transcriptCard = el('div', { className: 'sim-result' });
|
|
584
|
+
transcriptCard.appendChild(el('h3', null, 'Response'));
|
|
585
|
+
const text = data.transcript.result || JSON.stringify(data.transcript, null, 2);
|
|
586
|
+
transcriptCard.appendChild(el('div', { className: 'content' }, text));
|
|
587
|
+
app.appendChild(transcriptCard);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Changed files
|
|
591
|
+
if (data.changedFiles && data.changedFiles.length > 0) {
|
|
592
|
+
const filesCard = el('div', { className: 'card' });
|
|
593
|
+
filesCard.appendChild(el('h3', null, 'Changed Files (' + data.changedFiles.length + ')'));
|
|
594
|
+
for (const f of data.changedFiles) {
|
|
595
|
+
filesCard.appendChild(el('div', { className: 'meta' }, f));
|
|
596
|
+
}
|
|
597
|
+
app.appendChild(filesCard);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Errors
|
|
601
|
+
if (data.stderr) {
|
|
602
|
+
const errCard = el('div', { className: 'card', style: 'border-color:var(--red)' });
|
|
603
|
+
errCard.appendChild(el('h3', { style: 'color:var(--red)' }, 'Stderr'));
|
|
604
|
+
errCard.appendChild(el('div', { className: 'meta', style: 'white-space:pre-wrap' }, data.stderr));
|
|
605
|
+
app.appendChild(errCard);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function pageEvents() {
|
|
610
|
+
const events = await fetchJson('/api/events');
|
|
611
|
+
const app = document.getElementById('app');
|
|
612
|
+
app.innerHTML = '';
|
|
613
|
+
|
|
614
|
+
if (events.length === 0) {
|
|
615
|
+
app.innerHTML = '<div class="empty">No events recorded yet.</div>';
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
app.appendChild(el('h3', { style: 'margin-bottom:12px' }, 'Recent Events (last 7 days)'));
|
|
620
|
+
|
|
621
|
+
for (const ev of events.slice(0, 200)) {
|
|
622
|
+
const cls = ratingClass(ev.llm_assessment);
|
|
623
|
+
const div = el('div', { className: 'assessment ' + cls, onclick: function() { this.classList.toggle('expanded'); } });
|
|
624
|
+
const header = el('div');
|
|
625
|
+
header.appendChild(el('span', { className: 'time' }, shortTime(ev.timestamp) + ' '));
|
|
626
|
+
if (ev.tool_name) header.appendChild(el('span', { className: 'tool' }, ev.tool_name + ' '));
|
|
627
|
+
header.appendChild(el('span', { className: 'time' }, (ev.session_id || '').slice(0,8)));
|
|
628
|
+
div.appendChild(header);
|
|
629
|
+
div.appendChild(el('div', { className: 'text' }, ev.llm_assessment || ev.llm_error || '(no assessment)'));
|
|
630
|
+
app.appendChild(div);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ── Router ──────────────────────────────────────────────────────────────
|
|
635
|
+
|
|
636
|
+
async function route() {
|
|
637
|
+
const hash = location.hash || '#/';
|
|
638
|
+
document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active'));
|
|
639
|
+
|
|
640
|
+
let m;
|
|
641
|
+
if (m = hash.match(/^#\\/incident\\/([^\\/]+)\\/sim\\/(.+)$/)) {
|
|
642
|
+
await pageSimulation(m[1], m[2]);
|
|
643
|
+
} else if (m = hash.match(/^#\\/incident\\/(.+)$/)) {
|
|
644
|
+
document.getElementById('nav-incidents')?.classList.add('active');
|
|
645
|
+
await pageIncident(m[1]);
|
|
646
|
+
} else if (hash === '#/events') {
|
|
647
|
+
document.getElementById('nav-events')?.classList.add('active');
|
|
648
|
+
await pageEvents();
|
|
649
|
+
} else {
|
|
650
|
+
document.getElementById('nav-incidents')?.classList.add('active');
|
|
651
|
+
await pageIncidents();
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function init() {
|
|
656
|
+
const config = await fetchJson('/api/config');
|
|
657
|
+
const badge = document.getElementById('status-badge');
|
|
658
|
+
badge.textContent = config.enabled === false ? 'OFF' : 'ON';
|
|
659
|
+
badge.className = 'status ' + (config.enabled === false ? 'off' : 'on');
|
|
660
|
+
|
|
661
|
+
window.addEventListener('hashchange', route);
|
|
662
|
+
route();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
init();
|
|
666
|
+
</script>
|
|
667
|
+
</body>
|
|
668
|
+
</html>`;
|
|
669
|
+
|
|
670
|
+
// ── Server ──────────────────────────────────────────────────────────────────
|
|
671
|
+
|
|
672
|
+
function handleRequest(req, res) {
|
|
673
|
+
const url = new URL(req.url, 'http://localhost');
|
|
674
|
+
const pathname = url.pathname;
|
|
675
|
+
let params;
|
|
676
|
+
|
|
677
|
+
if (pathname === '/') return serveHtml(req, res);
|
|
678
|
+
if (pathname === '/api/incidents' && !pathname.slice(15)) return apiIncidents(req, res);
|
|
679
|
+
if (pathname === '/api/config') return apiConfig(req, res);
|
|
680
|
+
if (pathname === '/api/events') return apiEvents(req, res);
|
|
681
|
+
|
|
682
|
+
if (params = matchRoute(pathname, '/api/incidents/:id/assessments')) return apiAssessments(req, res, params);
|
|
683
|
+
if (params = matchRoute(pathname, '/api/incidents/:id/transcript')) return apiTranscript(req, res, params);
|
|
684
|
+
if (params = matchRoute(pathname, '/api/incidents/:id/simulations/:simId')) return apiSimulation(req, res, params);
|
|
685
|
+
if (params = matchRoute(pathname, '/api/incidents/:id/simulations')) return apiSimulations(req, res, params);
|
|
686
|
+
if (params = matchRoute(pathname, '/api/incidents/:id')) return apiIncident(req, res, params);
|
|
687
|
+
|
|
688
|
+
res.writeHead(404);
|
|
689
|
+
res.end('Not found');
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ── Main ────────────────────────────────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
const port = parseInt(process.argv.find((a, i) => process.argv[i - 1] === '--port') || '3456');
|
|
695
|
+
|
|
696
|
+
const server = http.createServer(handleRequest);
|
|
697
|
+
server.listen(port, () => {
|
|
698
|
+
const url = 'http://localhost:' + port;
|
|
699
|
+
console.log('Dashboard running at ' + url);
|
|
700
|
+
|
|
701
|
+
// Open browser
|
|
702
|
+
try {
|
|
703
|
+
if (process.platform === 'darwin') execSync('open ' + url);
|
|
704
|
+
else if (process.platform === 'linux') execSync('xdg-open ' + url);
|
|
705
|
+
} catch {}
|
|
706
|
+
});
|