heyiam 0.3.7 → 0.3.9

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-LQHTU1Wz.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 {
@@ -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)
@@ -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
  }
@@ -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);
@@ -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) {
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 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyiam",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Turn AI coding sessions into portfolio case studies",
5
5
  "type": "module",
6
6
  "license": "MIT",