heyiam 0.2.29 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +45 -0
  2. package/dist/auth.js +29 -3
  3. package/dist/config.js +10 -1
  4. package/dist/db.js +0 -1
  5. package/dist/export.js +124 -27
  6. package/dist/format-utils.js +5 -0
  7. package/dist/github.js +381 -0
  8. package/dist/index.js +168 -0
  9. package/dist/mount.js +300 -102
  10. package/dist/parsers/claude.js +2 -28
  11. package/dist/parsers/codex.js +2 -26
  12. package/dist/parsers/cursor.js +2 -26
  13. package/dist/parsers/duration.js +35 -0
  14. package/dist/parsers/gemini.js +2 -20
  15. package/dist/parsers/index.js +22 -3
  16. package/dist/parsers/types.js +0 -1
  17. package/dist/public/assets/index-Coilyhtr.css +1 -0
  18. package/dist/public/assets/index-D0noVMFu.js +44 -0
  19. package/dist/public/index.html +2 -2
  20. package/dist/redact.js +4 -104
  21. package/dist/render/build-render-data.js +9 -2
  22. package/dist/render/index.js +32 -5
  23. package/dist/render/liquid.js +147 -7
  24. package/dist/render/mock-data.js +303 -0
  25. package/dist/render/templates/aurora/portfolio.liquid +192 -0
  26. package/dist/render/templates/aurora/project.liquid +260 -0
  27. package/dist/render/templates/aurora/session.liquid +223 -0
  28. package/dist/render/templates/aurora/styles.css +1184 -0
  29. package/dist/render/templates/bauhaus/portfolio.liquid +169 -0
  30. package/dist/render/templates/bauhaus/project.liquid +300 -0
  31. package/dist/render/templates/bauhaus/session.liquid +333 -0
  32. package/dist/render/templates/bauhaus/styles.css +1645 -0
  33. package/dist/render/templates/blueprint/portfolio.liquid +153 -0
  34. package/dist/render/templates/blueprint/project.liquid +286 -0
  35. package/dist/render/templates/blueprint/session.liquid +248 -0
  36. package/dist/render/templates/blueprint/styles.css +1289 -0
  37. package/dist/render/templates/canvas/portfolio.liquid +203 -0
  38. package/dist/render/templates/canvas/project.liquid +235 -0
  39. package/dist/render/templates/canvas/session.liquid +223 -0
  40. package/dist/render/templates/canvas/styles.css +1440 -0
  41. package/dist/render/templates/carbon/portfolio.liquid +160 -0
  42. package/dist/render/templates/carbon/project.liquid +249 -0
  43. package/dist/render/templates/carbon/session.liquid +190 -0
  44. package/dist/render/templates/carbon/styles.css +1097 -0
  45. package/dist/render/templates/chalk/portfolio.liquid +189 -0
  46. package/dist/render/templates/chalk/project.liquid +245 -0
  47. package/dist/render/templates/chalk/session.liquid +215 -0
  48. package/dist/render/templates/chalk/styles.css +1161 -0
  49. package/dist/render/templates/circuit/portfolio.liquid +152 -0
  50. package/dist/render/templates/circuit/project.liquid +247 -0
  51. package/dist/render/templates/circuit/session.liquid +205 -0
  52. package/dist/render/templates/circuit/styles.css +1409 -0
  53. package/dist/render/templates/cosmos/portfolio.liquid +222 -0
  54. package/dist/render/templates/cosmos/project.liquid +327 -0
  55. package/dist/render/templates/cosmos/session.liquid +239 -0
  56. package/dist/render/templates/cosmos/styles.css +1157 -0
  57. package/dist/render/templates/daylight/portfolio.liquid +207 -0
  58. package/dist/render/templates/daylight/project.liquid +229 -0
  59. package/dist/render/templates/daylight/session.liquid +219 -0
  60. package/dist/render/templates/daylight/styles.css +1315 -0
  61. package/dist/render/templates/editorial/portfolio.liquid +110 -0
  62. package/dist/render/templates/editorial/project.liquid +202 -0
  63. package/dist/render/templates/editorial/session.liquid +171 -0
  64. package/dist/render/templates/editorial/styles.css +826 -0
  65. package/dist/render/templates/ember/portfolio.liquid +306 -0
  66. package/dist/render/templates/ember/project.liquid +232 -0
  67. package/dist/render/templates/ember/session.liquid +202 -0
  68. package/dist/render/templates/ember/styles.css +1289 -0
  69. package/dist/render/templates/glacier/portfolio.liquid +261 -0
  70. package/dist/render/templates/glacier/project.liquid +288 -0
  71. package/dist/render/templates/glacier/session.liquid +217 -0
  72. package/dist/render/templates/glacier/styles.css +1204 -0
  73. package/dist/render/templates/grid/portfolio.liquid +255 -0
  74. package/dist/render/templates/grid/project.liquid +306 -0
  75. package/dist/render/templates/grid/session.liquid +260 -0
  76. package/dist/render/templates/grid/styles.css +1445 -0
  77. package/dist/render/templates/kinetic/portfolio.liquid +158 -0
  78. package/dist/render/templates/kinetic/project.liquid +242 -0
  79. package/dist/render/templates/kinetic/session.liquid +228 -0
  80. package/dist/render/templates/kinetic/styles.css +948 -0
  81. package/dist/render/templates/meridian/portfolio.liquid +243 -0
  82. package/dist/render/templates/meridian/project.liquid +376 -0
  83. package/dist/render/templates/meridian/session.liquid +298 -0
  84. package/dist/render/templates/meridian/styles.css +1375 -0
  85. package/dist/render/templates/minimal/portfolio.liquid +71 -0
  86. package/dist/render/templates/minimal/project.liquid +154 -0
  87. package/dist/render/templates/minimal/session.liquid +140 -0
  88. package/dist/render/templates/minimal/styles.css +529 -0
  89. package/dist/render/templates/mono/portfolio.liquid +281 -0
  90. package/dist/render/templates/mono/project.liquid +275 -0
  91. package/dist/render/templates/mono/session.liquid +276 -0
  92. package/dist/render/templates/mono/styles.css +1022 -0
  93. package/dist/render/templates/neon/portfolio.liquid +207 -0
  94. package/dist/render/templates/neon/project.liquid +225 -0
  95. package/dist/render/templates/neon/session.liquid +195 -0
  96. package/dist/render/templates/neon/styles.css +1271 -0
  97. package/dist/render/templates/noir/portfolio.liquid +137 -0
  98. package/dist/render/templates/noir/project.liquid +220 -0
  99. package/dist/render/templates/noir/session.liquid +241 -0
  100. package/dist/render/templates/noir/styles.css +1229 -0
  101. package/dist/render/templates/obsidian/portfolio.liquid +247 -0
  102. package/dist/render/templates/obsidian/project.liquid +280 -0
  103. package/dist/render/templates/obsidian/session.liquid +241 -0
  104. package/dist/render/templates/obsidian/styles.css +1407 -0
  105. package/dist/render/templates/paper/portfolio.liquid +257 -0
  106. package/dist/render/templates/paper/project.liquid +235 -0
  107. package/dist/render/templates/paper/session.liquid +271 -0
  108. package/dist/render/templates/paper/styles.css +1513 -0
  109. package/dist/render/templates/parallax/portfolio.liquid +295 -0
  110. package/dist/render/templates/parallax/project.liquid +275 -0
  111. package/dist/render/templates/parallax/session.liquid +295 -0
  112. package/dist/render/templates/parallax/styles.css +1880 -0
  113. package/dist/render/templates/parchment/portfolio.liquid +280 -0
  114. package/dist/render/templates/parchment/project.liquid +289 -0
  115. package/dist/render/templates/parchment/session.liquid +346 -0
  116. package/dist/render/templates/parchment/styles.css +1401 -0
  117. package/dist/render/templates/partials/_beats.liquid +16 -0
  118. package/dist/render/templates/partials/_breadcrumb.liquid +9 -0
  119. package/dist/render/templates/partials/_footer.liquid +7 -0
  120. package/dist/render/templates/partials/_growth-chart.liquid +7 -0
  121. package/dist/render/templates/partials/_key-decisions.liquid +20 -0
  122. package/dist/render/templates/partials/_links.liquid +16 -0
  123. package/dist/render/templates/partials/_narrative.liquid +8 -0
  124. package/dist/render/templates/partials/_phases.liquid +20 -0
  125. package/dist/render/templates/partials/_portfolio-header.liquid +20 -0
  126. package/dist/render/templates/partials/_portfolio-projects.liquid +16 -0
  127. package/dist/render/templates/partials/_portfolio-stats.liquid +19 -0
  128. package/dist/render/templates/partials/_qa.liquid +13 -0
  129. package/dist/render/templates/partials/_screenshot.liquid +15 -0
  130. package/dist/render/templates/partials/_session-cards.liquid +30 -0
  131. package/dist/render/templates/partials/_session-header.liquid +39 -0
  132. package/dist/render/templates/partials/_session-sidebar.liquid +30 -0
  133. package/dist/render/templates/partials/_skills.liquid +12 -0
  134. package/dist/render/templates/partials/_source-breakdown.liquid +22 -0
  135. package/dist/render/templates/partials/_stats.liquid +38 -0
  136. package/dist/render/templates/partials/_work-timeline.liquid +7 -0
  137. package/dist/render/templates/project.liquid +7 -4
  138. package/dist/render/templates/radar/portfolio.liquid +223 -0
  139. package/dist/render/templates/radar/project.liquid +278 -0
  140. package/dist/render/templates/radar/session.liquid +300 -0
  141. package/dist/render/templates/radar/styles.css +1055 -0
  142. package/dist/render/templates/showcase/portfolio.liquid +221 -0
  143. package/dist/render/templates/showcase/project.liquid +237 -0
  144. package/dist/render/templates/showcase/session.liquid +210 -0
  145. package/dist/render/templates/showcase/styles.css +1284 -0
  146. package/dist/render/templates/signal/portfolio.liquid +217 -0
  147. package/dist/render/templates/signal/project.liquid +278 -0
  148. package/dist/render/templates/signal/session.liquid +282 -0
  149. package/dist/render/templates/signal/styles.css +1401 -0
  150. package/dist/render/templates/strata/portfolio.liquid +180 -0
  151. package/dist/render/templates/strata/project.liquid +282 -0
  152. package/dist/render/templates/strata/session.liquid +261 -0
  153. package/dist/render/templates/strata/styles.css +1354 -0
  154. package/dist/render/templates/styles.css +1190 -0
  155. package/dist/render/templates/terminal/portfolio.liquid +102 -0
  156. package/dist/render/templates/terminal/project.liquid +161 -0
  157. package/dist/render/templates/terminal/session.liquid +145 -0
  158. package/dist/render/templates/terminal/styles.css +497 -0
  159. package/dist/render/templates/verdant/portfolio.liquid +321 -0
  160. package/dist/render/templates/verdant/project.liquid +309 -0
  161. package/dist/render/templates/verdant/session.liquid +237 -0
  162. package/dist/render/templates/verdant/styles.css +1261 -0
  163. package/dist/render/templates/zen/portfolio.liquid +124 -0
  164. package/dist/render/templates/zen/project.liquid +187 -0
  165. package/dist/render/templates/zen/session.liquid +203 -0
  166. package/dist/render/templates/zen/styles.css +1211 -0
  167. package/dist/render/templates.js +90 -0
  168. package/dist/routes/auth.js +7 -3
  169. package/dist/routes/context.js +17 -10
  170. package/dist/routes/delete.js +195 -0
  171. package/dist/routes/enhance.js +57 -40
  172. package/dist/routes/export.js +14 -4
  173. package/dist/routes/github.js +254 -0
  174. package/dist/routes/index.js +2 -0
  175. package/dist/routes/portfolio-render-data.js +160 -0
  176. package/dist/routes/preview.js +555 -108
  177. package/dist/routes/projects.js +61 -24
  178. package/dist/routes/publish.js +320 -31
  179. package/dist/routes/settings.js +194 -1
  180. package/dist/routes/sse.js +9 -0
  181. package/dist/search.js +6 -0
  182. package/dist/server.js +11 -3
  183. package/dist/settings.js +112 -9
  184. package/package.json +3 -4
  185. package/dist/public/assets/index-CC9G8EF1.js +0 -21
  186. package/dist/public/assets/index-Dalqz2mC.css +0 -1
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Template registry for heyi.am project/session rendering.
3
+ *
4
+ * Built-in templates ship with the CLI. Custom user templates
5
+ * can live in ~/.config/heyiam/templates/{name}/ (Phase 2).
6
+ */
7
+ import { readFileSync } from 'node:fs';
8
+ import { resolve, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const TEMPLATES_DIR = resolve(__dirname, 'templates');
12
+ export const BUILT_IN_TEMPLATES = [
13
+ // Original 5
14
+ { name: 'editorial', label: 'Editorial', description: 'Classic light theme with card-based layout', accent: '#084471', mode: 'light', tags: [] },
15
+ { name: 'kinetic', label: 'Kinetic', description: 'Matches the heyi.am landing page — orange accent, section tags, narrative cards', accent: '#f97316', mode: 'dark', tags: ['animated'] },
16
+ { name: 'terminal', label: 'Terminal', description: 'Green-on-black terminal aesthetic with ASCII elements', accent: '#4ade80', mode: 'dark', tags: ['minimal'] },
17
+ { name: 'minimal', label: 'Typography', description: 'Ultra-clean light mode with serif typography', accent: '#1c1917', mode: 'light', tags: ['minimal'] },
18
+ { name: 'showcase', label: 'Showcase', description: 'Cinematic scroll animations with animated charts and stat counters', accent: '#818cf8', mode: 'dark', tags: ['animated'] },
19
+ // New templates
20
+ { name: 'parallax', label: 'Parallax', description: 'Fixed floating headshot with full-page parallax — content scrolls around the photo', accent: '#60a5fa', mode: 'dark', tags: ['animated'] },
21
+ { name: 'blueprint', label: 'Blueprint', description: 'Engineering schematic with SVG connector lines, grid background, and dimension annotations', accent: '#64748b', mode: 'light', tags: ['animated'] },
22
+ { name: 'radar', label: 'Radar', description: 'HUD cockpit with radar navigation widget and cyan-tinted luminous elements', accent: '#22d3ee', mode: 'dark', tags: ['animated', 'data-dense'] },
23
+ { name: 'strata', label: 'Strata', description: 'Depth-based parallax with overlapping card layers and warm amber palette', accent: '#d97706', mode: 'light', tags: ['animated'] },
24
+ { name: 'noir', label: 'Noir', description: 'Pure monochrome — black, white, gray only with bold typography and film noir drama', accent: '#e5e5e5', mode: 'dark', tags: ['minimal'] },
25
+ { name: 'verdant', label: 'Verdant', description: 'Nature-inspired with warm earthy palette, leaf motifs, and organic rounded shapes', accent: '#15803d', mode: 'light', tags: [] },
26
+ { name: 'neon', label: 'Neon', description: 'Synthwave aesthetic with pink and cyan dual accent and tasteful neon glow effects', accent: '#f472b6', mode: 'dark', tags: ['animated'] },
27
+ { name: 'paper', label: 'Paper', description: 'Newspaper print aesthetic with multi-column layout, drop caps, and serif typography', accent: '#1a1a1a', mode: 'light', tags: ['minimal'] },
28
+ { name: 'cosmos', label: 'Cosmos', description: 'Starfield background with gold accent and constellation SVG lines connecting elements', accent: '#fbbf24', mode: 'dark', tags: ['animated'] },
29
+ { name: 'bauhaus', label: 'Bauhaus', description: 'De Stijl geometric shapes in red, blue, yellow with thick borders and asymmetric grids', accent: '#dc2626', mode: 'light', tags: ['animated'] },
30
+ { name: 'mono', label: 'Mono', description: '100% monospace terminal — green on black, ASCII bar charts, git-log phases, typing animation', accent: '#4ade80', mode: 'dark', tags: ['minimal', 'data-dense'] },
31
+ { name: 'glacier', label: 'Glacier', description: 'Frosted glassmorphism with backdrop blur cards, cool blue palette, and soft shadows', accent: '#38bdf8', mode: 'light', tags: ['animated'] },
32
+ { name: 'ember', label: 'Ember', description: 'Warm dark theme with orange-to-red fire gradient accents and ember glow on stats', accent: '#f97316', mode: 'dark', tags: ['animated'] },
33
+ { name: 'zen', label: 'Zen', description: 'Japanese minimalism — maximum whitespace, no cards, thin rules, serif display, 640px column', accent: '#78716c', mode: 'light', tags: ['minimal'] },
34
+ { name: 'circuit', label: 'Circuit', description: 'PCB aesthetic with circuit trace patterns, component pads, and lime green accent', accent: '#a3e635', mode: 'dark', tags: ['animated'] },
35
+ { name: 'parchment', label: 'Parchment', description: 'Old book aesthetic with all-serif typography, sepia palette, drop caps, and colophon footer', accent: '#92400e', mode: 'light', tags: ['minimal'] },
36
+ { name: 'aurora', label: 'Aurora', description: 'Northern lights gradient header that slowly shifts — restrained dark with teal magic', accent: '#2dd4bf', mode: 'dark', tags: ['animated'] },
37
+ { name: 'grid', label: 'Grid', description: 'Bento dashboard layout with mixed-size CSS grid cells like iOS widgets', accent: '#6366f1', mode: 'light', tags: ['data-dense'] },
38
+ { name: 'obsidian', label: 'Obsidian', description: 'Deep black with purple gem accent and hover shimmer like light catching a gemstone', accent: '#a855f7', mode: 'dark', tags: ['animated'] },
39
+ { name: 'chalk', label: 'Chalk', description: 'Whiteboard aesthetic with handwritten display font, sketch-style borders, and annotation arrows', accent: '#334155', mode: 'light', tags: [] },
40
+ { name: 'signal', label: 'Signal', description: 'Mission control dashboard — dense data tables, status badges, and fast-updating metrics', accent: '#ef4444', mode: 'dark', tags: ['data-dense'] },
41
+ { name: 'canvas', label: 'Canvas', description: 'Art gallery with extreme whitespace, full-bleed images, and large airy typography', accent: '#fb7185', mode: 'light', tags: ['minimal'] },
42
+ { name: 'meridian', label: 'Meridian', description: 'Topographic map aesthetic with contour line patterns and elevation-style charts', accent: '#34d399', mode: 'dark', tags: ['animated'] },
43
+ { name: 'carbon', label: 'Carbon', description: 'Brushed metal industrial — diagonal stripe texture, silver chrome palette, no color', accent: '#94a3b8', mode: 'dark', tags: ['minimal'] },
44
+ { name: 'daylight', label: 'Daylight', description: 'Bright and airy with soft blue shadows, sky blue accent, and friendly rounded shapes', accent: '#0ea5e9', mode: 'light', tags: ['animated'] },
45
+ ];
46
+ const BUILT_IN_NAMES = new Set(BUILT_IN_TEMPLATES.map((t) => t.name));
47
+ export const DEFAULT_TEMPLATE = 'editorial';
48
+ export function isValidTemplate(name) {
49
+ return BUILT_IN_NAMES.has(name);
50
+ }
51
+ /**
52
+ * Resolve which template to use.
53
+ * Priority: project override → user default → 'editorial'
54
+ */
55
+ export function resolveTemplate(projectTemplate, userDefault) {
56
+ if (projectTemplate && isValidTemplate(projectTemplate))
57
+ return projectTemplate;
58
+ if (userDefault && isValidTemplate(userDefault))
59
+ return userDefault;
60
+ return DEFAULT_TEMPLATE;
61
+ }
62
+ /**
63
+ * Load concatenated CSS for a template (base + template-specific).
64
+ * Used by export.ts for standalone HTML and by preview.
65
+ */
66
+ const cssCache = new Map();
67
+ export function getTemplateCss(templateName) {
68
+ const name = isValidTemplate(templateName) ? templateName : DEFAULT_TEMPLATE;
69
+ const cached = cssCache.get(name);
70
+ if (cached !== undefined)
71
+ return cached;
72
+ let css = '';
73
+ try {
74
+ css = readFileSync(resolve(TEMPLATES_DIR, 'styles.css'), 'utf-8');
75
+ }
76
+ catch { /* empty */ }
77
+ try {
78
+ const templateCss = readFileSync(resolve(TEMPLATES_DIR, name, 'styles.css'), 'utf-8');
79
+ css += '\n\n/* === ' + name + ' template styles === */\n' + templateCss;
80
+ }
81
+ catch { /* no template-specific CSS — fine */ }
82
+ cssCache.set(name, css);
83
+ return css;
84
+ }
85
+ export function getTemplateNames() {
86
+ return BUILT_IN_TEMPLATES.map((t) => t.name);
87
+ }
88
+ export function getTemplateInfo(name) {
89
+ return BUILT_IN_TEMPLATES.find((t) => t.name === name);
90
+ }
@@ -1,5 +1,5 @@
1
1
  import { Router } from 'express';
2
- import { checkAuthStatus, saveAuthToken, deleteAuthToken } from '../auth.js';
2
+ import { checkAuthStatus, saveAuthToken, deleteAuthToken, normalizeUsername } from '../auth.js';
3
3
  import { API_URL } from '../config.js';
4
4
  export function createAuthRouter(_ctx) {
5
5
  const router = Router();
@@ -100,8 +100,12 @@ export function createAuthRouter(_ctx) {
100
100
  });
101
101
  const data = await response.json();
102
102
  if (response.ok && data.access_token) {
103
- saveAuthToken(data.access_token, data.username);
104
- res.json({ authenticated: true, username: data.username });
103
+ // Always persist and echo the lowercase form so downstream URL
104
+ // construction and UI display stay consistent with Phoenix's
105
+ // lowercase-only DB constraint.
106
+ const username = normalizeUsername(String(data.username ?? ''));
107
+ saveAuthToken(data.access_token, username);
108
+ res.json({ authenticated: true, username });
105
109
  }
106
110
  else {
107
111
  res.status(response.status).json(data);
@@ -3,12 +3,12 @@
3
3
  * Created during the server.ts refactor to avoid circular dependencies.
4
4
  */
5
5
  import path from 'node:path';
6
- import { readFileSync } from 'node:fs';
7
6
  import { fileURLToPath } from 'node:url';
8
7
  import { listSessions, parseSession } from '../parsers/index.js';
9
8
  import { bridgeToAnalyzer, mergeActiveIntervals, sumIntervalMs } from '../bridge.js';
10
9
  import { analyzeSession } from '../analyzer.js';
11
10
  import { loadEnhancedData, loadProjectEnhanceResult, getUploadedState, } from '../settings.js';
11
+ import { getTemplateCss } from '../render/templates.js';
12
12
  import { archiveSessionFiles } from '../archive.js';
13
13
  import { getDatabase, openDatabase, getSessionStats as dbGetSessionStats, getSessionCount, getAllSessionMetas, getAllProjectStats, getSessionsByProject, getProjectUuid, } from '../db.js';
14
14
  import { ensureSessionIndexed, displayNameFromDir } from '../sync.js';
@@ -39,7 +39,16 @@ function computeMergedDurationFromDb(db, projectDir, naiveSumMinutes) {
39
39
  return mergedMinutes > 0 ? mergedMinutes : naiveSumMinutes;
40
40
  }
41
41
  import { escapeHtml } from '../format-utils.js';
42
- export { escapeHtml };
42
+ /** Look up a project by name or dirName, sending 404 if not found. Returns null on miss. */
43
+ export async function requireProject(ctx, projectParam, res) {
44
+ const projects = await ctx.getProjects();
45
+ const proj = projects.find((p) => p.name === projectParam || p.dirName === projectParam);
46
+ if (!proj) {
47
+ res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
48
+ return null;
49
+ }
50
+ return proj;
51
+ }
43
52
  /**
44
53
  * Build an agent summary from child session metas. Returns null when
45
54
  * there are no children (or none produce valid stats).
@@ -418,6 +427,7 @@ export function createRouteContext(sessionsBasePath, dbPath) {
418
427
  uploadedSessionCount: published?.uploadedSessions?.length ?? 0,
419
428
  uploadedSessions: published?.uploadedSessions ?? [],
420
429
  enhancedAt: enhanceCache?.enhancedAt ?? null,
430
+ enhancedSessionCount: enhanceCache?.selectedSessionIds?.length ?? 0,
421
431
  totalAgentDuration: agentRow.total,
422
432
  totalInputTokens: dbStats.totalInputTokens,
423
433
  totalOutputTokens: dbStats.totalOutputTokens,
@@ -460,19 +470,16 @@ export function createRouteContext(sessionsBasePath, dbPath) {
460
470
  uploadedSessionCount: published?.uploadedSessions?.length ?? 0,
461
471
  uploadedSessions: published?.uploadedSessions ?? [],
462
472
  enhancedAt: enhanceCache?.enhancedAt ?? null,
473
+ enhancedSessionCount: enhanceCache?.selectedSessionIds?.length ?? 0,
463
474
  totalAgentDuration,
464
475
  totalInputTokens: 0,
465
476
  totalOutputTokens: 0,
466
477
  };
467
478
  }
468
479
  // ── buildPreviewPage ─────────────────────────────────────
469
- function buildPreviewPage(title, bodyHtml, banner) {
470
- const renderCssPath = path.resolve(__dirname, '..', 'render', 'templates', 'styles.css');
471
- let inlineCss = '';
472
- try {
473
- inlineCss = readFileSync(renderCssPath, 'utf-8');
474
- }
475
- catch { /* */ }
480
+ function buildPreviewPage(title, bodyHtml, banner, templateName) {
481
+ // Load full template CSS (base + template-specific) via the same path as the React embed
482
+ const inlineCss = getTemplateCss(templateName || 'editorial');
476
483
  const cssTag = `<style>${inlineCss}\n/* Preview override */\nbody { overflow: auto !important; min-height: auto !important; }\n#root { min-height: auto !important; }</style>`;
477
484
  const bannerHtml = banner
478
485
  ? `<div style="background: var(--primary, #084471); color: white; text-align: center; padding: 0.5rem; font-family: 'Inter', sans-serif; font-size: 0.75rem; letter-spacing: 0.05em;">${escapeHtml(banner)}</div>`
@@ -486,7 +493,7 @@ export function createRouteContext(sessionsBasePath, dbPath) {
486
493
  <title>${escapeHtml(title)} — Preview</title>
487
494
  <link rel="preconnect" href="https://fonts.googleapis.com" />
488
495
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
489
- <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
496
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Newsreader:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet" />
490
497
  ${cssTag}
491
498
  </head>
492
499
  <body>
@@ -0,0 +1,195 @@
1
+ import { Router } from 'express';
2
+ import { getAuthToken } from '../auth.js';
3
+ import { API_URL, warnIfNonDefaultApiUrl } from '../config.js';
4
+ import { getUploadedState, clearUploadedState, saveUploadedState, loadEnhancedData, saveEnhancedData, } from '../settings.js';
5
+ function sendError(res, status, error) {
6
+ res.status(status).json({ error });
7
+ }
8
+ function validatePathParam(value, field) {
9
+ if (typeof value !== 'string') {
10
+ return { ok: false, error: { code: 'INVALID_PARAM', message: `${field} is required` } };
11
+ }
12
+ const trimmed = value.trim();
13
+ if (trimmed.length === 0) {
14
+ return { ok: false, error: { code: 'INVALID_PARAM', message: `${field} is required` } };
15
+ }
16
+ if (trimmed.length > 200) {
17
+ return { ok: false, error: { code: 'INVALID_PARAM', message: `${field} exceeds 200 characters` } };
18
+ }
19
+ return { ok: true, value };
20
+ }
21
+ export function createDeleteRouter(_ctx) {
22
+ const router = Router();
23
+ /**
24
+ * DELETE /api/projects/:project/remote
25
+ *
26
+ * Removes the project (and all its sessions, per Phoenix contract) from
27
+ * heyi.am. Does NOT touch local archived session data — the user may be
28
+ * mid-edit. Clears the local uploaded-state record so the UI re-reflects
29
+ * "Local only" after the round-trip.
30
+ *
31
+ * :project is the CLI-side directory name, NOT the published slug. We
32
+ * resolve the published slug from local uploaded state — if the user
33
+ * never published from this machine we have no slug to delete against.
34
+ */
35
+ router.delete('/api/projects/:project/remote', async (req, res) => {
36
+ const projectResult = validatePathParam(req.params.project, 'project');
37
+ if (!projectResult.ok) {
38
+ sendError(res, 400, projectResult.error);
39
+ return;
40
+ }
41
+ const project = projectResult.value;
42
+ const auth = getAuthToken();
43
+ warnIfNonDefaultApiUrl();
44
+ if (!auth) {
45
+ sendError(res, 401, { code: 'UNAUTHENTICATED', message: 'Authentication required' });
46
+ return;
47
+ }
48
+ const uploaded = getUploadedState(project);
49
+ if (!uploaded?.slug) {
50
+ sendError(res, 404, {
51
+ code: 'NOT_PUBLISHED',
52
+ message: 'This project has no remote copy to delete',
53
+ });
54
+ return;
55
+ }
56
+ try {
57
+ const phoenixRes = await fetch(`${API_URL}/api/projects/${encodeURIComponent(uploaded.slug)}`, {
58
+ method: 'DELETE',
59
+ headers: { Authorization: `Bearer ${auth.token}` },
60
+ });
61
+ if (phoenixRes.status === 204) {
62
+ // Strip local uploaded state + session 'uploaded' flags so UI
63
+ // shows the correct status. Failure to clear local flags is
64
+ // non-fatal (the remote copy is already gone).
65
+ try {
66
+ clearUploadedState(project);
67
+ for (const sessionId of uploaded.uploadedSessions ?? []) {
68
+ const enhanced = loadEnhancedData(sessionId);
69
+ if (enhanced?.uploaded) {
70
+ saveEnhancedData(sessionId, { ...enhanced, uploaded: false });
71
+ }
72
+ }
73
+ }
74
+ catch (cleanupErr) {
75
+ console.warn('[delete-project] local cleanup failed:', cleanupErr.message);
76
+ }
77
+ res.json({ ok: true });
78
+ return;
79
+ }
80
+ if (phoenixRes.status === 404) {
81
+ // Remote already gone — still clear local state so UI
82
+ // re-renders as "Local only". Surface 404 so UI can inform
83
+ // the user the remote copy was already missing.
84
+ try {
85
+ clearUploadedState(project);
86
+ }
87
+ catch { /* best effort */ }
88
+ sendError(res, 404, {
89
+ code: 'NOT_FOUND',
90
+ message: 'Project not found on heyi.am (already deleted?)',
91
+ });
92
+ return;
93
+ }
94
+ if (phoenixRes.status === 401 || phoenixRes.status === 403) {
95
+ sendError(res, phoenixRes.status, {
96
+ code: 'UNAUTHORIZED',
97
+ message: 'Not authorized to delete this project',
98
+ });
99
+ return;
100
+ }
101
+ const status = phoenixRes.status >= 500 ? 502 : phoenixRes.status;
102
+ sendError(res, status, {
103
+ code: 'DELETE_FAILED',
104
+ message: `Remote delete failed (HTTP ${phoenixRes.status})`,
105
+ });
106
+ }
107
+ catch (err) {
108
+ const message = err.message;
109
+ console.error('[delete-project] Error:', message);
110
+ sendError(res, 502, { code: 'DELETE_FAILED', message });
111
+ }
112
+ });
113
+ /**
114
+ * DELETE /api/projects/:project/sessions/:sessionId/remote
115
+ *
116
+ * Removes a single session from heyi.am. Local archive is untouched.
117
+ * Updates the local uploaded-state record so the session no longer
118
+ * appears in the "uploaded" set. Leaves the project uploaded-state
119
+ * shell in place when this was the last session — per spec, the user
120
+ * may be mid-edit.
121
+ */
122
+ router.delete('/api/projects/:project/sessions/:sessionId/remote', async (req, res) => {
123
+ const projectResult = validatePathParam(req.params.project, 'project');
124
+ if (!projectResult.ok) {
125
+ sendError(res, 400, projectResult.error);
126
+ return;
127
+ }
128
+ const sessionResult = validatePathParam(req.params.sessionId, 'sessionId');
129
+ if (!sessionResult.ok) {
130
+ sendError(res, 400, sessionResult.error);
131
+ return;
132
+ }
133
+ const project = projectResult.value;
134
+ const sessionId = sessionResult.value;
135
+ const auth = getAuthToken();
136
+ warnIfNonDefaultApiUrl();
137
+ if (!auth) {
138
+ sendError(res, 401, { code: 'UNAUTHENTICATED', message: 'Authentication required' });
139
+ return;
140
+ }
141
+ try {
142
+ const phoenixRes = await fetch(`${API_URL}/api/sessions/${encodeURIComponent(sessionId)}`, {
143
+ method: 'DELETE',
144
+ headers: { Authorization: `Bearer ${auth.token}` },
145
+ });
146
+ if (phoenixRes.status === 204) {
147
+ try {
148
+ const uploaded = getUploadedState(project);
149
+ if (uploaded) {
150
+ const remaining = (uploaded.uploadedSessions ?? []).filter((id) => id !== sessionId);
151
+ saveUploadedState(project, {
152
+ slug: uploaded.slug,
153
+ projectId: uploaded.projectId,
154
+ uploadedSessions: remaining,
155
+ });
156
+ }
157
+ const enhanced = loadEnhancedData(sessionId);
158
+ if (enhanced?.uploaded) {
159
+ saveEnhancedData(sessionId, { ...enhanced, uploaded: false });
160
+ }
161
+ }
162
+ catch (cleanupErr) {
163
+ console.warn('[delete-session] local cleanup failed:', cleanupErr.message);
164
+ }
165
+ res.json({ ok: true });
166
+ return;
167
+ }
168
+ if (phoenixRes.status === 404) {
169
+ sendError(res, 404, {
170
+ code: 'NOT_FOUND',
171
+ message: 'Session not found on heyi.am (already deleted?)',
172
+ });
173
+ return;
174
+ }
175
+ if (phoenixRes.status === 401 || phoenixRes.status === 403) {
176
+ sendError(res, phoenixRes.status, {
177
+ code: 'UNAUTHORIZED',
178
+ message: 'Not authorized to delete this session',
179
+ });
180
+ return;
181
+ }
182
+ const status = phoenixRes.status >= 500 ? 502 : phoenixRes.status;
183
+ sendError(res, status, {
184
+ code: 'DELETE_FAILED',
185
+ message: `Remote delete failed (HTTP ${phoenixRes.status})`,
186
+ });
187
+ }
188
+ catch (err) {
189
+ const message = err.message;
190
+ console.error('[delete-session] Error:', message);
191
+ sendError(res, 502, { code: 'DELETE_FAILED', message });
192
+ }
193
+ });
194
+ return router;
195
+ }
@@ -3,6 +3,9 @@ import { getProvider } from '../llm/index.js';
3
3
  import { triageSessions } from '../llm/triage.js';
4
4
  import { enhanceProject, refineNarrative } from '../llm/project-enhance.js';
5
5
  import { getAnthropicApiKey, saveEnhancedData, loadEnhancedData, deleteEnhancedData, loadFreshProjectEnhanceResult, saveProjectEnhanceResult, loadProjectEnhanceResult, buildProjectFingerprint, getUploadedState, } from '../settings.js';
6
+ import { requireProject } from './context.js';
7
+ import { startSSE } from './sse.js';
8
+ import { invalidatePortfolioPreviewCache } from './preview.js';
6
9
  export function createEnhanceRouter(ctx) {
7
10
  const router = Router();
8
11
  // Triage endpoint -- AI selects which sessions are worth showcasing (SSE stream)
@@ -11,21 +14,11 @@ export function createEnhanceRouter(ctx) {
11
14
  res.status(400).json({ error: { code: 'NO_API_KEY', message: 'No Anthropic API key configured. Add one in Settings or set ANTHROPIC_API_KEY.' } });
12
15
  return;
13
16
  }
14
- const { project } = req.params;
15
- const projects = await ctx.getProjects();
16
- const proj = projects.find((p) => p.name === project || p.dirName === project);
17
- if (!proj) {
18
- res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
17
+ const project = String(req.params.project);
18
+ const proj = await requireProject(ctx, project, res);
19
+ if (!proj)
19
20
  return;
20
- }
21
- res.writeHead(200, {
22
- 'Content-Type': 'text/event-stream',
23
- 'Cache-Control': 'no-cache',
24
- Connection: 'keep-alive',
25
- });
26
- const send = (event) => {
27
- res.write(`data: ${JSON.stringify(event)}\n\n`);
28
- };
21
+ const send = startSSE(res);
29
22
  try {
30
23
  const total = proj.sessions.length;
31
24
  const sessionsWithStats = [];
@@ -62,13 +55,11 @@ export function createEnhanceRouter(ctx) {
62
55
  // Enhance a single session
63
56
  router.post('/api/projects/:project/sessions/:id/enhance', async (req, res) => {
64
57
  try {
65
- const { project, id } = req.params;
66
- const projects = await ctx.getProjects();
67
- const proj = projects.find((p) => p.name === project || p.dirName === project);
68
- if (!proj) {
69
- res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
58
+ const project = String(req.params.project);
59
+ const id = String(req.params.id);
60
+ const proj = await requireProject(ctx, project, res);
61
+ if (!proj)
70
62
  return;
71
- }
72
63
  const meta = proj.sessions.find((s) => s.sessionId === id);
73
64
  if (!meta) {
74
65
  res.status(404).json({ error: { code: 'SESSION_NOT_FOUND', message: 'Session not found' } });
@@ -91,6 +82,43 @@ export function createEnhanceRouter(ctx) {
91
82
  });
92
83
  }
93
84
  });
85
+ // Update locally-saved enhanced data (partial merge)
86
+ router.patch('/api/sessions/:id/enhanced', (req, res) => {
87
+ const { id } = req.params;
88
+ const existing = loadEnhancedData(id);
89
+ if (!existing) {
90
+ res.status(404).json({ error: { code: 'NOT_FOUND', message: 'No enhanced data for this session' } });
91
+ return;
92
+ }
93
+ const { title, developerTake, skills, qaPairs, executionSteps } = req.body;
94
+ if (title !== undefined && (typeof title !== 'string' || title.length === 0 || title.length > 200)) {
95
+ res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'title must be 1-200 characters' } });
96
+ return;
97
+ }
98
+ if (developerTake !== undefined && (typeof developerTake !== 'string' || developerTake.length > 2000)) {
99
+ res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'developerTake must be under 2000 characters' } });
100
+ return;
101
+ }
102
+ if (skills !== undefined && (!Array.isArray(skills) || !skills.every((s) => typeof s === 'string'))) {
103
+ res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'skills must be an array of strings' } });
104
+ return;
105
+ }
106
+ const merged = {
107
+ ...existing,
108
+ ...(title !== undefined ? { title } : {}),
109
+ ...(developerTake !== undefined ? { developerTake } : {}),
110
+ ...(skills !== undefined ? { skills } : {}),
111
+ ...(qaPairs !== undefined ? { qaPairs } : {}),
112
+ ...(executionSteps !== undefined ? { executionSteps } : {}),
113
+ };
114
+ // Strip runtime-only fields before saving — saveEnhancedData re-adds enhancedAt
115
+ const { enhancedAt: _ea, quickEnhanced: qe, ...rest } = merged;
116
+ saveEnhancedData(id, { ...rest, quickEnhanced: qe });
117
+ invalidatePortfolioPreviewCache();
118
+ console.log(`[enhance] Updated enhanced data for ${id}`);
119
+ const updated = loadEnhancedData(id);
120
+ res.json({ ok: true, enhancedAt: updated?.enhancedAt });
121
+ });
94
122
  // Delete locally-saved enhanced data
