heyiam 0.3.7 → 0.3.10

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.
@@ -5,7 +5,7 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>app</title>
8
- <script type="module" crossorigin src="/assets/index-BDh4ne9u.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-DU5On5Al.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-ByoBtx7P.css">
10
10
  </head>
11
11
  <body>
@@ -105,5 +105,6 @@ export function buildProjectRenderData(opts) {
105
105
  allSessions: opts.allSessionCards,
106
106
  sessionBaseUrl: opts.sessionBaseUrl,
107
107
  sessionSuffix: opts.sessionSuffix,
108
+ ...(opts.hideSessionDates ? { hideSessionDates: true } : {}),
108
109
  };
109
110
  }
@@ -110,7 +110,20 @@ export function renderProject(data, extras, templateName) {
110
110
  const slug = typeof existingSlug === 'string' && existingSlug !== ''
111
111
  ? existingSlug
112
112
  : (slugByToken.get(id) ?? '');
113
- return { ...rest, slug, rawLog: [] };
113
+ const merged = { ...rest, slug, rawLog: [] };
114
+ // When dates are hidden, strip every timestamp from the session so
115
+ // nothing leaks through the page source. The chart and overlay treat
116
+ // a missing `date` as "ordinal mode" — no axis labels, no gap markers.
117
+ if (data.hideSessionDates) {
118
+ delete merged.date;
119
+ delete merged.endTime;
120
+ const children = merged.children;
121
+ if (Array.isArray(children)) {
122
+ for (const c of children)
123
+ delete c.date;
124
+ }
125
+ }
126
+ return merged;
114
127
  });
115
128
  // Encode JSON safe for single-quoted HTML attributes
