heyiam 0.1.4 → 0.1.5

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/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 = 6;
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
- return null;
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(Boolean) });
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 attach child sessions
258
- const childSessions = await bridgeChildSessions(meta, proj.name);
259
- const aggregated = childSessions.length > 0 ? aggregateChildStats(childSessions) : undefined;
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
- ...(childSessions.length > 0 ? { childSessions, isOrchestrated: true } : {}),
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
- // Derive the project filesystem path from the dirName.
340
- // Claude Code encodes "/Users/ben/Dev/heyi-am" as "-Users-ben-Dev-heyi-am"
341
- // (replaces "/" with "-"). Reverse: replace leading "-" with "/".
342
- const projectPath = proj.dirName.replace(/^-/, '/').replace(/-/g, '/');
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
- try {
345
- // Use execFileSync to avoid shell injection — fixed args, no interpolation
346
- const raw = execFileSync('git', ['-C', projectPath, 'remote', 'get-url', 'origin'], {
347
- timeout: 5000,
348
- encoding: 'utf-8',
349
- stdio: ['pipe', 'pipe', 'pipe'],
350
- }).trim();
351
- // Clean the URL:
352
- // git@github.com:user/repo.git → github.com/user/repo
353
- // https://github.com/user/repo.git → github.com/user/repo
354
- remoteUrl = raw
355
- .replace(/\.git$/, '')
356
- .replace(/^git@([^:]+):/, '$1/')
357
- .replace(/^https?:\/\//, '');
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: enhanced?.developerTake ?? session.developerTake ?? '',
1117
+ dev_take: devTake,
778
1118
  context: enhanced?.context ?? '',
779
1119
  duration_minutes: session.durationMinutes ?? 0,
780
1120
  turns: session.turns ?? 0,
@@ -788,7 +1128,7 @@ 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: enhanced?.developerTake ?? '',
1131
+ narrative,
792
1132
  project_name: proj.name,
793
1133
  project_id: projectData.project_id,
794
1134
  slug: sessionSlug,
@@ -797,22 +1137,20 @@ export function createApp(sessionsBasePath) {
797
1137
  },
798
1138
  };
799
1139
  // session.json: full data including visualization fields for S3
800
- // session.json uses camelCase keys matching the @heyiam/ui Session type
801
- // so React islands can consume it directly without transformation
1140
+ // M1: Use consistent snake_case keys so Phoenix doesn't need dual-variant normalization
1141
+ // M5: Use sessionId (CLI's local UUID), not the slug
802
1142
  const sessionData = {
803
1143
  version: 1,
804
- id: sessionSlug,
1144
+ id: sessionId,
805
1145
  title: enhanced?.title ?? session.title,
806
- devTake: enhanced?.developerTake ?? session.developerTake ?? '',
1146
+ dev_take: devTake,
807
1147
  context: enhanced?.context ?? '',
808
- durationMinutes: session.durationMinutes ?? 0,
1148
+ duration_minutes: session.durationMinutes ?? 0,
809
1149
  turns: session.turns ?? 0,
810
- filesChanged: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
811
- linesOfCode: session.linesOfCode ?? 0,
1150
+ files_changed: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
1151
+ loc_changed: session.linesOfCode ?? 0,
812
1152
  date: session.date ? new Date(session.date).toISOString() : new Date().toISOString(),
813
- // Only use wall-clock endTime when reasonable ( 3x active time)
814
- // Overnight sessions span 20+ hours which breaks timeline clustering
815
- endTime: (() => {
1153
+ end_time: (() => {
816
1154
  if (!session.endTime || !session.date)
817
1155
  return null;
818
1156
  const wallMs = new Date(session.endTime).getTime() - new Date(session.date).getTime();
@@ -820,37 +1158,38 @@ export function createApp(sessionsBasePath) {
820
1158
  return wallMs <= activeMs * 3 ? new Date(session.endTime).toISOString() : null;
821
1159
  })(),
822
1160
  cwd: session.cwd ?? null,
823
- wallClockMinutes: session.wallClockMinutes ?? null,
1161
+ wall_clock_minutes: session.wallClockMinutes ?? null,
824
1162
  template: 'editorial',
825
1163
  skills: enhanced?.skills ?? session.skills ?? [],
826
1164
  tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
827
1165
  source: session.source ?? meta.source ?? 'claude',
828
1166
  slug: sessionSlug,
829
- projectName: proj.name,
830
- narrative: enhanced?.developerTake ?? '',
1167
+ project_name: proj.name,
1168
+ narrative,
831
1169
  status: 'listed',
832
- rawLog: [],
833
- executionPath: (enhanced?.executionSteps ?? session.executionPath ?? []).map((s, i) => ({
834
- stepNumber: i + 1,
835
- title: s.title,
836
- description: 'body' in s ? s.body : ('description' in s ? s.description : ''),
1170
+ raw_log: [],
1171
+ // M2: normalize execution_path steps to {label, description}
1172
+ execution_path: (enhanced?.executionSteps ?? session.executionPath ?? []).map((s, i) => ({
1173
+ label: s.title ?? `Step ${i + 1}`,
1174
+ description: s.description ?? s.body ?? '',
837
1175
  })),
838
- qaPairs: enhanced?.qaPairs ?? session.qaPairs ?? [],
1176
+ qa_pairs: enhanced?.qaPairs ?? session.qaPairs ?? [],
839
1177
  highlights: [],
840
- toolBreakdown: (session.toolBreakdown ?? []).map((t) => ({ tool: t.tool, count: t.count })),
841
- topFiles: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
842
- turnTimeline: (session.turnTimeline ?? []).map((t) => ({
1178
+ tool_breakdown: (session.toolBreakdown ?? []).map((t) => ({ tool: t.tool, count: t.count })),
1179
+ top_files: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
1180
+ turn_timeline: (session.turnTimeline ?? []).map((t) => ({
843
1181
  timestamp: t.timestamp,
844
1182
  type: t.type,
845
1183
  content: (t.content ?? '').slice(0, 200),
846
1184
  tools: t.tools ?? [],
847
1185
  })),
848
- transcriptExcerpt: (session.rawLog ?? []).slice(0, 10).map((line, i) => {
1186
+ // M6: Keep tool prefixes in transcript they're informative
1187
+ transcript_excerpt: (session.rawLog ?? []).slice(0, 10).map((line, i) => {
849
1188
  const role = line.startsWith('> ') ? 'dev' : 'ai';
850
- const text = role === 'dev' ? line.slice(2) : line.replace(/^\[AI\] |^\[TOOL\] /, '');
1189
+ const text = role === 'dev' ? line.slice(2) : line;
851
1190
  return { role, id: `Turn ${i + 1}`, text, timestamp: null };
852
1191
  }),
853
- agentSummary: agentSummary,
1192
+ agent_summary: agentSummary,
854
1193
  children: agentSummary?.agents?.map((a) => ({
855
1194
  sessionId: a.role,
856
1195
  role: a.role,
@@ -858,13 +1197,23 @@ export function createApp(sessionsBasePath) {
858
1197
  linesOfCode: a.loc_changed,
859
1198
  })) ?? [],
860
1199
  };
1200
+ // Redact secrets & PII, strip home directory paths before publishing
1201
+ const sessionCwd = session.cwd ?? undefined;
1202
+ const redactedPayload = redactSession(sessionPayload, 'high', sessionCwd);
1203
+ const redactedData = redactSession(sessionData, 'high', sessionCwd);
1204
+ // Warn about redacted content in CLI output
1205
+ const payloadFindings = scanTextSync(JSON.stringify(sessionPayload));
1206
+ if (payloadFindings.length > 0) {
1207
+ const summary = formatFindings(payloadFindings);
1208
+ send({ type: 'redaction', sessionId, message: summary });
1209
+ }
861
1210
  const sessionRes = await fetch(`${API_URL}/api/sessions`, {
862
1211
  method: 'POST',
863
1212
  headers: {
864
1213
  'Content-Type': 'application/json',
865
1214
  Authorization: `Bearer ${auth.token}`,
866
1215
  },
867
- body: JSON.stringify(sessionPayload),
1216
+ body: JSON.stringify(redactedPayload),
868
1217
  });
869
1218
  if (sessionRes.ok) {
870
1219
  uploadedCount++;
@@ -875,14 +1224,21 @@ export function createApp(sessionsBasePath) {
875
1224
  const { raw: rawUrl, log: logUrl } = sesData.upload_urls;
876
1225
  if (rawUrl && meta.path && !meta.path.startsWith('cursor://')) {
877
1226
  try {
878
- const rawBody = readFileSync(meta.path);
879
- await fetch(rawUrl, { method: 'PUT', body: rawBody, headers: { 'Content-Type': 'application/octet-stream' } });
1227
+ const rawText = readFileSync(meta.path, 'utf-8');
1228
+ let redactedRaw = redactText(rawText);
1229
+ redactedRaw = stripHomePathsInText(redactedRaw, sessionCwd);
1230
+ await fetch(rawUrl, { method: 'PUT', body: Buffer.from(redactedRaw, 'utf-8'), headers: { 'Content-Type': 'application/octet-stream' } });
880
1231
  }
881
1232
  catch { /* S3 upload is best-effort */ }
882
1233
  }
883
1234
  if (logUrl && session.rawLog && session.rawLog.length > 0) {
884
1235
  try {
885
- await fetch(logUrl, { method: 'PUT', body: JSON.stringify(session.rawLog), headers: { 'Content-Type': 'application/json' } });
1236
+ const redactedLog = session.rawLog.map((line) => {
1237
+ let cleaned = redactText(line);
1238
+ cleaned = stripHomePathsInText(cleaned, sessionCwd);
1239
+ return cleaned;
1240
+ });
1241
+ await fetch(logUrl, { method: 'PUT', body: JSON.stringify(redactedLog), headers: { 'Content-Type': 'application/json' } });
886
1242
  }
887
1243
  catch { /* S3 upload is best-effort */ }
888
1244
  }
@@ -890,7 +1246,7 @@ export function createApp(sessionsBasePath) {
890
1246
  try {
891
1247
  await fetch(sesData.upload_urls.session, {
892
1248
  method: 'PUT',
893
- body: JSON.stringify(sessionData),
1249
+ body: JSON.stringify(redactedData),
894
1250
  headers: { 'Content-Type': 'application/json' },
895
1251
  });
896
1252
  }