@opensassi/opencode 0.1.3 → 0.1.4
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/dashboard/dashboard.e2e.test.ts +247 -0
- package/dashboard/dist/index.d.ts +9 -0
- package/dashboard/dist/index.js +36 -0
- package/dashboard/dist/routes/api.d.ts +2 -0
- package/dashboard/dist/routes/api.js +215 -0
- package/dashboard/dist/services/cache.d.ts +13 -0
- package/dashboard/dist/services/cache.js +29 -0
- package/dashboard/dist/services/experiments.d.ts +11 -0
- package/dashboard/dist/services/experiments.js +108 -0
- package/dashboard/dist/services/git.d.ts +12 -0
- package/dashboard/dist/services/git.js +149 -0
- package/dashboard/dist/services/sessions.d.ts +25 -0
- package/dashboard/dist/services/sessions.js +208 -0
- package/dashboard/dist/services/specs.d.ts +9 -0
- package/dashboard/dist/services/specs.js +102 -0
- package/dashboard/dist/types.d.ts +173 -0
- package/dashboard/dist/types.js +1 -0
- package/dashboard/opencode.e2e.test.ts +100 -0
- package/dashboard/playwright.config.ts +11 -0
- package/dashboard/public/app.js +961 -0
- package/dashboard/public/index.html +29 -0
- package/dashboard/public/style.css +231 -0
- package/dashboard/src/index.ts +53 -0
- package/dashboard/src/routes/api.ts +235 -0
- package/dashboard/src/services/cache.ts +38 -0
- package/dashboard/src/services/experiments.ts +117 -0
- package/dashboard/src/services/git.ts +139 -0
- package/dashboard/src/services/sessions.ts +216 -0
- package/dashboard/src/services/specs.ts +95 -0
- package/dashboard/src/types.ts +168 -0
- package/dashboard/technical-specification.md +414 -0
- package/dashboard/test-api.sh +127 -0
- package/dashboard/tsconfig.json +16 -0
- package/lib/util/paths.js +9 -1
- package/package.json +9 -1
- package/scripts/dashboard.js +17 -0
- package/scripts/generate-daily-summaries.js +190 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>opencode dashboard</title>
|
|
7
|
+
<link rel="stylesheet" href="/style.css">
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
|
9
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github-dark.min.css">
|
|
10
|
+
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/highlight.min.js"></script>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<nav id="nav">
|
|
14
|
+
<a href="#/" class="nav-brand">opencode</a>
|
|
15
|
+
<div class="nav-links">
|
|
16
|
+
<a href="#/" class="nav-link">Overview</a>
|
|
17
|
+
<a href="#/daily" class="nav-link">Daily</a>
|
|
18
|
+
<a href="#/sessions" class="nav-link">Sessions</a>
|
|
19
|
+
<a href="#/git" class="nav-link">Git</a>
|
|
20
|
+
<a href="#/experiments" class="nav-link">Experiments</a>
|
|
21
|
+
<a href="#/specs" class="nav-link">Specs</a>
|
|
22
|
+
<a href="#/search" class="nav-link">Search</a>
|
|
23
|
+
</div>
|
|
24
|
+
</nav>
|
|
25
|
+
<main id="content"></main>
|
|
26
|
+
|
|
27
|
+
<script src="/app.js"></script>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #0d1117;
|
|
3
|
+
--bg2: #161b22;
|
|
4
|
+
--bg3: #21262d;
|
|
5
|
+
--border: #30363d;
|
|
6
|
+
--text: #f0f6fc;
|
|
7
|
+
--text2: #b1bac4;
|
|
8
|
+
--accent: #79c0ff;
|
|
9
|
+
--green: #56d364;
|
|
10
|
+
--orange: #e3b341;
|
|
11
|
+
--red: #ff6b6b;
|
|
12
|
+
--purple: #d2a8ff;
|
|
13
|
+
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
14
|
+
--mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
18
|
+
|
|
19
|
+
body {
|
|
20
|
+
background: var(--bg);
|
|
21
|
+
color: var(--text);
|
|
22
|
+
font-family: var(--font);
|
|
23
|
+
font-size: 15px;
|
|
24
|
+
font-weight: 400;
|
|
25
|
+
line-height: 1.6;
|
|
26
|
+
min-height: 100vh;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
a { color: var(--accent); text-decoration: none; }
|
|
30
|
+
a:hover { text-decoration: underline; }
|
|
31
|
+
|
|
32
|
+
nav {
|
|
33
|
+
background: var(--bg2);
|
|
34
|
+
border-bottom: 1px solid var(--border);
|
|
35
|
+
padding: 0 24px;
|
|
36
|
+
height: 48px;
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: 24px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.nav-brand {
|
|
43
|
+
color: var(--text);
|
|
44
|
+
font-weight: 600;
|
|
45
|
+
font-size: 15px;
|
|
46
|
+
}
|
|
47
|
+
.nav-brand:hover { text-decoration: none; }
|
|
48
|
+
|
|
49
|
+
.nav-links { display: flex; gap: 16px; }
|
|
50
|
+
|
|
51
|
+
.nav-link {
|
|
52
|
+
color: var(--text2);
|
|
53
|
+
font-size: 14px;
|
|
54
|
+
font-weight: 500;
|
|
55
|
+
padding: 4px 10px;
|
|
56
|
+
border-radius: 6px;
|
|
57
|
+
transition: background 0.15s;
|
|
58
|
+
}
|
|
59
|
+
.nav-link:hover { background: var(--bg3); color: var(--text); text-decoration: none; }
|
|
60
|
+
.nav-link.active { color: var(--accent); background: var(--bg3); }
|
|
61
|
+
|
|
62
|
+
#content { padding: 24px; max-width: 1280px; margin: 0 auto; }
|
|
63
|
+
|
|
64
|
+
.page-title {
|
|
65
|
+
font-size: 26px;
|
|
66
|
+
font-weight: 700;
|
|
67
|
+
margin-bottom: 20px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.card {
|
|
71
|
+
background: var(--bg2);
|
|
72
|
+
border: 1px solid var(--border);
|
|
73
|
+
border-radius: 8px;
|
|
74
|
+
padding: 18px;
|
|
75
|
+
margin-bottom: 16px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.card-title {
|
|
79
|
+
font-size: 13px;
|
|
80
|
+
font-weight: 600;
|
|
81
|
+
color: var(--text2);
|
|
82
|
+
text-transform: uppercase;
|
|
83
|
+
letter-spacing: 0.5px;
|
|
84
|
+
margin-bottom: 8px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.card-value {
|
|
88
|
+
font-size: 28px;
|
|
89
|
+
font-weight: 700;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.card-value.small { font-size: 17px; font-weight: 600; }
|
|
93
|
+
|
|
94
|
+
.stats-grid {
|
|
95
|
+
display: grid;
|
|
96
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
97
|
+
gap: 12px;
|
|
98
|
+
margin-bottom: 24px;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.chart-container {
|
|
102
|
+
background: var(--bg2);
|
|
103
|
+
border: 1px solid var(--border);
|
|
104
|
+
border-radius: 8px;
|
|
105
|
+
padding: 16px;
|
|
106
|
+
margin-bottom: 16px;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.chart-container canvas { max-height: 350px; }
|
|
110
|
+
|
|
111
|
+
table {
|
|
112
|
+
width: 100%;
|
|
113
|
+
border-collapse: collapse;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
th, td {
|
|
117
|
+
padding: 10px 14px;
|
|
118
|
+
text-align: left;
|
|
119
|
+
border-bottom: 1px solid var(--border);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
th {
|
|
123
|
+
color: var(--text2);
|
|
124
|
+
font-size: 13px;
|
|
125
|
+
font-weight: 600;
|
|
126
|
+
text-transform: uppercase;
|
|
127
|
+
letter-spacing: 0.5px;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
td {
|
|
131
|
+
font-size: 14px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
tr:hover td { background: var(--bg3); }
|
|
135
|
+
|
|
136
|
+
.tag {
|
|
137
|
+
display: inline-block;
|
|
138
|
+
padding: 3px 10px;
|
|
139
|
+
border-radius: 12px;
|
|
140
|
+
font-size: 12px;
|
|
141
|
+
font-weight: 500;
|
|
142
|
+
background: var(--bg3);
|
|
143
|
+
color: var(--accent);
|
|
144
|
+
margin: 2px;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.session-card {
|
|
148
|
+
padding: 14px;
|
|
149
|
+
border: 1px solid var(--border);
|
|
150
|
+
border-radius: 8px;
|
|
151
|
+
margin-bottom: 8px;
|
|
152
|
+
cursor: pointer;
|
|
153
|
+
transition: border-color 0.15s, background 0.15s;
|
|
154
|
+
background: var(--bg2);
|
|
155
|
+
}
|
|
156
|
+
.session-card:hover { border-color: var(--accent); }
|
|
157
|
+
|
|
158
|
+
.session-card-title {
|
|
159
|
+
font-weight: 600;
|
|
160
|
+
font-size: 15px;
|
|
161
|
+
margin-bottom: 4px;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.session-card-meta {
|
|
165
|
+
font-size: 13px;
|
|
166
|
+
color: var(--text2);
|
|
167
|
+
margin-bottom: 8px;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.search-box {
|
|
171
|
+
width: 100%;
|
|
172
|
+
padding: 12px 16px;
|
|
173
|
+
background: var(--bg3);
|
|
174
|
+
border: 1px solid var(--border);
|
|
175
|
+
border-radius: 8px;
|
|
176
|
+
color: var(--text);
|
|
177
|
+
font-size: 15px;
|
|
178
|
+
margin-bottom: 16px;
|
|
179
|
+
}
|
|
180
|
+
.search-box:focus { outline: none; border-color: var(--accent); }
|
|
181
|
+
|
|
182
|
+
.spinner {
|
|
183
|
+
text-align: center;
|
|
184
|
+
padding: 40px;
|
|
185
|
+
color: var(--text2);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.error {
|
|
189
|
+
color: var(--red);
|
|
190
|
+
padding: 20px;
|
|
191
|
+
text-align: center;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.badge {
|
|
195
|
+
display: inline-block;
|
|
196
|
+
padding: 3px 10px;
|
|
197
|
+
border-radius: 12px;
|
|
198
|
+
font-size: 12px;
|
|
199
|
+
font-weight: 600;
|
|
200
|
+
}
|
|
201
|
+
.badge-accepted { background: rgba(86, 211, 100, 0.15); color: var(--green); }
|
|
202
|
+
.badge-archived { background: rgba(227, 179, 65, 0.15); color: var(--orange); }
|
|
203
|
+
.badge-todo { background: rgba(139, 148, 158, 0.2); color: var(--text2); }
|
|
204
|
+
.badge-wip { background: rgba(121, 192, 255, 0.15); color: var(--accent); }
|
|
205
|
+
|
|
206
|
+
.tab-bar {
|
|
207
|
+
display: flex;
|
|
208
|
+
gap: 4px;
|
|
209
|
+
margin-bottom: 16px;
|
|
210
|
+
border-bottom: 1px solid var(--border);
|
|
211
|
+
padding-bottom: 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.tab {
|
|
215
|
+
padding: 8px 16px;
|
|
216
|
+
cursor: pointer;
|
|
217
|
+
color: var(--text2);
|
|
218
|
+
border-bottom: 2px solid transparent;
|
|
219
|
+
font-size: 13px;
|
|
220
|
+
transition: all 0.15s;
|
|
221
|
+
}
|
|
222
|
+
.tab:hover { color: var(--text); }
|
|
223
|
+
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
224
|
+
|
|
225
|
+
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
226
|
+
.three-col { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
|
|
227
|
+
|
|
228
|
+
@media (max-width: 800px) {
|
|
229
|
+
.two-col, .three-col { grid-template-columns: 1fr; }
|
|
230
|
+
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
|
231
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { resolve, dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { createApiRouter } from './routes/api.js';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
|
|
10
|
+
export interface DashboardOptions {
|
|
11
|
+
port: number;
|
|
12
|
+
sessionsDir: string;
|
|
13
|
+
repoDir: string;
|
|
14
|
+
experimentsDir?: string;
|
|
15
|
+
host: string;
|
|
16
|
+
gitSince?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function startDashboard(opts: Partial<DashboardOptions> = {}): void {
|
|
20
|
+
const port = opts.port ?? (parseInt(process.env.DEEPENC_DASHBOARD_PORT ?? '') || 3000);
|
|
21
|
+
const host = opts.host ?? '127.0.0.1';
|
|
22
|
+
const repoDir = opts.repoDir ?? process.cwd();
|
|
23
|
+
const sessionsDir = opts.sessionsDir ?? resolve(repoDir, 'sessions');
|
|
24
|
+
const experimentsDir = opts.experimentsDir ?? resolve(repoDir, 'perf', 'experiments');
|
|
25
|
+
const specsDir = resolve(repoDir);
|
|
26
|
+
const gitSince = opts.gitSince;
|
|
27
|
+
|
|
28
|
+
const app = express();
|
|
29
|
+
|
|
30
|
+
const publicDir = join(__dirname, '..', 'public');
|
|
31
|
+
app.use(express.static(publicDir));
|
|
32
|
+
|
|
33
|
+
app.use('/api', createApiRouter(sessionsDir, repoDir, experimentsDir, gitSince, specsDir));
|
|
34
|
+
|
|
35
|
+
app.get('/{*path}', (_req, res) => {
|
|
36
|
+
res.sendFile(join(publicDir, 'index.html'));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const server = app.listen(port, host, () => {
|
|
40
|
+
console.log(`opencode dashboard running at http://${host}:${port}`);
|
|
41
|
+
const hasData = existsSync(join(sessionsDir, 'daily'));
|
|
42
|
+
if (!hasData) {
|
|
43
|
+
console.log(` (sessions directory not found at ${sessionsDir})`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const shutdown = () => {
|
|
48
|
+
console.log('\nShutting down dashboard...');
|
|
49
|
+
server.close(() => process.exit(0));
|
|
50
|
+
};
|
|
51
|
+
process.on('SIGINT', shutdown);
|
|
52
|
+
process.on('SIGTERM', shutdown);
|
|
53
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { Router, type Request, type Response } from 'express';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
import { SessionsService } from '../services/sessions.js';
|
|
4
|
+
import { GitService } from '../services/git.js';
|
|
5
|
+
import { ExperimentsService } from '../services/experiments.js';
|
|
6
|
+
import { TechSpecService } from '../services/specs.js';
|
|
7
|
+
import type { CrossDayStats, HealthStatus } from '../types.js';
|
|
8
|
+
|
|
9
|
+
function qs(v: unknown): string | undefined {
|
|
10
|
+
if (typeof v === 'string') return v;
|
|
11
|
+
if (Array.isArray(v) && v.length > 0) return String(v[0]);
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createApiRouter(sessionsDir: string, repoDir: string, experimentsDir?: string, gitSince?: string, specsRoot?: string): Router {
|
|
16
|
+
const router = Router();
|
|
17
|
+
const sessions = new SessionsService(sessionsDir);
|
|
18
|
+
const git = new GitService(repoDir, gitSince);
|
|
19
|
+
const experiments = experimentsDir ? new ExperimentsService(experimentsDir) : null;
|
|
20
|
+
const specs = specsRoot ? new TechSpecService(specsRoot) : null;
|
|
21
|
+
|
|
22
|
+
router.get('/health', (_req: Request, res: Response) => {
|
|
23
|
+
const days = sessions.listDays();
|
|
24
|
+
const flat = sessions.getAllSessionsFlat();
|
|
25
|
+
const health: HealthStatus = {
|
|
26
|
+
status: days.length > 0 ? 'ok' : 'error',
|
|
27
|
+
days_count: days.length,
|
|
28
|
+
sessions_count: flat.length,
|
|
29
|
+
sessions_path: sessionsDir,
|
|
30
|
+
};
|
|
31
|
+
res.json(health);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
router.get('/days', (_req: Request, res: Response) => {
|
|
35
|
+
const days = sessions.listDays();
|
|
36
|
+
res.json({ days });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
router.get('/days/latest', (_req: Request, res: Response) => {
|
|
40
|
+
const days = sessions.listDays();
|
|
41
|
+
if (days.length === 0) {
|
|
42
|
+
res.status(404).json({ error: 'No daily data found' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const day = sessions.getDay(days[days.length - 1]);
|
|
46
|
+
res.json(day);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
router.get('/days/:date', (req: Request, res: Response) => {
|
|
50
|
+
const date = String(req.params.date);
|
|
51
|
+
const day = sessions.getDay(date);
|
|
52
|
+
if (day.total_sessions === 0 && day.top_subject_areas.length === 0) {
|
|
53
|
+
res.status(404).json({ error: `No data for date: ${date}` });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
res.json(day);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
router.get('/sessions', (req: Request, res: Response) => {
|
|
60
|
+
const flat = sessions.getAllSessionsFlat();
|
|
61
|
+
const page = parseInt(qs(req.query.page) ?? '') || 1;
|
|
62
|
+
const limit = parseInt(qs(req.query.limit) ?? '') || 50;
|
|
63
|
+
const start = (page - 1) * limit;
|
|
64
|
+
const paginated = flat.slice(start, start + limit);
|
|
65
|
+
res.json({ sessions: paginated, total: flat.length, page, limit });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
router.get('/sessions/:sessionId/summary', (req: Request, res: Response) => {
|
|
69
|
+
const sessionId = String(req.params.sessionId);
|
|
70
|
+
const flat = sessions.getAllSessionsFlat();
|
|
71
|
+
const summary = flat.find(s => s.entry.session_id === sessionId);
|
|
72
|
+
if (!summary) {
|
|
73
|
+
res.status(404).json({ error: `Session not found: ${sessionId}` });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const md = sessions.getSessionSummary(sessionId);
|
|
77
|
+
if (!md) {
|
|
78
|
+
res.status(404).json({ error: 'No summary file found for this session' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
res.json({ summary, markdown: md });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
router.get('/sessions/:sessionId', (req: Request, res: Response) => {
|
|
85
|
+
const sessionId = String(req.params.sessionId);
|
|
86
|
+
const flat = sessions.getAllSessionsFlat();
|
|
87
|
+
const summary = flat.find(s => s.entry.session_id === sessionId);
|
|
88
|
+
if (!summary) {
|
|
89
|
+
res.status(404).json({ error: `Session not found: ${sessionId}` });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const detail = sessions.getSessionDetail(sessionId);
|
|
93
|
+
res.json({ summary, detail });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
router.get('/stats', (_req: Request, res: Response) => {
|
|
97
|
+
const days = sessions.listDays();
|
|
98
|
+
const normalizedDays = days.map(d => sessions.getDay(d));
|
|
99
|
+
let totalSessions = 0;
|
|
100
|
+
let totalPrompter = 0;
|
|
101
|
+
let totalSme = 0;
|
|
102
|
+
let multiplierSum = 0;
|
|
103
|
+
let multiplierCount = 0;
|
|
104
|
+
for (const day of normalizedDays) {
|
|
105
|
+
totalSessions += day.total_sessions;
|
|
106
|
+
totalPrompter += day.total_prompter_time_hours;
|
|
107
|
+
totalSme += day.total_sme_time_hours;
|
|
108
|
+
if (day.ai_multiplier > 0) {
|
|
109
|
+
multiplierSum += day.ai_multiplier;
|
|
110
|
+
multiplierCount++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const stats: CrossDayStats = {
|
|
114
|
+
total_days: days.length,
|
|
115
|
+
total_sessions: totalSessions,
|
|
116
|
+
total_prompter_time_hours: Math.round(totalPrompter * 10) / 10,
|
|
117
|
+
total_sme_time_hours: Math.round(totalSme * 10) / 10,
|
|
118
|
+
avg_multiplier: multiplierCount > 0 ? Math.round((multiplierSum / multiplierCount) * 10) / 10 : 0,
|
|
119
|
+
per_day: normalizedDays,
|
|
120
|
+
};
|
|
121
|
+
res.json(stats);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
router.get('/search', (req: Request, res: Response) => {
|
|
125
|
+
const query = qs(req.query.q) ?? '';
|
|
126
|
+
if (!query.trim()) {
|
|
127
|
+
res.json({ results: [] });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const results = sessions.search(query.trim());
|
|
131
|
+
res.json({ results });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
router.get('/refresh', (_req: Request, res: Response) => {
|
|
135
|
+
sessions.refresh();
|
|
136
|
+
res.json({ status: 'refreshed' });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
router.get('/git/log', (req: Request, res: Response) => {
|
|
140
|
+
const since = qs(req.query.since);
|
|
141
|
+
const until = qs(req.query.until);
|
|
142
|
+
const forkRange = git.detectForkPoint();
|
|
143
|
+
const range = since ? undefined : git.getRange(forkRange);
|
|
144
|
+
const commits = git.getLog(since, until, range);
|
|
145
|
+
res.json({ commits, forkRange });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
router.get('/git/stats', (_req: Request, res: Response) => {
|
|
149
|
+
const forkRange = git.detectForkPoint();
|
|
150
|
+
const range = git.getRange(forkRange);
|
|
151
|
+
const stats = git.getStats(range);
|
|
152
|
+
res.json(stats);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
router.get('/git/commit/:hash', (req: Request, res: Response) => {
|
|
156
|
+
const diff = git.getCommitDiff(String(req.params.hash));
|
|
157
|
+
if (!diff) {
|
|
158
|
+
res.status(404).json({ error: 'Commit not found' });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
res.json({ diff });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (specs) {
|
|
165
|
+
router.get('/specs', (_req: Request, res: Response) => {
|
|
166
|
+
const tree = specs.getTree();
|
|
167
|
+
res.json(tree);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
router.get('/specs/read', (req: Request, res: Response) => {
|
|
171
|
+
const specPath = qs(req.query.path);
|
|
172
|
+
if (!specPath) {
|
|
173
|
+
res.status(400).json({ error: 'path query parameter required' });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const content = specs.readSpec(specPath);
|
|
177
|
+
if (content === null) {
|
|
178
|
+
res.status(404).json({ error: 'Spec not found' });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
res.json({ path: specPath, content });
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (experiments) {
|
|
186
|
+
router.get('/experiments', (_req: Request, res: Response) => {
|
|
187
|
+
const entries = experiments.listExperiments();
|
|
188
|
+
res.json({ experiments: entries });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
router.get('/experiments/:name', (req: Request, res: Response) => {
|
|
192
|
+
const detail = experiments.getExperiment(String(req.params.name));
|
|
193
|
+
if (!detail) {
|
|
194
|
+
res.status(404).json({ error: 'Experiment not found' });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
res.json(detail);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
router.get('/experiments/:name/read', (req: Request, res: Response) => {
|
|
201
|
+
const filePath = qs(req.query.path);
|
|
202
|
+
const raw = qs(req.query.raw);
|
|
203
|
+
if (!filePath) {
|
|
204
|
+
res.status(400).json({ error: 'path query parameter required' });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (raw === 'true') {
|
|
208
|
+
const buf = experiments.readFileRaw(filePath);
|
|
209
|
+
if (!buf) {
|
|
210
|
+
res.status(404).json({ error: 'File not found' });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const ext = extname(filePath).toLowerCase();
|
|
214
|
+
const mime: Record<string, string> = {
|
|
215
|
+
'.png': 'image/png',
|
|
216
|
+
'.jpg': 'image/jpeg',
|
|
217
|
+
'.jpeg': 'image/jpeg',
|
|
218
|
+
'.gif': 'image/gif',
|
|
219
|
+
'.svg': 'image/svg+xml',
|
|
220
|
+
'.html': 'text/html; charset=utf-8',
|
|
221
|
+
};
|
|
222
|
+
res.type(mime[ext] ?? 'application/octet-stream').send(buf);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const content = experiments.readFile(filePath);
|
|
226
|
+
if (content === null) {
|
|
227
|
+
res.status(404).json({ error: 'File not found' });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
res.json({ path: filePath, content });
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return router;
|
|
235
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface CacheEntry<T> {
|
|
2
|
+
value: T;
|
|
3
|
+
expiresAt: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export class TTLCache<T> {
|
|
7
|
+
private store = new Map<string, CacheEntry<T>>();
|
|
8
|
+
private defaultTTL: number;
|
|
9
|
+
|
|
10
|
+
constructor(defaultTTLMs: number = 60_000) {
|
|
11
|
+
this.defaultTTL = defaultTTLMs;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get(key: string): T | undefined {
|
|
15
|
+
const entry = this.store.get(key);
|
|
16
|
+
if (!entry) return undefined;
|
|
17
|
+
if (Date.now() > entry.expiresAt) {
|
|
18
|
+
this.store.delete(key);
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
return entry.value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
set(key: string, value: T, ttlMs?: number): void {
|
|
25
|
+
this.store.set(key, {
|
|
26
|
+
value,
|
|
27
|
+
expiresAt: Date.now() + (ttlMs ?? this.defaultTTL),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
invalidate(key: string): void {
|
|
32
|
+
this.store.delete(key);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
invalidateAll(): void {
|
|
36
|
+
this.store.clear();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, relative } from 'node:path';
|
|
3
|
+
import type { ExperimentEntry, ExperimentDetail, ExperimentFile, ExperimentSubdir } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export class ExperimentsService {
|
|
6
|
+
private experimentsDir: string;
|
|
7
|
+
|
|
8
|
+
constructor(experimentsDir: string) {
|
|
9
|
+
this.experimentsDir = resolve(experimentsDir);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
listExperiments(): ExperimentEntry[] {
|
|
13
|
+
const indexPath = join(this.experimentsDir, 'README.md');
|
|
14
|
+
if (!existsSync(indexPath)) return [];
|
|
15
|
+
const content = readFileSync(indexPath, 'utf-8');
|
|
16
|
+
return this.parseTable(content);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
getExperiment(directory: string): ExperimentDetail | null {
|
|
20
|
+
const entries = this.listExperiments();
|
|
21
|
+
const entry = entries.find(e => e.directory === directory || e.directory.replace(/\/$/, '') === directory.replace(/\/$/, ''));
|
|
22
|
+
if (!entry) return null;
|
|
23
|
+
|
|
24
|
+
const dirPath = join(this.experimentsDir, entry.directory.replace(/\/$/, ''));
|
|
25
|
+
if (!existsSync(dirPath)) return null;
|
|
26
|
+
|
|
27
|
+
let readme: string | null = null;
|
|
28
|
+
const readmePath = join(dirPath, 'README.md');
|
|
29
|
+
if (existsSync(readmePath)) {
|
|
30
|
+
readme = readFileSync(readmePath, 'utf-8');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const subdirs: ExperimentSubdir[] = [];
|
|
34
|
+
const allFiles: ExperimentFile[] = [];
|
|
35
|
+
const expPrefix = entry.directory.replace(/\/$/, '');
|
|
36
|
+
|
|
37
|
+
const items = readdirSync(dirPath);
|
|
38
|
+
for (const item of items) {
|
|
39
|
+
const itemPath = join(dirPath, item);
|
|
40
|
+
const stat = statSync(itemPath);
|
|
41
|
+
if (stat.isDirectory()) {
|
|
42
|
+
const files = this.listFilesRecursive(itemPath, this.experimentsDir);
|
|
43
|
+
subdirs.push({ name: item, files });
|
|
44
|
+
allFiles.push(...files);
|
|
45
|
+
} else {
|
|
46
|
+
const file: ExperimentFile = {
|
|
47
|
+
path: relative(this.experimentsDir, itemPath),
|
|
48
|
+
name: item,
|
|
49
|
+
size: stat.size,
|
|
50
|
+
};
|
|
51
|
+
if (item !== 'README.md') {
|
|
52
|
+
allFiles.push(file);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { entry, readme, subdirs, allFiles };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
readFile(filePath: string): string | null {
|
|
61
|
+
const fullPath = join(this.experimentsDir, filePath);
|
|
62
|
+
if (!existsSync(fullPath)) return null;
|
|
63
|
+
return readFileSync(fullPath, 'utf-8');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
readFileRaw(filePath: string): Buffer | null {
|
|
67
|
+
const fullPath = join(this.experimentsDir, filePath);
|
|
68
|
+
if (!existsSync(fullPath)) return null;
|
|
69
|
+
return readFileSync(fullPath);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private listFilesRecursive(dirPath: string, rootDir: string): ExperimentFile[] {
|
|
73
|
+
const files: ExperimentFile[] = [];
|
|
74
|
+
const items = readdirSync(dirPath);
|
|
75
|
+
for (const item of items) {
|
|
76
|
+
const itemPath = join(dirPath, item);
|
|
77
|
+
const stat = statSync(itemPath);
|
|
78
|
+
if (stat.isDirectory()) {
|
|
79
|
+
files.push(...this.listFilesRecursive(itemPath, rootDir));
|
|
80
|
+
} else {
|
|
81
|
+
files.push({
|
|
82
|
+
path: relative(rootDir, itemPath),
|
|
83
|
+
name: item,
|
|
84
|
+
size: stat.size,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return files;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private parseTable(content: string): ExperimentEntry[] {
|
|
92
|
+
const entries: ExperimentEntry[] = [];
|
|
93
|
+
const lines = content.split('\n');
|
|
94
|
+
let inTable = false;
|
|
95
|
+
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
const trimmed = line.trim();
|
|
98
|
+
if (trimmed.startsWith('|') && trimmed.includes('---')) {
|
|
99
|
+
inTable = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!trimmed.startsWith('|') || !inTable) continue;
|
|
103
|
+
|
|
104
|
+
const cells = trimmed.split('|').slice(1, -1).map(c => c.trim());
|
|
105
|
+
if (cells.length < 5) continue;
|
|
106
|
+
|
|
107
|
+
const date = cells[0].trim();
|
|
108
|
+
const dirCell = cells[1].replace(/^`|`$/g, '').replace(/\/$/, '').trim();
|
|
109
|
+
const desc = cells[2].replace(/^`|`$/g, '').trim();
|
|
110
|
+
const outcome = cells[3].replace(/\*\*/g, '').trim();
|
|
111
|
+
const agent = cells[4].replace(/^`|`$/g, '').trim();
|
|
112
|
+
|
|
113
|
+
entries.push({ date, directory: dirCell, description: desc, outcome, agent });
|
|
114
|
+
}
|
|
115
|
+
return entries;
|
|
116
|
+
}
|
|
117
|
+
}
|