heyiam 0.3.2 → 0.3.4

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,20 +5,19 @@ import path from 'node:path';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { getAuthToken } from '../auth.js';
7
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';
8
+ import { loadEnhancedData, saveUploadedState, getDefaultTemplate, getPortfolioProfile, hashPortfolioProfile, updatePortfolioPublishTarget, getPortfolioPublishState, DEFAULT_PORTFOLIO_TARGET, } from '../settings.js';
9
9
  import { generatePortfolioHtmlFragment, generateProjectHtmlFragment, generatePortfolioSite, createZipBuffer } from '../export.js';
10
10
  import { buildPortfolioRenderData } from './portfolio-render-data.js';
11
11
  import { buildProjectDetail } from './context.js';
12
12
  import { captureScreenshot } from '../screenshot.js';
13
- import { redactSession, redactText, scanTextSync, formatFindings, stripHomePathsInText } from '../redact.js';
14
- import { renderProjectHtml, renderSessionHtml } from '../render/index.js';
15
- import { buildSessionRenderData, buildSessionCard, buildProjectRenderData } from '../render/build-render-data.js';
16
- import { buildAgentSummary } from './context.js';
13
+ import { renderProjectHtml } from '../render/index.js';
14
+ import { buildProjectRenderData } from '../render/build-render-data.js';
15
+ import { uploadSelectedSessions } from './project-session-upload.js';
17
16
  import { invalidatePortfolioPreviewCache } from './preview.js';
18
17
  import { startSSE } from './sse.js';
19
18
  import { displayNameFromDir } from '../sync.js';
20
19
  import { toSlug } from '../format-utils.js';
21
- import { getProjectUuid, getFileCountWithChildren } from '../db.js';
20
+ import { getProjectUuid } from '../db.js';
22
21
  const IMAGE_KEY_PREFIX = 'images/';