116
129
  const sessionsJson = JSON.stringify(chartSessions).replace(/'/g, '&#39;');
@@ -218,7 +218,7 @@
218
218
  <tr>
219
219
  <th scope="col">#</th>
220
220
  <th scope="col">Session</th>
221
- <th scope="col">Date</th>
221
+ {% unless hideSessionDates %}<th scope="col">Date</th>{% endunless %}
222
222
  <th scope="col">Duration</th>
223
223
  <th scope="col">LOC</th>
224
224
  <th scope="col">Source</th>
@@ -230,7 +230,7 @@
230
230
  <tr>
231
231
  <td><span class="session-num">{{ forloop.index }}</span></td>
232
232
  <td><a href="{{ sessionBaseUrl }}/{{ s.slug }}{{ sessionSuffix }}" class="session-title-link">{{ s.title }}</a></td>
233
- <td>{{ s.recordedAt | formatDateShort }}</td>
233
+ {% unless hideSessionDates %}<td>{{ s.recordedAt | formatDateShort }}</td>{% endunless %}
234
234
  <td>{{ s.durationMinutes | formatDuration }}</td>
235
235
  <td>{{ s.locChanged | localeNumber }}</td>
236
236
  <td>{% if s.sourceTool %}<span class="session-source-badge badge-{{ s.sourceTool | downcase | replace: ' ', '-' }}">{{ s.sourceTool }}</span>{% endif %}</td>
@@ -90,7 +90,7 @@
90
90
  {% if sessionsJson %}
91
91
  <section class="ed-card-section" aria-label="Agent work timeline">
92
92
  <h2 class="ed-section-title">Work Timeline</h2>
93
- <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
93
+ <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
94
94
  </section>
95
95
  {% endif %}
96
96
 
@@ -131,7 +131,7 @@
131
131
  {% assign labelY = barY | minus: 7 %}
132
132
  <rect x="{{ barX }}" y="{{ barY }}" width="{{ barWidth }}" height="{{ barH }}" class="chart-bar" aria-label="{{ s.title }}: {{ s.locChanged }} LOC"/>
133
133
  <text x="{{ labelX }}" y="{{ labelY }}" class="chart-value" text-anchor="middle">{{ s.locChanged }}</text>
134
- <text x="{{ labelX }}" y="180" class="chart-label" text-anchor="middle">{{ s.recordedAt | formatDateShort }}</text>
134
+ <text x="{{ labelX }}" y="180" class="chart-label" text-anchor="middle">{% if hideSessionDates %}#{{ forloop.index }}{% else %}{{ s.recordedAt | formatDateShort }}{% endif %}</text>
135
135
  {% endfor %}
136
136
  </svg>
137
137
  </div>
@@ -223,7 +223,7 @@
223
223
  <tr>
224
224
  <th scope="col">#</th>
225
225
  <th scope="col">Title</th>
226
- <th scope="col">Date</th>
226
+ {% unless hideSessionDates %}<th scope="col">Date</th>{% endunless %}
227
227
  <th scope="col">Duration</th>
228
228
  <th scope="col">LOC</th>
229
229
  <th scope="col">Source</th>
@@ -235,7 +235,7 @@
235
235
  <tr>
236
236
  <td>{{ forloop.index }}</td>
237
237
  <td><a href="{{ sessionBaseUrl }}/{{ s.slug }}{{ sessionSuffix }}" class="session-title-link">{{ s.title }}</a></td>
238
- <td>{{ s.recordedAt | formatDateShort }}</td>
238
+ {% unless hideSessionDates %}<td>{{ s.recordedAt | formatDateShort }}</td>{% endunless %}
239
239
  <td>{{ s.durationMinutes | formatDuration }}</td>
240
240
  <td>{{ s.locChanged | localeNumber }}</td>
241
241
  <td>{% if s.sourceTool %}<span class="session-source-badge">{{ s.sourceTool }}</span>{% endif %}</td>
@@ -107,7 +107,7 @@
107
107
  <div class="section-tag">Agent work</div>
108
108
  <h2 class="section-title">Work timeline</h2>
109
109
  <div class="timeline-container">
110
- <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
110
+ <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
111
111
  </div>
112
112
  </section>
113
113
  {% endif %}
@@ -55,7 +55,7 @@
55
55
  {% if sessionsJson %}
56
56
  <div class="mn-section">
57
57
  <h2 class="mn-section-heading">Work Timeline</h2>
58
- <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
58
+ <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
59
59
  </div>
60
60
  <hr class="mn-rule" />
61
61
  {% endif %}
@@ -100,7 +100,7 @@
100
100
  <tr>
101
101
  <th scope="col">#</th>
102
102
  <th scope="col">Title</th>
103
- <th scope="col">Date</th>
103
+ {% unless hideSessionDates %}<th scope="col">Date</th>{% endunless %}
104
104
  <th scope="col" class="text-right">Duration</th>
105
105
  <th scope="col" class="text-right">LOC</th>
106
106
  <th scope="col">Source</th>
@@ -114,7 +114,7 @@
114
114
  <tr>
115
115
  <td>{{ forloop.index }}</td>
116
116
  <td>{{ s.title }}</td>
117
- <td>{{ s.recordedAt | formatDateShort }}</td>
117
+ {% unless hideSessionDates %}<td>{{ s.recordedAt | formatDateShort }}</td>{% endunless %}
118
118
  <td class="text-right">{{ s.durationMinutes | formatDuration }}</td>
119
119
  <td class="text-right">{{ s.locChanged | localeNumber }}</td>
120
120
  <td>{{ s.sourceTool }}</td>
@@ -3,5 +3,5 @@
3
3
  <h3 class="section-header__title">Work timeline</h3>
4
4
  <span class="section-header__meta">sessions over time</span>
5
5
  </div>
6
- <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
6
+ <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
7
7
  </div>
@@ -94,7 +94,7 @@
94
94
  <h3 class="section-header__title">Work timeline</h3>
95
95
  <span class="section-header__meta">sessions over time</span>
96
96
  </div>
97
- <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
97
+ <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
98
98
  </div>
99
99
 
100
100
  {%- comment -%} Growth Chart mount point {%- endcomment -%}
@@ -103,7 +103,7 @@
103
103
  <section class="radar-section" aria-label="Work timeline">
104
104
  <div class="radar-label">Work Timeline</div>
105
105
  <div class="chart-card">
106
- <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
106
+ <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
107
107
  </div>
108
108
  </section>
109
109
  {% endif %}
@@ -90,7 +90,7 @@
90
90
  <h3>Work Timeline</h3>
91
91
  <span class="sc-section-label">sessions over time</span>
92
92
  </div>
93
- <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
93
+ <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
94
94
  </div>
95
95
  </section>
96
96
 
@@ -69,7 +69,7 @@
69
69
  {% if sessionsJson %}
70
70
  <div class="term-section">
71
71
  <div class="term-comment"># work timeline</div>
72
- <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'></div>
72
+ <div data-work-timeline data-sessions='{{ sessionsJson | raw }}'{% if hideSessionDates %} data-hide-dates="1"{% endif %}></div>
73
73
  </div>
74
74
  {% endif %}
75
75
 
@@ -9,8 +9,8 @@ import { bridgeToAnalyzer, mergeActiveIntervals, sumIntervalMs } from '../bridge
9
9
  import { analyzeSession } from '../analyzer.js';
10
10
  import { loadEnhancedData, loadProjectEnhanceResult, getUploadedState, } from '../settings.js';
11
11
  import { getTemplateCss } from '../render/templates.js';
12
- import { archiveSessionFiles } from '../archive.js';
13
- import { getDatabase, openDatabase, getSessionStats as dbGetSessionStats, getSessionCount, getAllSessionMetas, getAllProjectStats, getSessionsByProject, getProjectUuid, } from '../db.js';
12
+ import { archiveSessionFiles, findReadableSessionPath } from '../archive.js';
13
+ import { getDatabase, openDatabase, getSessionStats as dbGetSessionStats, getSessionCount, getAllSessionMetas, getAllProjectStats, getSessionsByProject, getProjectUuid, getSessionRow, updateSessionPath, } from '../db.js';
14
14
  import { ensureSessionIndexed, displayNameFromDir } from '../sync.js';
15
15
  export { displayNameFromDir };
16
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -298,11 +298,34 @@ export function createRouteContext(sessionsBasePath, dbPath) {
298
298
  };
299
299
  }
