heyiam 0.1.7 → 0.1.8

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.
Files changed (136) hide show
  1. package/dist/analyzer.d.ts +3 -3
  2. package/dist/archive.d.ts +14 -0
  3. package/dist/archive.js +125 -0
  4. package/dist/archive.js.map +1 -0
  5. package/dist/auth.d.ts +0 -6
  6. package/dist/auth.js +2 -4
  7. package/dist/auth.js.map +1 -1
  8. package/dist/autostart.d.ts +19 -0
  9. package/dist/autostart.js +103 -0
  10. package/dist/autostart.js.map +1 -0
  11. package/dist/bridge.d.ts +0 -2
  12. package/dist/bridge.js +33 -4
  13. package/dist/bridge.js.map +1 -1
  14. package/dist/config.d.ts +1 -1
  15. package/dist/config.js +2 -2
  16. package/dist/config.js.map +1 -1
  17. package/dist/context-export.d.ts +22 -0
  18. package/dist/context-export.js +230 -0
  19. package/dist/context-export.js.map +1 -0
  20. package/dist/daemon-install.d.ts +23 -0
  21. package/dist/daemon-install.js +155 -0
  22. package/dist/daemon-install.js.map +1 -0
  23. package/dist/db.d.ts +118 -0
  24. package/dist/db.js +444 -0
  25. package/dist/db.js.map +1 -0
  26. package/dist/export.d.ts +30 -0
  27. package/dist/export.js +377 -0
  28. package/dist/export.js.map +1 -0
  29. package/dist/format-utils.d.ts +6 -0
  30. package/dist/format-utils.js +15 -0
  31. package/dist/format-utils.js.map +1 -0
  32. package/dist/index.js +474 -117
  33. package/dist/index.js.map +1 -1
  34. package/dist/llm/project-enhance.js +1 -1
  35. package/dist/parsers/claude.js +73 -0
  36. package/dist/parsers/claude.js.map +1 -1
  37. package/dist/parsers/codex.js +1 -1
  38. package/dist/parsers/codex.js.map +1 -1
  39. package/dist/parsers/cursor.d.ts +2 -0
  40. package/dist/parsers/cursor.js +14 -26
  41. package/dist/parsers/cursor.js.map +1 -1
  42. package/dist/parsers/gemini.d.ts +3 -2
  43. package/dist/parsers/gemini.js +198 -21
  44. package/dist/parsers/gemini.js.map +1 -1
  45. package/dist/parsers/index.d.ts +1 -1
  46. package/dist/parsers/index.js +23 -7
  47. package/dist/parsers/index.js.map +1 -1
  48. package/dist/parsers/types.d.ts +27 -1
  49. package/dist/render/build-render-data.d.ts +59 -0
  50. package/dist/render/build-render-data.js +101 -0
  51. package/dist/render/build-render-data.js.map +1 -0
  52. package/dist/render/components/PortfolioPage.d.ts +4 -0
  53. package/dist/render/components/PortfolioPage.js +16 -0
  54. package/dist/render/components/PortfolioPage.js.map +1 -0
  55. package/dist/render/components/ProjectPage.d.ts +4 -0
  56. package/dist/render/components/ProjectPage.js +101 -0
  57. package/dist/render/components/ProjectPage.js.map +1 -0
  58. package/dist/render/components/SessionPage.d.ts +4 -0
  59. package/dist/render/components/SessionPage.js +29 -0
  60. package/dist/render/components/SessionPage.js.map +1 -0
  61. package/dist/render/index.d.ts +37 -0
  62. package/dist/render/index.js +140 -0
  63. package/dist/render/index.js.map +1 -0
  64. package/dist/render/types.d.ts +121 -0
  65. package/dist/render/types.js +9 -0
  66. package/dist/render/types.js.map +1 -0
  67. package/dist/routes/archive.d.ts +3 -0
  68. package/dist/routes/archive.js +56 -0
  69. package/dist/routes/archive.js.map +1 -0
  70. package/dist/routes/auth.d.ts +3 -0
  71. package/dist/routes/auth.js +116 -0
  72. package/dist/routes/auth.js.map +1 -0
  73. package/dist/routes/context.d.ts +61 -0
  74. package/dist/routes/context.js +356 -0
  75. package/dist/routes/context.js.map +1 -0
  76. package/dist/routes/dashboard.d.ts +3 -0
  77. package/dist/routes/dashboard.js +103 -0
  78. package/dist/routes/dashboard.js.map +1 -0
  79. package/dist/routes/enhance.d.ts +3 -0
  80. package/dist/routes/enhance.js +305 -0
  81. package/dist/routes/enhance.js.map +1 -0
  82. package/dist/routes/export.d.ts +3 -0
  83. package/dist/routes/export.js +145 -0
  84. package/dist/routes/export.js.map +1 -0
  85. package/dist/routes/index.d.ts +12 -0
  86. package/dist/routes/index.js +13 -0
  87. package/dist/routes/index.js.map +1 -0
  88. package/dist/routes/preview.d.ts +3 -0
  89. package/dist/routes/preview.js +191 -0
  90. package/dist/routes/preview.js.map +1 -0
  91. package/dist/routes/projects.d.ts +3 -0
  92. package/dist/routes/projects.js +356 -0
  93. package/dist/routes/projects.js.map +1 -0
  94. package/dist/routes/publish.d.ts +3 -0
  95. package/dist/routes/publish.js +466 -0
  96. package/dist/routes/publish.js.map +1 -0
  97. package/dist/routes/search.d.ts +3 -0
  98. package/dist/routes/search.js +110 -0
  99. package/dist/routes/search.js.map +1 -0
  100. package/dist/routes/sessions.d.ts +3 -0
  101. package/dist/routes/sessions.js +103 -0
  102. package/dist/routes/sessions.js.map +1 -0
  103. package/dist/routes/settings.d.ts +3 -0
  104. package/dist/routes/settings.js +30 -0
  105. package/dist/routes/settings.js.map +1 -0
  106. package/dist/screenshot.d.ts +5 -2
  107. package/dist/screenshot.js +187 -13
  108. package/dist/screenshot.js.map +1 -1
  109. package/dist/search.d.ts +30 -0
  110. package/dist/search.js +153 -0
  111. package/dist/search.js.map +1 -0
  112. package/dist/server.d.ts +1 -1
  113. package/dist/server.js +55 -1318
  114. package/dist/server.js.map +1 -1
  115. package/dist/settings.d.ts +23 -6
  116. package/dist/settings.js +36 -12
  117. package/dist/settings.js.map +1 -1
  118. package/dist/source-audit.d.ts +29 -0
  119. package/dist/source-audit.js +203 -0
  120. package/dist/source-audit.js.map +1 -0
  121. package/dist/sync.d.ts +74 -0
  122. package/dist/sync.js +358 -0
  123. package/dist/sync.js.map +1 -0
  124. package/dist/transcript.d.ts +68 -0
  125. package/dist/transcript.js +268 -0
  126. package/dist/transcript.js.map +1 -0
  127. package/package.json +5 -1
  128. package/app/dist/assets/html2canvas-Cwn_rrOw.js +0 -5
  129. package/app/dist/assets/index-CEQyTkgN.js +0 -14
  130. package/app/dist/assets/index-DLh5xRE8.css +0 -1
  131. package/app/dist/favicon.svg +0 -5
  132. package/app/dist/icons.svg +0 -24
  133. package/app/dist/index.html +0 -20
  134. package/dist/machine-key.d.ts +0 -10
  135. package/dist/machine-key.js +0 -51
  136. package/dist/machine-key.js.map +0 -1