95
123
  router.delete('/api/sessions/:id/enhanced', (_req, res) => {
96
124
  const { id } = _req.params;
@@ -124,14 +152,7 @@ export function createEnhanceRouter(ctx) {
124
152
  res.status(400).json({ error: { code: 'NO_API_KEY', message: 'No Anthropic API key configured. Add one in Settings or set ANTHROPIC_API_KEY.' } });
125
153
  return;
126
154
  }
127
- res.writeHead(200, {
128
- 'Content-Type': 'text/event-stream',
129
- 'Cache-Control': 'no-cache',
130
- Connection: 'keep-alive',
131
- });
132
- const send = (data) => {
133
- res.write(`data: ${JSON.stringify(data)}\n\n`);
134
- };
155
+ const send = startSSE(res);
135
156
  try {
136
157
  const projects = await ctx.getProjects();
137
158
  const proj = projects.find((p) => p.name === project || p.dirName === project);
@@ -237,7 +258,7 @@ export function createEnhanceRouter(ctx) {
237
258
  });
238
259
  // Save project enhance result explicitly
239
260
  router.post('/api/projects/:project/enhance-save', async (req, res) => {
240
- const { project } = req.params;
261
+ const project = String(req.params.project);
241
262
  const { selectedSessionIds, result, title, repoUrl, projectUrl, screenshotBase64 } = req.body;
242
263
  if (!Array.isArray(selectedSessionIds) || !result?.narrative) {
243
264
  res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'selectedSessionIds and result are required' } });
@@ -248,13 +269,12 @@ export function createEnhanceRouter(ctx) {
248
269
  return;
249
270
  }
250
271
  try {
251
- const projects = await ctx.getProjects();
252
- const proj = projects.find((p) => p.name === project || p.dirName === project);
253
- if (!proj) {
254
- res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
272
+ const proj = await requireProject(ctx, project, res);
273
+ if (!proj)
255
274
  return;
256
- }
257
275
  saveProjectEnhanceResult(proj.dirName, selectedSessionIds, result, undefined, { title, repoUrl, projectUrl, screenshotBase64 });
276
+ // Project title/narrative/skills appear in portfolio listing — bust cache.
277
+ invalidatePortfolioPreviewCache();
258
278
  res.json({ saved: true, enhancedAt: new Date().toISOString() });
259
279
  }
260
280
  catch (err) {
@@ -263,14 +283,11 @@ export function createEnhanceRouter(ctx) {
263
283
  });
264
284
  // Get cached project enhance result
265
285
  router.get('/api/projects/:project/enhance-cache', async (req, res) => {
266
- const { project } = req.params;
286
+ const project = String(req.params.project);
267
287
  try {
268
- const projects = await ctx.getProjects();
269
- const proj = projects.find((p) => p.name === project || p.dirName === project);
270
- if (!proj) {
271
- res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
288
+ const proj = await requireProject(ctx, project, res);
289
+ if (!proj)
272
290
  return;
273
- }
274
291
  const cached = loadProjectEnhanceResult(proj.dirName);
275
292
  if (!cached) {
276
293
  res.status(404).json({ error: { code: 'NO_CACHE', message: 'No cached enhance result' } });
@@ -111,9 +111,14 @@ export function createExportRouter(ctx) {
111
111
  return;
112
112
  }
113
113
  const cache = data.enhanceCache ?? buildFallbackCache(data.sessions);
114
- const totalFilesChanged = data.project.totalFiles;
114
+ const proj = data.project;
115
115
  const outDir = safeExportPath(outputPath, dirName, 'html');
116
- const result = await exportHtml(dirName, cache, data.sessions, outDir, 'local', { totalFilesChanged });
116
+ const result = await exportHtml(dirName, cache, data.sessions, outDir, 'local', {
117
+ totalFilesChanged: proj.totalFiles,
118
+ totalAgentDurationMinutes: proj.totalAgentDuration,
119
+ totalInputTokens: proj.totalInputTokens,
120
+ totalOutputTokens: proj.totalOutputTokens,
121
+ });
117
122
  res.json(result);
118
123
  }
119
124
  catch (err) {
@@ -131,8 +136,13 @@ export function createExportRouter(ctx) {
131
136
  return;
132
137
  }
133
138
  const cache = data.enhanceCache ?? buildFallbackCache(data.sessions);
134
- const totalFilesChanged = data.project.totalFiles;
135
- const htmlFiles = generateHtmlFiles(dirName, cache, data.sessions, 'local', { totalFilesChanged });
139
+ const proj = data.project;
140
+ const htmlFiles = generateHtmlFiles(dirName, cache, data.sessions, 'local', {
141
+ totalFilesChanged: proj.totalFiles,
142
+ totalAgentDurationMinutes: proj.totalAgentDuration,
143
+ totalInputTokens: proj.totalInputTokens,
144
+ totalOutputTokens: proj.totalOutputTokens,
145
+ });
136
146
  const zipBuffer = createZipBuffer(htmlFiles);
137
147
  const filename = `${dirName.replace(/[^a-zA-Z0-9_-]/g, '_')}.zip`;
138
148
  res.setHeader('Content-Type', 'application/zip');