heyiam 0.1.0
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/app/dist/assets/index-BGQkmy7q.css +1 -0
- package/app/dist/assets/index-CbLHxerW.js +14 -0
- package/app/dist/favicon.svg +1 -0
- package/app/dist/icons.svg +24 -0
- package/app/dist/index.html +20 -0
- package/dist/analyzer.d.ts +96 -0
- package/dist/analyzer.js +249 -0
- package/dist/analyzer.js.map +1 -0
- package/dist/auth.d.ts +34 -0
- package/dist/auth.js +99 -0
- package/dist/auth.js.map +1 -0
- package/dist/bridge.d.ts +32 -0
- package/dist/bridge.js +211 -0
- package/dist/bridge.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +129 -0
- package/dist/index.js.map +1 -0
- package/dist/llm/anthropic-provider.d.ts +11 -0
- package/dist/llm/anthropic-provider.js +16 -0
- package/dist/llm/anthropic-provider.js.map +1 -0
- package/dist/llm/index.d.ts +16 -0
- package/dist/llm/index.js +25 -0
- package/dist/llm/index.js.map +1 -0
- package/dist/llm/project-enhance.d.ts +71 -0
- package/dist/llm/project-enhance.js +236 -0
- package/dist/llm/project-enhance.js.map +1 -0
- package/dist/llm/proxy-provider.d.ts +16 -0
- package/dist/llm/proxy-provider.js +60 -0
- package/dist/llm/proxy-provider.js.map +1 -0
- package/dist/llm/triage.d.ts +66 -0
- package/dist/llm/triage.js +283 -0
- package/dist/llm/triage.js.map +1 -0
- package/dist/llm/types.d.ts +19 -0
- package/dist/llm/types.js +2 -0
- package/dist/llm/types.js.map +1 -0
- package/dist/machine-key.d.ts +10 -0
- package/dist/machine-key.js +51 -0
- package/dist/machine-key.js.map +1 -0
- package/dist/parsers/claude.d.ts +18 -0
- package/dist/parsers/claude.js +265 -0
- package/dist/parsers/claude.js.map +1 -0
- package/dist/parsers/index.d.ts +28 -0
- package/dist/parsers/index.js +124 -0
- package/dist/parsers/index.js.map +1 -0
- package/dist/parsers/types.d.ts +81 -0
- package/dist/parsers/types.js +2 -0
- package/dist/parsers/types.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +857 -0
- package/dist/server.js.map +1 -0
- package/dist/settings.d.ts +92 -0
- package/dist/settings.js +160 -0
- package/dist/settings.js.map +1 -0
- package/dist/summarize.d.ts +72 -0
- package/dist/summarize.js +312 -0
- package/dist/summarize.js.map +1 -0
- package/package.json +47 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import path from 'node:path';
|
|
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 } 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
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
// Derive a human-readable project name from the encoded directory name.
|
|
17
|
+
// "-Users-ben-Dev-heyi-am" → "heyi-am"
|
|
18
|
+
// "-Users-ben-Dev-agent-sync" → "agent-sync"
|
|
19
|
+
// Heuristic: find "Dev-" prefix and take everything after it.
|
|
20
|
+
// Falls back to last path-like segment.
|
|
21
|
+
function displayNameFromDir(dirName) {
|
|
22
|
+
// Try to find a Dev- boundary (common pattern)
|
|
23
|
+
const devIdx = dirName.indexOf('-Dev-');
|
|
24
|
+
if (devIdx !== -1) {
|
|
25
|
+
return dirName.slice(devIdx + 5); // everything after "-Dev-"
|
|
26
|
+
}
|
|
27
|
+
// Fallback: last hyphen-separated segment
|
|
28
|
+
const segments = dirName.split('-').filter(Boolean);
|
|
29
|
+
return segments.length > 0 ? segments[segments.length - 1] : dirName;
|
|
30
|
+
}
|
|
31
|
+
async function getProjects(basePath) {
|
|
32
|
+
const allSessions = await listSessions(basePath);
|
|
33
|
+
// Group by projectDir (set by the scanner)
|
|
34
|
+
const byDir = new Map();
|
|
35
|
+
for (const s of allSessions) {
|
|
36
|
+
const existing = byDir.get(s.projectDir) ?? [];
|
|
37
|
+
existing.push(s);
|
|
38
|
+
byDir.set(s.projectDir, existing);
|
|
39
|
+
}
|
|
40
|
+
return [...byDir.entries()].map(([dirName, sessions]) => ({
|
|
41
|
+
name: displayNameFromDir(dirName),
|
|
42
|
+
dirName,
|
|
43
|
+
sessionCount: sessions.length,
|
|
44
|
+
sessions,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
async function loadSession(sessionPath, projectName, sessionId) {
|
|
48
|
+
const parsed = await parseSession(sessionPath);
|
|
49
|
+
const analyzerInput = bridgeToAnalyzer(parsed, { sessionId, projectName });
|
|
50
|
+
const session = analyzeSession(analyzerInput);
|
|
51
|
+
return mergeEnhancedData(session);
|
|
52
|
+
}
|
|
53
|
+
/** Merge locally-saved enhanced data into a session if it exists. */
|
|
54
|
+
function mergeEnhancedData(session) {
|
|
55
|
+
const enhanced = loadEnhancedData(session.id);
|
|
56
|
+
if (!enhanced)
|
|
57
|
+
return session;
|
|
58
|
+
return {
|
|
59
|
+
...session,
|
|
60
|
+
title: enhanced.title,
|
|
61
|
+
developerTake: enhanced.developerTake,
|
|
62
|
+
context: enhanced.context,
|
|
63
|
+
skills: enhanced.skills,
|
|
64
|
+
executionPath: enhanced.executionSteps.map((s) => ({
|
|
65
|
+
stepNumber: s.stepNumber,
|
|
66
|
+
title: s.title,
|
|
67
|
+
description: s.body,
|
|
68
|
+
})),
|
|
69
|
+
qaPairs: enhanced.qaPairs,
|
|
70
|
+
status: enhanced.uploaded ? 'published' : 'enhanced',
|
|
71
|
+
quickEnhanced: enhanced.quickEnhanced ?? false,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// ── Persistent stats cache ────────────────────────────────────
|
|
75
|
+
// Survives server restarts by writing to disk.
|
|
76
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
77
|
+
import { join } from 'node:path';
|
|
78
|
+
import { homedir } from 'node:os';
|
|
79
|
+
const STATS_CACHE_PATH = join(homedir(), '.config', 'heyiam', 'stats-cache.json');
|
|
80
|
+
function loadStatsCache() {
|
|
81
|
+
try {
|
|
82
|
+
if (!existsSync(STATS_CACHE_PATH))
|
|
83
|
+
return new Map();
|
|
84
|
+
const data = JSON.parse(readFileSync(STATS_CACHE_PATH, 'utf-8'));
|
|
85
|
+
return new Map(Object.entries(data));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return new Map();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function saveStatsCache(cache) {
|
|
92
|
+
try {
|
|
93
|
+
const dir = join(homedir(), '.config', 'heyiam');
|
|
94
|
+
mkdirSync(dir, { recursive: true });
|
|
95
|
+
const obj = Object.fromEntries(cache);
|
|
96
|
+
writeFileSync(STATS_CACHE_PATH, JSON.stringify(obj), { mode: 0o600 });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Non-critical — cache miss just means a slower first load
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function createApp(sessionsBasePath) {
|
|
103
|
+
const app = express();
|
|
104
|
+
// Stats cache — loaded from disk on startup, written back periodically
|
|
105
|
+
const statsCache = loadStatsCache();
|
|
106
|
+
let statsCacheDirty = false;
|
|
107
|
+
// Flush dirty cache to disk every 10 seconds
|
|
108
|
+
const flushInterval = setInterval(() => {
|
|
109
|
+
if (statsCacheDirty) {
|
|
110
|
+
saveStatsCache(statsCache);
|
|
111
|
+
statsCacheDirty = false;
|
|
112
|
+
}
|
|
113
|
+
}, 10_000);
|
|
114
|
+
// Don't let this interval keep the process alive
|
|
115
|
+
flushInterval.unref?.();
|
|
116
|
+
async function getSessionStats(meta, projectName) {
|
|
117
|
+
const cached = statsCache.get(meta.sessionId);
|
|
118
|
+
if (cached)
|
|
119
|
+
return cached;
|
|
120
|
+
try {
|
|
121
|
+
const session = await loadSession(meta.path, projectName, meta.sessionId);
|
|
122
|
+
const stats = {
|
|
123
|
+
loc: session.linesOfCode ?? 0,
|
|
124
|
+
duration: session.durationMinutes ?? 0,
|
|
125
|
+
files: session.filesChanged?.length ?? 0,
|
|
126
|
+
turns: session.turns ?? 0,
|
|
127
|
+
skills: session.skills ?? [],
|
|
128
|
+
date: session.date ?? '',
|
|
129
|
+
};
|
|
130
|
+
statsCache.set(meta.sessionId, stats);
|
|
131
|
+
statsCacheDirty = true;
|
|
132
|
+
return stats;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return { loc: 0, duration: 0, files: 0, turns: 0, skills: [], date: '' };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function getProjectWithStats(proj) {
|
|
139
|
+
const allStats = await Promise.all(proj.sessions.map((m) => getSessionStats(m, proj.name)));
|
|
140
|
+
const totalLoc = allStats.reduce((s, st) => s + st.loc, 0);
|
|
141
|
+
const totalDuration = allStats.reduce((s, st) => s + st.duration, 0);
|
|
142
|
+
const totalFiles = allStats.reduce((s, st) => s + st.files, 0);
|
|
143
|
+
// Deduplicated skills across all sessions
|
|
144
|
+
const skillSet = new Set();
|
|
145
|
+
for (const st of allStats) {
|
|
146
|
+
for (const sk of st.skills)
|
|
147
|
+
skillSet.add(sk);
|
|
148
|
+
}
|
|
149
|
+
// Date range
|
|
150
|
+
const dates = allStats.map((st) => st.date).filter(Boolean).sort();
|
|
151
|
+
const firstDate = dates[0] ?? '';
|
|
152
|
+
const lastDate = dates[dates.length - 1] ?? '';
|
|
153
|
+
const published = getPublishedState(proj.dirName);
|
|
154
|
+
const enhanceCache = loadProjectEnhanceResult(proj.dirName);
|
|
155
|
+
return {
|
|
156
|
+
name: proj.name,
|
|
157
|
+
dirName: proj.dirName,
|
|
158
|
+
sessionCount: proj.sessionCount,
|
|
159
|
+
description: '',
|
|
160
|
+
totalLoc,
|
|
161
|
+
totalDuration,
|
|
162
|
+
totalFiles,
|
|
163
|
+
skills: [...skillSet],
|
|
164
|
+
dateRange: firstDate && lastDate ? `${firstDate}|${lastDate}` : '',
|
|
165
|
+
lastSessionDate: lastDate,
|
|
166
|
+
isPublished: !!published,
|
|
167
|
+
publishedSessionCount: published?.publishedSessions.length ?? 0,
|
|
168
|
+
publishedSessions: published?.publishedSessions ?? [],
|
|
169
|
+
enhancedAt: enhanceCache?.enhancedAt ?? null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
app.use(cors({ origin: ['http://localhost:17845', 'http://127.0.0.1:17845'] }));
|
|
173
|
+
app.use(express.json({ limit: '50mb' }));
|
|
174
|
+
// API routes — wired to real parser pipeline
|
|
175
|
+
app.get('/api/projects', async (_req, res) => {
|
|
176
|
+
try {
|
|
177
|
+
const projects = await getProjects(sessionsBasePath);
|
|
178
|
+
const projectsWithStats = await Promise.all(projects.map((p) => getProjectWithStats(p)));
|
|
179
|
+
res.json({ projects: projectsWithStats });
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
res.status(500).json({ error: { code: 'SCAN_FAILED', message: err.message } });
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
app.get('/api/projects/:project/sessions', async (req, res) => {
|
|
186
|
+
try {
|
|
187
|
+
const { project } = req.params;
|
|
188
|
+
const projects = await getProjects(sessionsBasePath);
|
|
189
|
+
const proj = projects.find((p) => p.name === project || p.dirName === project);
|
|
190
|
+
if (!proj) {
|
|
191
|
+
res.json({ sessions: [] });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Return parent sessions with enriched child summaries.
|
|
195
|
+
// Children get stats via the cached getSessionStats — no redundant parsing.
|
|
196
|
+
const sessions = await Promise.all(proj.sessions.map(async (meta) => {
|
|
197
|
+
try {
|
|
198
|
+
const session = await loadSession(meta.path, proj.name, meta.sessionId);
|
|
199
|
+
// Deduplicate children by role (worktree agents create duplicates)
|
|
200
|
+
const seenRoles = new Set();
|
|
201
|
+
const children = [];
|
|
202
|
+
for (const c of meta.children ?? []) {
|
|
203
|
+
const role = c.agentRole ?? c.sessionId;
|
|
204
|
+
if (seenRoles.has(role))
|
|
205
|
+
continue;
|
|
206
|
+
seenRoles.add(role);
|
|
207
|
+
const childStats = await getSessionStats(c, proj.name);
|
|
208
|
+
children.push({
|
|
209
|
+
sessionId: c.sessionId,
|
|
210
|
+
role: c.agentRole,
|
|
211
|
+
durationMinutes: childStats.duration,
|
|
212
|
+
linesOfCode: childStats.loc,
|
|
213
|
+
date: childStats.date,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const childCount = children.length;
|
|
217
|
+
return { ...session, childCount, children: childCount > 0 ? children : undefined };
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}));
|
|
223
|
+
res.json({ sessions: sessions.filter(Boolean) });
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
res.status(500).json({ error: { code: 'LIST_FAILED', message: err.message } });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
app.get('/api/projects/:project/sessions/:id', async (req, res) => {
|
|
230
|
+
try {
|
|
231
|
+
const { project, id } = req.params;
|
|
232
|
+
const projects = await getProjects(sessionsBasePath);
|
|
233
|
+
const proj = projects.find((p) => p.name === project || p.dirName === project);
|
|
234
|
+
if (!proj) {
|
|
235
|
+
res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const meta = proj.sessions.find((s) => s.sessionId === id);
|
|
239
|
+
if (!meta) {
|
|
240
|
+
res.status(404).json({ error: { code: 'SESSION_NOT_FOUND', message: 'Session not found' } });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const session = await loadSession(meta.path, proj.name, meta.sessionId);
|
|
244
|
+
// Fully parse and attach child sessions
|
|
245
|
+
const childSessions = await bridgeChildSessions(meta, proj.name);
|
|
246
|
+
const aggregated = childSessions.length > 0 ? aggregateChildStats(childSessions) : undefined;
|
|
247
|
+
res.json({
|
|
248
|
+
session: {
|
|
249
|
+
...session,
|
|
250
|
+
...(childSessions.length > 0 ? { childSessions, isOrchestrated: true } : {}),
|
|
251
|
+
...(aggregated ? { aggregatedStats: aggregated } : {}),
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
res.status(500).json({ error: { code: 'PARSE_FAILED', message: err.message } });
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
// Triage endpoint — AI selects which sessions are worth showcasing (SSE stream)
|
|
260
|
+
app.post('/api/projects/:project/triage', async (req, res) => {
|
|
261
|
+
const { project } = req.params;
|
|
262
|
+
const projects = await getProjects(sessionsBasePath);
|
|
263
|
+
const proj = projects.find((p) => p.name === project || p.dirName === project);
|
|
264
|
+
if (!proj) {
|
|
265
|
+
res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// Set up SSE
|
|
269
|
+
res.writeHead(200, {
|
|
270
|
+
'Content-Type': 'text/event-stream',
|
|
271
|
+
'Cache-Control': 'no-cache',
|
|
272
|
+
Connection: 'keep-alive',
|
|
273
|
+
});
|
|
274
|
+
const send = (event) => {
|
|
275
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
276
|
+
};
|
|
277
|
+
try {
|
|
278
|
+
// Build session metadata with stats for triage (sequential for progress)
|
|
279
|
+
const total = proj.sessions.length;
|
|
280
|
+
const sessionsWithStats = [];
|
|
281
|
+
for (let i = 0; i < proj.sessions.length; i++) {
|
|
282
|
+
const meta = proj.sessions[i];
|
|
283
|
+
send({ type: 'loading_stats', sessionId: meta.sessionId, index: i, total });
|
|
284
|
+
const stats = await getSessionStats(meta, proj.name);
|
|
285
|
+
sessionsWithStats.push({
|
|
286
|
+
sessionId: meta.sessionId,
|
|
287
|
+
path: meta.path,
|
|
288
|
+
title: stats.date ? `Session ${meta.sessionId.slice(0, 8)}` : meta.sessionId,
|
|
289
|
+
duration: stats.duration,
|
|
290
|
+
loc: stats.loc,
|
|
291
|
+
turns: stats.turns,
|
|
292
|
+
files: stats.files,
|
|
293
|
+
skills: stats.skills,
|
|
294
|
+
date: stats.date,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
const useLLM = req.body?.useLLM !== false;
|
|
298
|
+
const result = await triageSessions(sessionsWithStats, useLLM, (event) => {
|
|
299
|
+
send(event);
|
|
300
|
+
});
|
|
301
|
+
// Include already-published sessions so frontend can pre-check them
|
|
302
|
+
const published = getPublishedState(proj.dirName);
|
|
303
|
+
const alreadyPublished = published?.publishedSessions ?? [];
|
|
304
|
+
send({ type: 'result', ...result, alreadyPublished });
|
|
305
|
+
res.end();
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
send({ type: 'error', code: 'TRIAGE_FAILED', message: err.message });
|
|
309
|
+
res.end();
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
// Git remote auto-detection — derives repo URL from project path
|
|
313
|
+
app.get('/api/projects/:project/git-remote', async (req, res) => {
|
|
314
|
+
try {
|
|
315
|
+
const { project } = req.params;
|
|
316
|
+
const projects = await getProjects(sessionsBasePath);
|
|
317
|
+
const proj = projects.find((p) => p.name === project || p.dirName === project);
|
|
318
|
+
if (!proj) {
|
|
319
|
+
res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// Derive the project filesystem path from the dirName.
|
|
323
|
+
// Claude Code encodes "/Users/ben/Dev/heyi-am" as "-Users-ben-Dev-heyi-am"
|
|
324
|
+
// (replaces "/" with "-"). Reverse: replace leading "-" with "/".
|
|
325
|
+
const projectPath = proj.dirName.replace(/^-/, '/').replace(/-/g, '/');
|
|
326
|
+
let remoteUrl = null;
|
|
327
|
+
try {
|
|
328
|
+
// Use execFileSync to avoid shell injection — fixed args, no interpolation
|
|
329
|
+
const raw = execFileSync('git', ['-C', projectPath, 'remote', 'get-url', 'origin'], {
|
|
330
|
+
timeout: 5000,
|
|
331
|
+
encoding: 'utf-8',
|
|
332
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
333
|
+
}).trim();
|
|
334
|
+
// Clean the URL:
|
|
335
|
+
// git@github.com:user/repo.git → github.com/user/repo
|
|
336
|
+
// https://github.com/user/repo.git → github.com/user/repo
|
|
337
|
+
remoteUrl = raw
|
|
338
|
+
.replace(/\.git$/, '')
|
|
339
|
+
.replace(/^git@([^:]+):/, '$1/')
|
|
340
|
+
.replace(/^https?:\/\//, '');
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// No git remote or not a git repo — return null
|
|
344
|
+
}
|
|
345
|
+
res.json({ url: remoteUrl });
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
res.status(500).json({ error: { code: 'GIT_REMOTE_FAILED', message: err.message } });
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
// Enhance endpoints — AI-powered session summarization
|
|
352
|
+
// Uses provider abstraction: BYOK (local Anthropic SDK) or proxy (Phoenix backend)
|
|
353
|
+
app.post('/api/projects/:project/sessions/:id/enhance', async (req, res) => {
|
|
354
|
+
try {
|
|
355
|
+
const { project, id } = req.params;
|
|
356
|
+
const projects = await getProjects(sessionsBasePath);
|
|
357
|
+
const proj = projects.find((p) => p.name === project || p.dirName === project);
|
|
358
|
+
if (!proj) {
|
|
359
|
+
res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const meta = proj.sessions.find((s) => s.sessionId === id);
|
|
363
|
+
if (!meta) {
|
|
364
|
+
res.status(404).json({ error: { code: 'SESSION_NOT_FOUND', message: 'Session not found' } });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const session = await loadSession(meta.path, proj.name, meta.sessionId);
|
|
368
|
+
const provider = getProvider();
|
|
369
|
+
const result = await provider.enhance(session);
|
|
370
|
+
// Auto-save enhanced data locally
|
|
371
|
+
saveEnhancedData(id, result);
|
|
372
|
+
console.log(`[enhance] Saved enhanced data for ${id}`);
|
|
373
|
+
res.json({ result, provider: provider.name });
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
const error = err;
|
|
377
|
+
res.status(500).json({
|
|
378
|
+
error: {
|
|
379
|
+
code: error.code ?? 'ENHANCE_FAILED',
|
|
380
|
+
message: error.message,
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
// Delete locally-saved enhanced data (allows re-enhancing)
|
|
386
|
+
app.delete('/api/sessions/:id/enhanced', (_req, res) => {
|
|
387
|
+
const { id } = _req.params;
|
|
388
|
+
deleteEnhancedData(id);
|
|
389
|
+
console.log(`[enhance] Deleted enhanced data for ${id}`);
|
|
390
|
+
res.json({ ok: true });
|
|
391
|
+
});
|
|
392
|
+
// Enhancement status — returns current mode and remaining quota
|
|
393
|
+
app.get('/api/enhance/status', async (_req, res) => {
|
|
394
|
+
try {
|
|
395
|
+
const mode = getEnhanceMode();
|
|
396
|
+
if (mode === 'local') {
|
|
397
|
+
res.json({ mode: 'local', remaining: null });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
// Proxy mode — check quota from Phoenix
|
|
401
|
+
const auth = getAuthToken();
|
|
402
|
+
if (!auth?.token) {
|
|
403
|
+
res.json({ mode: 'none', remaining: 0, message: 'Not configured' });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// We don't have a dedicated quota endpoint yet, so report proxy mode
|
|
407
|
+
res.json({ mode: 'proxy', remaining: null });
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
res.json({ mode: 'unknown', remaining: null });
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
// Save or clear the Anthropic API key
|
|
414
|
+
app.post('/api/settings/api-key', express.json(), (req, res) => {
|
|
415
|
+
const { apiKey } = req.body;
|
|
416
|
+
if (apiKey && typeof apiKey === 'string' && apiKey.trim()) {
|
|
417
|
+
saveAnthropicApiKey(apiKey.trim());
|
|
418
|
+
console.log('[settings] API key saved');
|
|
419
|
+
res.json({ ok: true, mode: getEnhanceMode() });
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
clearAnthropicApiKey();
|
|
423
|
+
console.log('[settings] API key cleared');
|
|
424
|
+
res.json({ ok: true, mode: getEnhanceMode() });
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
// Get current API key status (masked)
|
|
428
|
+
app.get('/api/settings/api-key', (_req, res) => {
|
|
429
|
+
const key = getAnthropicApiKey();
|
|
430
|
+
res.json({
|
|
431
|
+
hasKey: !!key,
|
|
432
|
+
maskedKey: key ? `${key.slice(0, 7)}...${key.slice(-4)}` : null,
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
app.get('/api/auth/status', async (_req, res) => {
|
|
436
|
+
try {
|
|
437
|
+
const status = await checkAuthStatus(API_URL);
|
|
438
|
+
console.log(`[auth/status] ${status.authenticated ? `authenticated as ${status.username}` : 'not authenticated'}`);
|
|
439
|
+
res.json(status);
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
console.log(`[auth/status] check failed: ${err}`);
|
|
443
|
+
res.json({ authenticated: false });
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
// Start device auth flow — proxy to Phoenix
|
|
447
|
+
app.post('/api/auth/login', async (_req, res) => {
|
|
448
|
+
try {
|
|
449
|
+
console.log(`[auth/login] Starting device auth via ${API_URL}/api/device/code`);
|
|
450
|
+
const response = await fetch(`${API_URL}/api/device/code`, { method: 'POST' });
|
|
451
|
+
if (!response.ok) {
|
|
452
|
+
console.log(`[auth/login] FAILED ${response.status}`);
|
|
453
|
+
res.status(response.status).json({ error: 'Failed to start device auth' });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const data = await response.json();
|
|
457
|
+
console.log(`[auth/login] Got code: ${data.user_code}, uri: ${data.verification_uri}`);
|
|
458
|
+
res.json(data);
|
|
459
|
+
}
|
|
460
|
+
catch (err) {
|
|
461
|
+
console.error('[auth/login] EXCEPTION:', err);
|
|
462
|
+
res.status(500).json({ error: 'Device auth request failed' });
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
// Poll for device authorization completion — client passes device_code
|
|
466
|
+
app.post('/api/auth/poll', async (req, res) => {
|
|
467
|
+
try {
|
|
468
|
+
const deviceCode = req.body?.device_code;
|
|
469
|
+
if (!deviceCode) {
|
|
470
|
+
res.status(400).json({ error: 'Missing device_code' });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const response = await fetch(`${API_URL}/api/device/token`, {
|
|
474
|
+
method: 'POST',
|
|
475
|
+
headers: { 'Content-Type': 'application/json' },
|
|
476
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
477
|
+
});
|
|
478
|
+
const data = await response.json();
|
|
479
|
+
if (response.ok && data.access_token) {
|
|
480
|
+
saveAuthToken(data.access_token, data.username);
|
|
481
|
+
res.json({ authenticated: true, username: data.username });
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
res.status(response.status).json(data);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
res.status(500).json({ error: 'Poll failed' });
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
// Project enhance — enhance selected sessions + generate project narrative
|
|
492
|
+
// SSE streaming: session_progress, project_enhance, done events
|
|
493
|
+
app.post('/api/projects/:project/enhance-project', async (req, res) => {
|
|
494
|
+
const { project } = req.params;
|
|
495
|
+
const { selectedSessionIds, skippedSessions, force } = req.body;
|
|
496
|
+
if (!Array.isArray(selectedSessionIds) || selectedSessionIds.length === 0) {
|
|
497
|
+
res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'selectedSessionIds must be a non-empty array' } });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// Set up SSE
|
|
501
|
+
res.writeHead(200, {
|
|
502
|
+
'Content-Type': 'text/event-stream',
|
|
503
|
+
'Cache-Control': 'no-cache',
|
|
504
|
+
Connection: 'keep-alive',
|
|
505
|
+
});
|
|
506
|
+
const send = (data) => {
|
|
507
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
508
|
+
};
|
|
509
|
+
try {
|
|
510
|
+
const projects = await getProjects(sessionsBasePath);
|
|
511
|
+
const proj = projects.find((p) => p.name === project || p.dirName === project);
|
|
512
|
+
if (!proj) {
|
|
513
|
+
send({ type: 'error', code: 'PROJECT_NOT_FOUND', message: 'Project not found' });
|
|
514
|
+
res.end();
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
// Check for cached project enhance result (unless force re-enhance)
|
|
518
|
+
if (!force) {
|
|
519
|
+
const cached = loadFreshProjectEnhanceResult(proj.dirName, selectedSessionIds);
|
|
520
|
+
if (cached) {
|
|
521
|
+
send({ type: 'cached', enhancedAt: cached.enhancedAt });
|
|
522
|
+
send({ type: 'done', result: cached.result });
|
|
523
|
+
res.end();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// Check if there's a stale cache (different fingerprint) — inform frontend
|
|
528
|
+
const staleCache = loadProjectEnhanceResult(proj.dirName);
|
|
529
|
+
if (staleCache) {
|
|
530
|
+
const currentFp = buildProjectFingerprint(selectedSessionIds);
|
|
531
|
+
if (staleCache.fingerprint !== currentFp) {
|
|
532
|
+
send({ type: 'stale_cache', previousEnhancedAt: staleCache.enhancedAt });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const provider = getProvider();
|
|
536
|
+
// Step 1: Enhance each selected session (skip already-enhanced)
|
|
537
|
+
const sessionSummaries = [];
|
|
538
|
+
const CONCURRENCY = 3;
|
|
539
|
+
// Process sessions in batches of CONCURRENCY
|
|
540
|
+
for (let i = 0; i < selectedSessionIds.length; i += CONCURRENCY) {
|
|
541
|
+
const batch = selectedSessionIds.slice(i, i + CONCURRENCY);
|
|
542
|
+
await Promise.all(batch.map(async (sessionId) => {
|
|
543
|
+
const meta = proj.sessions.find((s) => s.sessionId === sessionId);
|
|
544
|
+
if (!meta)
|
|
545
|
+
return;
|
|
546
|
+
// Check if already enhanced
|
|
547
|
+
const existing = loadEnhancedData(sessionId);
|
|
548
|
+
if (existing) {
|
|
549
|
+
send({ type: 'session_progress', sessionId, status: 'skipped', title: existing.title, skills: existing.skills });
|
|
550
|
+
sessionSummaries.push({
|
|
551
|
+
sessionId,
|
|
552
|
+
title: existing.title,
|
|
553
|
+
developerTake: existing.developerTake,
|
|
554
|
+
skills: existing.skills,
|
|
555
|
+
executionSteps: existing.executionSteps.map((s) => ({ title: s.title, body: s.body })),
|
|
556
|
+
duration: 0,
|
|
557
|
+
loc: 0,
|
|
558
|
+
turns: 0,
|
|
559
|
+
files: 0,
|
|
560
|
+
date: existing.enhancedAt,
|
|
561
|
+
});
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
send({ type: 'session_progress', sessionId, status: 'enhancing' });
|
|
565
|
+
try {
|
|
566
|
+
const session = await loadSession(meta.path, proj.name, sessionId);
|
|
567
|
+
const result = await provider.enhance(session);
|
|
568
|
+
saveEnhancedData(sessionId, result);
|
|
569
|
+
send({ type: 'session_progress', sessionId, status: 'done', title: result.title, skills: result.skills });
|
|
570
|
+
sessionSummaries.push({
|
|
571
|
+
sessionId,
|
|
572
|
+
title: result.title,
|
|
573
|
+
developerTake: result.developerTake,
|
|
574
|
+
skills: result.skills,
|
|
575
|
+
executionSteps: result.executionSteps.map((s) => ({ title: s.title, body: s.body })),
|
|
576
|
+
duration: session.durationMinutes ?? 0,
|
|
577
|
+
loc: session.linesOfCode ?? 0,
|
|
578
|
+
turns: session.turns ?? 0,
|
|
579
|
+
files: session.filesChanged?.length ?? 0,
|
|
580
|
+
date: session.date ?? '',
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
console.error(`[enhance-project] Session ${sessionId} failed:`, err.message);
|
|
585
|
+
send({ type: 'session_progress', sessionId, status: 'failed', error: err.message });
|
|
586
|
+
}
|
|
587
|
+
}));
|
|
588
|
+
}
|
|
589
|
+
// Fill in stats for already-enhanced sessions that had zeroed stats
|
|
590
|
+
for (const summary of sessionSummaries) {
|
|
591
|
+
if (summary.duration === 0) {
|
|
592
|
+
const meta = proj.sessions.find((s) => s.sessionId === summary.sessionId);
|
|
593
|
+
if (meta) {
|
|
594
|
+
const stats = await getSessionStats(meta, proj.name);
|
|
595
|
+
summary.duration = stats.duration;
|
|
596
|
+
summary.loc = stats.loc;
|
|
597
|
+
summary.turns = stats.turns;
|
|
598
|
+
summary.files = stats.files;
|
|
599
|
+
summary.date = stats.date || summary.date;
|
|
600
|
+
summary.correctionCount = undefined; // signals not available for cached
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// Step 2: Generate project narrative (streaming narrative chunks)
|
|
605
|
+
send({ type: 'project_enhance', status: 'generating' });
|
|
606
|
+
const projectResult = await enhanceProject(sessionSummaries, skippedSessions ?? [], (event) => {
|
|
607
|
+
send({ type: event.type, text: event.text });
|
|
608
|
+
});
|
|
609
|
+
// Save to cache for next time
|
|
610
|
+
saveProjectEnhanceResult(proj.dirName, selectedSessionIds, projectResult);
|
|
611
|
+
send({ type: 'done', result: projectResult });
|
|
612
|
+
res.end();
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
console.error('[enhance-project] Failed:', err.message);
|
|
616
|
+
send({ type: 'error', code: 'ENHANCE_FAILED', message: err.message });
|
|
617
|
+
res.end();
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
// Save project enhance result explicitly
|
|
621
|
+
app.post('/api/projects/:project/enhance-save', async (req, res) => {
|
|
622
|
+
const { project } = req.params;
|
|
623
|
+
const { selectedSessionIds, result } = req.body;
|
|
624
|
+
if (!Array.isArray(selectedSessionIds) || !result?.narrative) {
|
|
625
|
+
res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'selectedSessionIds and result are required' } });
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
const projects = await getProjects(sessionsBasePath);
|
|
630
|
+
const proj = projects.find((p) => p.name === project || p.dirName === project);
|
|
631
|
+
if (!proj) {
|
|
632
|
+
res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
saveProjectEnhanceResult(proj.dirName, selectedSessionIds, result);
|
|
636
|
+
res.json({ saved: true, enhancedAt: new Date().toISOString() });
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
res.status(500).json({ error: { code: 'SAVE_FAILED', message: err.message } });
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
// Get cached project enhance result (if any)
|
|
643
|
+
app.get('/api/projects/:project/enhance-cache', async (req, res) => {
|
|
644
|
+
const { project } = req.params;
|
|
645
|
+
try {
|
|
646
|
+
const projects = await getProjects(sessionsBasePath);
|
|
647
|
+
const proj = projects.find((p) => p.name === project || p.dirName === project);
|
|
648
|
+
if (!proj) {
|
|
649
|
+
res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const cached = loadProjectEnhanceResult(proj.dirName);
|
|
653
|
+
if (!cached) {
|
|
654
|
+
res.status(404).json({ error: { code: 'NO_CACHE', message: 'No cached enhance result' } });
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
// Check freshness against current session set
|
|
658
|
+
const currentFp = buildProjectFingerprint(cached.selectedSessionIds);
|
|
659
|
+
const isFresh = cached.fingerprint === currentFp;
|
|
660
|
+
res.json({
|
|
661
|
+
...cached,
|
|
662
|
+
isFresh,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
catch (err) {
|
|
666
|
+
res.status(500).json({ error: { code: 'CACHE_READ_FAILED', message: err.message } });
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
// Publish project — SSE stream with per-session progress
|
|
670
|
+
app.post('/api/projects/:project/publish', async (req, res) => {
|
|
671
|
+
const { project } = req.params;
|
|
672
|
+
const auth = getAuthToken();
|
|
673
|
+
if (!auth) {
|
|
674
|
+
res.status(401).json({ error: { message: 'Authentication required' } });
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const { title, slug, narrative, repoUrl, projectUrl, timeline, skills, totalSessions, totalLoc, totalDurationMinutes, totalFilesChanged, skippedSessions, selectedSessionIds, } = req.body;
|
|
678
|
+
// Set up SSE
|
|
679
|
+
res.writeHead(200, {
|
|
680
|
+
'Content-Type': 'text/event-stream',
|
|
681
|
+
'Cache-Control': 'no-cache',
|
|
682
|
+
Connection: 'keep-alive',
|
|
683
|
+
});
|
|
684
|
+
const send = (data) => {
|
|
685
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
686
|
+
};
|
|
687
|
+
try {
|
|
688
|
+
// Step 1: Upsert project on Phoenix (fatal if this fails)
|
|
689
|
+
send({ type: 'project', status: 'creating' });
|
|
690
|
+
const projectRes = await fetch(`${API_URL}/api/projects`, {
|
|
691
|
+
method: 'POST',
|
|
692
|
+
headers: {
|
|
693
|
+
'Content-Type': 'application/json',
|
|
694
|
+
Authorization: `Bearer ${auth.token}`,
|
|
695
|
+
},
|
|
696
|
+
body: JSON.stringify({
|
|
697
|
+
project: {
|
|
698
|
+
title, slug, narrative,
|
|
699
|
+
repo_url: repoUrl || null,
|
|
700
|
+
project_url: projectUrl || null,
|
|
701
|
+
timeline, skills,
|
|
702
|
+
total_sessions: totalSessions,
|
|
703
|
+
total_loc: totalLoc,
|
|
704
|
+
total_duration_minutes: totalDurationMinutes,
|
|
705
|
+
total_files_changed: totalFilesChanged,
|
|
706
|
+
skipped_sessions: skippedSessions,
|
|
707
|
+
},
|
|
708
|
+
}),
|
|
709
|
+
});
|
|
710
|
+
if (!projectRes.ok) {
|
|
711
|
+
const errBody = await projectRes.json().catch(() => ({ error: 'Project creation failed' }));
|
|
712
|
+
const errMsg = errBody.error ?? `HTTP ${projectRes.status}`;
|
|
713
|
+
send({ type: 'project', status: 'failed', error: errMsg, fatal: true });
|
|
714
|
+
res.end();
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const projectData = await projectRes.json();
|
|
718
|
+
send({ type: 'project', status: 'created', projectId: projectData.project_id, slug: projectData.slug });
|
|
719
|
+
// Step 2: Publish selected sessions (non-fatal per session)
|
|
720
|
+
const projects = await getProjects(sessionsBasePath);
|
|
721
|
+
const proj = projects.find((p) => p.dirName === project);
|
|
722
|
+
let uploadedCount = 0;
|
|
723
|
+
const failedSessions = [];
|
|
724
|
+
if (proj) {
|
|
725
|
+
for (const sessionId of selectedSessionIds) {
|
|
726
|
+
const meta = proj.sessions.find((s) => s.sessionId === sessionId);
|
|
727
|
+
if (!meta)
|
|
728
|
+
continue;
|
|
729
|
+
send({ type: 'session', sessionId, status: 'publishing' });
|
|
730
|
+
try {
|
|
731
|
+
const session = await loadSession(meta.path, proj.name, sessionId);
|
|
732
|
+
const enhanced = loadEnhancedData(sessionId);
|
|
733
|
+
const sessionSlug = (enhanced?.title ?? session.title ?? sessionId)
|
|
734
|
+
.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80);
|
|
735
|
+
const sessionPayload = {
|
|
736
|
+
session: {
|
|
737
|
+
title: enhanced?.title ?? session.title,
|
|
738
|
+
dev_take: enhanced?.developerTake ?? session.developerTake ?? '',
|
|
739
|
+
context: enhanced?.context ?? '',
|
|
740
|
+
duration_minutes: session.durationMinutes ?? 0,
|
|
741
|
+
turns: session.turns ?? 0,
|
|
742
|
+
files_changed: session.filesChanged?.length ?? 0,
|
|
743
|
+
loc_changed: session.linesOfCode ?? 0,
|
|
744
|
+
recorded_at: session.date ? new Date(session.date).toISOString() : new Date().toISOString(),
|
|
745
|
+
template: 'editorial',
|
|
746
|
+
language: null,
|
|
747
|
+
tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
|
|
748
|
+
skills: enhanced?.skills ?? session.skills ?? [],
|
|
749
|
+
beats: (enhanced?.executionSteps ?? session.executionPath ?? []).map((s, i) => ({
|
|
750
|
+
label: s.title,
|
|
751
|
+
description: 'body' in s ? s.body : ('description' in s ? s.description : ''),
|
|
752
|
+
position: i,
|
|
753
|
+
})),
|
|
754
|
+
qa_pairs: enhanced?.qaPairs ?? session.qaPairs ?? [],
|
|
755
|
+
highlights: [],
|
|
756
|
+
tool_breakdown: (session.toolBreakdown ?? []).map((t) => ({ name: t.tool, count: t.count })),
|
|
757
|
+
top_files: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f } : f)),
|
|
758
|
+
narrative: enhanced?.developerTake ?? '',
|
|
759
|
+
project_name: proj.name,
|
|
760
|
+
project_id: projectData.project_id,
|
|
761
|
+
slug: sessionSlug,
|
|
762
|
+
status: 'listed',
|
|
763
|
+
},
|
|
764
|
+
};
|
|
765
|
+
const sessionRes = await fetch(`${API_URL}/api/sessions`, {
|
|
766
|
+
method: 'POST',
|
|
767
|
+
headers: {
|
|
768
|
+
'Content-Type': 'application/json',
|
|
769
|
+
Authorization: `Bearer ${auth.token}`,
|
|
770
|
+
},
|
|
771
|
+
body: JSON.stringify(sessionPayload),
|
|
772
|
+
});
|
|
773
|
+
if (sessionRes.ok) {
|
|
774
|
+
uploadedCount++;
|
|
775
|
+
if (enhanced) {
|
|
776
|
+
saveEnhancedData(sessionId, { ...enhanced, uploaded: true });
|
|
777
|
+
}
|
|
778
|
+
send({ type: 'session', sessionId, status: 'published' });
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
const errMsg = `HTTP ${sessionRes.status}`;
|
|
782
|
+
failedSessions.push({ sessionId, error: errMsg });
|
|
783
|
+
send({ type: 'session', sessionId, status: 'failed', error: errMsg });
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
catch (err) {
|
|
787
|
+
const errMsg = err.message;
|
|
788
|
+
failedSessions.push({ sessionId, error: errMsg });
|
|
789
|
+
send({ type: 'session', sessionId, status: 'failed', error: errMsg });
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
// Track published state locally
|
|
794
|
+
const publishedSessionIds = selectedSessionIds.filter((sid) => {
|
|
795
|
+
const enhanced = loadEnhancedData(sid);
|
|
796
|
+
return enhanced?.uploaded;
|
|
797
|
+
});
|
|
798
|
+
if (proj) {
|
|
799
|
+
savePublishedState(proj.dirName, {
|
|
800
|
+
slug: projectData.slug,
|
|
801
|
+
projectId: projectData.project_id,
|
|
802
|
+
publishedSessions: publishedSessionIds,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
const projectUrl2 = `/${auth.username}/${projectData.slug}`;
|
|
806
|
+
send({
|
|
807
|
+
type: 'done',
|
|
808
|
+
projectUrl: projectUrl2,
|
|
809
|
+
projectId: projectData.project_id,
|
|
810
|
+
slug: projectData.slug,
|
|
811
|
+
uploaded: uploadedCount,
|
|
812
|
+
failed: failedSessions.length,
|
|
813
|
+
failedSessions,
|
|
814
|
+
});
|
|
815
|
+
res.end();
|
|
816
|
+
}
|
|
817
|
+
catch (err) {
|
|
818
|
+
console.error('[publish] Error:', err.message);
|
|
819
|
+
send({ type: 'error', code: 'PUBLISH_FAILED', message: err.message });
|
|
820
|
+
res.end();
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
// Narrative refinement — weave developer's answers into the draft narrative
|
|
824
|
+
app.post('/api/projects/:project/refine-narrative', async (req, res) => {
|
|
825
|
+
try {
|
|
826
|
+
const { draftNarrative, draftTimeline, answers } = req.body;
|
|
827
|
+
if (!draftNarrative || typeof draftNarrative !== 'string') {
|
|
828
|
+
res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'draftNarrative is required' } });
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const refined = await refineNarrative(draftNarrative, draftTimeline ?? [], answers ?? []);
|
|
832
|
+
res.json(refined);
|
|
833
|
+
}
|
|
834
|
+
catch (err) {
|
|
835
|
+
res.status(500).json({
|
|
836
|
+
error: { code: 'REFINE_FAILED', message: err.message },
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
// Serve React app static files
|
|
841
|
+
const staticDir = path.resolve(__dirname, '..', 'app', 'dist');
|
|
842
|
+
app.use(express.static(staticDir));
|
|
843
|
+
// SPA fallback — serve index.html for non-API routes
|
|
844
|
+
app.get('/{*splat}', (_req, res) => {
|
|
845
|
+
res.sendFile(path.join(staticDir, 'index.html'));
|
|
846
|
+
});
|
|
847
|
+
return app;
|
|
848
|
+
}
|
|
849
|
+
export function startServer(port = 17845) {
|
|
850
|
+
const app = createApp();
|
|
851
|
+
return new Promise((resolve) => {
|
|
852
|
+
const server = app.listen(port, () => {
|
|
853
|
+
resolve(server);
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
//# sourceMappingURL=server.js.map
|