heyiam 0.1.3 → 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);
@@ -749,10 +1085,36 @@ export function createApp(sessionsBasePath) {
749
1085
  const enhanced = loadEnhancedData(sessionId);
750
1086
  const sessionSlug = (enhanced?.title ?? session.title ?? sessionId)
751
1087
  .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80);
1088
+ // Build agent summary (shared between POST body and session.json)
1089
+ const agentSummary = await (async () => {
1090
+ const childMetas = meta.children ?? [];
1091
+ if (childMetas.length === 0)
1092
+ return null;
1093
+ const seenRoles = new Set();
1094
+ const agents = [];
1095
+ for (const c of childMetas) {
1096
+ const role = c.agentRole ?? c.sessionId;
1097
+ if (seenRoles.has(role))
1098
+ continue;
1099
+ seenRoles.add(role);
1100
+ const childStats = await getSessionStats(c, proj.name);
1101
+ agents.push({
1102
+ role: c.agentRole ?? 'agent',
1103
+ duration_minutes: childStats.duration,
1104
+ loc_changed: childStats.loc,
1105
+ });
1106
+ }
1107
+ return agents.length > 0 ? { is_orchestrated: true, agents } : null;
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 ?? '';
1113
+ // POST body: scalar/aggregate fields only
752
1114
  const sessionPayload = {
753
1115
  session: {
754
1116
  title: enhanced?.title ?? session.title,
755
- dev_take: enhanced?.developerTake ?? session.developerTake ?? '',
1117
+ dev_take: devTake,
756
1118
  context: enhanced?.context ?? '',
757
1119
  duration_minutes: session.durationMinutes ?? 0,
758
1120
  turns: session.turns ?? 0,
@@ -766,48 +1128,7 @@ export function createApp(sessionsBasePath) {
766
1128
  language: null,
767
1129
  tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
768
1130
  skills: enhanced?.skills ?? session.skills ?? [],
769
- beats: (enhanced?.executionSteps ?? session.executionPath ?? []).map((s, i) => ({
770
- label: s.title,
771
- description: 'body' in s ? s.body : ('description' in s ? s.description : ''),
772
- position: i,
773
- })),
774
- qa_pairs: enhanced?.qaPairs ?? session.qaPairs ?? [],
775
- highlights: [],
776
- tool_breakdown: (session.toolBreakdown ?? []).map((t) => ({ name: t.tool, count: t.count })),
777
- top_files: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f } : f)),
778
- narrative: enhanced?.developerTake ?? '',
779
- turn_timeline: (session.turnTimeline ?? []).map((t) => ({
780
- timestamp: t.timestamp,
781
- type: t.type,
782
- content: (t.content ?? '').slice(0, 200),
783
- tools: t.tools ?? [],
784
- })),
785
- transcript_excerpt: (session.rawLog ?? []).slice(0, 10).map((line, i) => {
786
- const role = line.startsWith('> ') ? 'dev' : 'ai';
787
- const text = role === 'dev' ? line.slice(2) : line.replace(/^\[AI\] |^\[TOOL\] /, '');
788
- return { role, id: `Turn ${i + 1}`, text, timestamp: null };
789
- }),
790
- agent_summary: await (async () => {
791
- // Build agent summary from child session metadata
792
- const childMetas = meta.children ?? [];
793
- if (childMetas.length === 0)
794
- return null;
795
- const seenRoles = new Set();
796
- const agents = [];
797
- for (const c of childMetas) {
798
- const role = c.agentRole ?? c.sessionId;
799
- if (seenRoles.has(role))
800
- continue;
801
- seenRoles.add(role);
802
- const childStats = await getSessionStats(c, proj.name);
803
- agents.push({
804
- role: c.agentRole ?? 'agent',
805
- duration_minutes: childStats.duration,
806
- loc_changed: childStats.loc,
807
- });
808
- }
809
- return agents.length > 0 ? { is_orchestrated: true, agents } : null;
810
- })(),
1131
+ narrative,
811
1132
  project_name: proj.name,
812
1133
  project_id: projectData.project_id,
813
1134
  slug: sessionSlug,
@@ -815,13 +1136,84 @@ export function createApp(sessionsBasePath) {
815
1136
  source_tool: session.source ?? meta.source ?? 'claude',
816
1137
  },
817
1138
  };
1139
+ // session.json: full data including visualization fields for S3
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
1142
+ const sessionData = {
1143
+ version: 1,
1144
+ id: sessionId,
1145
+ title: enhanced?.title ?? session.title,
1146
+ dev_take: devTake,
1147
+ context: enhanced?.context ?? '',
1148
+ duration_minutes: session.durationMinutes ?? 0,
1149
+ turns: session.turns ?? 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,
1152
+ date: session.date ? new Date(session.date).toISOString() : new Date().toISOString(),
1153
+ end_time: (() => {
1154
+ if (!session.endTime || !session.date)
1155
+ return null;
1156
+ const wallMs = new Date(session.endTime).getTime() - new Date(session.date).getTime();
1157
+ const activeMs = (session.durationMinutes ?? 0) * 60_000;
1158
+ return wallMs <= activeMs * 3 ? new Date(session.endTime).toISOString() : null;
1159
+ })(),
1160
+ cwd: session.cwd ?? null,
1161
+ wall_clock_minutes: session.wallClockMinutes ?? null,
1162
+ template: 'editorial',
1163
+ skills: enhanced?.skills ?? session.skills ?? [],
1164
+ tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
1165
+ source: session.source ?? meta.source ?? 'claude',
1166
+ slug: sessionSlug,
1167
+ project_name: proj.name,
1168
+ narrative,
1169
+ status: 'listed',
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 ?? '',
1175
+ })),
1176
+ qa_pairs: enhanced?.qaPairs ?? session.qaPairs ?? [],
1177
+ highlights: [],
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) => ({
1181
+ timestamp: t.timestamp,
1182
+ type: t.type,
1183
+ content: (t.content ?? '').slice(0, 200),
1184
+ tools: t.tools ?? [],
1185
+ })),
1186
+ // M6: Keep tool prefixes in transcript — they're informative
1187
+ transcript_excerpt: (session.rawLog ?? []).slice(0, 10).map((line, i) => {
1188
+ const role = line.startsWith('> ') ? 'dev' : 'ai';
1189
+ const text = role === 'dev' ? line.slice(2) : line;
1190
+ return { role, id: `Turn ${i + 1}`, text, timestamp: null };
1191
+ }),
1192
+ agent_summary: agentSummary,
1193
+ children: agentSummary?.agents?.map((a) => ({
1194
+ sessionId: a.role,
1195
+ role: a.role,
1196
+ durationMinutes: a.duration_minutes,
1197
+ linesOfCode: a.loc_changed,
1198
+ })) ?? [],
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
+ }
818
1210
  const sessionRes = await fetch(`${API_URL}/api/sessions`, {
819
1211
  method: 'POST',
820
1212
  headers: {
821
1213
  'Content-Type': 'application/json',
822
1214
  Authorization: `Bearer ${auth.token}`,
823
1215
  },
824
- body: JSON.stringify(sessionPayload),
1216
+ body: JSON.stringify(redactedPayload),
825
1217
  });
826
1218
  if (sessionRes.ok) {
827
1219
  uploadedCount++;
@@ -832,14 +1224,31 @@ export function createApp(sessionsBasePath) {
832
1224
  const { raw: rawUrl, log: logUrl } = sesData.upload_urls;
833
1225
  if (rawUrl && meta.path && !meta.path.startsWith('cursor://')) {
834
1226
  try {
835
- const rawBody = readFileSync(meta.path);
836
- 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' } });
837
1231
  }
838
1232
  catch { /* S3 upload is best-effort */ }
839
1233
  }
840
1234
  if (logUrl && session.rawLog && session.rawLog.length > 0) {
841
1235
  try {
842
- 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' } });
1242
+ }
1243
+ catch { /* S3 upload is best-effort */ }
1244
+ }
1245
+ if (sesData.upload_urls.session) {
1246
+ try {
1247
+ await fetch(sesData.upload_urls.session, {
1248
+ method: 'PUT',
1249
+ body: JSON.stringify(redactedData),
1250
+ headers: { 'Content-Type': 'application/json' },
1251
+ });
843
1252
  }
844
1253
  catch { /* S3 upload is best-effort */ }
845
1254
  }