23
22
  export function createPublishRouter(ctx) {
24
23
  const router = Router();
@@ -210,220 +209,17 @@ export function createPublishRouter(ctx) {
210
209
  const proj = projects.find((p) => p.dirName === project);
211
210
  let uploadedCount = 0;
212
211
  const failedSessions = [];
213
- const uploadedSessionCards = [];
212
+ let uploadedSessionCards = [];
214
213
  if (proj) {
215
- const selectedTemplate = getDefaultTemplate() || 'editorial';
216
- for (const sessionId of selectedSessionIds) {
217
- const meta = proj.sessions.find((s) => s.sessionId === sessionId);
218
- if (!meta)
219
- continue;
220
- send({ type: 'session', sessionId, status: 'uploading' });
221
- try {
222
- const session = await ctx.loadSession(meta.path, proj.name, sessionId);
223
- const enhanced = loadEnhancedData(sessionId);
224
- const sessionSlug = toSlug(enhanced?.title ?? session.title ?? sessionId, 80);
225
- const includeTranscript = isTranscriptIncluded(sessionId);
226
- const agentSummary = await buildAgentSummary(meta.children ?? [], (c) => ctx.getSessionStats(c, proj.name), { deduplicate: true });
227
- const devTake = (enhanced?.developerTake ?? session.developerTake ?? '').slice(0, 2000);
228
- const sessionNarrative = enhanced?.narrative ?? '';
229
- const sessionTitle = enhanced?.title ?? session.title;
230
- const sessionSkills = enhanced?.skills ?? session.skills ?? [];
231
- const sessionSourceTool = session.source ?? meta.source ?? 'claude';
232
- const sessionRecordedAt = session.date ? new Date(session.date).toISOString() : new Date().toISOString();
233
- const renderOpts = {
234
- sessionId,
235
- session,
236
- enhanced,
237
- username: auth.username,
238
- projectSlug: projectData.slug,
239
- sessionSlug,
240
- sourceTool: sessionSourceTool,
241
- agentSummary,
242
- template: selectedTemplate,
243
- };
244
- let sessionRenderedHtml = null;
245
- try {
246
- const sessionRenderData = buildSessionRenderData(renderOpts);
247
- sessionRenderedHtml = renderSessionHtml(sessionRenderData, selectedTemplate);
248
- }
249
- catch (renderErr) {
250
- console.error(`[upload] Session render failed for ${sessionId}:`, renderErr.message);
251
- }
252
- uploadedSessionCards.push(buildSessionCard(renderOpts));
253
- const childLoc = agentSummary?.agents?.reduce((s, a) => s + (a.loc_changed ?? 0), 0) ?? 0;
254
- const totalLocChanged = (session.linesOfCode ?? 0) + childLoc;
255
- const totalFilesChanged = getFileCountWithChildren(ctx.db, sessionId) || session.filesChanged?.length || 0;
256
- const sessionPayload = {
257
- session: {
258
- title: sessionTitle,
259
- dev_take: devTake,
260
- context: enhanced?.context ?? '',
261
- duration_minutes: session.durationMinutes ?? 0,
262
- turns: session.turns ?? 0,
263
- files_changed: totalFilesChanged,
264
- loc_changed: totalLocChanged,
265
- recorded_at: sessionRecordedAt,
266
- end_time: session.endTime ? new Date(session.endTime).toISOString() : null,
267
- cwd: session.cwd ?? null,
268
- wall_clock_minutes: session.wallClockMinutes ?? null,
269
- template: selectedTemplate,
270
- language: null,
271
- tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
272
- skills: sessionSkills,
273
- narrative: sessionNarrative,
274
- project_name: proj.name,
275
- project_id: projectData.project_id,
276
- slug: sessionSlug,
277
- status: 'unlisted',
278
- source_tool: sessionSourceTool,
279
- agent_summary: agentSummary,
280
- rendered_html: sessionRenderedHtml,
281
- },
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
- });
299
- const sessionData = {
300
- version: 1,
301
- id: sessionId,
302
- title: sessionTitle,
303
- dev_take: devTake,
304
- context: enhanced?.context ?? '',
305
- duration_minutes: session.durationMinutes ?? 0,
306
- turns: session.turns ?? 0,
307
- files_changed: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
308
- loc_changed: totalLocChanged,
309
- date: sessionRecordedAt,
310
- end_time: (() => {
311
- if (!session.endTime || !session.date)
312
- return null;
313
- const wallMs = new Date(session.endTime).getTime() - new Date(session.date).getTime();
314
- const activeMs = (session.durationMinutes ?? 0) * 60_000;
315
- return wallMs <= activeMs * 3 ? new Date(session.endTime).toISOString() : null;
316
- })(),
317
- cwd: session.cwd ?? null,
318
- wall_clock_minutes: session.wallClockMinutes ?? null,
319
- template: selectedTemplate,
320
- skills: sessionSkills,
321
- tools: session.toolBreakdown?.map((t) => t.tool) ?? [],
322
- source: sessionSourceTool,
323
- slug: sessionSlug,
324
- project_name: proj.name,
325
- narrative: sessionNarrative,
326
- status: 'unlisted',
327
- raw_log: [],
328
- execution_path: (enhanced?.executionSteps ?? session.executionPath ?? []).map((s, i) => ({
329
- label: s.title ?? `Step ${i + 1}`,
330
- description: s.description ?? s.body ?? '',
331
- })),
332
- qa_pairs: enhanced?.qaPairs ?? session.qaPairs ?? [],
333
- highlights: [],
334
- tool_breakdown: (session.toolBreakdown ?? []).map((t) => ({ tool: t.tool, count: t.count })),
335
- top_files: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
336
- ...(includeTranscript ? { turn_timeline: turnTimeline } : {}),
337
- ...(includeTranscript ? { transcript_excerpt: transcriptExcerpt } : {}),
338
- agent_summary: agentSummary,
339
- children: agentSummary?.agents?.map((a) => ({
340
- sessionId: a.role,
341
- role: a.role,
342
- durationMinutes: a.duration_minutes,
343
- linesOfCode: a.loc_changed,
344
- })) ?? [],
345
- };
346
- const sessionCwd = session.cwd ?? undefined;
347
- const redactedPayload = redactSession(sessionPayload, 'high', sessionCwd);
348
- const redactedData = redactSession(sessionData, 'high', sessionCwd);
349
- const payloadFindings = scanTextSync(JSON.stringify(sessionPayload));
350
- if (payloadFindings.length > 0) {
351
- const summary = formatFindings(payloadFindings);
352
- send({ type: 'redaction', sessionId, message: summary });
353
- }
354
- const sessionRes = await fetch(`${API_URL}/api/sessions`, {
355
- method: 'POST',
356
- headers: {
357
- 'Content-Type': 'application/json',
358
- Authorization: `Bearer ${auth.token}`,
359
- },
360
- body: JSON.stringify(redactedPayload),
361
- });
362
- if (sessionRes.ok) {
363
- uploadedCount++;
364
- try {
365
- const sesData = await sessionRes.json();
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.
372
- const { raw: rawUrl, log: logUrl } = sesData.upload_urls;
373
- if (rawUrl && meta.path && !meta.path.startsWith('cursor://')) {
374
- try {
375
- const rawText = readFileSync(meta.path, 'utf-8');
376
- let redactedRaw = redactText(rawText);
377
- redactedRaw = stripHomePathsInText(redactedRaw, sessionCwd);
378
- await fetch(rawUrl, { method: 'PUT', body: Buffer.from(redactedRaw, 'utf-8'), headers: { 'Content-Type': 'application/octet-stream' } });
379
- }
380
- catch { /* S3 upload is best-effort */ }
381
- }
382
- if (logUrl && session.rawLog && session.rawLog.length > 0) {
383
- try {
384
- const redactedLog = session.rawLog.map((line) => {
385
- let cleaned = redactText(line);
386
- cleaned = stripHomePathsInText(cleaned, sessionCwd);
387
- return cleaned;
388
- });
389
- await fetch(logUrl, { method: 'PUT', body: JSON.stringify(redactedLog), headers: { 'Content-Type': 'application/json' } });
390
- }
391
- catch { /* S3 upload is best-effort */ }
392
- }
393
- if (sesData.upload_urls.session) {
394
- try {
395
- await fetch(sesData.upload_urls.session, {
396
- method: 'PUT',
397
- body: JSON.stringify(redactedData),
398
- headers: { 'Content-Type': 'application/json' },
399
- });
400
- }
401
- catch { /* S3 upload is best-effort */ }
402
- }
403
- }
404
- }
405
- catch { /* Response already consumed or no upload_urls -- not fatal */ }
406
- if (enhanced) {
407
- saveEnhancedData(sessionId, { ...enhanced, uploaded: true });
408
- }
409
- send({ type: 'session', sessionId, status: 'uploaded' });
410
- }
411
- else {
412
- const sesErrBody = await sessionRes.json().catch(() => null);
413
- const rawSesErr = sesErrBody && typeof sesErrBody === 'object' ? sesErrBody.error : null;
414
- const errMsg = typeof rawSesErr === 'string' ? rawSesErr
415
- : (rawSesErr && typeof rawSesErr === 'object' && 'message' in rawSesErr) ? rawSesErr.message
416
- : `HTTP ${sessionRes.status}`;
417
- failedSessions.push({ sessionId, error: errMsg });
418
- send({ type: 'session', sessionId, status: 'failed', error: errMsg });
419
- }
420
- }
421
- catch (err) {
422
- const errMsg = err.message;
423
- failedSessions.push({ sessionId, error: errMsg });
424
- send({ type: 'session', sessionId, status: 'failed', error: errMsg });
425
- }
426
- }
214
+ const sessionResult = await uploadSelectedSessions(ctx, auth, {
215
+ proj,
216
+ projectData,
217
+ selectedSessionIds,
218
+ send,
219
+ });
220
+ uploadedCount = sessionResult.uploadedCount;
221
+ failedSessions.push(...sessionResult.failedSessions);
222
+ uploadedSessionCards = sessionResult.uploadedSessionCards;
427
223
  }