300
300
  async function loadSession(sessionPath, projectName, sessionId) {
301
- const parsed = await parseSession(sessionPath);
301
+ const readablePath = await resolveSessionReadPath(sessionPath, sessionId);
302
+ const parsed = await parseSession(readablePath);
302
303
  const analyzerInput = bridgeToAnalyzer(parsed, { sessionId, projectName });
303
304
  const session = analyzeSession(analyzerInput);
304
305
  return mergeEnhancedData(session);
305
306
  }
307
+ /**
308
+ * Returns a path to a readable session file. If the originally-indexed
309
+ * path still exists, returns it unchanged. Otherwise falls back to the
310
+ * archive copy and heals the DB cache so future reads skip the lookup.
311
+ *
312
+ * This compensates for source tools (notably Claude Code's 30-day
313
+ * cleanup) deleting session files after we've indexed them.
314
+ */
315
+ async function resolveSessionReadPath(originalPath, sessionId) {
316
+ const row = getSessionRow(db, sessionId);
317
+ const projectDir = row?.project_dir;
318
+ if (!projectDir)
319
+ return originalPath;
320
+ const readable = await findReadableSessionPath(originalPath, projectDir);
321
+ if (!readable)
322
+ return originalPath;
323
+ if (readable !== originalPath) {
324
+ updateSessionPath(db, sessionId, readable);
325
+ console.log(`[loadSession] Healed stale path for ${sessionId} → archive copy`);
326
+ }
327
+ return readable;
328
+ }
306
329
  // ── getSessionStats ──────────────────────────────────────
