heyiam 0.3.0 → 0.3.2

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 (94) hide show
  1. package/dist/auth.js +29 -3
  2. package/dist/db.js +1 -1
  3. package/dist/export.js +84 -2
  4. package/dist/github.js +381 -0
  5. package/dist/parsers/index.js +22 -3
  6. package/dist/public/assets/index-Coilyhtr.css +1 -0
  7. package/dist/public/assets/index-D0noVMFu.js +44 -0
  8. package/dist/public/index.html +2 -2
  9. package/dist/render/templates/aurora/portfolio.liquid +10 -22
  10. package/dist/render/templates/aurora/project.liquid +1 -1
  11. package/dist/render/templates/aurora/styles.css +6 -0
  12. package/dist/render/templates/bauhaus/portfolio.liquid +9 -19
  13. package/dist/render/templates/bauhaus/styles.css +4 -0
  14. package/dist/render/templates/blueprint/portfolio.liquid +10 -24
  15. package/dist/render/templates/blueprint/styles.css +4 -0
  16. package/dist/render/templates/canvas/portfolio.liquid +17 -29
  17. package/dist/render/templates/canvas/styles.css +4 -0
  18. package/dist/render/templates/carbon/portfolio.liquid +9 -19
  19. package/dist/render/templates/carbon/styles.css +6 -0
  20. package/dist/render/templates/chalk/portfolio.liquid +9 -19
  21. package/dist/render/templates/chalk/styles.css +4 -0
  22. package/dist/render/templates/circuit/portfolio.liquid +10 -20
  23. package/dist/render/templates/circuit/project.liquid +1 -1
  24. package/dist/render/templates/circuit/styles.css +6 -0
  25. package/dist/render/templates/cosmos/portfolio.liquid +10 -20
  26. package/dist/render/templates/cosmos/project.liquid +1 -1
  27. package/dist/render/templates/cosmos/styles.css +6 -0
  28. package/dist/render/templates/daylight/portfolio.liquid +10 -20
  29. package/dist/render/templates/daylight/project.liquid +1 -1
  30. package/dist/render/templates/daylight/styles.css +4 -0
  31. package/dist/render/templates/editorial/portfolio.liquid +11 -27
  32. package/dist/render/templates/editorial/styles.css +4 -0
  33. package/dist/render/templates/ember/portfolio.liquid +11 -23
  34. package/dist/render/templates/ember/project.liquid +1 -1
  35. package/dist/render/templates/ember/styles.css +6 -0
  36. package/dist/render/templates/glacier/portfolio.liquid +10 -20
  37. package/dist/render/templates/glacier/project.liquid +1 -1
  38. package/dist/render/templates/glacier/styles.css +4 -0
  39. package/dist/render/templates/grid/portfolio.liquid +9 -19
  40. package/dist/render/templates/grid/styles.css +4 -0
  41. package/dist/render/templates/kinetic/portfolio.liquid +10 -22
  42. package/dist/render/templates/kinetic/project.liquid +1 -1
  43. package/dist/render/templates/kinetic/styles.css +4 -0
  44. package/dist/render/templates/meridian/portfolio.liquid +11 -23
  45. package/dist/render/templates/meridian/styles.css +6 -0
  46. package/dist/render/templates/minimal/portfolio.liquid +10 -10
  47. package/dist/render/templates/minimal/styles.css +4 -0
  48. package/dist/render/templates/mono/portfolio.liquid +9 -19
  49. package/dist/render/templates/mono/styles.css +6 -0
  50. package/dist/render/templates/neon/portfolio.liquid +10 -20
  51. package/dist/render/templates/neon/project.liquid +1 -1
  52. package/dist/render/templates/neon/styles.css +6 -0
  53. package/dist/render/templates/noir/portfolio.liquid +5 -5
  54. package/dist/render/templates/noir/styles.css +6 -0
  55. package/dist/render/templates/obsidian/portfolio.liquid +9 -19
  56. package/dist/render/templates/obsidian/styles.css +6 -0
  57. package/dist/render/templates/paper/portfolio.liquid +9 -19
  58. package/dist/render/templates/paper/styles.css +4 -0
  59. package/dist/render/templates/parallax/portfolio.liquid +9 -19
  60. package/dist/render/templates/parallax/styles.css +6 -0
  61. package/dist/render/templates/parchment/portfolio.liquid +9 -19
  62. package/dist/render/templates/parchment/styles.css +4 -0
  63. package/dist/render/templates/radar/portfolio.liquid +9 -19
  64. package/dist/render/templates/radar/styles.css +6 -0
  65. package/dist/render/templates/showcase/portfolio.liquid +9 -19
  66. package/dist/render/templates/showcase/styles.css +5 -0
  67. package/dist/render/templates/signal/portfolio.liquid +9 -19
  68. package/dist/render/templates/signal/styles.css +6 -0
  69. package/dist/render/templates/strata/portfolio.liquid +10 -22
  70. package/dist/render/templates/strata/styles.css +4 -0
  71. package/dist/render/templates/terminal/portfolio.liquid +10 -26
  72. package/dist/render/templates/terminal/styles.css +5 -0
  73. package/dist/render/templates/verdant/portfolio.liquid +11 -23
  74. package/dist/render/templates/verdant/project.liquid +1 -1
  75. package/dist/render/templates/verdant/styles.css +4 -0
  76. package/dist/render/templates/zen/portfolio.liquid +10 -22
  77. package/dist/render/templates/zen/styles.css +4 -0
  78. package/dist/routes/auth.js +7 -3
  79. package/dist/routes/context.js +2 -0
  80. package/dist/routes/delete.js +195 -0
  81. package/dist/routes/enhance.js +40 -0
  82. package/dist/routes/github.js +254 -0
  83. package/dist/routes/index.js +2 -0
  84. package/dist/routes/portfolio-render-data.js +160 -0
  85. package/dist/routes/preview.js +85 -10
  86. package/dist/routes/projects.js +50 -5
  87. package/dist/routes/publish.js +306 -15
  88. package/dist/routes/settings.js +102 -2
  89. package/dist/search.js +6 -0
  90. package/dist/server.js +3 -1
  91. package/dist/settings.js +95 -0
  92. package/package.json +2 -1
  93. package/dist/public/assets/index-BZ65TU_Y.js +0 -40
  94. package/dist/public/assets/index-CqCaW2cb.css +0 -1
