heyiam 0.1.4 → 0.1.6
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/app/dist/assets/html2canvas-Cwn_rrOw.js +5 -0
- package/app/dist/assets/index-CEQyTkgN.js +14 -0
- package/app/dist/assets/index-DLh5xRE8.css +1 -0
- package/app/dist/index.html +2 -2
- package/dist/analyzer.d.ts +9 -2
- package/dist/analyzer.js +10 -3
- package/dist/analyzer.js.map +1 -1
- package/dist/bridge.d.ts +26 -6
- package/dist/bridge.js +86 -19
- package/dist/bridge.js.map +1 -1
- package/dist/index.js +105 -1
- package/dist/index.js.map +1 -1
- package/dist/parsers/claude.js +0 -5
- package/dist/parsers/claude.js.map +1 -1
- package/dist/parsers/codex.js +1 -1
- package/dist/parsers/codex.js.map +1 -1
- package/dist/parsers/index.js +29 -16
- package/dist/parsers/index.js.map +1 -1
- package/dist/redact.d.ts +31 -0
- package/dist/redact.js +373 -0
- package/dist/redact.js.map +1 -0
- package/dist/screenshot.d.ts +10 -0
- package/dist/screenshot.js +80 -0
- package/dist/screenshot.js.map +1 -0
- package/dist/server.js +439 -82
- package/dist/server.js.map +1 -1
- package/package.json +4 -1
- package/app/dist/assets/index-Cv-3KLuW.js +0 -14
- package/app/dist/assets/index-DopBsBwR.css +0 -1
package/dist/server.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
import { listSessions, parseSession } from './parsers/index.js';
|
|
7
|
-
import { bridgeToAnalyzer, bridgeChildSessions, aggregateChildStats } from './bridge.js';
|
|
7
|
+
import { bridgeToAnalyzer, bridgeChildSessions, aggregateChildStats, toAgentChild } from './bridge.js';
|
|
8
8
|
import { analyzeSession } from './analyzer.js';
|
|
9
9
|
import { checkAuthStatus, getAuthToken, saveAuthToken } from './auth.js';
|
|
10
10
|
import { API_URL } from './config.js';
|
|
@@ -12,6 +12,8 @@ import { getProvider, getEnhanceMode } from './llm/index.js';
|
|
|
12
12
|
import { triageSessions } from './llm/triage.js';
|
|
13
13
|
import { enhanceProject, refineNarrative } from './llm/project-enhance.js';
|
|
14
14
|
import { saveAnthropicApiKey, clearAnthropicApiKey, getAnthropicApiKey, saveEnhancedData, loadEnhancedData, deleteEnhancedData, loadFreshProjectEnhanceResult, saveProjectEnhanceResult, loadProjectEnhanceResult, buildProjectFingerprint, savePublishedState, getPublishedState } from './settings.js';
|
|
15
|
+
import { captureScreenshot } from './screenshot.js';
|
|
16
|
+
import { redactSession, redactText, scanTextSync, formatFindings, stripHomePathsInText } from './redact.js';
|
|
15
17
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
18
|
// Derive a human-readable project name from the encoded directory name.
|
|
17
19
|
// "-Users-ben-Dev-heyi-am" → "heyi-am"
|
|
@@ -73,12 +75,12 @@ function mergeEnhancedData(session) {
|
|
|
73
75
|
}
|
|
74
76
|
// ── Persistent stats cache ────────────────────────────────────
|
|
75
77
|
// Survives server restarts by writing to disk.
|
|
76
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
78
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
|
|
77
79
|
import { join } from 'node:path';
|
|
78
80
|
import { homedir } from 'node:os';
|
|
79
81
|
const STATS_CACHE_PATH = join(homedir(), '.config', 'heyiam', 'stats-cache.json');
|
|
80
82
|
// Bump this when parser logic changes to auto-invalidate stale cache entries.
|
|
81
|
-
const STATS_CACHE_VERSION =
|
|
83
|
+
const STATS_CACHE_VERSION = 7;
|
|
82
84
|
function loadStatsCache() {
|
|
83
85
|
try {
|
|
84
86
|
if (!existsSync(STATS_CACHE_PATH))
|
|
@@ -132,6 +134,14 @@ export function createApp(sessionsBasePath) {
|
|
|
132
134
|
return cached;
|
|
133
135
|
try {
|
|
134
136
|
const session = await loadSession(meta.path, projectName, meta.sessionId);
|
|
137
|
+
// Compute end time: prefer endTime, fall back to date + wallClock or duration
|
|
138
|
+
let endTime = session.endTime;
|
|
139
|
+
if (!endTime && session.date) {
|
|
140
|
+
const mins = session.wallClockMinutes ?? session.durationMinutes ?? 0;
|
|
141
|
+
if (mins > 0) {
|
|
142
|
+
endTime = new Date(new Date(session.date).getTime() + mins * 60_000).toISOString();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
135
145
|
const stats = {
|
|
136
146
|
loc: session.linesOfCode ?? 0,
|
|
137
147
|
duration: session.durationMinutes ?? 0,
|
|
@@ -139,6 +149,7 @@ export function createApp(sessionsBasePath) {
|
|
|
139
149
|
turns: session.turns ?? 0,
|
|
140
150
|
skills: session.skills ?? [],
|
|
141
151
|
date: session.date ?? '',
|
|
152
|
+
endTime,
|
|
142
153
|
};
|
|
143
154
|
statsCache.set(meta.sessionId, stats);
|
|
144
155
|
statsCacheDirty = true;
|
|
@@ -148,11 +159,68 @@ export function createApp(sessionsBasePath) {
|
|
|
148
159
|
return { loc: 0, duration: 0, files: 0, turns: 0, skills: [], date: '' };
|
|
149
160
|
}
|
|
150
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Merge overlapping session time intervals to compute real wall-clock developer time.
|
|
164
|
+
* If two sessions overlap (running concurrently), that time is counted once.
|
|
165
|
+
* Falls back to simple sum if timestamps are missing.
|
|
166
|
+
*/
|
|
167
|
+
function mergeSessionIntervals(stats) {
|
|
168
|
+
// Build [start, end] intervals from sessions that have timestamps
|
|
169
|
+
const intervals = [];
|
|
170
|
+
let fallbackSum = 0;
|
|
171
|
+
for (const st of stats) {
|
|
172
|
+
if (st.date && st.endTime) {
|
|
173
|
+
const start = new Date(st.date).getTime();
|
|
174
|
+
const end = new Date(st.endTime).getTime();
|
|
175
|
+
if (!isNaN(start) && !isNaN(end) && end > start) {
|
|
176
|
+
intervals.push([start, end]);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// No valid interval — add duration to fallback sum
|
|
181
|
+
fallbackSum += st.duration;
|
|
182
|
+
}
|
|
183
|
+
if (intervals.length === 0)
|
|
184
|
+
return fallbackSum;
|
|
185
|
+
// Sort by start time
|
|
186
|
+
intervals.sort((a, b) => a[0] - b[0]);
|
|
187
|
+
// Merge overlapping intervals
|
|
188
|
+
let totalMs = 0;
|
|
189
|
+
let [curStart, curEnd] = intervals[0];
|
|
190
|
+
for (let i = 1; i < intervals.length; i++) {
|
|
191
|
+
const [start, end] = intervals[i];
|
|
192
|
+
if (start <= curEnd) {
|
|
193
|
+
// Overlapping — extend current interval
|
|
194
|
+
curEnd = Math.max(curEnd, end);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
// Gap — flush current interval
|
|
198
|
+
totalMs += curEnd - curStart;
|
|
199
|
+
curStart = start;
|
|
200
|
+
curEnd = end;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
totalMs += curEnd - curStart;
|
|
204
|
+
return Math.round(totalMs / 60_000) + fallbackSum;
|
|
205
|
+
}
|
|
151
206
|
async function getProjectWithStats(proj) {
|
|
152
207
|
const allStats = await Promise.all(proj.sessions.map((m) => getSessionStats(m, proj.name)));
|
|
153
208
|
const totalLoc = allStats.reduce((s, st) => s + st.loc, 0);
|
|
154
|
-
const totalDuration = allStats.reduce((s, st) => s + st.duration, 0);
|
|
155
209
|
const totalFiles = allStats.reduce((s, st) => s + st.files, 0);
|
|
210
|
+
// Developer active time: sum of durationMinutes (already excludes idle gaps >5min).
|
|
211
|
+
// We don't merge overlapping intervals here because durationMinutes is active time,
|
|
212
|
+
// not wall-clock — if you're actively working two sessions concurrently, both count.
|
|
213
|
+
const totalDuration = allStats.reduce((s, st) => s + st.duration, 0);
|
|
214
|
+
// Agent time = every session's duration (the AI was working the whole time)
|
|
215
|
+
// + child/subagent durations on top (additional parallel agent work).
|
|
216
|
+
// Use raw sum, not merged intervals — each agent's work is real work.
|
|
217
|
+
let totalAgentDuration = allStats.reduce((s, st) => s + st.duration, 0);
|
|
218
|
+
for (const meta of proj.sessions) {
|
|
219
|
+
for (const child of meta.children ?? []) {
|
|
220
|
+
const childStats = await getSessionStats(child, proj.name);
|
|
221
|
+
totalAgentDuration += childStats.duration;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
156
224
|
// Deduplicated skills across all sessions
|
|
157
225
|
const skillSet = new Set();
|
|
158
226
|
for (const st of allStats) {
|
|
@@ -180,6 +248,7 @@ export function createApp(sessionsBasePath) {
|
|
|
180
248
|
publishedSessionCount: published?.publishedSessions.length ?? 0,
|
|
181
249
|
publishedSessions: published?.publishedSessions ?? [],
|
|
182
250
|
enhancedAt: enhanceCache?.enhancedAt ?? null,
|
|
251
|
+
totalAgentDuration,
|
|
183
252
|
};
|
|
184
253
|
}
|
|
185
254
|
app.use(cors({ origin: ['http://localhost:17845', 'http://127.0.0.1:17845'] }));
|
|
@@ -195,6 +264,93 @@ export function createApp(sessionsBasePath) {
|
|
|
195
264
|
res.status(500).json({ error: { code: 'SCAN_FAILED', message: err.message } });
|
|
196
265
|
}
|
|
197
266
|
});
|
|
267
|
+
// ── Time stats — rich per-project agent breakdown ──────────
|
|
268
|
+
app.get('/api/time-stats', async (_req, res) => {
|
|
269
|
+
try {
|
|
270
|
+
const projects = await getProjects(sessionsBasePath);
|
|
271
|
+
const projectStats = await Promise.all(projects.map(async (proj) => {
|
|
272
|
+
const parents = proj.sessions.filter(s => !s.isSubagent);
|
|
273
|
+
let yourMinutes = 0;
|
|
274
|
+
let agentMinutes = 0;
|
|
275
|
+
let orchestratedCount = 0;
|
|
276
|
+
let maxParallelAgents = 0;
|
|
277
|
+
let totalChildAgents = 0;
|
|
278
|
+
const roleSet = new Set();
|
|
279
|
+
for (const meta of parents) {
|
|
280
|
+
const stats = await getSessionStats(meta, proj.name);
|
|
281
|
+
const dur = stats.duration;
|
|
282
|
+
yourMinutes += dur;
|
|
283
|
+
agentMinutes += dur; // primary agent present every session
|
|
284
|
+
const children = meta.children ?? [];
|
|
285
|
+
if (children.length > 0) {
|
|
286
|
+
orchestratedCount++;
|
|
287
|
+
maxParallelAgents = Math.max(maxParallelAgents, children.length);
|
|
288
|
+
totalChildAgents += children.length;
|
|
289
|
+
}
|
|
290
|
+
for (const child of children) {
|
|
291
|
+
const childStats = await getSessionStats(child, proj.name);
|
|
292
|
+
agentMinutes += childStats.duration;
|
|
293
|
+
if (child.agentRole)
|
|
294
|
+
roleSet.add(child.agentRole);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (yourMinutes === 0)
|
|
298
|
+
return null;
|
|
299
|
+
return {
|
|
300
|
+
name: proj.name,
|
|
301
|
+
dirName: proj.dirName,
|
|
302
|
+
sessions: parents.length,
|
|
303
|
+
yourMinutes,
|
|
304
|
+
agentMinutes,
|
|
305
|
+
orchestratedSessions: orchestratedCount,
|
|
306
|
+
maxParallelAgents,
|
|
307
|
+
avgAgentsPerSession: parents.length > 0
|
|
308
|
+
? +((totalChildAgents / parents.length) + 1).toFixed(1) // +1 for primary agent
|
|
309
|
+
: 1,
|
|
310
|
+
uniqueRoles: [...roleSet],
|
|
311
|
+
};
|
|
312
|
+
}));
|
|
313
|
+
const results = projectStats.filter(Boolean);
|
|
314
|
+
results.sort((a, b) => b.agentMinutes - a.agentMinutes);
|
|
315
|
+
const totalYou = results.reduce((s, p) => s + p.yourMinutes, 0);
|
|
316
|
+
const totalAgent = results.reduce((s, p) => s + p.agentMinutes, 0);
|
|
317
|
+
const totalSessions = results.reduce((s, p) => s + p.sessions, 0);
|
|
318
|
+
res.json({
|
|
319
|
+
projects: results,
|
|
320
|
+
totals: {
|
|
321
|
+
yourMinutes: totalYou,
|
|
322
|
+
agentMinutes: totalAgent,
|
|
323
|
+
sessions: totalSessions,
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
res.status(500).json({ error: { code: 'STATS_FAILED', message: err.message } });
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
// Proxy publish time stats to Phoenix
|
|
332
|
+
app.post('/api/publish-time-stats', async (req, res) => {
|
|
333
|
+
const auth = getAuthToken();
|
|
334
|
+
if (!auth) {
|
|
335
|
+
res.status(401).json({ error: 'Authentication required. Run heyiam login first.' });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
const phoenixRes = await fetch(`${API_URL}/api/time-stats`, {
|
|
340
|
+
method: 'POST',
|
|
341
|
+
headers: {
|
|
342
|
+
'Content-Type': 'application/json',
|
|
343
|
+
Authorization: `Bearer ${auth.token}`,
|
|
344
|
+
},
|
|
345
|
+
body: JSON.stringify(req.body),
|
|
346
|
+
});
|
|
347
|
+
const result = await phoenixRes.json();
|
|
348
|
+
res.status(phoenixRes.status).json(result);
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
res.status(500).json({ error: err.message });
|
|
352
|
+
}
|
|
353
|
+
});
|
|
198
354
|
app.get('/api/projects/:project/sessions', async (req, res) => {
|
|
199
355
|
try {
|
|
200
356
|
const { project } = req.params;
|
|
@@ -206,34 +362,59 @@ export function createApp(sessionsBasePath) {
|
|
|
206
362
|
}
|
|
207
363
|
// Return parent sessions with enriched child summaries.
|
|
208
364
|
// Children get stats via the cached getSessionStats — no redundant parsing.
|
|
365
|
+
// If full parsing fails, fall back to a minimal session from stats.
|
|
209
366
|
const sessions = await Promise.all(proj.sessions.map(async (meta) => {
|
|
367
|
+
// Build child summaries (used by both full and fallback paths)
|
|
368
|
+
// Deduplicate by sessionId (true duplicates), not by role
|
|
369
|
+
const seenIds = new Set();
|
|
370
|
+
const children = [];
|
|
371
|
+
for (const c of meta.children ?? []) {
|
|
372
|
+
if (seenIds.has(c.sessionId))
|
|
373
|
+
continue;
|
|
374
|
+
seenIds.add(c.sessionId);
|
|
375
|
+
const childStats = await getSessionStats(c, proj.name);
|
|
376
|
+
children.push({
|
|
377
|
+
sessionId: c.sessionId,
|
|
378
|
+
role: c.agentRole ?? 'agent',
|
|
379
|
+
durationMinutes: childStats.duration,
|
|
380
|
+
linesOfCode: childStats.loc,
|
|
381
|
+
date: childStats.date,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
const childCount = children.length;
|
|
210
385
|
try {
|
|
211
386
|
const session = await loadSession(meta.path, proj.name, meta.sessionId);
|
|
212
|
-
// Deduplicate children by role (worktree agents create duplicates)
|
|
213
|
-
const seenRoles = new Set();
|
|
214
|
-
const children = [];
|
|
215
|
-
for (const c of meta.children ?? []) {
|
|
216
|
-
const role = c.agentRole ?? c.sessionId;
|
|
217
|
-
if (seenRoles.has(role))
|
|
218
|
-
continue;
|
|
219
|
-
seenRoles.add(role);
|
|
220
|
-
const childStats = await getSessionStats(c, proj.name);
|
|
221
|
-
children.push({
|
|
222
|
-
sessionId: c.sessionId,
|
|
223
|
-
role: c.agentRole,
|
|
224
|
-
durationMinutes: childStats.duration,
|
|
225
|
-
linesOfCode: childStats.loc,
|
|
226
|
-
date: childStats.date,
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
const childCount = children.length;
|
|
230
387
|
return { ...session, childCount, children: childCount > 0 ? children : undefined };
|
|
231
388
|
}
|
|
232
389
|
catch {
|
|
233
|
-
|
|
390
|
+
// Full parse failed — build minimal session from stats so it still appears.
|
|
391
|
+
// Use file mtime as fallback date so the session isn't filtered out.
|
|
392
|
+
const stats = await getSessionStats(meta, proj.name);
|
|
393
|
+
let fallbackDate = stats.date || '';
|
|
394
|
+
if (!fallbackDate) {
|
|
395
|
+
try {
|
|
396
|
+
fallbackDate = statSync(meta.path).mtime.toISOString();
|
|
397
|
+
}
|
|
398
|
+
catch { /* file gone — will be filtered */ }
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
id: meta.sessionId,
|
|
402
|
+
title: 'Untitled session',
|
|
403
|
+
date: fallbackDate,
|
|
404
|
+
durationMinutes: stats.duration,
|
|
405
|
+
turns: stats.turns,
|
|
406
|
+
linesOfCode: stats.loc,
|
|
407
|
+
status: 'draft',
|
|
408
|
+
projectName: proj.name,
|
|
409
|
+
rawLog: [],
|
|
410
|
+
skills: stats.skills,
|
|
411
|
+
source: meta.source,
|
|
412
|
+
childCount,
|
|
413
|
+
children: childCount > 0 ? children : undefined,
|
|
414
|
+
};
|
|
234
415
|
}
|
|
235
416
|
}));
|
|
236
|
-
res.json({ sessions: sessions.filter(
|
|
417
|
+
res.json({ sessions: sessions.filter((s) => s.date) });
|
|
237
418
|
}
|
|
238
419
|
catch (err) {
|
|
239
420
|
res.status(500).json({ error: { code: 'LIST_FAILED', message: err.message } });
|
|
@@ -254,13 +435,14 @@ export function createApp(sessionsBasePath) {
|
|
|
254
435
|
return;
|
|
255
436
|
}
|
|
256
437
|
const session = await loadSession(meta.path, proj.name, meta.sessionId);
|
|
257
|
-
// Fully parse and
|
|
258
|
-
const
|
|
259
|
-
const
|
|
438
|
+
// Fully parse child sessions and map to canonical AgentChild shape
|
|
439
|
+
const parsedChildren = await bridgeChildSessions(meta, proj.name);
|
|
440
|
+
const children = parsedChildren.map(toAgentChild);
|
|
441
|
+
const aggregated = children.length > 0 ? aggregateChildStats(parsedChildren) : undefined;
|
|
260
442
|
res.json({
|
|
261
443
|
session: {
|
|
262
444
|
...session,
|
|
263
|
-
...(
|
|
445
|
+
...(children.length > 0 ? { children, isOrchestrated: true } : {}),
|
|
264
446
|
...(aggregated ? { aggregatedStats: aggregated } : {}),
|
|
265
447
|
},
|
|
266
448
|
});
|
|
@@ -336,28 +518,36 @@ export function createApp(sessionsBasePath) {
|
|
|
336
518
|
res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
|
|
337
519
|
return;
|
|
338
520
|
}
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
|
|
521
|
+
// C2: The dirName encoding is lossy (both / and . become -), so we
|
|
522
|
+
// can't decode it back to the original path. Instead, find the real
|
|
523
|
+
// path by checking session cwd fields from parsed sessions.
|
|
524
|
+
let projectPath = null;
|
|
525
|
+
for (const meta of proj.sessions) {
|
|
526
|
+
try {
|
|
527
|
+
const parsed = await loadSession(meta.path, proj.name, meta.sessionId);
|
|
528
|
+
if (parsed.cwd) {
|
|
529
|
+
projectPath = parsed.cwd;
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch { /* skip unparseable sessions */ }
|
|
534
|
+
}
|
|
343
535
|
let remoteUrl = null;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
catch {
|
|
360
|
-
// No git remote or not a git repo — return null
|
|
536
|
+
if (projectPath) {
|
|
537
|
+
try {
|
|
538
|
+
const raw = execFileSync('git', ['-C', projectPath, 'remote', 'get-url', 'origin'], {
|
|
539
|
+
timeout: 5000,
|
|
540
|
+
encoding: 'utf-8',
|
|
541
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
542
|
+
}).trim();
|
|
543
|
+
remoteUrl = raw
|
|
544
|
+
.replace(/\.git$/, '')
|
|
545
|
+
.replace(/^git@([^:]+):/, '$1/')
|
|
546
|
+
.replace(/^https?:\/\//, '');
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
// No git remote or not a git repo — return null
|
|
550
|
+
}
|
|
361
551
|
}
|
|
362
552
|
res.json({ url: remoteUrl });
|
|
363
553
|
}
|
|
@@ -680,6 +870,105 @@ export function createApp(sessionsBasePath) {
|
|
|
680
870
|
res.status(500).json({ error: { code: 'CACHE_READ_FAILED', message: err.message } });
|
|
681
871
|
}
|
|
682
872
|
});
|
|
873
|
+
// Upload screenshot manually (base64 image from browser)
|
|
874
|
+
app.post('/api/projects/:project/screenshot-upload', async (req, res) => {
|
|
875
|
+
const { project } = req.params;
|
|
876
|
+
const auth = getAuthToken();
|
|
877
|
+
if (!auth) {
|
|
878
|
+
res.status(401).json({ error: 'Auth required' });
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const { image, slug } = req.body;
|
|
882
|
+
if (!image) {
|
|
883
|
+
res.status(400).json({ error: 'No image data' });
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const projectSlug = slug || String(project);
|
|
887
|
+
try {
|
|
888
|
+
// image is "data:image/png;base64,..." or raw base64
|
|
889
|
+
const base64 = image.includes(',') ? image.split(',')[1] : image;
|
|
890
|
+
const buffer = Buffer.from(base64, 'base64');
|
|
891
|
+
const ext = image.startsWith('data:image/jpeg') || image.startsWith('data:image/jpg') ? 'jpg' : 'png';
|
|
892
|
+
// Get presigned PUT URL
|
|
893
|
+
const ssUrlRes = await fetch(`${API_URL}/api/projects/${projectSlug}/screenshot-url`, {
|
|
894
|
+
method: 'POST',
|
|
895
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
|
|
896
|
+
body: JSON.stringify({ ext }),
|
|
897
|
+
});
|
|
898
|
+
if (!ssUrlRes.ok) {
|
|
899
|
+
res.status(502).json({ error: 'Presign failed' });
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const { upload_url, key } = await ssUrlRes.json();
|
|
903
|
+
// Upload to S3
|
|
904
|
+
await fetch(upload_url, {
|
|
905
|
+
method: 'PUT',
|
|
906
|
+
body: buffer,
|
|
907
|
+
headers: { 'Content-Type': `image/${ext}` },
|
|
908
|
+
});
|
|
909
|
+
// Update screenshot key in DB
|
|
910
|
+
await fetch(`${API_URL}/api/projects/${projectSlug}/screenshot-key`, {
|
|
911
|
+
method: 'PATCH',
|
|
912
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
|
|
913
|
+
body: JSON.stringify({ key }),
|
|
914
|
+
});
|
|
915
|
+
res.json({ ok: true, key });
|
|
916
|
+
}
|
|
917
|
+
catch (err) {
|
|
918
|
+
res.status(500).json({ error: err.message });
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
// Auto-capture screenshot from URL using headless Chrome
|
|
922
|
+
app.post('/api/projects/:project/screenshot-capture', async (req, res) => {
|
|
923
|
+
const { project } = req.params;
|
|
924
|
+
const auth = getAuthToken();
|
|
925
|
+
if (!auth) {
|
|
926
|
+
res.status(401).json({ error: 'Auth required' });
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const { url, slug } = req.body;
|
|
930
|
+
if (!url) {
|
|
931
|
+
res.status(400).json({ error: 'No URL provided' });
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const projectSlug = slug || String(project);
|
|
935
|
+
try {
|
|
936
|
+
const screenshotPath = await captureScreenshot(url, projectSlug);
|
|
937
|
+
if (!screenshotPath) {
|
|
938
|
+
res.status(422).json({ error: 'Chrome not available or capture failed' });
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
// Get presigned PUT URL
|
|
942
|
+
const ssUrlRes = await fetch(`${API_URL}/api/projects/${projectSlug}/screenshot-url`, {
|
|
943
|
+
method: 'POST',
|
|
944
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
|
|
945
|
+
body: JSON.stringify({ ext: 'png' }),
|
|
946
|
+
});
|
|
947
|
+
if (!ssUrlRes.ok) {
|
|
948
|
+
res.status(502).json({ error: 'Presign failed' });
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const { upload_url, key } = await ssUrlRes.json();
|
|
952
|
+
const imageData = readFileSync(screenshotPath);
|
|
953
|
+
await fetch(upload_url, {
|
|
954
|
+
method: 'PUT',
|
|
955
|
+
body: imageData,
|
|
956
|
+
headers: { 'Content-Type': 'image/png' },
|
|
957
|
+
});
|
|
958
|
+
// Update screenshot key
|
|
959
|
+
await fetch(`${API_URL}/api/projects/${projectSlug}/screenshot-key`, {
|
|
960
|
+
method: 'PATCH',
|
|
961
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
|
|
962
|
+
body: JSON.stringify({ key }),
|
|
963
|
+
});
|
|
964
|
+
// Return the screenshot as base64 for preview
|
|
965
|
+
const base64 = imageData.toString('base64');
|
|
966
|
+
res.json({ ok: true, key, preview: `data:image/png;base64,${base64}` });
|
|
967
|
+
}
|
|
968
|
+
catch (err) {
|
|
969
|
+
res.status(500).json({ error: err.message });
|
|
970
|
+
}
|
|
971
|
+
});
|
|
683
972
|
// Publish project — SSE stream with per-session progress
|
|
684
973
|
app.post('/api/projects/:project/publish', async (req, res) => {
|
|
685
974
|
const { project } = req.params;
|
|
@@ -688,7 +977,7 @@ export function createApp(sessionsBasePath) {
|
|
|
688
977
|
res.status(401).json({ error: { message: 'Authentication required' } });
|
|
689
978
|
return;
|
|
690
979
|
}
|
|
691
|
-
const { title, slug, narrative, repoUrl, projectUrl, timeline, skills, totalSessions, totalLoc, totalDurationMinutes, totalFilesChanged, skippedSessions, selectedSessionIds, } = req.body;
|
|
980
|
+
const { title, slug, narrative, repoUrl, projectUrl, timeline, skills, totalSessions, totalLoc, totalDurationMinutes, totalAgentDurationMinutes, totalFilesChanged, skippedSessions, selectedSessionIds, } = req.body;
|
|
692
981
|
// Set up SSE
|
|
693
982
|
res.writeHead(200, {
|
|
694
983
|
'Content-Type': 'text/event-stream',
|
|
@@ -716,6 +1005,7 @@ export function createApp(sessionsBasePath) {
|
|
|
716
1005
|
total_sessions: totalSessions,
|
|
717
1006
|
total_loc: totalLoc,
|
|
718
1007
|
total_duration_minutes: totalDurationMinutes,
|
|
1008
|
+
total_agent_duration_minutes: totalAgentDurationMinutes || null,
|
|
719
1009
|
total_files_changed: totalFilesChanged,
|
|
720
1010
|
skipped_sessions: skippedSessions,
|
|
721
1011
|
},
|
|
@@ -733,6 +1023,52 @@ export function createApp(sessionsBasePath) {
|
|
|
733
1023
|
}
|
|
734
1024
|
const projectData = await projectRes.json();
|
|
735
1025
|
send({ type: 'project', status: 'created', projectId: projectData.project_id, slug: projectData.slug });
|
|
1026
|
+
// Step 1b: Auto-capture and upload screenshot from project URL (non-fatal)
|
|
1027
|
+
if (projectUrl) {
|
|
1028
|
+
try {
|
|
1029
|
+
send({ type: 'screenshot', status: 'capturing' });
|
|
1030
|
+
const screenshotPath = await captureScreenshot(projectUrl, projectData.slug);
|
|
1031
|
+
if (screenshotPath) {
|
|
1032
|
+
// Get presigned PUT URL from Phoenix
|
|
1033
|
+
const ssUrlRes = await fetch(`${API_URL}/api/projects/${projectData.slug}/screenshot-url`, {
|
|
1034
|
+
method: 'POST',
|
|
1035
|
+
headers: {
|
|
1036
|
+
'Content-Type': 'application/json',
|
|
1037
|
+
Authorization: `Bearer ${auth.token}`,
|
|
1038
|
+
},
|
|
1039
|
+
body: JSON.stringify({ ext: 'png' }),
|
|
1040
|
+
});
|
|
1041
|
+
if (ssUrlRes.ok) {
|
|
1042
|
+
const { upload_url, key } = await ssUrlRes.json();
|
|
1043
|
+
const imageData = readFileSync(screenshotPath);
|
|
1044
|
+
await fetch(upload_url, {
|
|
1045
|
+
method: 'PUT',
|
|
1046
|
+
body: imageData,
|
|
1047
|
+
headers: { 'Content-Type': 'image/png' },
|
|
1048
|
+
});
|
|
1049
|
+
// Update the project's screenshot_key
|
|
1050
|
+
await fetch(`${API_URL}/api/projects/${projectData.slug}/screenshot-key`, {
|
|
1051
|
+
method: 'PATCH',
|
|
1052
|
+
headers: {
|
|
1053
|
+
'Content-Type': 'application/json',
|
|
1054
|
+
Authorization: `Bearer ${auth.token}`,
|
|
1055
|
+
},
|
|
1056
|
+
body: JSON.stringify({ key }),
|
|
1057
|
+
});
|
|
1058
|
+
send({ type: 'screenshot', status: 'uploaded' });
|
|
1059
|
+
}
|
|
1060
|
+
else {
|
|
1061
|
+
send({ type: 'screenshot', status: 'skipped', reason: 'presign failed' });
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
else {
|
|
1065
|
+
send({ type: 'screenshot', status: 'skipped', reason: 'Chrome not available' });
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
catch {
|
|
1069
|
+
send({ type: 'screenshot', status: 'skipped', reason: 'capture failed' });
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
736
1072
|
// Step 2: Publish selected sessions (non-fatal per session)
|
|
737
1073
|
const projects = await getProjects(sessionsBasePath);
|
|
738
1074
|
const proj = projects.find((p) => p.dirName === project);
|
|
@@ -770,11 +1106,15 @@ export function createApp(sessionsBasePath) {
|
|
|
770
1106
|
}
|
|
771
1107
|
return agents.length > 0 ? { is_orchestrated: true, agents } : null;
|
|
772
1108
|
})();
|
|
1109
|
+
// M3: narrative and dev_take are distinct values
|
|
1110
|
+
// M4: truncate dev_take to Phoenix's 2000-char limit
|
|
1111
|
+
const devTake = (enhanced?.developerTake ?? session.developerTake ?? '').slice(0, 2000);
|
|
1112
|
+
const narrative = enhanced?.narrative ?? '';
|
|
773
1113
|
// POST body: scalar/aggregate fields only
|
|
774
1114
|
const sessionPayload = {
|
|
775
1115
|
session: {
|
|
776
1116
|
title: enhanced?.title ?? session.title,
|
|
777
|
-
dev_take:
|
|
1117
|
+
dev_take: devTake,
|
|
778
1118
|
context: enhanced?.context ?? '',
|
|
779
1119
|
duration_minutes: session.durationMinutes ?? 0,
|
|
780
1120
|
turns: session.turns ?? 0,
|
|
@@ -788,31 +1128,30 @@ export function createApp(sessionsBasePath) {
|
|
|
788
1128
|
language: null,
|
|
789
1129
|
tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
|
|
790
1130
|
skills: enhanced?.skills ?? session.skills ?? [],
|
|
791
|
-
narrative
|
|
1131
|
+
narrative,
|
|
792
1132
|
project_name: proj.name,
|
|
793
1133
|
project_id: projectData.project_id,
|
|
794
1134
|
slug: sessionSlug,
|
|
795
1135
|
status: 'listed',
|
|
796
1136
|
source_tool: session.source ?? meta.source ?? 'claude',
|
|
1137
|
+
agent_summary: agentSummary,
|
|
797
1138
|
},
|
|
798
1139
|
};
|
|
799
1140
|
// session.json: full data including visualization fields for S3
|
|
800
|
-
//
|
|
801
|
-
//
|
|
1141
|
+
// M1: Use consistent snake_case keys so Phoenix doesn't need dual-variant normalization
|
|
1142
|
+
// M5: Use sessionId (CLI's local UUID), not the slug
|
|
802
1143
|
const sessionData = {
|
|
803
1144
|
version: 1,
|
|
804
|
-
id:
|
|
1145
|
+
id: sessionId,
|
|
805
1146
|
title: enhanced?.title ?? session.title,
|
|
806
|
-
|
|
1147
|
+
dev_take: devTake,
|
|
807
1148
|
context: enhanced?.context ?? '',
|
|
808
|
-
|
|
1149
|
+
duration_minutes: session.durationMinutes ?? 0,
|
|
809
1150
|
turns: session.turns ?? 0,
|
|
810
|
-
|
|
811
|
-
|
|
1151
|
+
files_changed: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
|
|
1152
|
+
loc_changed: session.linesOfCode ?? 0,
|
|
812
1153
|
date: session.date ? new Date(session.date).toISOString() : new Date().toISOString(),
|
|
813
|
-
|
|
814
|
-
// Overnight sessions span 20+ hours which breaks timeline clustering
|
|
815
|
-
endTime: (() => {
|
|
1154
|
+
end_time: (() => {
|
|
816
1155
|
if (!session.endTime || !session.date)
|
|
817
1156
|
return null;
|
|
818
1157
|
const wallMs = new Date(session.endTime).getTime() - new Date(session.date).getTime();
|
|
@@ -820,37 +1159,38 @@ export function createApp(sessionsBasePath) {
|
|
|
820
1159
|
return wallMs <= activeMs * 3 ? new Date(session.endTime).toISOString() : null;
|
|
821
1160
|
})(),
|
|
822
1161
|
cwd: session.cwd ?? null,
|
|
823
|
-
|
|
1162
|
+
wall_clock_minutes: session.wallClockMinutes ?? null,
|
|
824
1163
|
template: 'editorial',
|
|
825
1164
|
skills: enhanced?.skills ?? session.skills ?? [],
|
|
826
1165
|
tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
|
|
827
1166
|
source: session.source ?? meta.source ?? 'claude',
|
|
828
1167
|
slug: sessionSlug,
|
|
829
|
-
|
|
830
|
-
narrative
|
|
1168
|
+
project_name: proj.name,
|
|
1169
|
+
narrative,
|
|
831
1170
|
status: 'listed',
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
description:
|
|
1171
|
+
raw_log: [],
|
|
1172
|
+
// M2: normalize execution_path steps to {label, description}
|
|
1173
|
+
execution_path: (enhanced?.executionSteps ?? session.executionPath ?? []).map((s, i) => ({
|
|
1174
|
+
label: s.title ?? `Step ${i + 1}`,
|
|
1175
|
+
description: s.description ?? s.body ?? '',
|
|
837
1176
|
})),
|
|
838
|
-
|
|
1177
|
+
qa_pairs: enhanced?.qaPairs ?? session.qaPairs ?? [],
|
|
839
1178
|
highlights: [],
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1179
|
+
tool_breakdown: (session.toolBreakdown ?? []).map((t) => ({ tool: t.tool, count: t.count })),
|
|
1180
|
+
top_files: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
|
|
1181
|
+
turn_timeline: (session.turnTimeline ?? []).map((t) => ({
|
|
843
1182
|
timestamp: t.timestamp,
|
|
844
1183
|
type: t.type,
|
|
845
1184
|
content: (t.content ?? '').slice(0, 200),
|
|
846
1185
|
tools: t.tools ?? [],
|
|
847
1186
|
})),
|
|
848
|
-
|
|
1187
|
+
// M6: Keep tool prefixes in transcript — they're informative
|
|
1188
|
+
transcript_excerpt: (session.rawLog ?? []).slice(0, 10).map((line, i) => {
|
|
849
1189
|
const role = line.startsWith('> ') ? 'dev' : 'ai';
|
|
850
|
-
const text = role === 'dev' ? line.slice(2) : line
|
|
1190
|
+
const text = role === 'dev' ? line.slice(2) : line;
|
|
851
1191
|
return { role, id: `Turn ${i + 1}`, text, timestamp: null };
|
|
852
1192
|
}),
|
|
853
|
-
|
|
1193
|
+
agent_summary: agentSummary,
|
|
854
1194
|
children: agentSummary?.agents?.map((a) => ({
|
|
855
1195
|
sessionId: a.role,
|
|
856
1196
|
role: a.role,
|
|
@@ -858,13 +1198,23 @@ export function createApp(sessionsBasePath) {
|
|
|
858
1198
|
linesOfCode: a.loc_changed,
|
|
859
1199
|
})) ?? [],
|
|
860
1200
|
};
|
|
1201
|
+
// Redact secrets & PII, strip home directory paths before publishing
|
|
1202
|
+
const sessionCwd = session.cwd ?? undefined;
|
|
1203
|
+
const redactedPayload = redactSession(sessionPayload, 'high', sessionCwd);
|
|
1204
|
+
const redactedData = redactSession(sessionData, 'high', sessionCwd);
|
|
1205
|
+
// Warn about redacted content in CLI output
|
|
1206
|
+
const payloadFindings = scanTextSync(JSON.stringify(sessionPayload));
|
|
1207
|
+
if (payloadFindings.length > 0) {
|
|
1208
|
+
const summary = formatFindings(payloadFindings);
|
|
1209
|
+
send({ type: 'redaction', sessionId, message: summary });
|
|
1210
|
+
}
|
|
861
1211
|
const sessionRes = await fetch(`${API_URL}/api/sessions`, {
|
|
862
1212
|
method: 'POST',
|
|
863
1213
|
headers: {
|
|
864
1214
|
'Content-Type': 'application/json',
|
|
865
1215
|
Authorization: `Bearer ${auth.token}`,
|
|
866
1216
|
},
|
|
867
|
-
body: JSON.stringify(
|
|
1217
|
+
body: JSON.stringify(redactedPayload),
|
|
868
1218
|
});
|
|
869
1219
|
if (sessionRes.ok) {
|
|
870
1220
|
uploadedCount++;
|
|
@@ -875,14 +1225,21 @@ export function createApp(sessionsBasePath) {
|
|
|
875
1225
|
const { raw: rawUrl, log: logUrl } = sesData.upload_urls;
|
|
876
1226
|
if (rawUrl && meta.path && !meta.path.startsWith('cursor://')) {
|
|
877
1227
|
try {
|
|
878
|
-
const
|
|
879
|
-
|
|
1228
|
+
const rawText = readFileSync(meta.path, 'utf-8');
|
|
1229
|
+
let redactedRaw = redactText(rawText);
|
|
1230
|
+
redactedRaw = stripHomePathsInText(redactedRaw, sessionCwd);
|
|
1231
|
+
await fetch(rawUrl, { method: 'PUT', body: Buffer.from(redactedRaw, 'utf-8'), headers: { 'Content-Type': 'application/octet-stream' } });
|
|
880
1232
|
}
|
|
881
1233
|
catch { /* S3 upload is best-effort */ }
|
|
882
1234
|
}
|
|
883
1235
|
if (logUrl && session.rawLog && session.rawLog.length > 0) {
|
|
884
1236
|
try {
|
|
885
|
-
|
|
1237
|
+
const redactedLog = session.rawLog.map((line) => {
|
|
1238
|
+
let cleaned = redactText(line);
|
|
1239
|
+
cleaned = stripHomePathsInText(cleaned, sessionCwd);
|
|
1240
|
+
return cleaned;
|
|
1241
|
+
});
|
|
1242
|
+
await fetch(logUrl, { method: 'PUT', body: JSON.stringify(redactedLog), headers: { 'Content-Type': 'application/json' } });
|
|
886
1243
|
}
|
|
887
1244
|
catch { /* S3 upload is best-effort */ }
|
|
888
1245
|
}
|
|
@@ -890,7 +1247,7 @@ export function createApp(sessionsBasePath) {
|
|
|
890
1247
|
try {
|
|
891
1248
|
await fetch(sesData.upload_urls.session, {
|
|
892
1249
|
method: 'PUT',
|
|
893
|
-
body: JSON.stringify(
|
|
1250
|
+
body: JSON.stringify(redactedData),
|
|
894
1251
|
headers: { 'Content-Type': 'application/json' },
|
|
895
1252
|
});
|
|
896
1253
|
}
|