package/dist/server.js CHANGED
@@ -2,1333 +2,54 @@ import express from 'express';
2
2
  import cors from 'cors';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { execFileSync } from 'node:child_process';
6
- import { listSessions, parseSession } from './parsers/index.js';
7
- import { bridgeToAnalyzer, bridgeChildSessions, aggregateChildStats, toAgentChild } from './bridge.js';
8
- import { analyzeSession } from './analyzer.js';
9
- import { checkAuthStatus, getAuthToken, saveAuthToken } from './auth.js';
10
- import { API_URL } from './config.js';
11
- import { getProvider, getEnhanceMode } from './llm/index.js';
12
- import { triageSessions } from './llm/triage.js';
13
- import { enhanceProject, refineNarrative } from './llm/project-enhance.js';
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';
5
+ import { getDatabase } from './db.js';
6
+ import { syncWithTracking, startFileWatcher, startCursorPolling } from './sync.js';
7
+ import { createRouteContext, createProjectsRouter, createEnhanceRouter, createPublishRouter, createSearchRouter, createSessionsRouter, createArchiveRouter, createAuthRouter, createSettingsRouter, createExportRouter, createPreviewRouter, createDashboardRouter, } from './routes/index.js';
17
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
- // Derive a human-readable project name from the encoded directory name.
19
- // "-Users-ben-Dev-heyi-am" → "heyi-am"
20
- // "-Users-ben-Dev-agent-sync" → "agent-sync"
21
- // Heuristic: find "Dev-" prefix and take everything after it.
22
- // Falls back to last path-like segment.
23
- function displayNameFromDir(dirName) {
24
- // Try to find a Dev- boundary (common pattern)
25
- const devIdx = dirName.indexOf('-Dev-');
26
- if (devIdx !== -1) {
27
- return dirName.slice(devIdx + 5); // everything after "-Dev-"
28
- }
29
- // Fallback: last hyphen-separated segment
30
- const segments = dirName.split('-').filter(Boolean);
31
- return segments.length > 0 ? segments[segments.length - 1] : dirName;
32
- }
33
- async function getProjects(basePath) {
34
- const allSessions = await listSessions(basePath);
35
- // Group by projectDir (set by the scanner)
36
- const byDir = new Map();
37
- for (const s of allSessions) {
38
- const existing = byDir.get(s.projectDir) ?? [];
39
- existing.push(s);
40
- byDir.set(s.projectDir, existing);
41
- }
42
- return [...byDir.entries()].map(([dirName, sessions]) => ({
43
- name: displayNameFromDir(dirName),
44
- dirName,
45
- sessionCount: sessions.length,
46
- sessions,
47
- }));
48
- }
49
- async function loadSession(sessionPath, projectName, sessionId) {
50
- const parsed = await parseSession(sessionPath);
51
- const analyzerInput = bridgeToAnalyzer(parsed, { sessionId, projectName });
52
- const session = analyzeSession(analyzerInput);
53
- return mergeEnhancedData(session);
54
- }
55
- /** Merge locally-saved enhanced data into a session if it exists. */
56
- function mergeEnhancedData(session) {
57
- const enhanced = loadEnhancedData(session.id);
58
- if (!enhanced)
59
- return session;
60
- return {
61
- ...session,
62
- title: enhanced.title,
63
- developerTake: enhanced.developerTake,
64
- context: enhanced.context,
65
- skills: enhanced.skills,
66
- executionPath: enhanced.executionSteps.map((s) => ({
67
- stepNumber: s.stepNumber,
68
- title: s.title,
69
- description: s.body,
70
- })),
71
- qaPairs: enhanced.qaPairs,
72
- status: enhanced.uploaded ? 'published' : 'enhanced',
73
- quickEnhanced: enhanced.quickEnhanced ?? false,
74
- };
75
- }
76
- // ── Persistent stats cache ────────────────────────────────────
77
- // Survives server restarts by writing to disk.
78
- import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
79
- import { join } from 'node:path';
80
- import { homedir } from 'node:os';
81
- const STATS_CACHE_PATH = join(homedir(), '.config', 'heyiam', 'stats-cache.json');
82
- // Bump this when parser logic changes to auto-invalidate stale cache entries.
83
- const STATS_CACHE_VERSION = 7;
84
- function loadStatsCache() {
85
- try {
86
- if (!existsSync(STATS_CACHE_PATH))
87
- return new Map();
88
- const raw = JSON.parse(readFileSync(STATS_CACHE_PATH, 'utf-8'));
89
- // Versioned format
90
- if (raw && typeof raw === 'object' && 'version' in raw) {
91
- const file = raw;
92
- if (file.version !== STATS_CACHE_VERSION)
93
- return new Map(); // stale — rebuild
94
- return new Map(Object.entries(file.entries));
95
- }
96
- // Legacy unversioned format — discard
97
- return new Map();
98
- }
99
- catch {
100
- return new Map();
101
- }
102
- }
103
- function saveStatsCache(cache) {
104
- try {
105
- const dir = join(homedir(), '.config', 'heyiam');
106
- mkdirSync(dir, { recursive: true });
107
- const file = {
108
- version: STATS_CACHE_VERSION,
109
- entries: Object.fromEntries(cache),
110
- };
111
- writeFileSync(STATS_CACHE_PATH, JSON.stringify(file), { mode: 0o600 });
112
- }
113
- catch {
114
- // Non-critical — cache miss just means a slower first load
115
- }
116
- }
117
- export function createApp(sessionsBasePath) {
9
+ export function createApp(sessionsBasePath, dbPath) {
118
10
  const app = express();
119
- // Stats cache — loaded from disk on startup, written back periodically
120
- const statsCache = loadStatsCache();
121
- let statsCacheDirty = false;
122
- // Flush dirty cache to disk every 10 seconds
123
- const flushInterval = setInterval(() => {
124
- if (statsCacheDirty) {
125
- saveStatsCache(statsCache);
126
- statsCacheDirty = false;
127
- }
128
- }, 10_000);
129
- // Don't let this interval keep the process alive
130
- flushInterval.unref?.();
131
- async function getSessionStats(meta, projectName) {
132
- const cached = statsCache.get(meta.sessionId);
133
- if (cached)
134
- return cached;
135
- try {
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
- }
145
- const stats = {
146
- loc: session.linesOfCode ?? 0,
147
- duration: session.durationMinutes ?? 0,
148
- files: session.filesChanged?.length ?? 0,
149
- turns: session.turns ?? 0,
150
- skills: session.skills ?? [],
151
- date: session.date ?? '',
152
- endTime,
153
- };
154
- statsCache.set(meta.sessionId, stats);
155
- statsCacheDirty = true;
156
- return stats;
157
- }
158
- catch {
159
- return { loc: 0, duration: 0, files: 0, turns: 0, skills: [], date: '' };
160
- }
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
- }
206
- async function getProjectWithStats(proj) {
207
- const allStats = await Promise.all(proj.sessions.map((m) => getSessionStats(m, proj.name)));
208
- const totalLoc = allStats.reduce((s, st) => s + st.loc, 0);
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
- }
224
- // Deduplicated skills across all sessions
225
- const skillSet = new Set();
226
- for (const st of allStats) {
227
- for (const sk of st.skills)
228
- skillSet.add(sk);
229
- }
230
- // Date range
231
- const dates = allStats.map((st) => st.date).filter(Boolean).sort();
232
- const firstDate = dates[0] ?? '';
233
- const lastDate = dates[dates.length - 1] ?? '';
234
- const published = getPublishedState(proj.dirName);
235
- const enhanceCache = loadProjectEnhanceResult(proj.dirName);
236
- return {
237
- name: proj.name,
238
- dirName: proj.dirName,
239
- sessionCount: proj.sessionCount,
240
- description: '',
241
- totalLoc,
242
- totalDuration,
243
- totalFiles,
244
- skills: [...skillSet],
245
- dateRange: firstDate && lastDate ? `${firstDate}|${lastDate}` : '',
246
- lastSessionDate: lastDate,
247
- isPublished: !!published,
248
- publishedSessionCount: published?.publishedSessions.length ?? 0,
249
- publishedSessions: published?.publishedSessions ?? [],
250
- enhancedAt: enhanceCache?.enhancedAt ?? null,
251
- totalAgentDuration,
252
- };
253
- }
254
- app.use(cors({ origin: ['http://localhost:17845', 'http://127.0.0.1:17845'] }));
255
- app.use(express.json({ limit: '50mb' }));
256
- // API routes — wired to real parser pipeline
257
- app.get('/api/projects', async (_req, res) => {
258
- try {
259
- const projects = await getProjects(sessionsBasePath);
260
- const projectsWithStats = await Promise.all(projects.map((p) => getProjectWithStats(p)));
261
- res.json({ projects: projectsWithStats });
262
- }
263
- catch (err) {
264
- res.status(500).json({ error: { code: 'SCAN_FAILED', message: err.message } });
265
- }
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
- });
354
- app.get('/api/projects/:project/sessions', async (req, res) => {
355
- try {
356
- const { project } = req.params;
357
- const projects = await getProjects(sessionsBasePath);
358
- const proj = projects.find((p) => p.name === project || p.dirName === project);
359
- if (!proj) {
360
- res.json({ sessions: [] });
361
- return;
362
- }
363
- // Return parent sessions with enriched child summaries.
364
- // Children get stats via the cached getSessionStats — no redundant parsing.
365
- // If full parsing fails, fall back to a minimal session from stats.
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;
385
- try {
386
- const session = await loadSession(meta.path, proj.name, meta.sessionId);
387
- return { ...session, childCount, children: childCount > 0 ? children : undefined };
388
- }
389
- catch {
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
- };
415
- }
416
- }));
417
- res.json({ sessions: sessions.filter((s) => s.date) });
418
- }
419
- catch (err) {
420
- res.status(500).json({ error: { code: 'LIST_FAILED', message: err.message } });
421
- }
422
- });
423
- app.get('/api/projects/:project/sessions/:id', async (req, res) => {
424
- try {
425
- const { project, id } = req.params;
426
- const projects = await getProjects(sessionsBasePath);
427
- const proj = projects.find((p) => p.name === project || p.dirName === project);
428
- if (!proj) {
429
- res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
430
- return;
431
- }
432
- const meta = proj.sessions.find((s) => s.sessionId === id);
433
- if (!meta) {
434
- res.status(404).json({ error: { code: 'SESSION_NOT_FOUND', message: 'Session not found' } });
435
- return;
436
- }
437
- const session = await loadSession(meta.path, proj.name, meta.sessionId);
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;
442
- res.json({
443
- session: {
444
- ...session,
445
- ...(children.length > 0 ? { children, isOrchestrated: true } : {}),
446
- ...(aggregated ? { aggregatedStats: aggregated } : {}),
447
- },
448
- });
449
- }
450
- catch (err) {
451
- res.status(500).json({ error: { code: 'PARSE_FAILED', message: err.message } });
452
- }
453
- });
454
- // Triage endpoint — AI selects which sessions are worth showcasing (SSE stream)
455
- app.post('/api/projects/:project/triage', async (req, res) => {
456
- if (!getAnthropicApiKey()) {
457
- res.status(400).json({ error: { code: 'NO_API_KEY', message: 'No Anthropic API key configured. Add one in Settings or set ANTHROPIC_API_KEY.' } });
11
+ const ctx = createRouteContext(sessionsBasePath, dbPath);
12
+ // ── Shared middleware ──────────────────────────────────────
13
+ // DNS rebinding guard: reject requests where the Host header isn't localhost.
14
+ // Browsers always set Host to the target hostname, so a DNS-rebind from
15
+ // evil.com:17845 127.0.0.1 will arrive with Host: evil.com:17845.
16
+ const ALLOWED_HOSTNAMES = new Set(['localhost', '127.0.0.1']);
17
+ app.use((req, res, next) => {
18
+ const host = req.headers.host;
19
+ if (!host) {
20
+ res.status(403).json({ error: 'Forbidden' });
458
21
  return;
459
22
  }
460
- const { project } = req.params;
461
- const projects = await getProjects(sessionsBasePath);
462
- const proj = projects.find((p) => p.name === project || p.dirName === project);
463
- if (!proj) {
464
- res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
23
+ const hostname = host.includes(':') ? host.slice(0, host.lastIndexOf(':')) : host;
24
+ if (!ALLOWED_HOSTNAMES.has(hostname)) {
25
+ res.status(403).json({ error: 'Forbidden' });
465
26
  return;
466
27
  }
467
- // Set up SSE
468
- res.writeHead(200, {
469
- 'Content-Type': 'text/event-stream',
470
- 'Cache-Control': 'no-cache',
471
- Connection: 'keep-alive',
472
- });
473
- const send = (event) => {
474
- res.write(`data: ${JSON.stringify(event)}\n\n`);
475
- };
476
- try {
477
- // Build session metadata with stats for triage (sequential for progress)
478
- const total = proj.sessions.length;
479
- const sessionsWithStats = [];
480
- for (let i = 0; i < proj.sessions.length; i++) {
481
- const meta = proj.sessions[i];
482
- send({ type: 'loading_stats', sessionId: meta.sessionId, index: i, total });
483
- const stats = await getSessionStats(meta, proj.name);
484
- sessionsWithStats.push({
485
- sessionId: meta.sessionId,
486
- path: meta.path,
487
- title: stats.date ? `Session ${meta.sessionId.slice(0, 8)}` : meta.sessionId,
488
- duration: stats.duration,
489
- loc: stats.loc,
490
- turns: stats.turns,
491
- files: stats.files,
492
- skills: stats.skills,
493
- date: stats.date,
494
- });
495
- }
496
- const useLLM = req.body?.useLLM !== false;
497
- const result = await triageSessions(sessionsWithStats, useLLM, (event) => {
498
- send(event);
499
- });
500
- // Include already-published sessions so frontend can pre-check them
501
- const published = getPublishedState(proj.dirName);
502
- const alreadyPublished = published?.publishedSessions ?? [];
503
- send({ type: 'result', ...result, alreadyPublished });
504
- res.end();
505
- }
506
- catch (err) {
507
- send({ type: 'error', code: 'TRIAGE_FAILED', message: err.message });
508
- res.end();
509
- }
510
- });
511
- // Git remote auto-detection — derives repo URL from project path
512
- app.get('/api/projects/:project/git-remote', async (req, res) => {
513
- try {
514
- const { project } = req.params;
515
- const projects = await getProjects(sessionsBasePath);
516
- const proj = projects.find((p) => p.name === project || p.dirName === project);
517
- if (!proj) {
518
- res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
519
- return;
520
- }
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
- }
535
- let remoteUrl = 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
- }
551
- }
552
- res.json({ url: remoteUrl });
553
- }
554
- catch (err) {
555
- res.status(500).json({ error: { code: 'GIT_REMOTE_FAILED', message: err.message } });
556
- }
557
- });
558
- // Enhance endpoints — AI-powered session summarization
559
- // Uses provider abstraction: BYOK (local Anthropic SDK) or proxy (Phoenix backend)
560
- app.post('/api/projects/:project/sessions/:id/enhance', async (req, res) => {
561
- try {
562
- const { project, id } = req.params;
563
- const projects = await getProjects(sessionsBasePath);
564
- const proj = projects.find((p) => p.name === project || p.dirName === project);
565
- if (!proj) {
566
- res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
567
- return;
568
- }
569
- const meta = proj.sessions.find((s) => s.sessionId === id);
570
- if (!meta) {
571
- res.status(404).json({ error: { code: 'SESSION_NOT_FOUND', message: 'Session not found' } });
572
- return;
573
- }
574
- const session = await loadSession(meta.path, proj.name, meta.sessionId);
575
- const provider = getProvider();
576
- const result = await provider.enhance(session);
577
- // Auto-save enhanced data locally
578
- saveEnhancedData(id, result);
579
- console.log(`[enhance] Saved enhanced data for ${id}`);
580
- res.json({ result, provider: provider.name });
581
- }
582
- catch (err) {
583
- const error = err;
584
- res.status(500).json({
585
- error: {
586
- code: error.code ?? 'ENHANCE_FAILED',
587
- message: error.message,
588
- },
589
- });
590
- }
591
- });
592
- // Delete locally-saved enhanced data (allows re-enhancing)
593
- app.delete('/api/sessions/:id/enhanced', (_req, res) => {
594
- const { id } = _req.params;
595
- deleteEnhancedData(id);
596
- console.log(`[enhance] Deleted enhanced data for ${id}`);
597
- res.json({ ok: true });
598
- });
599
- // Enhancement status — returns current mode and remaining quota
600
- app.get('/api/enhance/status', async (_req, res) => {
601
- try {
602
- if (getAnthropicApiKey()) {
603
- res.json({ mode: 'local', remaining: null });
604
- }
605
- else {
606
- res.json({ mode: 'none', remaining: 0, message: 'No API key configured' });
607
- }
608
- }
609
- catch {
610
- res.json({ mode: 'unknown', remaining: null });
611
- }
28
+ next();
612
29
  });
613
- // Save or clear the Anthropic API key
614
- app.post('/api/settings/api-key', express.json(), (req, res) => {
615
- const { apiKey } = req.body;
616
- if (apiKey && typeof apiKey === 'string' && apiKey.trim()) {
617
- saveAnthropicApiKey(apiKey.trim());
618
- console.log('[settings] API key saved');
619
- res.json({ ok: true, mode: getEnhanceMode() });
620
- }
621
- else {
622
- clearAnthropicApiKey();
623
- console.log('[settings] API key cleared');
624
- res.json({ ok: true, mode: getEnhanceMode() });
625
- }
626
- });
627
- // Get current API key status (masked)
628
- app.get('/api/settings/api-key', (_req, res) => {
629
- const key = getAnthropicApiKey();
630
- res.json({
631
- hasKey: !!key,
632
- maskedKey: key ? `${key.slice(0, 7)}...${key.slice(-4)}` : null,
633
- });
634
- });
635
- app.get('/api/auth/status', async (_req, res) => {
636
- try {
637
- const status = await checkAuthStatus(API_URL);
638
- console.log(`[auth/status] ${status.authenticated ? `authenticated as ${status.username}` : 'not authenticated'}`);
639
- res.json(status);
640
- }
641
- catch (err) {
642
- console.log(`[auth/status] check failed: ${err}`);
643
- res.json({ authenticated: false });
644
- }
645
- });
646
- // Start device auth flow — proxy to Phoenix
647
- app.post('/api/auth/login', async (_req, res) => {
648
- try {
649
- console.log(`[auth/login] Starting device auth via ${API_URL}/api/device/code`);
650
- const response = await fetch(`${API_URL}/api/device/code`, { method: 'POST' });
651
- if (!response.ok) {
652
- console.log(`[auth/login] FAILED ${response.status}`);
653
- res.status(response.status).json({ error: 'Failed to start device auth' });
654
- return;
655
- }
656
- const data = await response.json();
657
- console.log(`[auth/login] Got code: ${data.user_code}, uri: ${data.verification_uri}`);
658
- res.json(data);
659
- }
660
- catch (err) {
661
- console.error('[auth/login] EXCEPTION:', err);
662
- res.status(500).json({ error: 'Device auth request failed' });
663
- }
664
- });
665
- // Poll for device authorization completion — client passes device_code
666
- app.post('/api/auth/poll', async (req, res) => {
667
- try {
668
- const deviceCode = req.body?.device_code;
669
- if (!deviceCode) {
670
- res.status(400).json({ error: 'Missing device_code' });
671
- return;
672
- }
673
- const response = await fetch(`${API_URL}/api/device/token`, {
674
- method: 'POST',
675
- headers: { 'Content-Type': 'application/json' },
676
- body: JSON.stringify({ device_code: deviceCode }),
677
- });
678
- const data = await response.json();
679
- if (response.ok && data.access_token) {
680
- saveAuthToken(data.access_token, data.username);
681
- res.json({ authenticated: true, username: data.username });
682
- }
683
- else {
684
- res.status(response.status).json(data);
685
- }
686
- }
687
- catch {
688
- res.status(500).json({ error: 'Poll failed' });
689
- }
690
- });
691
- // Project enhance — enhance selected sessions + generate project narrative
692
- // SSE streaming: session_progress, project_enhance, done events
693
- app.post('/api/projects/:project/enhance-project', async (req, res) => {
694
- const { project } = req.params;
695
- const { selectedSessionIds, skippedSessions, force } = req.body;
696
- if (!Array.isArray(selectedSessionIds) || selectedSessionIds.length === 0) {
697
- res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'selectedSessionIds must be a non-empty array' } });
698
- return;
699
- }
700
- if (!getAnthropicApiKey()) {
701
- res.status(400).json({ error: { code: 'NO_API_KEY', message: 'No Anthropic API key configured. Add one in Settings or set ANTHROPIC_API_KEY.' } });
702
- return;
703
- }
704
- // Set up SSE
705
- res.writeHead(200, {
706
- 'Content-Type': 'text/event-stream',
707
- 'Cache-Control': 'no-cache',
708
- Connection: 'keep-alive',
709
- });
710
- const send = (data) => {
711
- res.write(`data: ${JSON.stringify(data)}\n\n`);
712
- };
713
- try {
714
- const projects = await getProjects(sessionsBasePath);
715
- const proj = projects.find((p) => p.name === project || p.dirName === project);
716
- if (!proj) {
717
- send({ type: 'error', code: 'PROJECT_NOT_FOUND', message: 'Project not found' });
718
- res.end();
719
- return;
720
- }
721
- // Check for cached project enhance result (unless force re-enhance)
722
- if (!force) {
723
- const cached = loadFreshProjectEnhanceResult(proj.dirName, selectedSessionIds);
724
- if (cached) {
725
- send({ type: 'cached', enhancedAt: cached.enhancedAt });
726
- send({ type: 'done', result: cached.result });
727
- res.end();
728
- return;
729
- }
730
- }
731
- // Check if there's a stale cache (different fingerprint) — inform frontend
732
- const staleCache = loadProjectEnhanceResult(proj.dirName);
733
- if (staleCache) {
734
- const currentFp = buildProjectFingerprint(selectedSessionIds);
735
- if (staleCache.fingerprint !== currentFp) {
736
- send({ type: 'stale_cache', previousEnhancedAt: staleCache.enhancedAt });
737
- }
738
- }
739
- const provider = getProvider();
740
- // Step 1: Enhance each selected session (skip already-enhanced)
741
- const sessionSummaries = [];
742
- const CONCURRENCY = 3;
743
- // Process sessions in batches of CONCURRENCY
744
- for (let i = 0; i < selectedSessionIds.length; i += CONCURRENCY) {
745
- const batch = selectedSessionIds.slice(i, i + CONCURRENCY);
746
- await Promise.all(batch.map(async (sessionId) => {
747
- const meta = proj.sessions.find((s) => s.sessionId === sessionId);
748
- if (!meta)
749
- return;
750
- // Check if already enhanced
751
- const existing = loadEnhancedData(sessionId);
752
- if (existing) {
753
- send({ type: 'session_progress', sessionId, status: 'skipped', title: existing.title, skills: existing.skills });
754
- sessionSummaries.push({
755
- sessionId,
756
- title: existing.title,
757
- developerTake: existing.developerTake,
758
- skills: existing.skills,
759
- executionSteps: existing.executionSteps.map((s) => ({ title: s.title, body: s.body })),
760
- duration: 0,
761
- loc: 0,
762
- turns: 0,
763
- files: 0,
764
- date: existing.enhancedAt,
765
- });
766
- return;
767
- }
768
- send({ type: 'session_progress', sessionId, status: 'enhancing' });
769
- try {
770
- const session = await loadSession(meta.path, proj.name, sessionId);
771
- const result = await provider.enhance(session);
772
- saveEnhancedData(sessionId, result);
773
- send({ type: 'session_progress', sessionId, status: 'done', title: result.title, skills: result.skills });
774
- sessionSummaries.push({
775
- sessionId,
776
- title: result.title,
777
- developerTake: result.developerTake,
778
- skills: result.skills,
779
- executionSteps: result.executionSteps.map((s) => ({ title: s.title, body: s.body })),
780
- duration: session.durationMinutes ?? 0,
781
- loc: session.linesOfCode ?? 0,
782
- turns: session.turns ?? 0,
783
- files: session.filesChanged?.length ?? 0,
784
- date: session.date ?? '',
785
- });
786
- }
787
- catch (err) {
788
- console.error(`[enhance-project] Session ${sessionId} failed:`, err.message);
789
- send({ type: 'session_progress', sessionId, status: 'failed', error: err.message });
790
- }
791
- }));
792
- }
793
- // Fill in stats for already-enhanced sessions that had zeroed stats
794
- for (const summary of sessionSummaries) {
795
- if (summary.duration === 0) {
796
- const meta = proj.sessions.find((s) => s.sessionId === summary.sessionId);
797
- if (meta) {
798
- const stats = await getSessionStats(meta, proj.name);
799
- summary.duration = stats.duration;
800
- summary.loc = stats.loc;
801
- summary.turns = stats.turns;
802
- summary.files = stats.files;
803
- summary.date = stats.date || summary.date;
804
- summary.correctionCount = undefined; // signals not available for cached
805
- }
806
- }
807
- }
808
- // Step 2: Generate project narrative (streaming narrative chunks)
809
- send({ type: 'project_enhance', status: 'generating' });
810
- const projectResult = await enhanceProject(sessionSummaries, skippedSessions ?? [], (event) => {
811
- send({ type: event.type, text: event.text });
812
- });
813
- // Save to cache for next time
814
- saveProjectEnhanceResult(proj.dirName, selectedSessionIds, projectResult);
815
- send({ type: 'done', result: projectResult });
816
- res.end();
817
- }
818
- catch (err) {
819
- console.error('[enhance-project] Failed:', err.message);
820
- send({ type: 'error', code: 'ENHANCE_FAILED', message: err.message });
821
- res.end();
822
- }
823
- });
824
- // Save project enhance result explicitly
825
- app.post('/api/projects/:project/enhance-save', async (req, res) => {
826
- const { project } = req.params;
827
- const { selectedSessionIds, result } = req.body;
828
- if (!Array.isArray(selectedSessionIds) || !result?.narrative) {
829
- res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'selectedSessionIds and result are required' } });
830
- return;
831
- }
832
- try {
833
- const projects = await getProjects(sessionsBasePath);
834
- const proj = projects.find((p) => p.name === project || p.dirName === project);
835
- if (!proj) {
836
- res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
837
- return;
838
- }
839
- saveProjectEnhanceResult(proj.dirName, selectedSessionIds, result);
840
- res.json({ saved: true, enhancedAt: new Date().toISOString() });
841
- }
842
- catch (err) {
843
- res.status(500).json({ error: { code: 'SAVE_FAILED', message: err.message } });
844
- }
845
- });
846
- // Get cached project enhance result (if any)
847
- app.get('/api/projects/:project/enhance-cache', async (req, res) => {
848
- const { project } = req.params;
849
- try {
850
- const projects = await getProjects(sessionsBasePath);
851
- const proj = projects.find((p) => p.name === project || p.dirName === project);
852
- if (!proj) {
853
- res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
854
- return;
855
- }
856
- const cached = loadProjectEnhanceResult(proj.dirName);
857
- if (!cached) {
858
- res.status(404).json({ error: { code: 'NO_CACHE', message: 'No cached enhance result' } });
859
- return;
860
- }
861
- // Check freshness against current session set
862
- const currentFp = buildProjectFingerprint(cached.selectedSessionIds);
863
- const isFresh = cached.fingerprint === currentFp;
864
- res.json({
865
- ...cached,
866
- isFresh,
867
- });
868
- }
869
- catch (err) {
870
- res.status(500).json({ error: { code: 'CACHE_READ_FAILED', message: err.message } });
871
- }
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
- });
972
- // Publish project — SSE stream with per-session progress
973
- app.post('/api/projects/:project/publish', async (req, res) => {
974
- const { project } = req.params;
975
- const auth = getAuthToken();
976
- if (!auth) {
977
- res.status(401).json({ error: { message: 'Authentication required' } });
978
- return;
979
- }
980
- const { title, slug, narrative, repoUrl, projectUrl, timeline, skills, totalSessions, totalLoc, totalDurationMinutes, totalAgentDurationMinutes, totalFilesChanged, skippedSessions, selectedSessionIds, } = req.body;
981
- // Set up SSE
982
- res.writeHead(200, {
983
- 'Content-Type': 'text/event-stream',
984
- 'Cache-Control': 'no-cache',
985
- Connection: 'keep-alive',
986
- });
987
- const send = (data) => {
988
- res.write(`data: ${JSON.stringify(data)}\n\n`);
989
- };
990
- try {
991
- // Step 1: Upsert project on Phoenix (fatal if this fails)
992
- send({ type: 'project', status: 'creating' });
993
- const projectRes = await fetch(`${API_URL}/api/projects`, {
994
- method: 'POST',
995
- headers: {
996
- 'Content-Type': 'application/json',
997
- Authorization: `Bearer ${auth.token}`,
998
- },
999
- body: JSON.stringify({
1000
- project: {
1001
- title, slug, narrative,
1002
- repo_url: repoUrl || null,
1003
- project_url: projectUrl || null,
1004
- timeline, skills,
1005
- total_sessions: totalSessions,
1006
- total_loc: totalLoc,
1007
- total_duration_minutes: totalDurationMinutes,
1008
- total_agent_duration_minutes: totalAgentDurationMinutes || null,
1009
- total_files_changed: totalFilesChanged,
1010
- skipped_sessions: skippedSessions,
1011
- },
1012
- }),
1013
- });
1014
- if (!projectRes.ok) {
1015
- const errBody = await projectRes.json().catch(() => ({ error: 'Project creation failed' }));
1016
- const rawErr = errBody.error;
1017
- const errMsg = typeof rawErr === 'string' ? rawErr
1018
- : (rawErr && typeof rawErr === 'object' && 'message' in rawErr) ? rawErr.message
1019
- : `HTTP ${projectRes.status}`;
1020
- send({ type: 'project', status: 'failed', error: errMsg, fatal: true });
1021
- res.end();
1022
- return;
1023
- }
1024
- const projectData = await projectRes.json();
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
- }
1072
- // Step 2: Publish selected sessions (non-fatal per session)
1073
- const projects = await getProjects(sessionsBasePath);
1074
- const proj = projects.find((p) => p.dirName === project);
1075
- let uploadedCount = 0;
1076
- const failedSessions = [];
1077
- if (proj) {
1078
- for (const sessionId of selectedSessionIds) {
1079
- const meta = proj.sessions.find((s) => s.sessionId === sessionId);
1080
- if (!meta)
1081
- continue;
1082
- send({ type: 'session', sessionId, status: 'publishing' });
1083
- try {
1084
- const session = await loadSession(meta.path, proj.name, sessionId);
1085
- const enhanced = loadEnhancedData(sessionId);
1086
- const sessionSlug = (enhanced?.title ?? session.title ?? sessionId)
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
1114
- const sessionPayload = {
1115
- session: {
1116
- title: enhanced?.title ?? session.title,
1117
- dev_take: devTake,
1118
- context: enhanced?.context ?? '',
1119
- duration_minutes: session.durationMinutes ?? 0,
1120
- turns: session.turns ?? 0,
1121
- files_changed: session.filesChanged?.length ?? 0,
1122
- loc_changed: session.linesOfCode ?? 0,
1123
- recorded_at: session.date ? new Date(session.date).toISOString() : new Date().toISOString(),
1124
- end_time: session.endTime ? new Date(session.endTime).toISOString() : null,
1125
- cwd: session.cwd ?? null,
1126
- wall_clock_minutes: session.wallClockMinutes ?? null,
1127
- template: 'editorial',
1128
- language: null,
1129
- tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
1130
- skills: enhanced?.skills ?? session.skills ?? [],
1131
- narrative,
1132
- project_name: proj.name,
1133
- project_id: projectData.project_id,
1134
- slug: sessionSlug,
1135
- status: 'listed',
1136
- source_tool: session.source ?? meta.source ?? 'claude',
1137
- agent_summary: agentSummary,
1138
- },
1139
- };
1140
- // session.json: full data including visualization fields for S3
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
1143
- const sessionData = {
1144
- version: 1,
1145
- id: sessionId,
1146
- title: enhanced?.title ?? session.title,
1147
- dev_take: devTake,
1148
- context: enhanced?.context ?? '',
1149
- duration_minutes: session.durationMinutes ?? 0,
1150
- turns: session.turns ?? 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,
1153
- date: session.date ? new Date(session.date).toISOString() : new Date().toISOString(),
1154
- end_time: (() => {
1155
- if (!session.endTime || !session.date)
1156
- return null;
1157
- const wallMs = new Date(session.endTime).getTime() - new Date(session.date).getTime();
1158
- const activeMs = (session.durationMinutes ?? 0) * 60_000;
1159
- return wallMs <= activeMs * 3 ? new Date(session.endTime).toISOString() : null;
1160
- })(),
1161
- cwd: session.cwd ?? null,
1162
- wall_clock_minutes: session.wallClockMinutes ?? null,
1163
- template: 'editorial',
1164
- skills: enhanced?.skills ?? session.skills ?? [],
1165
- tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
1166
- source: session.source ?? meta.source ?? 'claude',
1167
- slug: sessionSlug,
1168
- project_name: proj.name,
1169
- narrative,
1170
- status: 'listed',
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 ?? '',
1176
- })),
1177
- qa_pairs: enhanced?.qaPairs ?? session.qaPairs ?? [],
1178
- highlights: [],
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) => ({
1182
- timestamp: t.timestamp,
1183
- type: t.type,
1184
- content: (t.content ?? '').slice(0, 200),
1185
- tools: t.tools ?? [],
1186
- })),
1187
- // M6: Keep tool prefixes in transcript — they're informative
1188
- transcript_excerpt: (session.rawLog ?? []).slice(0, 10).map((line, i) => {
1189
- const role = line.startsWith('> ') ? 'dev' : 'ai';
1190
- const text = role === 'dev' ? line.slice(2) : line;
1191
- return { role, id: `Turn ${i + 1}`, text, timestamp: null };
1192
- }),
1193
- agent_summary: agentSummary,
1194
- children: agentSummary?.agents?.map((a) => ({
1195
- sessionId: a.role,
1196
- role: a.role,
1197
- durationMinutes: a.duration_minutes,
1198
- linesOfCode: a.loc_changed,
1199
- })) ?? [],
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
- }
1211
- const sessionRes = await fetch(`${API_URL}/api/sessions`, {
1212
- method: 'POST',
1213
- headers: {
1214
- 'Content-Type': 'application/json',
1215
- Authorization: `Bearer ${auth.token}`,
1216
- },
1217
- body: JSON.stringify(redactedPayload),
1218
- });
1219
- if (sessionRes.ok) {
1220
- uploadedCount++;
1221
- // Upload raw JSONL and log JSON to S3 (best-effort, non-fatal)
1222
- try {
1223
- const sesData = await sessionRes.json();
1224
- if (sesData.upload_urls) {
1225
- const { raw: rawUrl, log: logUrl } = sesData.upload_urls;
1226
- if (rawUrl && meta.path && !meta.path.startsWith('cursor://')) {
1227
- try {
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' } });
1232
- }
1233
- catch { /* S3 upload is best-effort */ }
1234
- }
1235
- if (logUrl && session.rawLog && session.rawLog.length > 0) {
1236
- try {
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' } });
1243
- }
1244
- catch { /* S3 upload is best-effort */ }
1245
- }
1246
- if (sesData.upload_urls.session) {
1247
- try {
1248
- await fetch(sesData.upload_urls.session, {
1249
- method: 'PUT',
1250
- body: JSON.stringify(redactedData),
1251
- headers: { 'Content-Type': 'application/json' },
1252
- });
1253
- }
1254
- catch { /* S3 upload is best-effort */ }
1255
- }
1256
- }
1257
- }
1258
- catch { /* Response already consumed or no upload_urls — not fatal */ }
1259
- if (enhanced) {
1260
- saveEnhancedData(sessionId, { ...enhanced, uploaded: true });
1261
- }
1262
- send({ type: 'session', sessionId, status: 'published' });
1263
- }
1264
- else {
1265
- const sesErrBody = await sessionRes.json().catch(() => null);
1266
- const rawSesErr = sesErrBody && typeof sesErrBody === 'object' ? sesErrBody.error : null;
1267
- const errMsg = typeof rawSesErr === 'string' ? rawSesErr
1268
- : (rawSesErr && typeof rawSesErr === 'object' && 'message' in rawSesErr) ? rawSesErr.message
1269
- : `HTTP ${sessionRes.status}`;
1270
- failedSessions.push({ sessionId, error: errMsg });
1271
- send({ type: 'session', sessionId, status: 'failed', error: errMsg });
1272
- }
1273
- }
1274
- catch (err) {
1275
- const errMsg = err.message;
1276
- failedSessions.push({ sessionId, error: errMsg });
1277
- send({ type: 'session', sessionId, status: 'failed', error: errMsg });
1278
- }
1279
- }
1280
- }
1281
- // Track published state locally
1282
- const publishedSessionIds = selectedSessionIds.filter((sid) => {
1283
- const enhanced = loadEnhancedData(sid);
1284
- return enhanced?.uploaded;
1285
- });
1286
- if (proj) {
1287
- savePublishedState(proj.dirName, {
1288
- slug: projectData.slug,
1289
- projectId: projectData.project_id,
1290
- publishedSessions: publishedSessionIds,
1291
- });
1292
- }
1293
- const projectUrl2 = `/${auth.username}/${projectData.slug}`;
1294
- send({
1295
- type: 'done',
1296
- projectUrl: projectUrl2,
1297
- projectId: projectData.project_id,
1298
- slug: projectData.slug,
1299
- uploaded: uploadedCount,
1300
- failed: failedSessions.length,
1301
- failedSessions,
1302
- });
1303
- res.end();
1304
- }
1305
- catch (err) {
1306
- console.error('[publish] Error:', err.message);
1307
- send({ type: 'error', code: 'PUBLISH_FAILED', message: err.message });
1308
- res.end();
1309
- }
1310
- });
1311
- // Narrative refinement — weave developer's answers into the draft narrative
1312
- app.post('/api/projects/:project/refine-narrative', async (req, res) => {
1313
- try {
1314
- const { draftNarrative, draftTimeline, answers } = req.body;
1315
- if (!draftNarrative || typeof draftNarrative !== 'string') {
1316
- res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'draftNarrative is required' } });
1317
- return;
1318
- }
1319
- const refined = await refineNarrative(draftNarrative, draftTimeline ?? [], answers ?? []);
1320
- res.json(refined);
1321
- }
1322
- catch (err) {
1323
- res.status(500).json({
1324
- error: { code: 'REFINE_FAILED', message: err.message },
1325
- });
1326
- }
30
+ app.use(cors({ origin: ['http://localhost:17845', 'http://127.0.0.1:17845'] }));
31
+ app.use((_req, res, next) => {
32
+ res.setHeader('X-Content-Type-Options', 'nosniff');
33
+ res.setHeader('X-Frame-Options', 'DENY');
34
+ next();
1327
35
  });
