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
@@ -1,6 +1,8 @@
1
1
  import { Router } from 'express';
2
2
  import { statSync } from 'node:fs';
3
- import { buildSessionList, buildProjectDetail } from './context.js';
3
+ import { loadProjectEnhanceResult, saveProjectEnhanceResult } from '../settings.js';
4
+ import { requireProject, buildSessionList, buildProjectDetail } from './context.js';
5
+ import { invalidatePortfolioPreviewCache } from './preview.js';
4
6
  export function createProjectsRouter(ctx) {
5
7
  const router = Router();
6
8
  router.get('/api/projects', async (_req, res) => {
@@ -18,13 +20,10 @@ export function createProjectsRouter(ctx) {
18
20
  // Aggregated project detail for the hub screen
19
21
  router.get('/api/projects/:project/detail', async (req, res) => {
20
22
  try {
21
- const { project } = req.params;
22
- const projects = await ctx.getProjects();
23
- const proj = projects.find((p) => p.name === project || p.dirName === project);
24
- if (!proj) {
25
- res.status(404).json({ error: 'Project not found' });
23
+ const project = String(req.params.project);
24
+ const proj = await requireProject(ctx, project, res);
25
+ if (!proj)
26
26
  return;
27
- }
28
27
  res.json(buildProjectDetail(ctx.db, proj));
29
28
  }
30
29
  catch (err) {
@@ -95,13 +94,11 @@ export function createProjectsRouter(ctx) {
95
94
  });
96
95
  router.get('/api/projects/:project/sessions/:id', async (req, res) => {
97
96
  try {
98
- const { project, id } = req.params;
99
- const projects = await ctx.getProjects();
100
- const proj = projects.find((p) => p.name === project || p.dirName === project);
101
- if (!proj) {
102
- res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
97
+ const project = String(req.params.project);
98
+ const id = String(req.params.id);
99
+ const proj = await requireProject(ctx, project, res);
100
+ if (!proj)
103
101
  return;
104
- }
105
102
  const meta = proj.sessions.find((s) => s.sessionId === id);
106
103
  if (!meta) {
107
104
  res.status(404).json({ error: { code: 'SESSION_NOT_FOUND', message: 'Session not found' } });
@@ -140,13 +137,10 @@ export function createProjectsRouter(ctx) {
140
137
  router.get('/api/projects/:project/git-remote', async (req, res) => {
141
138
  const { execFileSync } = await import('node:child_process');
142
139
  try {
143
- const { project } = req.params;
144
- const projects = await ctx.getProjects();
145
- const proj = projects.find((p) => p.name === project || p.dirName === project);
146
- if (!proj) {
147
- res.status(404).json({ error: { code: 'PROJECT_NOT_FOUND', message: 'Project not found' } });
140
+ const project = String(req.params.project);
141
+ const proj = await requireProject(ctx, project, res);
142
+ if (!proj)
148
143
  return;
149
- }
150
144
  let projectPath = null;
151
145
  for (const meta of proj.sessions) {
152
146
  try {
@@ -181,12 +175,55 @@ export function createProjectsRouter(ctx) {
181
175
  res.status(500).json({ error: { code: 'GIT_REMOTE_FAILED', message: err.message } });
182
176
  }
183
177
  });
184
- // ── Boundaries ────────────────────────────────────────────────
185
- router.get('/api/projects/:project/boundaries', (_req, res) => {
186
- res.json({ selectedSessionIds: [], skippedSessions: [] });
178
+ // ── Boundaries — manage which sessions belong to a project ───
179
+ router.get('/api/projects/:project/boundaries', async (req, res) => {
180
+ try {
181
+ const project = String(req.params.project);
182
+ const proj = await requireProject(ctx, project, res);
183
+ if (!proj)
184
+ return;
185
+ const cache = loadProjectEnhanceResult(proj.dirName);
186
+ const allSessionIds = proj.sessions
187
+ .filter((s) => !s.isSubagent)
188
+ .map((s) => s.sessionId);
189
+ res.json({
190
+ selectedSessionIds: cache?.selectedSessionIds ?? [],
191
+ allSessionIds,
192
+ });
193
+ }
194
+ catch (err) {
195
+ res.status(500).json({ error: { code: 'BOUNDARIES_FAILED', message: err.message } });
196
+ }
187
197
  });
188
- router.put('/api/projects/:project/boundaries', (_req, res) => {
189
- res.json({ ok: true });
198
+ router.put('/api/projects/:project/boundaries', async (req, res) => {
199
+ try {
200
+ const project = String(req.params.project);
201
+ const proj = await requireProject(ctx, project, res);
202
+ if (!proj)
203
+ return;
204
+ const { selectedSessionIds } = req.body;
205
+ if (!Array.isArray(selectedSessionIds) || selectedSessionIds.length === 0) {
206
+ res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'selectedSessionIds must be a non-empty array' } });
207
+ return;
208
+ }
209
+ const allIds = new Set(proj.sessions.map((s) => s.sessionId));
210
+ const invalid = selectedSessionIds.filter((id) => !allIds.has(id));
211
+ if (invalid.length > 0) {
212
+ res.status(400).json({ error: { code: 'INVALID_SESSION_IDS', message: `Unknown session IDs: ${invalid.join(', ')}` } });
213
+ return;
214
+ }
215
+ const cache = loadProjectEnhanceResult(proj.dirName);
216
+ if (!cache) {
217
+ res.status(400).json({ error: { code: 'NO_CACHE', message: 'Project must be enhanced before managing sessions' } });
218
+ return;
219
+ }
220
+ saveProjectEnhanceResult(proj.dirName, selectedSessionIds, cache.result, undefined, { title: cache.title, repoUrl: cache.repoUrl, projectUrl: cache.projectUrl, screenshotBase64: cache.screenshotBase64 });
221
+ invalidatePortfolioPreviewCache();
222
+ res.json({ ok: true, selectedSessionIds });
223
+ }
224
+ catch (err) {
225
+ res.status(500).json({ error: { code: 'BOUNDARIES_FAILED', message: err.message } });
226
+ }
190
227
  });
191
228
  return router;
192
229
  }
@@ -1,15 +1,23 @@
1
1
  import { Router } from 'express';
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, mkdtempSync, rmSync, statSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
3
5
  import { randomUUID } from 'node:crypto';
4
6
  import { getAuthToken } from '../auth.js';
5
- import { API_URL, PUBLIC_URL } from '../config.js';
6
- import { loadEnhancedData, saveEnhancedData, saveUploadedState } from '../settings.js';
7
+ import { API_URL, PUBLIC_URL, warnIfNonDefaultApiUrl } from '../config.js';
8
+ import { loadEnhancedData, saveEnhancedData, saveUploadedState, getDefaultTemplate, getPortfolioProfile, hashPortfolioProfile, updatePortfolioPublishTarget, getPortfolioPublishState, isTranscriptIncluded, DEFAULT_PORTFOLIO_TARGET, } from '../settings.js';
9
+ import { generatePortfolioHtmlFragment, generateProjectHtmlFragment, generatePortfolioSite, createZipBuffer } from '../export.js';
10
+ import { buildPortfolioRenderData } from './portfolio-render-data.js';
11
+ import { buildProjectDetail } from './context.js';
7
12
  import { captureScreenshot } from '../screenshot.js';
8
13
  import { redactSession, redactText, scanTextSync, formatFindings, stripHomePathsInText } from '../redact.js';
9
14
  import { renderProjectHtml, renderSessionHtml } from '../render/index.js';
10
15
  import { buildSessionRenderData, buildSessionCard, buildProjectRenderData } from '../render/build-render-data.js';
11
16
  import { buildAgentSummary } from './context.js';
17
+ import { invalidatePortfolioPreviewCache } from './preview.js';
18
+ import { startSSE } from './sse.js';
12
19
  import { displayNameFromDir } from '../sync.js';
20
+ import { toSlug } from '../format-utils.js';
13
21
  import { getProjectUuid, getFileCountWithChildren } from '../db.js';
14
22
  const IMAGE_KEY_PREFIX = 'images/';
15
23
  export function createPublishRouter(ctx) {
@@ -32,7 +40,8 @@ export function createPublishRouter(ctx) {
32
40
  totalTokens,
33
41
  sessionCards: sessionCards || [],
34
42
  });
35
- const html = renderProjectHtml(renderData);
43
+ const templateName = getDefaultTemplate() || 'editorial';
44
+ const html = renderProjectHtml(renderData, undefined, templateName);
36
45
  res.json({ html });
37
46
  }
38
47
  catch (err) {
@@ -55,6 +64,8 @@ export function createPublishRouter(ctx) {
55
64
  }
56
65
  const imageData = readFileSync(screenshotPath);
57
66
  const base64 = imageData.toString('base64');
67
+ // Portfolio listing shows project screenshots — bust the cache.
68
+ invalidatePortfolioPreviewCache();
58
69
  res.json({ ok: true, preview: `data:image/png;base64,${base64}` });
59
70
  }
60
71
  catch (err) {
@@ -65,6 +76,7 @@ export function createPublishRouter(ctx) {
65
76
  router.post('/api/projects/:project/upload', async (req, res) => {
66
77
  const { project } = req.params;
67
78
  const auth = getAuthToken();
79
+ warnIfNonDefaultApiUrl();
68
80
  if (!auth) {
69
81
  res.status(401).json({ error: { message: 'Authentication required' } });
70
82
  return;
@@ -72,18 +84,11 @@ export function createPublishRouter(ctx) {
72
84
  const { title: rawTitle, slug: rawSlug, narrative, repoUrl, projectUrl, timeline, skills, totalSessions, totalLoc, totalDurationMinutes, totalAgentDurationMinutes, totalFilesChanged, skippedSessions, selectedSessionIds, screenshotBase64, } = req.body;
73
85
  // Ensure slug is the short project name, not the full encoded directory path
74
86
  const shortName = displayNameFromDir(String(project));
75
- const baseSlug = shortName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
87
+ const baseSlug = toSlug(shortName);
76
88
  const title = rawTitle === rawSlug ? shortName : rawTitle;
77
89
  // Get stable project UUID from CLI database
78
90
  const clientProjectId = getProjectUuid(ctx.db, String(project));
79
- res.writeHead(200, {
80
- 'Content-Type': 'text/event-stream',
81
- 'Cache-Control': 'no-cache',
82
- Connection: 'keep-alive',
83
- });
84
- const send = (data) => {
85
- res.write(`data: ${JSON.stringify(data)}\n\n`);
86
- };
91
+ const send = startSSE(res);
87
92
  try {
88
93
  // Step 1: Upsert project on Phoenix (with slug conflict retry)
89
94
  send({ type: 'project', status: 'creating' });
@@ -207,6 +212,7 @@ export function createPublishRouter(ctx) {
207
212
  const failedSessions = [];
208
213
  const uploadedSessionCards = [];
209
214
  if (proj) {
215
+ const selectedTemplate = getDefaultTemplate() || 'editorial';
210
216
  for (const sessionId of selectedSessionIds) {
211
217
  const meta = proj.sessions.find((s) => s.sessionId === sessionId);
212
218
  if (!meta)
@@ -215,8 +221,8 @@ export function createPublishRouter(ctx) {
215
221
  try {
216
222
  const session = await ctx.loadSession(meta.path, proj.name, sessionId);
217
223
  const enhanced = loadEnhancedData(sessionId);
218
- const sessionSlug = (enhanced?.title ?? session.title ?? sessionId)
219
- .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80);
224
+ const sessionSlug = toSlug(enhanced?.title ?? session.title ?? sessionId, 80);
225
+ const includeTranscript = isTranscriptIncluded(sessionId);
220
226
  const agentSummary = await buildAgentSummary(meta.children ?? [], (c) => ctx.getSessionStats(c, proj.name), { deduplicate: true });
221
227
  const devTake = (enhanced?.developerTake ?? session.developerTake ?? '').slice(0, 2000);
222
228
  const sessionNarrative = enhanced?.narrative ?? '';
@@ -233,11 +239,12 @@ export function createPublishRouter(ctx) {
233
239
  sessionSlug,
234
240
  sourceTool: sessionSourceTool,
235
241
  agentSummary,
242
+ template: selectedTemplate,
236
243
  };
237
244
  let sessionRenderedHtml = null;
238
245
  try {
239
246
  const sessionRenderData = buildSessionRenderData(renderOpts);
240
- sessionRenderedHtml = renderSessionHtml(sessionRenderData);
247
+ sessionRenderedHtml = renderSessionHtml(sessionRenderData, selectedTemplate);
241
248
  }
242
249
  catch (renderErr) {
243
250
  console.error(`[upload] Session render failed for ${sessionId}:`, renderErr.message);
@@ -259,7 +266,7 @@ export function createPublishRouter(ctx) {
259
266
  end_time: session.endTime ? new Date(session.endTime).toISOString() : null,
260
267
  cwd: session.cwd ?? null,
261
268
  wall_clock_minutes: session.wallClockMinutes ?? null,
262
- template: 'editorial',
269
+ template: selectedTemplate,
263
270
  language: null,
264
271
  tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
265
272
  skills: sessionSkills,
@@ -273,6 +280,22 @@ export function createPublishRouter(ctx) {
273
280
  rendered_html: sessionRenderedHtml,
274
281
  },
275
282
  };
283
+ // Transcript-derived fields. When the user has toggled the
284
+ // transcript OFF for this session we omit them from the
285
+ // uploaded JSON entirely — see the spread below. The
286
+ // session's main payload (dev take, skills, Q&A, etc.) is
287
+ // unaffected.
288
+ const turnTimeline = (session.turnTimeline ?? []).map((t) => ({
289
+ timestamp: t.timestamp,
290
+ type: t.type,
291
+ content: (t.content ?? '').slice(0, 200),
292
+ tools: t.tools ?? [],
293
+ }));
294
+ const transcriptExcerpt = (session.rawLog ?? []).slice(0, 10).map((line, i) => {
295
+ const role = line.startsWith('> ') ? 'dev' : 'ai';
296
+ const text = role === 'dev' ? line.slice(2) : line;
297
+ return { role, id: `Turn ${i + 1}`, text, timestamp: null };
298
+ });
276
299
  const sessionData = {
277
300
  version: 1,
278
301
  id: sessionId,
@@ -293,7 +316,7 @@ export function createPublishRouter(ctx) {
293
316
  })(),
294
317
  cwd: session.cwd ?? null,
295
318
  wall_clock_minutes: session.wallClockMinutes ?? null,
296
- template: 'editorial',
319
+ template: selectedTemplate,
297
320
  skills: sessionSkills,
298
321
  tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
299
322
  source: sessionSourceTool,
@@ -310,17 +333,8 @@ export function createPublishRouter(ctx) {
310
333
  highlights: [],
311
334
  tool_breakdown: (session.toolBreakdown ?? []).map((t) => ({ tool: t.tool, count: t.count })),
312
335
  top_files: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
313
- turn_timeline: (session.turnTimeline ?? []).map((t) => ({
314
- timestamp: t.timestamp,
315
- type: t.type,
316
- content: (t.content ?? '').slice(0, 200),
317
- tools: t.tools ?? [],
318
- })),
319
- transcript_excerpt: (session.rawLog ?? []).slice(0, 10).map((line, i) => {
320
- const role = line.startsWith('> ') ? 'dev' : 'ai';
321
- const text = role === 'dev' ? line.slice(2) : line;
322
- return { role, id: `Turn ${i + 1}`, text, timestamp: null };
323
- }),
336
+ ...(includeTranscript ? { turn_timeline: turnTimeline } : {}),
337
+ ...(includeTranscript ? { transcript_excerpt: transcriptExcerpt } : {}),
324
338
  agent_summary: agentSummary,
325
339
  children: agentSummary?.agents?.map((a) => ({
326
340
  sessionId: a.role,
@@ -349,7 +363,12 @@ export function createPublishRouter(ctx) {
349
363
  uploadedCount++;
350
364
  try {
351
365
  const sesData = await sessionRes.json();
352
- if (sesData.upload_urls) {
366
+ if (sesData.upload_urls && includeTranscript) {
367
+ // Transcript-bearing S3 uploads. Skipped entirely when
368
+ // the user has toggled the transcript OFF for this
369
+ // session — the server never sees raw_log, turn
370
+ // timeline, or the bundled session-data JSON in that
371
+ // case.
353
372
  const { raw: rawUrl, log: logUrl } = sesData.upload_urls;
354
373
  if (rawUrl && meta.path && !meta.path.startsWith('cursor://')) {
355
374
  try {
@@ -425,7 +444,14 @@ export function createPublishRouter(ctx) {
425
444
  const screenshotUrl = uploadedImageKey
426
445
  ? `${PUBLIC_URL}/_img/${uploadedImageKey.replace(IMAGE_KEY_PREFIX, '')}`
427
446
  : undefined;
428
- const projectHtml = generateProjectHtmlFragment(String(project), enrichedCache, detail.sessions, auth.username, { totalFilesChanged: totalFiles, title, screenshotUrl });
447
+ const detailProj = detail.project;
448
+ const projectHtml = generateProjectHtmlFragment(String(project), enrichedCache, detail.sessions, auth.username, {
449
+ totalFilesChanged: totalFiles,
450
+ title,
451
+ screenshotUrl,
452
+ totalInputTokens: detailProj.totalInputTokens,
453
+ totalOutputTokens: detailProj.totalOutputTokens,
454
+ });
429
455
  const renderRes = await fetch(`${API_URL}/api/projects`, {
430
456
  method: 'POST',
431
457
  headers: {
@@ -492,5 +518,268 @@ export function createPublishRouter(ctx) {
492
518
  res.end();
493
519
  }
494
520
  });
521
+ // Read the current portfolio publish state (per-target snapshots, hashes,
522
+ // last errors, visibility). Authenticated via the same Bearer pattern as
523
+ // the upload route — the state file lives in the user's local config dir
524
+ // but it still references the signed-in identity, so don't leak it to
525
+ // unauthenticated callers.
526
+ router.get('/api/portfolio/state', async (_req, res) => {
527
+ const auth = getAuthToken();
528
+ if (!auth) {
529
+ res.status(401).json({
530
+ error: { code: 'UNAUTHENTICATED', message: 'Authentication required' },
531
+ });
532
+ return;
533
+ }
534
+ try {
535
+ const state = getPortfolioPublishState();
536
+ res.json(state);
537
+ }
538
+ catch (err) {
539
+ const message = err.message;
540
+ console.error('[portfolio-state] Error:', message);
541
+ res.status(500).json({
542
+ error: { code: 'PORTFOLIO_STATE_READ_FAILED', message },
543
+ });
544
+ }
545
+ });
546
+ // Publish portfolio landing page to heyi.am
547
+ // Renders the portfolio HTML fragment locally, POSTs it to Phoenix,
548
+ // and persists the published snapshot + hash to settings on success.
549
+ router.post('/api/portfolio/upload', async (req, res) => {
550
+ const auth = getAuthToken();
551
+ warnIfNonDefaultApiUrl();
552
+ if (!auth) {
553
+ res.status(401).json({
554
+ error: { code: 'UNAUTHENTICATED', message: 'Authentication required' },
555
+ });
556
+ return;
557
+ }
558
+ try {
559
+ const profile = getPortfolioProfile();
560
+ const templateName = getDefaultTemplate() || 'editorial';
561
+ const { renderData, filteredProjects } = await buildPortfolioRenderData(ctx, auth);
562
+ const renderedHtml = generatePortfolioHtmlFragment(renderData, templateName);
563
+ // Upload individual project pages for every project included in the
564
+ // portfolio. This ensures project detail pages exist on heyi.am even
565
+ // if the user never published them individually.
566
+ for (const rawProj of filteredProjects) {
567
+ try {
568
+ const detail = buildProjectDetail(ctx.db, rawProj);
569
+ const proj = detail.project;
570
+ const cache = detail.enhanceCache
571
+ ?? { fingerprint: 'portfolio-upload', enhancedAt: new Date().toISOString(), selectedSessionIds: detail.sessions.map((s) => s.id), result: { narrative: '', arc: [], skills: [], timeline: [], questions: [] } };
572
+ const title = cache.title
573
+ || proj.name || displayNameFromDir(rawProj.dirName);
574
+ const slug = toSlug(title);
575
+ const clientProjectId = getProjectUuid(ctx.db, rawProj.dirName);
576
+ const projectHtml = generateProjectHtmlFragment(rawProj.dirName, cache, detail.sessions, auth.username, {
577
+ totalFilesChanged: proj.totalFiles,
578
+ totalAgentDurationMinutes: proj.totalAgentDuration,
579
+ totalInputTokens: proj.totalInputTokens,
580
+ totalOutputTokens: proj.totalOutputTokens,
581
+ });
582
+ await fetch(`${API_URL}/api/projects`, {
583
+ method: 'POST',
584
+ headers: {
585
+ 'Content-Type': 'application/json',
586
+ Authorization: `Bearer ${auth.token}`,
587
+ },
588
+ body: JSON.stringify({
589
+ project: {
590
+ client_project_id: clientProjectId,
591
+ title, slug,
592
+ narrative: cache.result?.narrative ?? '',
593
+ skills: cache.result?.skills ?? [],
594
+ total_sessions: proj.sessionCount,
595
+ total_loc: proj.totalLoc,
596
+ total_duration_minutes: proj.totalDuration,
597
+ total_agent_duration_minutes: proj.totalAgentDuration || null,
598
+ total_files_changed: proj.totalFiles,
599
+ total_input_tokens: proj.totalInputTokens,
600
+ total_output_tokens: proj.totalOutputTokens,
601
+ rendered_html: projectHtml,
602
+ },
603
+ }),
604
+ });
605
+ }
606
+ catch (projErr) {
607
+ console.warn(`[portfolio-upload] skipping project ${rawProj.dirName}:`, projErr.message);
608
+ }
609
+ }
610
+ // POST portfolio landing page to Phoenix.
611
+ // Phoenix sanitizes the HTML, persists to users.rendered_portfolio_html,
612
+ // and applies the optional profile snapshot via Accounts.update_user_profile.
613
+ const phoenixRes = await fetch(`${API_URL}/api/portfolio/upload`, {
614
+ method: 'POST',
615
+ headers: {
616
+ 'Content-Type': 'application/json',
617
+ Authorization: `Bearer ${auth.token}`,
618
+ },
619
+ body: JSON.stringify({
620
+ html: renderedHtml,
621
+ profile,
622
+ }),
623
+ });
624
+ if (!phoenixRes.ok) {
625
+ const errBody = await phoenixRes.json().catch(() => null);
626
+ const rawErr = errBody && typeof errBody === 'object'
627
+ ? errBody.error
628
+ : null;
629
+ const errMsg = typeof rawErr === 'string'
630
+ ? rawErr
631
+ : (rawErr && typeof rawErr === 'object' && 'message' in rawErr)
632
+ ? rawErr.message
633
+ : `HTTP ${phoenixRes.status}`;
634
+ updatePortfolioPublishTarget(DEFAULT_PORTFOLIO_TARGET, {
635
+ lastError: errMsg,
636
+ lastErrorAt: new Date().toISOString(),
637
+ });
638
+ res.status(phoenixRes.status >= 500 ? 502 : phoenixRes.status).json({
639
+ error: { code: 'PORTFOLIO_UPLOAD_FAILED', message: errMsg },
640
+ });
641
+ return;
642
+ }
643
+ const okBody = await phoenixRes.json().catch(() => ({}));
644
+ const publishedUrl = okBody.username ? `${API_URL}/${okBody.username}` : undefined;
645
+ const publishedAt = new Date().toISOString();
646
+ const hash = hashPortfolioProfile(profile);
647
+ updatePortfolioPublishTarget(DEFAULT_PORTFOLIO_TARGET, {
648
+ lastPublishedAt: publishedAt,
649
+ lastPublishedProfileHash: hash,
650
+ lastPublishedProfile: profile,
651
+ config: {},
652
+ url: publishedUrl,
653
+ lastError: undefined,
654
+ lastErrorAt: undefined,
655
+ });
656
+ // Published version of the portfolio just changed — drop any cached
657
+ // /preview/portfolio HTML so the next preview reflects reality.
658
+ invalidatePortfolioPreviewCache();
659
+ res.json({
660
+ ok: true,
661
+ url: publishedUrl ?? `${API_URL}/${auth.username}`,
662
+ publishedAt,
663
+ hash,
664
+ });
665
+ }
666
+ catch (err) {
667
+ const errMsg = err.message;
668
+ console.error('[portfolio-upload] Error:', errMsg);
669
+ try {
670
+ updatePortfolioPublishTarget(DEFAULT_PORTFOLIO_TARGET, {
671
+ lastError: errMsg,
672
+ lastErrorAt: new Date().toISOString(),
673
+ });
674
+ }
675
+ catch { /* don't mask the original error */ }
676
+ res.status(500).json({
677
+ error: { code: 'PORTFOLIO_UPLOAD_FAILED', message: errMsg },
678
+ });
679
+ }
680
+ });
681
+ // Export the portfolio as a downloadable .zip file.
682
+ //
683
+ // No path arg from the client. We render the portfolio site into a temp
684
+ // directory, zip it via createZipBuffer, stream the zip back as an
685
+ // attachment, then clean up the temp dir. Mirrors the existing
686
+ // single-project HTML download pattern (cli/src/routes/export.ts).
687
+ router.post('/api/portfolio/export', async (_req, res) => {
688
+ const auth = getAuthToken();
689
+ if (!auth) {
690
+ res.status(401).json({
691
+ error: { code: 'UNAUTHENTICATED', message: 'Authentication required' },
692
+ });
693
+ return;
694
+ }
695
+ let tmpDir = null;
696
+ try {
697
+ const templateName = getDefaultTemplate() || 'editorial';
698
+ const { renderData } = await buildPortfolioRenderData(ctx, auth);
699
+ // Build per-project inputs for generatePortfolioSite. Each project
700
+ // needs its full session list + enhance cache — use the same
701
+ // buildProjectDetail path the single-project HTML export uses so
702
+ // there's exactly one "load everything about a project" helper.
703
+ const rawProjects = await ctx.getProjects();
704
+ const projectInputs = [];
705
+ for (const rawProj of rawProjects) {
706
+ try {
707
+ const detail = buildProjectDetail(ctx.db, rawProj);
708
+ const cache = detail.enhanceCache ?? {
709
+ fingerprint: 'export',
710
+ enhancedAt: new Date().toISOString(),
711
+ selectedSessionIds: detail.sessions.map((s) => s.id),
712
+ result: {
713
+ narrative: '',
714
+ arc: [],
715
+ skills: [],
716
+ timeline: [],
717
+ questions: [],
718
+ },
719
+ };
720
+ const proj = detail.project;
721
+ projectInputs.push({
722
+ dirName: rawProj.dirName,
723
+ cache,
724
+ sessions: detail.sessions,
725
+ opts: {
726
+ totalFilesChanged: proj.totalFiles,
727
+ totalAgentDurationMinutes: proj.totalAgentDuration,
728
+ totalInputTokens: proj.totalInputTokens,
729
+ totalOutputTokens: proj.totalOutputTokens,
730
+ },
731
+ });
732
+ }
733
+ catch (projErr) {
734
+ console.warn(`[portfolio-export] skipping project ${rawProj.dirName}:`, projErr.message);
735
+ }
736
+ }
737
+ tmpDir = mkdtempSync(path.join(tmpdir(), 'heyiam-portfolio-'));
738
+ const result = await generatePortfolioSite(renderData, projectInputs, tmpDir, templateName);
739
+ // Read every file generated into memory keyed by its path relative
740
+ // to the temp dir, then zip. Skip non-files defensively (the result
741
+ // list is files-only, but stat lets us catch directories that snuck
742
+ // in via a future code path).
743
+ const entries = [];
744
+ for (const filePath of result.files) {
745
+ try {
746
+ if (!statSync(filePath).isFile())
747
+ continue;
748
+ entries.push({
749
+ path: path.relative(tmpDir, filePath),
750
+ content: readFileSync(filePath, 'utf-8'),
751
+ });
752
+ }
753
+ catch (readErr) {
754
+ console.warn(`[portfolio-export] skipping unreadable file ${filePath}:`, readErr.message);
755
+ }
756
+ }
757
+ const zipBuffer = createZipBuffer(entries);
758
+ const datestamp = new Date().toISOString().slice(0, 10);
759
+ const safeUsername = auth.username.replace(/[^a-zA-Z0-9_-]/g, '_');
760
+ const filename = `portfolio-${safeUsername}-${datestamp}.zip`;
761
+ res.setHeader('Content-Type', 'application/zip');
762
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
763
+ res.setHeader('Content-Length', zipBuffer.length);
764
+ res.send(zipBuffer);
765
+ }
766
+ catch (err) {
767
+ const errMsg = err.message;
768
+ console.error('[portfolio-export] Error:', errMsg);
769
+ if (!res.headersSent) {
770
+ res.status(500).json({
771
+ error: { code: 'PORTFOLIO_EXPORT_FAILED', message: errMsg },
772
+ });
773
+ }
774
+ }
775
+ finally {
776
+ if (tmpDir) {
777
+ try {
778
+ rmSync(tmpDir, { recursive: true, force: true });
779
+ }
780
+ catch { /* best effort */ }
781
+ }
782
+ }
783
+ });
495
784
  return router;
496
785
  }