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.
- package/dist/analyzer.d.ts +3 -3
- package/dist/archive.d.ts +14 -0
- package/dist/archive.js +125 -0
- package/dist/archive.js.map +1 -0
- package/dist/auth.d.ts +0 -6
- package/dist/auth.js +2 -4
- package/dist/auth.js.map +1 -1
- package/dist/autostart.d.ts +19 -0
- package/dist/autostart.js +103 -0
- package/dist/autostart.js.map +1 -0
- package/dist/bridge.d.ts +0 -2
- package/dist/bridge.js +33 -4
- package/dist/bridge.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/context-export.d.ts +22 -0
- package/dist/context-export.js +230 -0
- package/dist/context-export.js.map +1 -0
- package/dist/daemon-install.d.ts +23 -0
- package/dist/daemon-install.js +155 -0
- package/dist/daemon-install.js.map +1 -0
- package/dist/db.d.ts +118 -0
- package/dist/db.js +444 -0
- package/dist/db.js.map +1 -0
- package/dist/export.d.ts +30 -0
- package/dist/export.js +377 -0
- package/dist/export.js.map +1 -0
- package/dist/format-utils.d.ts +6 -0
- package/dist/format-utils.js +15 -0
- package/dist/format-utils.js.map +1 -0
- package/dist/index.js +474 -117
- package/dist/index.js.map +1 -1
- package/dist/llm/project-enhance.js +1 -1
- package/dist/parsers/claude.js +73 -0
- package/dist/parsers/claude.js.map +1 -1
- package/dist/parsers/codex.js +1 -1
- package/dist/parsers/codex.js.map +1 -1
- package/dist/parsers/cursor.d.ts +2 -0
- package/dist/parsers/cursor.js +14 -26
- package/dist/parsers/cursor.js.map +1 -1
- package/dist/parsers/gemini.d.ts +3 -2
- package/dist/parsers/gemini.js +198 -21
- package/dist/parsers/gemini.js.map +1 -1
- package/dist/parsers/index.d.ts +1 -1
- package/dist/parsers/index.js +23 -7
- package/dist/parsers/index.js.map +1 -1
- package/dist/parsers/types.d.ts +27 -1
- package/dist/render/build-render-data.d.ts +59 -0
- package/dist/render/build-render-data.js +101 -0
- package/dist/render/build-render-data.js.map +1 -0
- package/dist/render/components/PortfolioPage.d.ts +4 -0
- package/dist/render/components/PortfolioPage.js +16 -0
- package/dist/render/components/PortfolioPage.js.map +1 -0
- package/dist/render/components/ProjectPage.d.ts +4 -0
- package/dist/render/components/ProjectPage.js +101 -0
- package/dist/render/components/ProjectPage.js.map +1 -0
- package/dist/render/components/SessionPage.d.ts +4 -0
- package/dist/render/components/SessionPage.js +29 -0
- package/dist/render/components/SessionPage.js.map +1 -0
- package/dist/render/index.d.ts +37 -0
- package/dist/render/index.js +140 -0
- package/dist/render/index.js.map +1 -0
- package/dist/render/types.d.ts +121 -0
- package/dist/render/types.js +9 -0
- package/dist/render/types.js.map +1 -0
- package/dist/routes/archive.d.ts +3 -0
- package/dist/routes/archive.js +56 -0
- package/dist/routes/archive.js.map +1 -0
- package/dist/routes/auth.d.ts +3 -0
- package/dist/routes/auth.js +116 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/context.d.ts +61 -0
- package/dist/routes/context.js +356 -0
- package/dist/routes/context.js.map +1 -0
- package/dist/routes/dashboard.d.ts +3 -0
- package/dist/routes/dashboard.js +103 -0
- package/dist/routes/dashboard.js.map +1 -0
- package/dist/routes/enhance.d.ts +3 -0
- package/dist/routes/enhance.js +305 -0
- package/dist/routes/enhance.js.map +1 -0
- package/dist/routes/export.d.ts +3 -0
- package/dist/routes/export.js +145 -0
- package/dist/routes/export.js.map +1 -0
- package/dist/routes/index.d.ts +12 -0
- package/dist/routes/index.js +13 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/preview.d.ts +3 -0
- package/dist/routes/preview.js +191 -0
- package/dist/routes/preview.js.map +1 -0
- package/dist/routes/projects.d.ts +3 -0
- package/dist/routes/projects.js +356 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/publish.d.ts +3 -0
- package/dist/routes/publish.js +466 -0
- package/dist/routes/publish.js.map +1 -0
- package/dist/routes/search.d.ts +3 -0
- package/dist/routes/search.js +110 -0
- package/dist/routes/search.js.map +1 -0
- package/dist/routes/sessions.d.ts +3 -0
- package/dist/routes/sessions.js +103 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/settings.d.ts +3 -0
- package/dist/routes/settings.js +30 -0
- package/dist/routes/settings.js.map +1 -0
- package/dist/screenshot.d.ts +5 -2
- package/dist/screenshot.js +187 -13
- package/dist/screenshot.js.map +1 -1
- package/dist/search.d.ts +30 -0
- package/dist/search.js +153 -0
- package/dist/search.js.map +1 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +55 -1318
- package/dist/server.js.map +1 -1
- package/dist/settings.d.ts +23 -6
- package/dist/settings.js +36 -12
- package/dist/settings.js.map +1 -1
- package/dist/source-audit.d.ts +29 -0
- package/dist/source-audit.js +203 -0
- package/dist/source-audit.js.map +1 -0
- package/dist/sync.d.ts +74 -0
- package/dist/sync.js +358 -0
- package/dist/sync.js.map +1 -0
- package/dist/transcript.d.ts +68 -0
- package/dist/transcript.js +268 -0
- package/dist/transcript.js.map +1 -0
- package/package.json +5 -1
- package/app/dist/assets/html2canvas-Cwn_rrOw.js +0 -5
- package/app/dist/assets/index-CEQyTkgN.js +0 -14
- package/app/dist/assets/index-DLh5xRE8.css +0 -1
- package/app/dist/favicon.svg +0 -5
- package/app/dist/icons.svg +0 -24
- package/app/dist/index.html +0 -20
- package/dist/machine-key.d.ts +0 -10
- package/dist/machine-key.js +0 -51
- 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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
-
|
|
614
|
-
app.
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
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
|
|
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
|
});
|