428
224
  // Step 3: Render project HTML using the same path as HTML export
429
225
  if (uploadedSessionCards.length > 0) {
@@ -563,44 +359,156 @@ export function createPublishRouter(ctx) {
563
359
  // Upload individual project pages for every project included in the
564
360
  // portfolio. This ensures project detail pages exist on heyi.am even
565
361
  // if the user never published them individually.
362
+ const MAX_SLUG_RETRIES = 10;
566
363
  for (const rawProj of filteredProjects) {
567
364
  try {
365
+ const allProjectsList = await ctx.getProjects();
366
+ const projInfo = allProjectsList.find((p) => p.dirName === rawProj.dirName);
367
+ if (!projInfo) {
368
+ console.warn(`[portfolio-upload] project not found: ${rawProj.dirName}`);
369
+ continue;
370
+ }
568
371
  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: [] } };
372
+ const enhance = detail.enhanceCache;
373
+ const cache = enhance ?? {
374
+ fingerprint: 'portfolio-upload',
375
+ enhancedAt: new Date().toISOString(),
376
+ selectedSessionIds: detail.sessions.map((s) => s.id),
377
+ result: { narrative: '', arc: [], skills: [], timeline: [], questions: [] },
378
+ };
379
+ const selectedSessionIds = enhance !== null && enhance.selectedSessionIds !== undefined
380
+ ? enhance.selectedSessionIds
381
+ : detail.sessions.map((s) => s.id);
382
+ const projRecord = detail.project;
572
383
  const title = cache.title
573
- || proj.name || displayNameFromDir(rawProj.dirName);
574
- const slug = toSlug(title);
384
+ || projRecord.name || displayNameFromDir(rawProj.dirName);
385
+ const baseSlug = toSlug(title);
575
386
  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,
387
+ const projectHtmlPreview = generateProjectHtmlFragment(rawProj.dirName, cache, detail.sessions, auth.username, {
388
+ totalFilesChanged: projRecord.totalFiles,
389
+ totalAgentDurationMinutes: projRecord.totalAgentDuration,
390
+ totalInputTokens: projRecord.totalInputTokens,
391
+ totalOutputTokens: projRecord.totalOutputTokens,
581
392
  });
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,
393
+ const projectBodyBase = {
394
+ client_project_id: clientProjectId,
395
+ title,
396
+ narrative: cache.result?.narrative ?? '',
397
+ repo_url: cache.repoUrl || null,
398
+ project_url: cache.projectUrl || null,
399
+ timeline: cache.result?.timeline ?? [],
400
+ skills: cache.result?.skills ?? [],
401
+ total_sessions: projRecord.sessionCount,
402
+ total_loc: projRecord.totalLoc,
403
+ total_duration_minutes: projRecord.totalDuration,
404
+ total_agent_duration_minutes: projRecord.totalAgentDuration || null,
405
+ total_files_changed: projRecord.totalFiles,
406
+ total_input_tokens: projRecord.totalInputTokens,
407
+ total_output_tokens: projRecord.totalOutputTokens,
408
+ skipped_sessions: [],
409
+ };
410
+ let slug = baseSlug;
411
+ let projectRes = null;
412
+ if (selectedSessionIds.length === 0) {
413
+ for (let attempt = 0; attempt <= MAX_SLUG_RETRIES; attempt++) {
414
+ projectRes = await fetch(`${API_URL}/api/projects`, {
415
+ method: 'POST',
416
+ headers: {
417
+ 'Content-Type': 'application/json',
418
+ Authorization: `Bearer ${auth.token}`,
419
+ },
420
+ body: JSON.stringify({
421
+ project: {
422
+ ...projectBodyBase,
423
+ slug,
424
+ rendered_html: projectHtmlPreview,
425
+ },
426
+ }),
427
+ });
428
+ if (projectRes.status === 409) {
429
+ slug = `${baseSlug}-${attempt + 1}`;
430
+ continue;
431
+ }
432
+ break;
433
+ }
434
+ if (!projectRes?.ok) {
435
+ const errText = await projectRes?.text().catch(() => '');
436
+ console.warn(`[portfolio-upload] project ${rawProj.dirName} create failed:`, projectRes?.status, errText);
437
+ }
438
+ continue;
439
+ }
440
+ for (let attempt = 0; attempt <= MAX_SLUG_RETRIES; attempt++) {
441
+ projectRes = await fetch(`${API_URL}/api/projects`, {
442
+ method: 'POST',
443
+ headers: {
444
+ 'Content-Type': 'application/json',
445
+ Authorization: `Bearer ${auth.token}`,
602
446
  },
603
- }),
447
+ body: JSON.stringify({
448
+ project: {
449
+ ...projectBodyBase,
450
+ slug,
451
+ },
452
+ }),
453
+ });
454
+ if (projectRes.status === 409) {
455
+ slug = `${baseSlug}-${attempt + 1}`;
456
+ continue;
457
+ }
458
+ break;
459
+ }
460
+ if (!projectRes || !projectRes.ok) {
461
+ const errText = await projectRes?.text().catch(() => '') ?? '';
462
+ console.warn(`[portfolio-upload] project ${rawProj.dirName} create failed:`, projectRes?.status, errText);
463
+ continue;
464
+ }
465
+ const projectData = await projectRes.json();
466
+ const { uploadedSessionCards } = await uploadSelectedSessions(ctx, auth, {
467
+ proj: projInfo,
468
+ projectData,
469
+ selectedSessionIds,
470
+ });
471
+ if (uploadedSessionCards.length === 0) {
472
+ continue;
473
+ }
474
+ try {
475
+ const detailAfter = buildProjectDetail(ctx.db, rawProj);
476
+ const cacheAfter = detailAfter.enhanceCache ?? cache;
477
+ const totalFiles = detailAfter.project.totalFiles;
478
+ const projectHtmlFinal = generateProjectHtmlFragment(rawProj.dirName, cacheAfter, detailAfter.sessions, auth.username, {
479
+ totalFilesChanged: totalFiles,
480
+ totalInputTokens: detailAfter.project.totalInputTokens,
481
+ totalOutputTokens: detailAfter.project.totalOutputTokens,
482
+ });
483
+ const renderRes = await fetch(`${API_URL}/api/projects`, {
484
+ method: 'POST',
485
+ headers: {
486
+ 'Content-Type': 'application/json',
487
+ Authorization: `Bearer ${auth.token}`,
488
+ },
489
+ body: JSON.stringify({
490
+ project: {
491
+ ...projectBodyBase,
492
+ slug: projectData.slug,
493
+ rendered_html: projectHtmlFinal,
494
+ },
495
+ }),
496
+ });
497
+ if (!renderRes.ok) {
498
+ console.warn(`[portfolio-upload] project ${rawProj.dirName} rendered_html update failed:`, renderRes.status);
499
+ }
500
+ }
501
+ catch (renderErr) {
502
+ console.warn(`[portfolio-upload] project render update ${rawProj.dirName}:`, renderErr.message);
503
+ }
504
+ const uploadedSessionIds = selectedSessionIds.filter((sid) => {
505
+ const enhanced = loadEnhancedData(sid);
506
+ return enhanced?.uploaded;
507
+ });
508
+ saveUploadedState(rawProj.dirName, {
509
+ slug: projectData.slug,
510
+ projectId: projectData.project_id,
511
+ uploadedSessions: uploadedSessionIds,
604
512
  });