307
330
  async function getSessionStats(meta, projectName) {
308
331
  try {
@@ -2,6 +2,7 @@ import { Router } from 'express';
2
2
  import { getAuthToken } from '../auth.js';
3
3
  import { API_URL, warnIfNonDefaultApiUrl } from '../config.js';
4
4
  import { getUploadedState, clearUploadedState, saveUploadedState, loadEnhancedData, saveEnhancedData, } from '../settings.js';
5
+ import { toSlug } from '../format-utils.js';
5
6
  function sendError(res, status, error) {
6
7
  res.status(status).json({ error });
7
8
  }
@@ -139,7 +140,23 @@ export function createDeleteRouter(_ctx) {
139
140
  return;
140
141
  }
141
142
  try {
142
- const phoenixRes = await fetch(`${API_URL}/api/sessions/${encodeURIComponent(sessionId)}`, {
143
+ // Phoenix DELETE /api/sessions/:id expects an integer share ID,
144
+ // which the CLI never has — POST /api/sessions returns a token,
145
+ // not the DB row id. So we send the Claude UUID in the path
146
+ // (Phoenix accepts it as an opaque marker) and the real lookup
147
+ // key — (project_id, slug) — in query params. The server resolves
148
+ // by (project_id, slug) when the path :id isn't a valid integer.
149
+ const uploadedState = getUploadedState(project);
150
+ const enhanced = loadEnhancedData(sessionId);
151
+ const slug = toSlug(enhanced?.title ?? sessionId, 80);
152
+ const serverProjectId = uploadedState?.projectId;
153
+ const query = new URLSearchParams();
154
+ if (serverProjectId !== undefined)
155
+ query.set('project_id', String(serverProjectId));
156
+ if (slug)
157
+ query.set('slug', slug);
158
+ const qs = query.toString();
159
+ const phoenixRes = await fetch(`${API_URL}/api/sessions/${encodeURIComponent(sessionId)}${qs ? `?${qs}` : ''}`, {
143
160
  method: 'DELETE',
144
161
  headers: { Authorization: `Bearer ${auth.token}` },
145
162
  });
@@ -5,7 +5,7 @@ import { enhanceProject, refineNarrative } from '../llm/project-enhance.js';
5
5
  import { getAnthropicApiKey, saveEnhancedData, loadEnhancedData, deleteEnhancedData, loadFreshProjectEnhanceResult, saveProjectEnhanceResult, loadProjectEnhanceResult, buildProjectFingerprint, getUploadedState, } from '../settings.js';
6
6
  import { requireProject } from './context.js';
7
7
  import { startSSE } from './sse.js';
8
- import { invalidatePortfolioPreviewCache } from './preview.js';
8
+ import { invalidatePortfolioPreviewCache, invalidateProjectPreviewCache } from './preview.js';
9
9
  export function createEnhanceRouter(ctx) {
10
10
  const router = Router();
11
11
  // Triage endpoint -- AI selects which sessions are worth showcasing (SSE stream)
@@ -259,7 +259,11 @@ export function createEnhanceRouter(ctx) {
259
259
  // Save project enhance result explicitly
260
260
  router.post('/api/projects/:project/enhance-save', async (req, res) => {
261
261
  const project = String(req.params.project);
262
- const { selectedSessionIds, result, title, repoUrl, projectUrl, screenshotBase64 } = req.body;
262
+ // FIXME(security): screenshotBase64 is accepted with no size cap and no
263
+ // `data:image/(png|jpeg|jpg|webp);base64,...` shape check. Local-only CLI
264
+ // so not a privilege issue today, but worth a regex + ~4 MB cap for
265
+ // defense-in-depth and to keep the cache JSON from ballooning.
266
+ const { selectedSessionIds, result, title, repoUrl, projectUrl, screenshotBase64, hideSessionDates } = req.body;
263
267
  if (!Array.isArray(selectedSessionIds) || !result?.narrative) {
264
268
  res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'selectedSessionIds and result are required' } });
265
269
  return;
@@ -272,9 +276,12 @@ export function createEnhanceRouter(ctx) {
272
276
  const proj = await requireProject(ctx, project, res);
273
277
  if (!proj)
274
278
  return;
275
- saveProjectEnhanceResult(proj.dirName, selectedSessionIds, result, undefined, { title, repoUrl, projectUrl, screenshotBase64 });
279
+ saveProjectEnhanceResult(proj.dirName, selectedSessionIds, result, undefined, { title, repoUrl, projectUrl, screenshotBase64, hideSessionDates });
276
280
  // Project title/narrative/skills appear in portfolio listing — bust cache.
277
281
  invalidatePortfolioPreviewCache();
282
+ // Per-project render cache is keyed by the URL param the client passed.
283
+ invalidateProjectPreviewCache(project);
284
+ invalidateProjectPreviewCache(proj.dirName);
278
285
  res.json({ saved: true, enhancedAt: new Date().toISOString() });
279
286
  }
280
287
  catch (err) {
@@ -54,6 +54,15 @@ function portfolioCacheKey(templateName) {
54
54
  export function invalidatePortfolioPreviewCache() {
55
55
  portfolioPreviewCache.clear();
56
56
  }
57
+ /**
58
+ * Drop the per-project render-data cache for `projectParam`. Call after any
59
+ * mutation that affects the rendered project page (title, URLs, narrative,
60
+ * screenshot, skills, timeline). Without this, the 30s TTL on
61
+ * previewDataCache masks the change in the React UI.
62
+ */
63
+ export function invalidateProjectPreviewCache(projectParam) {
64
+ previewDataCache.delete(projectParam);
65
+ }
57
66
  /** Test helper: read current cache entry without mutating. */
58
67
  export function _getPortfolioPreviewCacheEntry(templateName = 'editorial') {
59
68
  return portfolioPreviewCache.get(portfolioCacheKey(templateName));
@@ -177,6 +186,12 @@ async function buildProjectPreviewData(ctx, projectParam, queryOverrides) {
177
186
  repoUrl: metaRepoUrl,
178
187
  projectUrl: metaProjectUrl,
179
188
  screenshotUrl: (() => {
189
+ // Manual uploads land only in the enhance-cache JSON (no disk write),
190
+ // so check the cache first — mirrors export.ts:resolveScreenshotDataUri.
191
+ const b64 = cachedAny?.screenshotBase64;
192
+ if (b64) {
193
+ return b64.startsWith('data:') ? b64 : `data:image/png;base64,${b64}`;
194
+ }
180
195
  return existsSync(path.join(SCREENSHOTS_DIR, `${slug}.png`))
181
196
  ? `/screenshots/${slug}.png`
182
197
  : undefined;
@@ -193,6 +208,7 @@ async function buildProjectPreviewData(ctx, projectParam, queryOverrides) {
193
208
  allSessionCards,
194
209
  sessionBaseUrl: `/preview/project/${encodeURIComponent(projectParam)}/session`,
195
210
  sessionSuffix: '.html',
211
+ hideSessionDates: cachedAny?.hideSessionDates,
196
212
  });
197
213
  const result = { renderData, enhanceResult, projName: projAny.name };
198
214
  // Cache the result (template-agnostic data, re-rendered cheaply per template)
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { API_URL } from '../config.js';
7
- import { loadEnhancedData, saveEnhancedData, getDefaultTemplate, isTranscriptIncluded, } from '../settings.js';
7
+ import { loadEnhancedData, saveEnhancedData, getDefaultTemplate, isTranscriptIncluded, getUploadedState, } from '../settings.js';
8
8
  import { redactSession, redactText, scanTextSync, formatFindings, stripHomePathsInText } from '../redact.js';
9
9
  import { renderSessionHtml } from '../render/index.js';
10
10
  import { buildSessionRenderData, buildSessionCard } from '../render/build-render-data.js';
@@ -222,3 +222,65 @@ export async function uploadSelectedSessions(ctx, auth, options) {
222
222
  }
223
223
  return { uploadedCount, failedSessions, uploadedSessionCards };
224
224
  }
225
+ /**
226
+ * Delete previously-uploaded sessions that are no longer in the user's
227
+ * selected set. Called before re-uploading so the published portfolio
228
+ * reflects deselections made through the Manage Sessions modal.
229
+ *
230
+ * Identifies each session on the server by (project_id, slug) — the same
231
+ * fallback contract used by the single-session trash button. Failures
232
+ * are logged but non-fatal: the upload continues so partial cleanup
233
+ * doesn't block the user's primary action.
234
+ */
235
+ export async function demoteRemovedSessions(auth, options) {
236
+ const { projectDirName, selectedSessionIds, send } = options;
237
+ const notify = send ?? (() => { });
238
+ const uploaded = getUploadedState(projectDirName);
239
+ if (!uploaded || !uploaded.projectId) {
240
+ return { demotedCount: 0, failed: [] };
241
+ }
242
+ const selected = new Set(selectedSessionIds);
243
+ const removed = (uploaded.uploadedSessions ?? []).filter((id) => !selected.has(id));
244
+ if (removed.length === 0) {
245
+ return { demotedCount: 0, failed: [] };
246
+ }
247
+ let demotedCount = 0;
248
+ const failed = [];
249
+ for (const sessionId of removed) {
250
+ const enhanced = loadEnhancedData(sessionId);
251
+ const slug = toSlug(enhanced?.title ?? sessionId, 80);
252
+ notify({ type: 'session', sessionId, status: 'demoting' });
253
+ try {
254
+ const query = new URLSearchParams({
255
+ project_id: String(uploaded.projectId),
256
+ slug,
257
+ });
258
+ const res = await fetch(`${API_URL}/api/sessions/${encodeURIComponent(sessionId)}?${query.toString()}`, {
259
+ method: 'DELETE',
260
+ headers: { Authorization: `Bearer ${auth.token}` },
261
+ });
262
+ if (res.status === 204 || res.status === 404) {
263
+ // 404 = already gone server-side; either way, the local state
264
+ // should reflect "not uploaded" so the next re-publish doesn't
265
+ // try to demote it again.
266
+ if (enhanced?.uploaded) {
267
+ saveEnhancedData(sessionId, { ...enhanced, uploaded: false });
268
+ }
269
+ demotedCount++;
270
+ notify({ type: 'session', sessionId, status: 'demoted' });
271
+ }
272
+ else {
273
+ const body = await res.text().catch(() => '');
274
+ const error = `HTTP ${res.status}: ${body.slice(0, 200)}`;
275
+ failed.push({ sessionId, error });
276
+ notify({ type: 'session', sessionId, status: 'demote_failed', error });
277
+ }
278
+ }
279
+ catch (err) {
280
+ const error = err.message;
281
+ failed.push({ sessionId, error });
282
+ notify({ type: 'session', sessionId, status: 'demote_failed', error });
283
+ }
284
+ }
285
+ return { demotedCount, failed };
286
+ }
@@ -217,7 +217,14 @@ export function createProjectsRouter(ctx) {
217
217
  res.status(400).json({ error: { code: 'NO_CACHE', message: 'Project must be enhanced before managing sessions' } });
218
218
  return;
219
219
  }
220
- saveProjectEnhanceResult(proj.dirName, selectedSessionIds, cache.result, undefined, { title: cache.title, repoUrl: cache.repoUrl, projectUrl: cache.projectUrl, screenshotBase64: cache.screenshotBase64 });
220
+ saveProjectEnhanceResult(proj.dirName, selectedSessionIds, cache.result, undefined, {
221
+ title: cache.title,
222
+ repoUrl: cache.repoUrl,
223
+ projectUrl: cache.projectUrl,
224
+ screenshotBase64: cache.screenshotBase64,
225
+ template: cache.template,
226
+ hideSessionDates: cache.hideSessionDates,
227
+ });
221
228
  invalidatePortfolioPreviewCache();
222
229
  res.json({ ok: true, selectedSessionIds });
223
230
  }
@@ -12,7 +12,7 @@ import { buildProjectDetail } from './context.js';
12
12
  import { captureScreenshot } from '../screenshot.js';
13
13
  import { renderProjectHtml } from '../render/index.js';
14
14
  import { buildProjectRenderData } from '../render/build-render-data.js';
15
- import { uploadSelectedSessions } from './project-session-upload.js';
15
+ import { uploadSelectedSessions, demoteRemovedSessions } from './project-session-upload.js';
16
16
  import { invalidatePortfolioPreviewCache } from './preview.js';
17
17
  import { startSSE } from './sse.js';
18
18
  import { displayNameFromDir } from '../sync.js';
@@ -55,7 +55,7 @@ export function createPublishRouter(ctx) {
55
55
  // Render project preview HTML
56
56
  router.post('/api/projects/:project/render-preview', async (req, res) => {
57
57
  try {
58
- const { username, slug, title, narrative, repoUrl, projectUrl, screenshotUrl, timeline, skills, totalSessions, totalLoc, totalDurationMinutes, totalAgentDurationMinutes, totalFilesChanged, totalTokens, sessionCards, } = req.body;
58
+ const { username, slug, title, narrative, repoUrl, projectUrl, screenshotUrl, timeline, skills, totalSessions, totalLoc, totalDurationMinutes, totalAgentDurationMinutes, totalFilesChanged, totalTokens, sessionCards, hideSessionDates, } = req.body;
59
59
  const renderData = buildProjectRenderData({
60
60
  username: username || 'preview',
61
61
  slug, title, narrative,
@@ -69,6 +69,7 @@ export function createPublishRouter(ctx) {
69
69
  totalFilesChanged: totalFilesChanged || 0,
70
70
  totalTokens,
71
71
  sessionCards: sessionCards || [],
72
+ hideSessionDates,
72
73
  });
73
74
  const templateName = getDefaultTemplate() || 'editorial';
74
75
  const html = renderProjectHtml(renderData, undefined, templateName);
@@ -104,7 +105,7 @@ export function createPublishRouter(ctx) {
104
105
  });
105
106
  // Publish project -- SSE stream with per-session progress
106
107
  router.post('/api/projects/:project/upload', async (req, res) => {
107
- const { project } = req.params;
108
+ const project = String(req.params.project);
108
109
  const auth = getAuthToken();
109
110
  warnIfNonDefaultApiUrl();
110
111
  if (!auth) {
@@ -113,11 +114,11 @@ export function createPublishRouter(ctx) {
113
114
  }
114
115
  const { title: rawTitle, slug: rawSlug, narrative, repoUrl, projectUrl, timeline, skills, totalSessions, totalLoc, totalDurationMinutes, totalAgentDurationMinutes, totalFilesChanged, skippedSessions, selectedSessionIds, screenshotBase64, } = req.body;
115
116
  // Ensure slug is the short project name, not the full encoded directory path
116
- const shortName = displayNameFromDir(String(project));
117
+ const shortName = displayNameFromDir(project);
117
118
  const baseSlug = toSlug(shortName);
118
119
  const title = rawTitle === rawSlug ? shortName : rawTitle;
119
120
  // Get stable project UUID from CLI database
120
- const clientProjectId = getProjectUuid(ctx.db, String(project));
121
+ const clientProjectId = getProjectUuid(ctx.db, project);
121
122
  const send = startSSE(res);
122
123
  try {
123
124
  // Step 1: Upsert project on Phoenix (with slug conflict retry)
@@ -181,6 +182,11 @@ export function createPublishRouter(ctx) {
181
182
  send({ type: 'screenshot', status: 'capturing' });
182
183
  const raw = screenshotBase64.includes(',') ? screenshotBase64.split(',')[1] : screenshotBase64;
183
184
  imageBuffer = Buffer.from(raw, 'base64');
185
+ // FIXME(security): ext detection only handles png/jpg, so an SVG
186
+ // (allowed by the file picker's `accept="image/*"`) gets uploaded
187
+ // to S3 with `Content-Type: image/png` while the bytes are SVG.
188
+ // CSP on heyi.am mitigates script execution, but reject SVG here
189
+ // (or convert it) instead of relying on downstream defenses.
184
190
  ext = screenshotBase64.startsWith('data:image/jpeg') || screenshotBase64.startsWith('data:image/jpg') ? 'jpg' : 'png';
185
191
  }
186
192
  else if (projectUrl) {
@@ -242,6 +248,17 @@ export function createPublishRouter(ctx) {
242
248
  const failedSessions = [];
243
249
  let uploadedSessionCards = [];
244
250
  if (proj) {
251
+ // Honor Manage Sessions deselections: remove server-side sessions
252
+ // that were previously uploaded but are no longer in the
253
+ // selection. Failures are non-fatal — proceed with the upload.
254
+ const demoteResult = await demoteRemovedSessions(auth, {
255
+ projectDirName: project,
256
+ selectedSessionIds,
257
+ send,
258
+ });
259
+ if (demoteResult.failed.length > 0) {
260
+ console.warn(`[upload] ${demoteResult.failed.length} session(s) could not be removed from heyi.am`);
261
+ }
245
262
  const sessionResult = await uploadSelectedSessions(ctx, auth, {
246
263
  proj,
247
264
  projectData,
@@ -524,6 +541,18 @@ export function createPublishRouter(ctx) {
524
541
  slugMap.set(baseSlug, projectData.slug);
525
542
  }
526
543
  send({ type: 'project', project: title, index: projectIndex, total: filteredProjects.length, status: 'created' });
544
+ // Honor Manage Sessions deselections before re-uploading: remove
545
+ // server-side sessions that were previously uploaded but aren't
546
+ // in the current `selectedSessionIds`. Without this step, the
547
+ // bulk-status PATCH below silently re-lists everything.
548
+ const demoteResult = await demoteRemovedSessions(auth, {
549
+ projectDirName: rawProj.dirName,
550
+ selectedSessionIds,
551
+ send: (evt) => send({ ...evt, project: title }),
552
+ });
553
+ if (demoteResult.failed.length > 0) {
554
+ console.warn(`[portfolio-upload] ${title}: ${demoteResult.failed.length} session(s) could not be removed from heyi.am`);
555
+ }
527
556
  send({ type: 'progress', message: `Uploading ${selectedSessionIds.length} session${selectedSessionIds.length === 1 ? '' : 's'} for ${title}…` });
528
557
  const { uploadedSessionCards } = await uploadSelectedSessions(ctx, auth, {
529
558
  proj: projInfo,
@@ -2,23 +2,48 @@ import { Router } from 'express';
2
2
  import { listSessions, parseSession } from '../parsers/index.js';
3
3
  import { bridgeToAnalyzer } from '../bridge.js';
4
4
  import { analyzeSession } from '../analyzer.js';
5
- import { getSessionRow } from '../db.js';
5
+ import { getSessionRow, getAllSessionMetas } from '../db.js';
6
6
  import { ensureSessionIndexed } from '../sync.js';
7
7
  import { exportSessionContext } from '../context-export.js';
8
8
  import { buildTranscriptResponse } from '../transcript.js';
9
9
  import { loadEnhancedData } from '../settings.js';
10
+ import { toSlug } from '../format-utils.js';
10
11
  import { displayNameFromDir } from './context.js';
11
12
  /**
12
- * Find a session's file path and project name, checking the DB first,
13
- * then falling back to live discovery (triggers indexing as a side effect).
13
+ * Resolve an :id param to a real session. Accepts either:
14
+ * - A canonical UUID (the path used by SPA links built from session.id)
15
+ * - A title-derived slug, optionally with a ".html" suffix (the path
16
+ * baked into rendered Liquid HTML — used when a user lands on a
17
+ * session URL directly via refresh, bookmark, or modifier-click that
18
+ * slipped past the SPA's click interceptor)
19
+ *
20
+ * Falls back to live discovery on a UUID miss, mirroring the original
21
+ * behavior. Slug resolution is DB-only because the slug source-of-truth
22
+ * is the enhanced title, which lives in the local-state store.
14
23
  */
15
- async function resolveSession(ctx, id) {
16
- // Try DB first
24
+ async function resolveSession(ctx, rawId) {
25
+ const id = rawId.replace(/\.html$/, '');
26
+ // 1. Try DB by UUID
17
27
  const row = getSessionRow(ctx.db, id);
18
28
  if (row?.file_path) {
19
- return { filePath: row.file_path, projectName: displayNameFromDir(row.project_dir) };
29
+ return { filePath: row.file_path, projectName: displayNameFromDir(row.project_dir), sessionId: id };
20
30
  }
21
- // Fallback: discover live sessions and index the match
31
+ // 2. Try DB by title-derived slug
32
+ const slugTarget = toSlug(id, 80);
33
+ if (slugTarget) {
34
+ const metas = getAllSessionMetas(ctx.db);
35
+ for (const meta of metas) {
36
+ if (meta.isSubagent)
37
+ continue;
38
+ const enhanced = loadEnhancedData(meta.sessionId);
39
+ const titleRow = getSessionRow(ctx.db, meta.sessionId);
40
+ const title = enhanced?.title ?? titleRow?.title ?? meta.sessionId;
41
+ if (toSlug(title, 80) === slugTarget) {
42
+ return { filePath: meta.path, projectName: displayNameFromDir(meta.projectDir), sessionId: meta.sessionId };
43
+ }
44
+ }
45
+ }
46
+ // 3. Fallback: discover live sessions and index the match (UUID only)
22
47
  const allSessions = await listSessions(ctx.sessionsBasePath);
23
48
  const meta = allSessions.find((s) => s.sessionId === id);
24
49
  if (!meta)
@@ -28,7 +53,7 @@ async function resolveSession(ctx, id) {
28
53
  await ensureSessionIndexed(ctx.db, meta, projectName);
29
54
  }
30
55
  catch { /* best effort */ }
31
- return { filePath: meta.path, projectName };
56
+ return { filePath: meta.path, projectName, sessionId: id };
32
57
  }
33
58
  export function createSessionsRouter(ctx) {
34
59
  const router = Router();
@@ -41,7 +66,7 @@ export function createSessionsRouter(ctx) {
41
66
  res.status(404).json({ error: { code: 'SESSION_NOT_FOUND', message: 'Session not found' } });
42
67
  return;
43
68
  }
44
- const session = await ctx.loadSession(resolved.filePath, resolved.projectName, id);
69
+ const session = await ctx.loadSession(resolved.filePath, resolved.projectName, resolved.sessionId);
45
70
  res.json({ session });
46
71
  }
47
72
  catch (err) {
@@ -59,12 +84,12 @@ export function createSessionsRouter(ctx) {
59
84
  return;
60
85
  }
61
86
  const parsed = await parseSession(resolved.filePath);
62
- const analyzerInput = bridgeToAnalyzer(parsed, { sessionId: id, projectName: resolved.projectName });
87
+ const analyzerInput = bridgeToAnalyzer(parsed, { sessionId: resolved.sessionId, projectName: resolved.projectName });
63
88
  let session = analyzeSession(analyzerInput);
64
89
  const turns = analyzerInput.turns;
65
90
  // Merge enhanced data (LLM-generated title, context, developer take,
66
91
  // execution steps, Q&A) when available — hybrid output
67
- const enhanced = loadEnhancedData(id);
92
+ const enhanced = loadEnhancedData(resolved.sessionId);
68
93
  if (enhanced) {
69
94
  session = {
70
95
  ...session,
package/dist/settings.js CHANGED
@@ -163,6 +163,8 @@ export function saveProjectEnhanceResult(projectDirName, selectedSessionIds, res
163
163
  ...(extras?.repoUrl ? { repoUrl: extras.repoUrl } : {}),
164
164
  ...(extras?.projectUrl ? { projectUrl: extras.projectUrl } : {}),
165
165
  ...(extras?.screenshotBase64 ? { screenshotBase64: extras.screenshotBase64 } : {}),
166
+ ...(extras?.template ? { template: extras.template } : {}),
167
+ ...(extras?.hideSessionDates ? { hideSessionDates: true } : {}),
166
168
  result,
167
169
  };
168
170
  writeFileSync(projectEnhancePath(projectDirName, configDir), JSON.stringify(cache, null, 2), { mode: 0o600 });