1328
- // Serve React app static files
36
+ app.use(express.json({ limit: '50mb' }));
37
+ // ── Mount domain routers ───────────────────────────────────
38
+ app.use(createProjectsRouter(ctx));
39
+ app.use(createEnhanceRouter(ctx));
40
+ app.use(createPublishRouter(ctx));
41
+ app.use(createSearchRouter(ctx));
42
+ app.use(createSessionsRouter(ctx));
43
+ app.use(createArchiveRouter(ctx));
44
+ app.use(createAuthRouter(ctx));
45
+ app.use(createSettingsRouter(ctx));
46
+ app.use(createExportRouter(ctx));
47
+ app.use(createPreviewRouter(ctx));
48
+ app.use(createDashboardRouter(ctx));
49
+ // ── Static files ───────────────────────────────────────────
1329
50
  const staticDir = path.resolve(__dirname, '..', 'app', 'dist');
1330
51
  app.use(express.static(staticDir));
1331
- // SPA fallback serve index.html for non-API routes
52
+ // SPA fallback -- serve index.html for non-API routes
1332
53
  app.get('/{*splat}', (_req, res) => {
1333
54
  res.sendFile(path.join(staticDir, 'index.html'));
1334
55
  });
@@ -1336,8 +57,24 @@ export function createApp(sessionsBasePath) {
1336
57
  }
1337
58
  export function startServer(port = 17845) {
1338
59
  const app = createApp();
60
+ const db = getDatabase();
61
+ // Run initial sync in the background (non-blocking)
62
+ // Uses syncWithTracking so the dashboard can observe progress via /api/sync/progress
63
+ syncWithTracking(db).then((result) => {
64
+ if (result.indexed > 0) {
65
+ console.log(`Indexed ${result.indexed} sessions (${result.skipped} up-to-date)`);
66
+ }
67
+ }).catch(() => { });
1339
68
  return new Promise((resolve) => {
1340
- const server = app.listen(port, () => {
69
+ const server = app.listen(port, '127.0.0.1', () => {
70
+ // Start live sync after server is listening
71
+ const stopFileWatcher = startFileWatcher(db);
72
+ const stopCursorPolling = startCursorPolling(db);
73
+ // Clean up watchers when server closes
74
+ server.on('close', () => {
75
+ stopFileWatcher();
76
+ stopCursorPolling();
77
+ });
1341
78
  resolve(server);
1342
79
  });
1343
80
  });