605
513
  }
606
514
  catch (projErr) {
@@ -1,9 +1,6 @@
1
1
  // Source Audit — cross-references live sessions with the archive to
2
2
  // produce per-source scan results and archive health metrics.
3
- import { readdir, stat } from "node:fs/promises";
4
- import { join } from "node:path";
5
3
  import { SOURCE_DISPLAY_NAMES } from "./parsers/types.js";
6
- import { getArchiveDir } from "./settings.js";
7
4
  import { getDatabase, getSessionCount } from "./db.js";
8
5
  // ── Source paths ─────────────────────────────────────────────
9
6
  const SOURCE_PATHS = {
@@ -74,64 +71,47 @@ export async function getSourceAudit(configDir) {
74
71
  return { sources };
75
72
  }
76
73
  /**
77
- * Return archive-level statistics: total archived, oldest session date,
78
- * source count, last sync time, and disk usage.
74
+ * Return archive-level statistics: total preserved sessions, oldest session
75
+ * date, source count, last sync time.
76
+ *
77
+ * Reads from the SQLite `sessions` table rather than walking the filesystem
78
+ * archive dir. The DB is authoritative for "what's been preserved" — the
79
+ * filesystem archive is a secondary copy, and the previous version reported
80
+ * `total: 0` whenever the archive dir was empty even though the sessions
81
+ * table held hundreds of indexed sessions. That made the top card diverge
82
+ * from the "By source" table directly beneath it, which was the bug the
83
+ * user called out.
79
84
  */
80
- export async function getArchiveStats(configDir) {
81
- const archiveDir = getArchiveDir(configDir);
85
+ export async function getArchiveStats(_configDir) {
82
86
  let total = 0;
83
- let oldestMs = Infinity;
87
+ let oldestIso = null;
88
+ let lastSyncMs = 0;
84
89
  const sourcesFound = new Set();
85
- let newestMs = 0;
86
- try {
87
- const projectDirs = await readdir(archiveDir, { withFileTypes: true });
88
- for (const projectEntry of projectDirs) {
89
- if (!projectEntry.isDirectory())
90
- continue;
91
- const projectPath = join(archiveDir, projectEntry.name);
92
- const files = await readdir(projectPath, { withFileTypes: true }).catch(() => []);
93
- for (const file of files) {
94
- if (!file.name.endsWith(".jsonl") || file.isDirectory())
95
- continue;
96
- total++;
97
- const filePath = join(projectPath, file.name);
98
- try {
99
- const fileStat = await stat(filePath);
100
- const mtimeMs = fileStat.mtimeMs;
101
- if (mtimeMs < oldestMs)
102
- oldestMs = mtimeMs;
103
- if (mtimeMs > newestMs)
104
- newestMs = mtimeMs;
105
- }
106
- catch {
107
- // stat failed — skip
108
- }
109
- // Detect source from file content (first line) would be expensive;
110
- // instead use the parser detection on the archive path.
111
- // For now, we count by project dir presence — the main signal.
112
- }
113
- }
114
- }
115
- catch {
116
- // Archive directory doesn't exist yet
117
- }
118
- // Count distinct sources from SQLite (fast)
119
90
  try {
120
91
  const db = getDatabase();
121
- const rows = db.prepare('SELECT DISTINCT source FROM sessions WHERE is_subagent = 0').all();
122
- for (const row of rows) {
92
+ const totalRow = db.prepare('SELECT COUNT(*) as c, MIN(start_time) as earliest FROM sessions WHERE is_subagent = 0').get();
93
+ total = totalRow.c;
94
+ oldestIso = totalRow.earliest;
95
+ const sourceRows = db.prepare('SELECT DISTINCT source FROM sessions WHERE is_subagent = 0').all();
96
+ for (const row of sourceRows)
123
97
  sourcesFound.add(row.source);
98
+ // Last sync = newest indexed_at (the clock set when sync wrote the row).
99
+ const syncRow = db.prepare('SELECT MAX(indexed_at) as latest FROM sessions WHERE is_subagent = 0').get();
100
+ if (syncRow.latest) {
101
+ const ms = Date.parse(syncRow.latest);
102
+ if (!Number.isNaN(ms))
103
+ lastSyncMs = ms;
124
104
  }
125
105
  }
126
106
  catch {
127
- // DB not ready
107
+ // DB not ready — return empty state.
128
108
  }
129
- const oldest = oldestMs === Infinity
130
- ? "none"
131
- : formatMonthYear(new Date(oldestMs));
132
- const lastSync = newestMs === 0
109
+ const oldest = oldestIso
110
+ ? formatMonthYear(new Date(oldestIso))
111
+ : "none";
112
+ const lastSync = lastSyncMs === 0
133
113
  ? "never"
134
- : formatRelativeTime(newestMs);
114
+ : formatRelativeTime(lastSyncMs);
135
115
  return {
136
116
  total,
137
117
  oldest,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyiam",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Turn AI coding sessions into portfolio case studies",
5
5
  "type": "module",
6
6
  "license": "MIT",