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/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,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: enhanced?.developerTake ?? '',
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
- // session.json uses camelCase keys matching the @heyiam/ui Session type
801
- // so React islands can consume it directly without transformation
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: sessionSlug,
1145
+ id: sessionId,
805
1146
  title: enhanced?.title ?? session.title,
806
- devTake: enhanced?.developerTake ?? session.developerTake ?? '',
1147
+ dev_take: devTake,
807
1148
  context: enhanced?.context ?? '',
808
- durationMinutes: session.durationMinutes ?? 0,
1149
+ duration_minutes: session.durationMinutes ?? 0,
809
1150
  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,
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
- // Only use wall-clock endTime when reasonable ( 3x active time)
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
- wallClockMinutes: session.wallClockMinutes ?? null,
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
- projectName: proj.name,
830
- narrative: enhanced?.developerTake ?? '',
1168
+ project_name: proj.name,
1169
+ narrative,
831
1170
  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 : ''),
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
- qaPairs: enhanced?.qaPairs ?? session.qaPairs ?? [],
1177
+ qa_pairs: enhanced?.qaPairs ?? session.qaPairs ?? [],
839
1178
  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) => ({
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
- transcriptExcerpt: (session.rawLog ?? []).slice(0, 10).map((line, i) => {
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.replace(/^\[AI\] |^\[TOOL\] /, '');
1190
+ const text = role === 'dev' ? line.slice(2) : line;
851
1191
  return { role, id: `Turn ${i + 1}`, text, timestamp: null };
852
1192
  }),
853
- agentSummary: agentSummary,
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(sessionPayload),
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 rawBody = readFileSync(meta.path);
879
- await fetch(rawUrl, { method: 'PUT', body: rawBody, headers: { 'Content-Type': 'application/octet-stream' } });
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
- await fetch(logUrl, { method: 'PUT', body: JSON.stringify(session.rawLog), headers: { 'Content-Type': 'application/json' } });
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(sessionData),
1250
+ body: JSON.stringify(redactedData),
894
1251
  headers: { 'Content-Type': 'application/json' },
895
1252
  });
896
1253
  }