@@ -1,14 +1,20 @@
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
7
  import { API_URL, PUBLIC_URL, warnIfNonDefaultApiUrl } from '../config.js';
6
- import { loadEnhancedData, saveEnhancedData, saveUploadedState, getDefaultTemplate } from '../settings.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';
12
18
  import { startSSE } from './sse.js';
13
19
  import { displayNameFromDir } from '../sync.js';
14
20
  import { toSlug } from '../format-utils.js';
@@ -58,6 +64,8 @@ export function createPublishRouter(ctx) {
58
64
  }
59
65
  const imageData = readFileSync(screenshotPath);
60
66
  const base64 = imageData.toString('base64');
67
+ // Portfolio listing shows project screenshots — bust the cache.
68
+ invalidatePortfolioPreviewCache();
61
69
  res.json({ ok: true, preview: `data:image/png;base64,${base64}` });
62
70
  }
63
71
  catch (err) {
@@ -214,6 +222,7 @@ export function createPublishRouter(ctx) {
214
222
  const session = await ctx.loadSession(meta.path, proj.name, sessionId);
215
223
  const enhanced = loadEnhancedData(sessionId);
216
224
  const sessionSlug = toSlug(enhanced?.title ?? session.title ?? sessionId, 80);
225
+ const includeTranscript = isTranscriptIncluded(sessionId);
217
226
  const agentSummary = await buildAgentSummary(meta.children ?? [], (c) => ctx.getSessionStats(c, proj.name), { deduplicate: true });
218
227
  const devTake = (enhanced?.developerTake ?? session.developerTake ?? '').slice(0, 2000);
219
228
  const sessionNarrative = enhanced?.narrative ?? '';
@@ -271,6 +280,22 @@ export function createPublishRouter(ctx) {
271
280
  rendered_html: sessionRenderedHtml,
272
281
  },
273
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
+ });
274
299
  const sessionData = {
275
300
  version: 1,
276
301
  id: sessionId,
@@ -308,17 +333,8 @@ export function createPublishRouter(ctx) {
308
333
  highlights: [],
309
334
  tool_breakdown: (session.toolBreakdown ?? []).map((t) => ({ tool: t.tool, count: t.count })),
310
335
  top_files: (session.filesChanged ?? []).slice(0, 20).map((f) => (typeof f === 'string' ? { path: f, additions: 0, deletions: 0 } : f)),
311
- turn_timeline: (session.turnTimeline ?? []).map((t) => ({
312
- timestamp: t.timestamp,
313
- type: t.type,
314
- content: (t.content ?? '').slice(0, 200),
315
- tools: t.tools ?? [],
316
- })),
317
- transcript_excerpt: (session.rawLog ?? []).slice(0, 10).map((line, i) => {
318
- const role = line.startsWith('> ') ? 'dev' : 'ai';
319
- const text = role === 'dev' ? line.slice(2) : line;
320
- return { role, id: `Turn ${i + 1}`, text, timestamp: null };
321
- }),
336
+ ...(includeTranscript ? { turn_timeline: turnTimeline } : {}),
337
+ ...(includeTranscript ? { transcript_excerpt: transcriptExcerpt } : {}),
322
338
  agent_summary: agentSummary,
323
339
  children: agentSummary?.agents?.map((a) => ({
324
340
  sessionId: a.role,
@@ -347,7 +363,12 @@ export function createPublishRouter(ctx) {
347
363
  uploadedCount++;
348
364
  try {
349
365
  const sesData = await sessionRes.json();
350
- 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.
351
372
  const { raw: rawUrl, log: logUrl } = sesData.upload_urls;
352
373
  if (rawUrl && meta.path && !meta.path.startsWith('cursor://')) {
353
374
  try {
@@ -423,7 +444,14 @@ export function createPublishRouter(ctx) {
423
444
  const screenshotUrl = uploadedImageKey
424
445
  ? `${PUBLIC_URL}/_img/${uploadedImageKey.replace(IMAGE_KEY_PREFIX, '')}`
425
446
  : undefined;
426
- 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
+ });
427
455
  const renderRes = await fetch(`${API_URL}/api/projects`, {
428
456
  method: 'POST',
429
457
  headers: {
@@ -490,5 +518,268 @@ export function createPublishRouter(ctx) {
490
518
  res.end();
491
519
  }
492
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 ? `${PUBLIC_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 ?? `${PUBLIC_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
+ });
493
784
  return router;
494
785
  }
@@ -1,7 +1,11 @@
1
1
  import { Router } from 'express';
2
- import { saveAnthropicApiKey, clearAnthropicApiKey, getAnthropicApiKey, getSettings, setDefaultTemplate, getPortfolioProfile, savePortfolioProfile } from '../settings.js';
2
+ import { existsSync } from 'node:fs';
3
+ import { saveAnthropicApiKey, clearAnthropicApiKey, getAnthropicApiKey, getSettings, setDefaultTemplate, getPortfolioProfile, savePortfolioProfile, isTranscriptIncluded, setTranscriptIncluded } from '../settings.js';
4
+ import { invalidatePortfolioPreviewCache } from './preview.js';
3
5
  import { hasApiKey } from '../llm/index.js';
4
6
  import { isValidTemplate, DEFAULT_TEMPLATE, BUILT_IN_TEMPLATES } from '../render/templates.js';
7
+ import { getDbPath } from '../db.js';
8
+ import { getDaemonBinaryPath } from '../daemon-install.js';
5
9
  export function createSettingsRouter(_ctx) {
6
10
  const router = Router();
7
11
  // Save or clear the Anthropic API key
@@ -52,9 +56,42 @@ export function createSettingsRouter(_ctx) {
52
56
  return;
53
57
  }
54
58
  setDefaultTemplate(template);
59
+ invalidatePortfolioPreviewCache();
55
60
  console.log(`[settings] Portfolio theme set to: ${template}`);
56
61
  res.json({ ok: true, template });
57
62
  });
63
+ // ── Per-session transcript toggle (publish-time, CLI-only) ───
64
+ //
65
+ // GET /api/sessions/:sessionId/transcript-setting — returns whether the
66
+ // session transcript will be included in the next publish. Defaults to
67
+ // true.
68
+ //
69
+ // PUT /api/sessions/:sessionId/transcript-setting { included: boolean }
70
+ // — flips the flag. When `false`, publish.ts skips all S3 transcript
71
+ // uploads and strips transcript-derived fields from the uploaded
72
+ // session JSON.
73
+ router.get('/api/sessions/:sessionId/transcript-setting', (req, res) => {
74
+ const sessionId = typeof req.params.sessionId === 'string' ? req.params.sessionId.trim() : '';
75
+ if (!sessionId || sessionId.length > 200) {
76
+ res.status(400).json({ error: { code: 'INVALID_PARAM', message: 'sessionId is required' } });
77
+ return;
78
+ }
79
+ res.json({ sessionId, included: isTranscriptIncluded(sessionId) });
80
+ });
81
+ router.put('/api/sessions/:sessionId/transcript-setting', (req, res) => {
82
+ const sessionId = typeof req.params.sessionId === 'string' ? req.params.sessionId.trim() : '';
83
+ if (!sessionId || sessionId.length > 200) {
84
+ res.status(400).json({ error: { code: 'INVALID_PARAM', message: 'sessionId is required' } });
85
+ return;
86
+ }
87
+ const body = req.body;
88
+ if (typeof body?.included !== 'boolean') {
89
+ res.status(400).json({ error: { code: 'INVALID_BODY', message: 'included must be a boolean' } });
90
+ return;
91
+ }
92
+ setTranscriptIncluded(sessionId, body.included);
93
+ res.json({ ok: true, sessionId, included: body.included });
94
+ });
58
95
  // Get portfolio profile data
59
96
  router.get('/api/portfolio', (_req, res) => {
60
97
  res.json(getPortfolioProfile());
@@ -69,7 +106,7 @@ export function createSettingsRouter(_ctx) {
69
106
  const ALLOWED_FIELDS = [
70
107
  'displayName', 'bio', 'photoBase64', 'location', 'email', 'phone',
71
108
  'linkedinUrl', 'githubUrl', 'twitterHandle', 'websiteUrl',
72
- 'resumeBase64', 'resumeFilename',
109
+ 'resumeBase64', 'resumeFilename', 'accent',
73
110
  ];
74
111
  const errors = [];
75
112
  // Structural validation: only allow known string fields
@@ -103,6 +140,9 @@ export function createSettingsRouter(_ctx) {
103
140
  if (cleaned.websiteUrl && !cleaned.websiteUrl.startsWith('http')) {
104
141
  errors.push({ field: 'websiteUrl', message: 'Website URL must start with http' });
105
142
  }
143
+ if (cleaned.accent && !/^#[0-9a-fA-F]{6}$/.test(cleaned.accent)) {
144
+ errors.push({ field: 'accent', message: 'Accent must be a 6-digit hex color (e.g. #084471)' });
145
+ }
106
146
  // File size limits (base64 ~1.37x raw; cap photo at ~5MB, resume at ~10MB)
107
147
  if (cleaned.photoBase64 && cleaned.photoBase64.length > 7_000_000) {
108
148
  errors.push({ field: 'photoBase64', message: 'Photo must be under 5MB' });
@@ -110,13 +150,73 @@ export function createSettingsRouter(_ctx) {
110
150
  if (cleaned.resumeBase64 && cleaned.resumeBase64.length > 14_000_000) {
111
151
  errors.push({ field: 'resumeBase64', message: 'Resume must be under 10MB' });
112
152
  }
153
+ // projectsOnPortfolio: array of {projectId, included, order}. Validated
154
+ // structurally; unknown entries are dropped silently. Empty array allowed
155
+ // (means "default ordering, include everything" at render time).
156
+ if (body.projectsOnPortfolio !== undefined) {
157
+ if (!Array.isArray(body.projectsOnPortfolio)) {
158
+ errors.push({ field: 'projectsOnPortfolio', message: 'projectsOnPortfolio must be an array' });
159
+ }
160
+ else {
161
+ const cleanedProjects = [];
162
+ for (let i = 0; i < body.projectsOnPortfolio.length; i++) {
163
+ const raw = body.projectsOnPortfolio[i];
164
+ if (!raw || typeof raw !== 'object') {
165
+ errors.push({ field: `projectsOnPortfolio[${i}]`, message: 'must be an object' });
166
+ continue;
167
+ }
168
+ if (typeof raw.projectId !== 'string' || raw.projectId.length === 0) {
169
+ errors.push({ field: `projectsOnPortfolio[${i}].projectId`, message: 'must be a non-empty string' });
170
+ continue;
171
+ }
172
+ if (typeof raw.included !== 'boolean') {
173
+ errors.push({ field: `projectsOnPortfolio[${i}].included`, message: 'must be a boolean' });
174
+ continue;
175
+ }
176
+ if (typeof raw.order !== 'number' || !Number.isFinite(raw.order)) {
177
+ errors.push({ field: `projectsOnPortfolio[${i}].order`, message: 'must be a finite number' });
178
+ continue;
179
+ }
180
+ cleanedProjects.push({
181
+ projectId: raw.projectId,
182
+ included: raw.included,
183
+ order: raw.order,
184
+ });
185
+ }
186
+ cleaned.projectsOnPortfolio = cleanedProjects;
187
+ }
188
+ }
113
189
  if (errors.length > 0) {
114
190
  res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Validation failed', fields: errors } });
115
191
  return;
116
192
  }
117
193
  savePortfolioProfile(cleaned);
194
+ invalidatePortfolioPreviewCache();
118
195
  console.log('[settings] Portfolio profile saved');
119
196
  res.json({ ok: true });
120
197
  });
198
+ // Local data summary: read-only diagnostic info displayed in Settings
199
+ // (DB path, daemon install state). Archive count + last sync are served
200
+ // by /api/archive/stats so the frontend composes both responses.
201
+ router.get('/api/local-data', (_req, res) => {
202
+ try {
203
+ const dbPath = getDbPath();
204
+ const daemonBinaryPath = getDaemonBinaryPath();
205
+ const daemonInstalled = existsSync(daemonBinaryPath);
206
+ res.json({
207
+ dbPath,
208
+ daemon: {
209
+ installed: daemonInstalled,
210
+ binaryPath: daemonBinaryPath,
211
+ },
212
+ });
213
+ }
214
+ catch (err) {
215
+ console.error('[local-data]', err.message);
216
+ res.status(500).json({
217
+ error: { code: 'LOCAL_DATA_FAILED', message: 'Failed to read local data summary' },
218
+ });
219
+ }
220
+ });
121
221
  return router;
122
222
  }
package/dist/search.js CHANGED
@@ -7,6 +7,12 @@ import { escapeLikeWildcards } from './format-utils.js';
7
7
  * e.g. "-Users-test-Dev-myapp" → "Users/test/Dev/myapp"
8
8
  */
9
9
  export function decodeProjectName(projectDir) {
10
+ // Windows: "C-Users-ben-Dev-myapp" → "C:/Users/ben/Dev/myapp"
11
+ const winMatch = projectDir.match(/^([A-Z])-(.+)$/);
12
+ if (winMatch) {
13
+ return `${winMatch[1]}:/${winMatch[2].replace(/-/g, "/")}`;
14
+ }
15
+ // Unix: "-Users-ben-Dev-myapp" → "Users/ben/Dev/myapp"
10
16
  return projectDir.replace(/^-/, '').replace(/-/g, '/');
11
17
  }
12
18
  function rowToResult(row, snippet, score) {
package/dist/server.js CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import { homedir } from 'node:os';
8
8
  import { getDatabase, closeDatabase } from './db.js';
9
9
  import { syncWithTracking, startFileWatcher, startCursorPolling, markSyncPending } from './sync.js';
10
- import { createRouteContext, createProjectsRouter, createEnhanceRouter, createPublishRouter, createSearchRouter, createSessionsRouter, createArchiveRouter, createAuthRouter, createSettingsRouter, createExportRouter, createPreviewRouter, createDashboardRouter, } from './routes/index.js';
10
+ import { createRouteContext, createProjectsRouter, createEnhanceRouter, createPublishRouter, createDeleteRouter, createSearchRouter, createSessionsRouter, createArchiveRouter, createAuthRouter, createSettingsRouter, createExportRouter, createPreviewRouter, createDashboardRouter, createGithubRouter, } from './routes/index.js';
11
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
12
  function getPackageVersion() {
13
13
  try {
@@ -97,6 +97,7 @@ export function createApp(sessionsBasePath, dbPath) {
97
97
  app.use(createProjectsRouter(ctx));
98
98
  app.use(createEnhanceRouter(ctx));
99
99
  app.use(createPublishRouter(ctx));
100
+ app.use(createDeleteRouter(ctx));
100
101
  app.use(createSearchRouter(ctx));
101
102
  app.use(createSessionsRouter(ctx));
102
103
  app.use(createArchiveRouter(ctx));
@@ -105,6 +106,7 @@ export function createApp(sessionsBasePath, dbPath) {
105
106
  app.use(createExportRouter(ctx));
106
107
  app.use(createPreviewRouter(ctx));
107
108
  app.use(createDashboardRouter(ctx));
109
+ app.use(createGithubRouter(ctx));
108
110
  // ── Version endpoint (used by `heyiam open` to detect stale instances) ──
109
111
  app.get('/api/version', (_req, res) => {
110
112
  res.json({ server: 'heyiam', version: SERVER_VERSION });