claude-transcript-viewer 1.1.0

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 (44) hide show
  1. package/README.md +215 -0
  2. package/dist/api/search.d.ts +50 -0
  3. package/dist/api/search.js +181 -0
  4. package/dist/api/search.js.map +1 -0
  5. package/dist/api/snippets.d.ts +2 -0
  6. package/dist/api/snippets.js +49 -0
  7. package/dist/api/snippets.js.map +1 -0
  8. package/dist/config.d.ts +14 -0
  9. package/dist/config.js +52 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/db/chunks.d.ts +14 -0
  12. package/dist/db/chunks.js +43 -0
  13. package/dist/db/chunks.js.map +1 -0
  14. package/dist/db/conversations.d.ts +16 -0
  15. package/dist/db/conversations.js +40 -0
  16. package/dist/db/conversations.js.map +1 -0
  17. package/dist/db/index.d.ts +6 -0
  18. package/dist/db/index.js +40 -0
  19. package/dist/db/index.js.map +1 -0
  20. package/dist/db/schema.d.ts +5 -0
  21. package/dist/db/schema.js +71 -0
  22. package/dist/db/schema.js.map +1 -0
  23. package/dist/embeddings/client.d.ts +16 -0
  24. package/dist/embeddings/client.js +155 -0
  25. package/dist/embeddings/client.js.map +1 -0
  26. package/dist/indexer/changeDetection.d.ts +16 -0
  27. package/dist/indexer/changeDetection.js +81 -0
  28. package/dist/indexer/changeDetection.js.map +1 -0
  29. package/dist/indexer/chunker.d.ts +5 -0
  30. package/dist/indexer/chunker.js +44 -0
  31. package/dist/indexer/chunker.js.map +1 -0
  32. package/dist/indexer/fileUtils.d.ts +2 -0
  33. package/dist/indexer/fileUtils.js +9 -0
  34. package/dist/indexer/fileUtils.js.map +1 -0
  35. package/dist/indexer/index.d.ts +19 -0
  36. package/dist/indexer/index.js +267 -0
  37. package/dist/indexer/index.js.map +1 -0
  38. package/dist/indexer/parser.d.ts +12 -0
  39. package/dist/indexer/parser.js +45 -0
  40. package/dist/indexer/parser.js.map +1 -0
  41. package/dist/server.d.ts +2 -0
  42. package/dist/server.js +1851 -0
  43. package/dist/server.js.map +1 -0
  44. package/package.json +62 -0
package/dist/server.js ADDED
@@ -0,0 +1,1851 @@
1
+ #!/usr/bin/env node
2
+ import express from "express";
3
+ import { readFileSync, existsSync, readdirSync, mkdirSync } from "fs";
4
+ import { join, resolve } from "path";
5
+ import { spawn } from "child_process";
6
+ import * as cheerio from "cheerio";
7
+ import { createDatabase, getDatabase } from "./db/index.js";
8
+ import { searchHybrid } from "./api/search.js";
9
+ import { generateSnippet, highlightTerms } from "./api/snippets.js";
10
+ import { createEmbeddingClient } from "./embeddings/client.js";
11
+ import { getConfig } from "./config.js";
12
+ import { runIndexer } from "./indexer/index.js";
13
+ const app = express();
14
+ const config = getConfig();
15
+ const PORT = process.env.PORT || 3000;
16
+ const ARCHIVE_DIR = process.env.ARCHIVE_DIR || process.argv[2] || "./claude-archive";
17
+ const SOURCE_DIR = process.env.SOURCE_DIR || process.argv[3] || "";
18
+ const DATABASE_PATH = process.env.DATABASE_PATH || join(ARCHIVE_DIR, ".search.db");
19
+ // Initialize database and embedding client
20
+ let embeddingClient;
21
+ // Background indexing state
22
+ let indexingStatus = { isIndexing: false };
23
+ // Archive generation state
24
+ let archiveStatus = { isGenerating: false };
25
+ // Cache mapping database project slugs to archive directory names
26
+ let projectToArchiveMap = new Map();
27
+ // Generate HTML archive using claude-code-transcripts Python CLI
28
+ async function generateArchive() {
29
+ if (!SOURCE_DIR || archiveStatus.isGenerating) {
30
+ return false;
31
+ }
32
+ if (!existsSync(SOURCE_DIR)) {
33
+ console.log(`Source directory not found: ${SOURCE_DIR} - skipping archive generation`);
34
+ return false;
35
+ }
36
+ // Ensure archive directory exists
37
+ if (!existsSync(ARCHIVE_DIR)) {
38
+ mkdirSync(ARCHIVE_DIR, { recursive: true });
39
+ }
40
+ console.log(`Generating HTML archive from ${SOURCE_DIR} to ${ARCHIVE_DIR}...`);
41
+ archiveStatus.isGenerating = true;
42
+ archiveStatus.progress = "Starting...";
43
+ return new Promise((resolve) => {
44
+ // Use uv run to execute claude-code-transcripts all command
45
+ const proc = spawn("uv", [
46
+ "run",
47
+ "claude-code-transcripts",
48
+ "all",
49
+ "-s",
50
+ SOURCE_DIR,
51
+ "-o",
52
+ ARCHIVE_DIR,
53
+ "--include-agents",
54
+ "-q",
55
+ ], {
56
+ cwd: process.env.TRANSCRIPTS_CLI_PATH || undefined,
57
+ stdio: ["ignore", "pipe", "pipe"],
58
+ });
59
+ let stdout = "";
60
+ let stderr = "";
61
+ proc.stdout.on("data", (data) => {
62
+ const line = data.toString();
63
+ stdout += line;
64
+ // Update progress from output
65
+ const match = line.match(/Processing|Generating|Writing/i);
66
+ if (match) {
67
+ archiveStatus.progress = line.trim().slice(0, 50);
68
+ }
69
+ });
70
+ proc.stderr.on("data", (data) => {
71
+ stderr += data.toString();
72
+ });
73
+ proc.on("close", (code) => {
74
+ archiveStatus.isGenerating = false;
75
+ archiveStatus.lastRun = new Date().toISOString();
76
+ if (code === 0) {
77
+ archiveStatus.progress = "Complete";
78
+ console.log("Archive generation complete");
79
+ resolve(true);
80
+ }
81
+ else {
82
+ archiveStatus.lastError = stderr || `Exit code ${code}`;
83
+ console.error("Archive generation failed:", stderr || `Exit code ${code}`);
84
+ resolve(false);
85
+ }
86
+ });
87
+ proc.on("error", (err) => {
88
+ archiveStatus.isGenerating = false;
89
+ archiveStatus.lastError = err.message;
90
+ console.error("Failed to spawn claude-code-transcripts:", err.message);
91
+ console.log("Make sure claude-code-transcripts is installed: pip install claude-code-transcripts");
92
+ resolve(false);
93
+ });
94
+ });
95
+ }
96
+ // Start background indexing if SOURCE_DIR is provided
97
+ async function startBackgroundIndexing() {
98
+ if (!SOURCE_DIR || indexingStatus.isIndexing) {
99
+ return;
100
+ }
101
+ if (!existsSync(SOURCE_DIR)) {
102
+ console.log(`Source directory not found: ${SOURCE_DIR} - skipping auto-indexing`);
103
+ return;
104
+ }
105
+ console.log(`Starting background indexing from ${SOURCE_DIR}...`);
106
+ indexingStatus.isIndexing = true;
107
+ indexingStatus.progress = "Starting...";
108
+ try {
109
+ const stats = await runIndexer({
110
+ sourceDir: SOURCE_DIR,
111
+ databasePath: DATABASE_PATH,
112
+ embedSocketPath: process.env.EMBED_SOCKET,
113
+ embedUrl: process.env.EMBED_URL,
114
+ verbose: false,
115
+ });
116
+ indexingStatus.lastStats = {
117
+ added: stats.added,
118
+ modified: stats.modified,
119
+ deleted: stats.deleted,
120
+ chunks: stats.chunks,
121
+ };
122
+ indexingStatus.progress = "Complete";
123
+ if (stats.errors.length > 0) {
124
+ indexingStatus.lastError = `${stats.errors.length} files had errors`;
125
+ }
126
+ console.log(`Background indexing complete: ${stats.added} added, ${stats.modified} modified, ${stats.chunks} chunks`);
127
+ }
128
+ catch (err) {
129
+ indexingStatus.lastError = err instanceof Error ? err.message : String(err);
130
+ console.error("Background indexing failed:", err);
131
+ }
132
+ finally {
133
+ indexingStatus.isIndexing = false;
134
+ }
135
+ }
136
+ /**
137
+ * Build mapping from database project slugs to archive directory names.
138
+ * Database stores path-based slugs like "-Users-varunr-projects-podcast-summarizer-v2"
139
+ * Archive uses normalized names like "podcast-summarizer-v2"
140
+ */
141
+ function buildProjectMapping() {
142
+ projectToArchiveMap.clear();
143
+ // Get archive directories
144
+ const archiveDirs = new Set();
145
+ try {
146
+ const entries = readdirSync(ARCHIVE_DIR, { withFileTypes: true });
147
+ for (const entry of entries) {
148
+ if (entry.isDirectory()) {
149
+ archiveDirs.add(entry.name);
150
+ }
151
+ }
152
+ }
153
+ catch (err) {
154
+ console.error("Failed to read archive directory:", err);
155
+ return;
156
+ }
157
+ // Get database projects
158
+ const db = getDatabase();
159
+ const projects = db.prepare("SELECT DISTINCT project FROM conversations").all();
160
+ // Match each database project to an archive directory
161
+ for (const { project } of projects) {
162
+ // Try direct match first
163
+ if (archiveDirs.has(project)) {
164
+ projectToArchiveMap.set(project, project);
165
+ continue;
166
+ }
167
+ // Convert slug to path segments for matching
168
+ // -Users-varunr-projects-podcast-summarizer-v2 -> potential matches
169
+ const slug = project.replace(/^-/, "");
170
+ // Try to find a matching archive directory
171
+ // Strategy: the archive dir should be a suffix of the slug when hyphens are considered
172
+ let bestMatch;
173
+ let bestMatchLen = 0;
174
+ for (const dir of archiveDirs) {
175
+ // Check if the slug ends with this directory name
176
+ // Account for the hyphen separator: slug should end with -<dir> or be exactly <dir>
177
+ if (slug === dir || slug.endsWith(`-${dir}`)) {
178
+ if (dir.length > bestMatchLen) {
179
+ bestMatch = dir;
180
+ bestMatchLen = dir.length;
181
+ }
182
+ }
183
+ }
184
+ if (bestMatch) {
185
+ projectToArchiveMap.set(project, bestMatch);
186
+ }
187
+ else {
188
+ // Fallback: use the last hyphen-separated segment
189
+ // This handles edge cases but may not always be correct
190
+ const segments = slug.split("-");
191
+ const lastSegment = segments[segments.length - 1];
192
+ if (lastSegment && archiveDirs.has(lastSegment)) {
193
+ projectToArchiveMap.set(project, lastSegment);
194
+ }
195
+ }
196
+ }
197
+ console.log(`Built project mapping: ${projectToArchiveMap.size} projects mapped to archive directories`);
198
+ }
199
+ async function initializeSearch() {
200
+ try {
201
+ createDatabase(DATABASE_PATH);
202
+ console.log(`Search database initialized at ${DATABASE_PATH}`);
203
+ // Build project to archive directory mapping
204
+ buildProjectMapping();
205
+ // Try to connect to embedding server (HTTP URL or Unix socket)
206
+ const embedUrl = process.env.EMBED_URL; // e.g., http://localhost:8000
207
+ const socketPath = process.env.EMBED_SOCKET || "/tmp/qwen-embed.sock";
208
+ if (embedUrl) {
209
+ // HTTP endpoint (e.g., qwen3-embeddings-mlx)
210
+ embeddingClient = createEmbeddingClient(embedUrl);
211
+ const healthy = await embeddingClient.isHealthy();
212
+ if (healthy) {
213
+ const info = await embeddingClient.getModelInfo();
214
+ console.log(`Embedding client connected to ${embedUrl} (model: ${info?.model}, dim: ${info?.dim})`);
215
+ }
216
+ else {
217
+ console.log(`Embedding server at ${embedUrl} not responding - using FTS-only search`);
218
+ embeddingClient = undefined;
219
+ }
220
+ }
221
+ else if (existsSync(socketPath)) {
222
+ // Unix socket
223
+ embeddingClient = createEmbeddingClient(socketPath);
224
+ console.log(`Embedding client connected to ${socketPath}`);
225
+ }
226
+ else {
227
+ console.log(`Embedding server not found - using FTS-only search`);
228
+ console.log(` Set EMBED_URL=http://localhost:8000 for HTTP or EMBED_SOCKET for Unix socket`);
229
+ }
230
+ }
231
+ catch (err) {
232
+ console.error("Failed to initialize search database:", err);
233
+ }
234
+ }
235
+ // CSS to inject for progressive disclosure and search bar
236
+ const INJECTED_CSS = `
237
+ <style id="viewer-enhancements">
238
+ /* Dark mode support - follows system preference */
239
+ @media (prefers-color-scheme: dark) {
240
+ :root {
241
+ --bg-color: #1a1a2e !important;
242
+ --card-bg: #16213e !important;
243
+ --user-bg: #1e3a5f !important;
244
+ --user-border: #4fc3f7 !important;
245
+ --assistant-bg: #16213e !important;
246
+ --assistant-border: #9e9e9e !important;
247
+ --thinking-bg: #2d2a1e !important;
248
+ --thinking-border: #ffc107 !important;
249
+ --thinking-text: #ccc !important;
250
+ --tool-bg: #2a1f3d !important;
251
+ --tool-border: #ce93d8 !important;
252
+ --tool-result-bg: #1e3d2f !important;
253
+ --tool-error-bg: #3d1e1e !important;
254
+ --text-color: #eee !important;
255
+ --text-muted: #888 !important;
256
+ --code-bg: #0d1117 !important;
257
+ --code-text: #aed581 !important;
258
+ --bg: #1a1a2e;
259
+ --surface: #16213e;
260
+ --primary: #e94560;
261
+ --text: #eee;
262
+ --border: #333;
263
+ }
264
+ body {
265
+ background: var(--bg-color) !important;
266
+ color: var(--text-color) !important;
267
+ }
268
+ .message.user { background: var(--user-bg) !important; }
269
+ .message.assistant { background: var(--card-bg) !important; }
270
+ .index-item { background: var(--card-bg) !important; }
271
+ pre { background: var(--code-bg) !important; }
272
+ code { background: rgba(255,255,255,0.1) !important; }
273
+ .expand-btn, .message-expand-btn {
274
+ background: rgba(255,255,255,0.1) !important;
275
+ color: var(--text-muted) !important;
276
+ border-color: rgba(255,255,255,0.2) !important;
277
+ }
278
+ .pagination a {
279
+ background: var(--card-bg) !important;
280
+ border-color: var(--user-border) !important;
281
+ }
282
+ .pagination .current { background: var(--user-border) !important; }
283
+ /* Fix gradients for dark mode */
284
+ .message-content.collapsed::after,
285
+ .index-item-content.collapsed::after {
286
+ background: linear-gradient(to bottom, transparent, var(--card-bg)) !important;
287
+ }
288
+ .message.user .message-content.collapsed::after {
289
+ background: linear-gradient(to bottom, transparent, var(--user-bg)) !important;
290
+ }
291
+ .truncatable.truncated::after {
292
+ background: linear-gradient(to bottom, transparent, var(--card-bg)) !important;
293
+ }
294
+ .message.user .truncatable.truncated::after {
295
+ background: linear-gradient(to bottom, transparent, var(--user-bg)) !important;
296
+ }
297
+ }
298
+
299
+ /* Global search bar styles */
300
+ .viewer-search-bar {
301
+ position: sticky;
302
+ top: 0;
303
+ z-index: 1000;
304
+ background: var(--bg, #1a1a2e);
305
+ padding: 0.75rem 1rem;
306
+ border-bottom: 1px solid var(--border, #333);
307
+ display: flex;
308
+ align-items: center;
309
+ gap: 1rem;
310
+ }
311
+ .viewer-search-bar a.home-link {
312
+ color: var(--primary, #e94560);
313
+ text-decoration: none;
314
+ font-weight: bold;
315
+ font-size: 0.875rem;
316
+ }
317
+ .viewer-search-bar a.home-link:hover { text-decoration: underline; }
318
+ .viewer-search-bar .search-wrapper {
319
+ position: relative;
320
+ flex: 1;
321
+ max-width: 400px;
322
+ }
323
+ .viewer-search-bar input {
324
+ width: 100%;
325
+ padding: 0.5rem 0.75rem;
326
+ font-size: 0.875rem;
327
+ border: 1px solid var(--border, #333);
328
+ border-radius: 4px;
329
+ background: var(--surface, #16213e);
330
+ color: var(--text, #eee);
331
+ }
332
+ .viewer-search-bar input:focus {
333
+ outline: none;
334
+ border-color: var(--primary, #e94560);
335
+ }
336
+ .viewer-search-bar .search-dropdown {
337
+ position: absolute;
338
+ top: 100%;
339
+ left: 0;
340
+ right: 0;
341
+ background: var(--surface, #16213e);
342
+ border: 1px solid var(--border, #333);
343
+ border-radius: 4px;
344
+ margin-top: 4px;
345
+ display: none;
346
+ max-height: 400px;
347
+ overflow-y: auto;
348
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
349
+ }
350
+ .viewer-search-bar .search-dropdown.visible { display: block; }
351
+ .viewer-search-bar .search-dropdown-item {
352
+ padding: 0.75rem;
353
+ border-bottom: 1px solid var(--border, #333);
354
+ cursor: pointer;
355
+ }
356
+ .viewer-search-bar .search-dropdown-item:hover { background: rgba(255,255,255,0.05); }
357
+ .viewer-search-bar .search-dropdown-item:last-child { border-bottom: none; }
358
+ .viewer-search-bar .search-dropdown-item h4 {
359
+ margin: 0 0 0.25rem 0;
360
+ font-size: 0.875rem;
361
+ color: var(--text, #eee);
362
+ }
363
+ .viewer-search-bar .search-dropdown-item p {
364
+ margin: 0;
365
+ font-size: 0.75rem;
366
+ color: var(--text-muted, #888);
367
+ }
368
+ .viewer-search-bar .search-dropdown-item p strong { color: var(--primary, #e94560); }
369
+
370
+ /* Filter toggle buttons */
371
+ .filter-toggles {
372
+ display: flex;
373
+ gap: 0.5rem;
374
+ flex-wrap: wrap;
375
+ }
376
+ .filter-btn {
377
+ padding: 0.35rem 0.6rem;
378
+ font-size: 0.75rem;
379
+ border: 1px solid var(--border, #333);
380
+ border-radius: 4px;
381
+ background: transparent;
382
+ color: var(--text-muted, #888);
383
+ cursor: pointer;
384
+ opacity: 0.5;
385
+ transition: all 0.15s ease;
386
+ }
387
+ .filter-btn:hover {
388
+ opacity: 0.8;
389
+ border-color: var(--primary, #e94560);
390
+ }
391
+ .filter-btn.active {
392
+ opacity: 1;
393
+ background: var(--surface, #16213e);
394
+ border-color: var(--primary, #e94560);
395
+ color: var(--text, #eee);
396
+ }
397
+
398
+ /* Filter visibility - elements get .filter-hidden class from JS */
399
+ .filter-hidden { display: none !important; }
400
+
401
+ /* Assistant text wrapper (dynamically added) */
402
+ .assistant-text { display: block; }
403
+
404
+ /* Insight blocks */
405
+ .insight-block {
406
+ border-left: 3px solid #ffd700;
407
+ padding-left: 0.5rem;
408
+ margin: 0.5rem 0;
409
+ }
410
+
411
+ /* Performance optimization - removed content-visibility from .message to allow proper collapse sizing */
412
+ .cell {
413
+ content-visibility: auto;
414
+ contain: layout style paint;
415
+ contain-intrinsic-size: 1px 400px;
416
+ }
417
+
418
+ /* Content collapsing - line-based (messages and index items) */
419
+ .message-content.collapsed,
420
+ .index-item-content.collapsed {
421
+ --collapse-lines: 10;
422
+ --line-height: 1.5em;
423
+ max-height: calc(var(--collapse-lines) * var(--line-height));
424
+ overflow: hidden;
425
+ position: relative;
426
+ }
427
+ .message-content.collapsed::after,
428
+ .index-item-content.collapsed::after {
429
+ content: '';
430
+ position: absolute;
431
+ bottom: 0;
432
+ left: 0;
433
+ right: 0;
434
+ height: 3em;
435
+ background: linear-gradient(to bottom, transparent, var(--card-bg, #fff));
436
+ pointer-events: none;
437
+ }
438
+ .message.user .message-content.collapsed::after {
439
+ background: linear-gradient(to bottom, transparent, var(--user-bg, #e3f2fd));
440
+ }
441
+ .message.tool-reply .message-content.collapsed::after {
442
+ background: linear-gradient(to bottom, transparent, #fff8e1);
443
+ }
444
+ .message-expand-btn {
445
+ display: block;
446
+ width: 100%;
447
+ padding: 8px;
448
+ margin-top: 4px;
449
+ background: rgba(0,0,0,0.05);
450
+ border: 1px solid rgba(0,0,0,0.1);
451
+ border-radius: 6px;
452
+ cursor: pointer;
453
+ font-size: 0.85rem;
454
+ color: var(--text-muted, #757575);
455
+ text-align: center;
456
+ }
457
+ .message-expand-btn:hover { background: rgba(0,0,0,0.1); }
458
+
459
+ /* Infinite scroll loading indicator */
460
+ #infinite-scroll-loader {
461
+ text-align: center;
462
+ padding: 20px;
463
+ color: var(--text-muted, #757575);
464
+ }
465
+ #infinite-scroll-loader.loading::after {
466
+ content: "Loading more...";
467
+ }
468
+ #infinite-scroll-loader.done::after {
469
+ content: "End of conversation";
470
+ }
471
+ </style>
472
+ `;
473
+ // JavaScript for infinite scroll and search bar
474
+ const INJECTED_JS = `
475
+ <script id="viewer-enhancements-js">
476
+ (function() {
477
+ // Collapse long content - line-based collapsing
478
+ // Applies to both message pages (.message-content) and index pages (.index-item-content)
479
+ const MAX_LINES = 10;
480
+ const MIN_HIDDEN_LINES = 5; // Only collapse if hiding at least this many lines
481
+
482
+ function collapseContent(root) {
483
+ const container = root || document;
484
+ // Target both message content and index item content
485
+ container.querySelectorAll('.message-content, .index-item-content').forEach(function(content) {
486
+ // Skip if already processed or has truncatable handling
487
+ if (content.dataset.collapseProcessed) return;
488
+ if (content.closest('.truncatable')) return;
489
+ content.dataset.collapseProcessed = 'true';
490
+
491
+ // Calculate line height and total lines
492
+ const style = window.getComputedStyle(content);
493
+ const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5;
494
+ const totalHeight = content.scrollHeight;
495
+ const totalLines = Math.ceil(totalHeight / lineHeight);
496
+ const hiddenLines = totalLines - MAX_LINES;
497
+
498
+ if (hiddenLines >= MIN_HIDDEN_LINES) {
499
+ content.classList.add('collapsed');
500
+ content.style.setProperty('--line-height', lineHeight + 'px');
501
+
502
+ const btn = document.createElement('button');
503
+ btn.className = 'message-expand-btn';
504
+ btn.textContent = 'Show ' + hiddenLines + ' more lines';
505
+ btn.addEventListener('click', function() {
506
+ if (content.classList.contains('collapsed')) {
507
+ content.classList.remove('collapsed');
508
+ btn.textContent = 'Show less';
509
+ } else {
510
+ content.classList.add('collapsed');
511
+ btn.textContent = 'Show ' + hiddenLines + ' more lines';
512
+ }
513
+ });
514
+ content.parentNode.insertBefore(btn, content.nextSibling);
515
+ }
516
+ });
517
+ }
518
+
519
+ // Initial collapse
520
+ collapseContent();
521
+
522
+ // Export for use by infinite scroll
523
+ window.collapseContent = collapseContent;
524
+
525
+ // Infinite scroll state - use window to persist across any script re-execution
526
+ if (typeof window.__infiniteScrollState === 'undefined') {
527
+ const pageMatch = window.location.pathname.match(/page-([0-9]+)\\.html/);
528
+ window.__infiniteScrollState = {
529
+ currentPage: pageMatch ? parseInt(pageMatch[1], 10) : 1,
530
+ totalPages: 1,
531
+ isLoading: false
532
+ };
533
+ }
534
+ const state = window.__infiniteScrollState;
535
+
536
+ // Detect total pages from pagination
537
+ function detectTotalPages() {
538
+ const pagination = document.querySelector('.pagination');
539
+ if (pagination) {
540
+ const links = pagination.querySelectorAll('a[href^="page-"]');
541
+ links.forEach(link => {
542
+ const match = link.href.match(/page-([0-9]+)\\.html/);
543
+ if (match) {
544
+ const pageNum = parseInt(match[1], 10);
545
+ if (pageNum > state.totalPages) state.totalPages = pageNum;
546
+ }
547
+ });
548
+ }
549
+ }
550
+
551
+ // Load next page content
552
+ async function loadNextPage() {
553
+ // Set isLoading and increment currentPage IMMEDIATELY to prevent race conditions
554
+ if (state.isLoading || state.currentPage >= state.totalPages) {
555
+ return;
556
+ }
557
+ state.isLoading = true;
558
+ state.currentPage++; // Increment synchronously BEFORE any async work
559
+
560
+ const pageToLoad = state.currentPage;
561
+ const loader = document.getElementById('infinite-scroll-loader');
562
+ if (loader) loader.className = 'loading';
563
+
564
+ const nextUrl = window.location.pathname.replace(
565
+ /page-[0-9]+\\.html/,
566
+ 'page-' + String(pageToLoad).padStart(3, '0') + '.html'
567
+ );
568
+
569
+ try {
570
+ const response = await fetch(nextUrl);
571
+ if (!response.ok) throw new Error('Page not found');
572
+
573
+ const html = await response.text();
574
+ const parser = new DOMParser();
575
+ const doc = parser.parseFromString(html, 'text/html');
576
+
577
+ // Get messages from next page
578
+ const messages = doc.querySelectorAll('.message');
579
+ const container = document.querySelector('.container');
580
+ const loader = document.getElementById('infinite-scroll-loader');
581
+
582
+ if (container && messages.length > 0) {
583
+ messages.forEach(msg => {
584
+ const clone = document.importNode(msg, true);
585
+ // Always insert before loader to maintain correct order
586
+ container.insertBefore(clone, loader);
587
+ });
588
+
589
+ // Collapse newly loaded messages
590
+ if (window.collapseContent) {
591
+ window.collapseContent();
592
+ }
593
+
594
+ // Apply filters to newly loaded content (order: wrap, then extract insights)
595
+ if (window.wrapAssistantText) window.wrapAssistantText();
596
+ if (window.markInsightBlocks) window.markInsightBlocks();
597
+ if (window.applyFilters) window.applyFilters();
598
+ }
599
+ } catch (err) {
600
+ console.error('Failed to load page', pageToLoad, ':', err);
601
+ // Rollback on error
602
+ state.currentPage--;
603
+ } finally {
604
+ state.isLoading = false;
605
+ const loader = document.getElementById('infinite-scroll-loader');
606
+ if (loader) {
607
+ loader.className = state.currentPage >= state.totalPages ? 'done' : '';
608
+ }
609
+ }
610
+ }
611
+
612
+ // Set up infinite scroll observer
613
+ function setupInfiniteScroll() {
614
+ // Check if already set up by looking for the loader element (most reliable guard)
615
+ if (document.getElementById('infinite-scroll-loader')) {
616
+ return; // Already set up
617
+ }
618
+
619
+ const container = document.querySelector('.container');
620
+ const paginations = document.querySelectorAll('.pagination');
621
+
622
+ if (container) {
623
+ // Hide ALL pagination elements
624
+ paginations.forEach(p => p.style.display = 'none');
625
+
626
+ const loader = document.createElement('div');
627
+ loader.id = 'infinite-scroll-loader';
628
+ container.appendChild(loader);
629
+
630
+ // Observe loader for infinite scroll
631
+ const observer = new IntersectionObserver((entries) => {
632
+ if (entries[0].isIntersecting) {
633
+ loadNextPage();
634
+ }
635
+ }, { rootMargin: '200px' });
636
+
637
+ observer.observe(loader);
638
+ }
639
+ }
640
+
641
+ // Initialize on DOM ready
642
+ if (document.readyState === 'loading') {
643
+ document.addEventListener('DOMContentLoaded', init);
644
+ } else {
645
+ init();
646
+ }
647
+
648
+ function init() {
649
+ detectTotalPages();
650
+ setupInfiniteScroll();
651
+ setupSearchBar();
652
+ setupFilters();
653
+ }
654
+
655
+ // Filter toggle functionality
656
+ function setupFilters() {
657
+ const filterContainer = document.getElementById('filter-toggles');
658
+ if (!filterContainer) return;
659
+
660
+ // Order matters: wrap first, then extract insights
661
+ // 1. Wrap assistant text (excluding thinking, tool-use - insights handled after)
662
+ wrapAssistantText();
663
+
664
+ // 2. Extract insight blocks from assistant-text and move them out
665
+ markInsightBlocks();
666
+
667
+ filterContainer.addEventListener('click', (e) => {
668
+ const btn = e.target.closest('.filter-btn');
669
+ if (!btn) return;
670
+
671
+ btn.classList.toggle('active');
672
+ applyFilters();
673
+ });
674
+ }
675
+
676
+ // Extract insight blocks from assistant-text and wrap them properly
677
+ function markInsightBlocks() {
678
+ document.querySelectorAll('.message.assistant .message-content').forEach(content => {
679
+ if (content.dataset.insightProcessed) return;
680
+ content.dataset.insightProcessed = 'true';
681
+
682
+ // Find all code/pre elements that contain insight markers
683
+ const insightStarts = Array.from(content.querySelectorAll('code, pre')).filter(el => {
684
+ if (!el.textContent.includes('★ Insight')) return false;
685
+ // Skip if already inside an insight-block
686
+ if (el.closest('.insight-block')) return false;
687
+ return true;
688
+ });
689
+
690
+ insightStarts.forEach(startEl => {
691
+ // Find the container we need to work within (either assistant-text or message-content)
692
+ const assistantText = startEl.closest('.assistant-text');
693
+ const container = assistantText || content;
694
+
695
+ // Find the parent block element (usually a <p> tag) that is a direct child of container
696
+ let startBlock = startEl;
697
+ while (startBlock.parentElement && startBlock.parentElement !== container) {
698
+ startBlock = startBlock.parentElement;
699
+ }
700
+
701
+ // If startBlock is not a direct child of container, skip
702
+ if (startBlock.parentElement !== container) return;
703
+
704
+ // Collect all sibling elements until we find the closing dashes
705
+ const elementsToWrap = [startBlock];
706
+ let current = startBlock.nextElementSibling;
707
+
708
+ while (current) {
709
+ elementsToWrap.push(current);
710
+
711
+ // Check if this element contains the closing dashes (────)
712
+ const hasClosingDashes = current.textContent && current.textContent.includes('────') &&
713
+ !current.textContent.includes('★ Insight');
714
+
715
+ if (hasClosingDashes) {
716
+ break;
717
+ }
718
+ current = current.nextElementSibling;
719
+ }
720
+
721
+ // Create the insight wrapper
722
+ if (elementsToWrap.length > 0) {
723
+ const wrapper = document.createElement('div');
724
+ wrapper.className = 'insight-block';
725
+
726
+ // Insert wrapper before the first element in the container
727
+ container.insertBefore(wrapper, startBlock);
728
+
729
+ // Move all elements into the wrapper
730
+ elementsToWrap.forEach(el => wrapper.appendChild(el));
731
+
732
+ // If we extracted from assistant-text, move the wrapper to message-content level
733
+ if (assistantText && content !== assistantText) {
734
+ // Insert the insight wrapper after assistant-text in message-content
735
+ content.insertBefore(wrapper, assistantText.nextSibling);
736
+ }
737
+ }
738
+ });
739
+ });
740
+ }
741
+
742
+ // Wrap non-special content in assistant messages
743
+ function wrapAssistantText() {
744
+ document.querySelectorAll('.message.assistant .message-content').forEach(content => {
745
+ if (content.querySelector(':scope > .assistant-text')) return; // Already wrapped at this level
746
+
747
+ const children = [...content.childNodes];
748
+ const wrapper = document.createElement('div');
749
+ wrapper.className = 'assistant-text';
750
+
751
+ children.forEach(child => {
752
+ // Skip special blocks - they stay outside the wrapper
753
+ if (child.nodeType === 1) { // Element node
754
+ const el = child;
755
+ if (el.classList.contains('thinking') ||
756
+ el.classList.contains('tool-use') ||
757
+ el.classList.contains('insight-block')) {
758
+ return;
759
+ }
760
+ }
761
+ // Move to wrapper
762
+ wrapper.appendChild(child);
763
+ });
764
+
765
+ // Insert wrapper at the beginning
766
+ if (wrapper.childNodes.length > 0) {
767
+ content.insertBefore(wrapper, content.firstChild);
768
+ }
769
+ });
770
+ }
771
+
772
+ // Apply current filter state
773
+ function applyFilters() {
774
+ const filters = {};
775
+ document.querySelectorAll('.filter-btn').forEach(btn => {
776
+ filters[btn.dataset.filter] = btn.classList.contains('active');
777
+ });
778
+
779
+ // Apply to user messages
780
+ document.querySelectorAll('.message.user').forEach(el => {
781
+ el.classList.toggle('filter-hidden', !filters.user);
782
+ });
783
+
784
+ // Apply to tool results
785
+ document.querySelectorAll('.message.tool-reply').forEach(el => {
786
+ el.classList.toggle('filter-hidden', !filters['tool-reply']);
787
+ });
788
+
789
+ // Apply to thinking blocks
790
+ document.querySelectorAll('.thinking').forEach(el => {
791
+ el.classList.toggle('filter-hidden', !filters.thinking);
792
+ });
793
+
794
+ // Apply to tool-use blocks
795
+ document.querySelectorAll('.tool-use').forEach(el => {
796
+ el.classList.toggle('filter-hidden', !filters['tool-use']);
797
+ });
798
+
799
+ // Apply to insight blocks
800
+ document.querySelectorAll('.insight-block').forEach(el => {
801
+ el.classList.toggle('filter-hidden', !filters.insight);
802
+ });
803
+
804
+ // Apply to assistant text
805
+ document.querySelectorAll('.assistant-text').forEach(el => {
806
+ el.classList.toggle('filter-hidden', !filters.assistant);
807
+ });
808
+
809
+ // Hide assistant messages that are now empty
810
+ document.querySelectorAll('.message.assistant').forEach(msg => {
811
+ const content = msg.querySelector('.message-content');
812
+ if (!content) return;
813
+
814
+ // Check if any visible filterable content remains
815
+ const hasVisibleThinking = filters.thinking && content.querySelector('.thinking:not(.filter-hidden)');
816
+ const hasVisibleToolUse = filters['tool-use'] && content.querySelector('.tool-use:not(.filter-hidden)');
817
+ const hasVisibleInsight = filters.insight && content.querySelector('.insight-block:not(.filter-hidden)');
818
+
819
+ // For assistant text, also check it has meaningful content (not just whitespace)
820
+ let hasVisibleText = false;
821
+ if (filters.assistant) {
822
+ const assistantText = content.querySelector('.assistant-text:not(.filter-hidden)');
823
+ if (assistantText && assistantText.textContent.trim().length > 0) {
824
+ hasVisibleText = true;
825
+ }
826
+ }
827
+
828
+ const hasVisibleContent = hasVisibleThinking || hasVisibleToolUse || hasVisibleInsight || hasVisibleText;
829
+
830
+ msg.classList.toggle('filter-hidden', !hasVisibleContent);
831
+ });
832
+ }
833
+
834
+ // Re-apply filters when new content is loaded (infinite scroll)
835
+ window.applyFilters = applyFilters;
836
+ window.markInsightBlocks = markInsightBlocks;
837
+ window.wrapAssistantText = wrapAssistantText;
838
+
839
+ // Search bar functionality
840
+ function setupSearchBar() {
841
+ const searchInput = document.getElementById('viewer-search-input');
842
+ const dropdown = document.getElementById('viewer-search-dropdown');
843
+ if (!searchInput || !dropdown) return;
844
+
845
+ let debounceTimer;
846
+
847
+ searchInput.addEventListener('input', () => {
848
+ clearTimeout(debounceTimer);
849
+ const q = searchInput.value.trim();
850
+ if (!q) {
851
+ dropdown.classList.remove('visible');
852
+ return;
853
+ }
854
+ debounceTimer = setTimeout(() => performSearch(q), 200);
855
+ });
856
+
857
+ // Handle Enter key to go to full search page
858
+ searchInput.addEventListener('keydown', (e) => {
859
+ if (e.key === 'Enter') {
860
+ e.preventDefault();
861
+ const q = searchInput.value.trim();
862
+ if (q) {
863
+ window.location.href = '/search?q=' + encodeURIComponent(q);
864
+ }
865
+ }
866
+ });
867
+
868
+ // Close dropdown when clicking outside
869
+ document.addEventListener('click', (e) => {
870
+ if (!e.target.closest('.viewer-search-bar')) {
871
+ dropdown.classList.remove('visible');
872
+ }
873
+ });
874
+
875
+ async function performSearch(q) {
876
+ try {
877
+ const res = await fetch('/api/search?q=' + encodeURIComponent(q) + '&limit=5');
878
+ const data = await res.json();
879
+
880
+ dropdown.textContent = '';
881
+
882
+ if (data.results && data.results.length > 0) {
883
+ data.results.forEach(r => {
884
+ const item = document.createElement('div');
885
+ item.className = 'search-dropdown-item';
886
+ item.onclick = () => window.location.href = r.url;
887
+
888
+ const title = document.createElement('h4');
889
+ title.textContent = (r.title || 'Untitled').slice(0, 50);
890
+ item.appendChild(title);
891
+
892
+ const snippet = document.createElement('p');
893
+ // Safe: server escapes content before adding <strong> tags
894
+ snippet.innerHTML = r.snippet;
895
+ item.appendChild(snippet);
896
+
897
+ dropdown.appendChild(item);
898
+ });
899
+
900
+ // Add "view all" link
901
+ const viewAll = document.createElement('div');
902
+ viewAll.className = 'search-dropdown-item';
903
+ viewAll.onclick = () => window.location.href = '/search?q=' + encodeURIComponent(q);
904
+ const em = document.createElement('em');
905
+ em.textContent = 'View all results...';
906
+ em.style.color = 'var(--primary, #e94560)';
907
+ viewAll.appendChild(em);
908
+ dropdown.appendChild(viewAll);
909
+
910
+ dropdown.classList.add('visible');
911
+ } else {
912
+ const noResults = document.createElement('div');
913
+ noResults.className = 'search-dropdown-item';
914
+ const em = document.createElement('em');
915
+ em.textContent = 'No results found';
916
+ noResults.appendChild(em);
917
+ dropdown.appendChild(noResults);
918
+ dropdown.classList.add('visible');
919
+ }
920
+ } catch (err) {
921
+ console.error('Search failed:', err);
922
+ }
923
+ }
924
+ }
925
+ })();
926
+ </script>
927
+ `;
928
+ // Search bar HTML to inject at top of body
929
+ const SEARCH_BAR_HTML = `
930
+ <div class="viewer-search-bar">
931
+ <a href="/" class="home-link">← Home</a>
932
+ <div class="search-wrapper">
933
+ <input type="search" id="viewer-search-input" placeholder="Search all conversations..." autocomplete="off" />
934
+ <div id="viewer-search-dropdown" class="search-dropdown"></div>
935
+ </div>
936
+ <div class="filter-toggles" id="filter-toggles">
937
+ <button class="filter-btn active" data-filter="user" title="User messages">👤 User</button>
938
+ <button class="filter-btn active" data-filter="assistant" title="Assistant text">🤖 Assistant</button>
939
+ <button class="filter-btn active" data-filter="tool-use" title="Tool calls">🔧 Tools</button>
940
+ <button class="filter-btn active" data-filter="tool-reply" title="Tool results">📋 Results</button>
941
+ <button class="filter-btn active" data-filter="thinking" title="Thinking blocks">💭 Thinking</button>
942
+ <button class="filter-btn active" data-filter="insight" title="Insight blocks">★ Insight</button>
943
+ </div>
944
+ </div>
945
+ `;
946
+ // Inject enhancements into HTML
947
+ function enhanceHtml(html) {
948
+ const $ = cheerio.load(html);
949
+ // Inject CSS before </head>
950
+ $("head").append(INJECTED_CSS);
951
+ // Inject search bar at beginning of body
952
+ $("body").prepend(SEARCH_BAR_HTML);
953
+ // Inject JS before </body>
954
+ $("body").append(INJECTED_JS);
955
+ return $.html();
956
+ }
957
+ // Serve enhanced HTML files
958
+ // Redirect session index.html to page-001.html for direct navigation
959
+ app.get(/.*\.html$/, (req, res) => {
960
+ // Redirect session index.html to page-001.html
961
+ // Pattern: /project/uuid/index.html -> /project/uuid/page-001.html
962
+ const sessionIndexMatch = req.path.match(/^\/([^/]+)\/([a-f0-9-]{36})\/index\.html$/);
963
+ if (sessionIndexMatch) {
964
+ const [, project, session] = sessionIndexMatch;
965
+ return res.redirect(`/${project}/${session}/page-001.html`);
966
+ }
967
+ const filePath = join(ARCHIVE_DIR, req.path);
968
+ if (!existsSync(filePath)) {
969
+ res.status(404).send("File not found");
970
+ return;
971
+ }
972
+ try {
973
+ const html = readFileSync(filePath, "utf-8");
974
+ const enhanced = enhanceHtml(html);
975
+ res.type("html").send(enhanced);
976
+ }
977
+ catch (err) {
978
+ console.error("Error serving file:", err);
979
+ res.status(500).send("Error serving file");
980
+ }
981
+ });
982
+ // Serve static assets (CSS, JS, images) - disable index.html serving so our dynamic routes work
983
+ app.use(express.static(ARCHIVE_DIR, { index: false }));
984
+ // Search results page - MUST be before /:project/ route to avoid matching "search" as project name
985
+ app.get("/search", async (req, res) => {
986
+ const startTime = Date.now();
987
+ try {
988
+ const query = req.query.q || "";
989
+ const options = {
990
+ project: req.query.project,
991
+ role: req.query.role,
992
+ after: req.query.after,
993
+ before: req.query.before,
994
+ limit: req.query.limit ? parseInt(req.query.limit, 10) : 50,
995
+ offset: req.query.offset ? parseInt(req.query.offset, 10) : 0,
996
+ };
997
+ const db = getDatabase();
998
+ // Get list of projects for filter dropdown
999
+ const projects = db.prepare(`
1000
+ SELECT DISTINCT project FROM conversations ORDER BY project
1001
+ `).all();
1002
+ let results = [];
1003
+ if (query) {
1004
+ const searchResult = await searchHybrid(query, options, embeddingClient);
1005
+ results = (searchResult.results || []).map((r) => {
1006
+ const terms = query.split(/\s+/).filter(Boolean);
1007
+ const snippet = generateSnippet(r.content, query, 150);
1008
+ const highlightedSnippet = highlightTerms(snippet, terms);
1009
+ return {
1010
+ chunk_id: r.chunk_id,
1011
+ conversation_id: r.conversation_id,
1012
+ project: r.project,
1013
+ title: r.title || "Untitled",
1014
+ snippet: highlightedSnippet,
1015
+ role: r.role,
1016
+ page: r.page_number || 1,
1017
+ score: r.score,
1018
+ url: `/${projectToArchivePath(r.project)}/${r.conversation_id}/page-001.html`,
1019
+ };
1020
+ });
1021
+ }
1022
+ const html = renderSearchPage({
1023
+ query,
1024
+ results,
1025
+ projects: projects.map((p) => p.project),
1026
+ filters: {
1027
+ project: options.project,
1028
+ role: options.role,
1029
+ after: options.after,
1030
+ before: options.before,
1031
+ },
1032
+ queryTimeMs: Date.now() - startTime,
1033
+ offset: options.offset || 0,
1034
+ limit: options.limit || 50,
1035
+ });
1036
+ res.type("html").send(html);
1037
+ }
1038
+ catch (err) {
1039
+ console.error("Search page error:", err);
1040
+ res.status(500).send("Search failed");
1041
+ }
1042
+ });
1043
+ // Serve project directory index.html files (since we disabled automatic index serving)
1044
+ // Rewrite session links to go directly to page-001.html instead of session index.html
1045
+ app.get("/:project/", (req, res) => {
1046
+ const project = req.params.project;
1047
+ const projectDir = join(ARCHIVE_DIR, project);
1048
+ const indexPath = resolve(projectDir, "index.html");
1049
+ if (existsSync(indexPath)) {
1050
+ try {
1051
+ let html = readFileSync(indexPath, "utf-8");
1052
+ // Rewrite session links: href="uuid/index.html" -> href="uuid/page-001.html"
1053
+ html = html.replace(/href="([a-f0-9-]{36})\/index\.html"/g, 'href="$1/page-001.html"');
1054
+ // Inject our enhancements (dark mode, etc.)
1055
+ html = enhanceHtml(html);
1056
+ res.type("html").send(html);
1057
+ }
1058
+ catch (err) {
1059
+ console.error("Error serving project index:", err);
1060
+ res.status(500).send("Error serving file");
1061
+ }
1062
+ }
1063
+ else {
1064
+ res.status(404).send("Project not found");
1065
+ }
1066
+ });
1067
+ // List available projects
1068
+ app.get("/api/projects", (req, res) => {
1069
+ try {
1070
+ const projects = readdirSync(ARCHIVE_DIR, { withFileTypes: true })
1071
+ .filter(d => d.isDirectory())
1072
+ .map(d => d.name);
1073
+ res.json({ projects });
1074
+ }
1075
+ catch (err) {
1076
+ res.status(500).json({ error: "Failed to list projects" });
1077
+ }
1078
+ });
1079
+ // Search API endpoint
1080
+ app.get("/api/search", async (req, res) => {
1081
+ const startTime = Date.now();
1082
+ try {
1083
+ const query = req.query.q || "";
1084
+ const options = {
1085
+ project: req.query.project,
1086
+ role: req.query.role,
1087
+ after: req.query.after,
1088
+ before: req.query.before,
1089
+ limit: req.query.limit ? parseInt(req.query.limit, 10) : 20,
1090
+ offset: req.query.offset ? parseInt(req.query.offset, 10) : 0,
1091
+ };
1092
+ const result = await searchHybrid(query, options, embeddingClient);
1093
+ // Format response according to design spec
1094
+ if (result.type === "recent") {
1095
+ res.json({
1096
+ type: "recent",
1097
+ conversations: result.conversations,
1098
+ query_time_ms: Date.now() - startTime,
1099
+ });
1100
+ return;
1101
+ }
1102
+ // Add snippets with highlighting to results
1103
+ const resultsWithSnippets = (result.results || []).map((r) => {
1104
+ const terms = query.split(/\s+/).filter(Boolean);
1105
+ const snippet = generateSnippet(r.content, query, 100);
1106
+ const highlightedSnippet = highlightTerms(snippet, terms);
1107
+ return {
1108
+ chunk_id: r.chunk_id,
1109
+ conversation_id: r.conversation_id,
1110
+ project: r.project,
1111
+ title: r.title,
1112
+ snippet: highlightedSnippet,
1113
+ role: r.role,
1114
+ page: r.page_number,
1115
+ score: r.score,
1116
+ url: `/${projectToArchivePath(r.project)}/${r.conversation_id}/page-001.html`,
1117
+ };
1118
+ });
1119
+ res.json({
1120
+ type: result.type,
1121
+ results: resultsWithSnippets,
1122
+ total: resultsWithSnippets.length,
1123
+ query_time_ms: Date.now() - startTime,
1124
+ embedding_status: result.embeddingStatus,
1125
+ });
1126
+ }
1127
+ catch (err) {
1128
+ console.error("Search error:", err);
1129
+ res.status(500).json({ error: "Search failed" });
1130
+ }
1131
+ });
1132
+ // Index status endpoint
1133
+ app.get("/api/index/status", (req, res) => {
1134
+ try {
1135
+ const db = getDatabase();
1136
+ const chunkCount = db.prepare("SELECT COUNT(*) as count FROM chunks").get();
1137
+ const convCount = db.prepare("SELECT COUNT(*) as count FROM conversations").get();
1138
+ const overallStatus = archiveStatus.isGenerating
1139
+ ? "generating"
1140
+ : indexingStatus.isIndexing
1141
+ ? "indexing"
1142
+ : "ready";
1143
+ res.json({
1144
+ status: overallStatus,
1145
+ conversations: convCount.count,
1146
+ chunks: chunkCount.count,
1147
+ embedding_server: embeddingClient ? "connected" : "unavailable",
1148
+ archive: {
1149
+ isGenerating: archiveStatus.isGenerating,
1150
+ progress: archiveStatus.progress,
1151
+ lastError: archiveStatus.lastError,
1152
+ lastRun: archiveStatus.lastRun,
1153
+ },
1154
+ indexing: {
1155
+ isIndexing: indexingStatus.isIndexing,
1156
+ progress: indexingStatus.progress,
1157
+ lastError: indexingStatus.lastError,
1158
+ lastStats: indexingStatus.lastStats,
1159
+ },
1160
+ });
1161
+ }
1162
+ catch (err) {
1163
+ res.json({
1164
+ status: "not_initialized",
1165
+ conversations: 0,
1166
+ chunks: 0,
1167
+ embedding_server: "unavailable",
1168
+ archive: archiveStatus,
1169
+ indexing: indexingStatus,
1170
+ });
1171
+ }
1172
+ });
1173
+ // Manually trigger archive regeneration
1174
+ app.post("/api/archive/regenerate", async (req, res) => {
1175
+ if (archiveStatus.isGenerating) {
1176
+ res.status(409).json({ error: "Archive generation already in progress" });
1177
+ return;
1178
+ }
1179
+ if (!SOURCE_DIR) {
1180
+ res.status(400).json({ error: "No SOURCE_DIR configured" });
1181
+ return;
1182
+ }
1183
+ // Start archive generation in background, then index
1184
+ (async () => {
1185
+ await generateArchive();
1186
+ await startBackgroundIndexing();
1187
+ })();
1188
+ res.json({ status: "started", message: "Archive generation started in background" });
1189
+ });
1190
+ // Manually trigger re-indexing
1191
+ app.post("/api/index/reindex", async (req, res) => {
1192
+ if (indexingStatus.isIndexing) {
1193
+ res.status(409).json({ error: "Indexing already in progress" });
1194
+ return;
1195
+ }
1196
+ if (!SOURCE_DIR) {
1197
+ res.status(400).json({ error: "No SOURCE_DIR configured" });
1198
+ return;
1199
+ }
1200
+ // Start indexing in background
1201
+ startBackgroundIndexing();
1202
+ res.json({ status: "started", message: "Indexing started in background" });
1203
+ });
1204
+ function renderSearchPage(data) {
1205
+ const projectOptions = data.projects
1206
+ .map((p) => `<option value="${escapeHtml(p)}"${data.filters.project === p ? " selected" : ""}>${escapeHtml(p)}</option>`)
1207
+ .join("");
1208
+ const resultItems = data.results
1209
+ .map((r) => `
1210
+ <div class="search-result-item">
1211
+ <a href="${escapeHtml(r.url)}">
1212
+ <h3>${escapeHtml(r.title?.slice(0, 80) || "Untitled")}${r.title && r.title.length > 80 ? "..." : ""}</h3>
1213
+ <div class="result-meta">
1214
+ <span class="project">${escapeHtml(r.project)}</span>
1215
+ <span class="role ${r.role}">${escapeHtml(r.role)}</span>
1216
+ </div>
1217
+ <p class="result-snippet">${r.snippet}</p>
1218
+ </a>
1219
+ </div>
1220
+ `)
1221
+ .join("");
1222
+ const hasMore = data.results.length === data.limit;
1223
+ const prevOffset = Math.max(0, data.offset - data.limit);
1224
+ const nextOffset = data.offset + data.limit;
1225
+ const buildUrl = (offset) => {
1226
+ const params = new URLSearchParams();
1227
+ if (data.query)
1228
+ params.set("q", data.query);
1229
+ if (data.filters.project)
1230
+ params.set("project", data.filters.project);
1231
+ if (data.filters.role)
1232
+ params.set("role", data.filters.role);
1233
+ if (data.filters.after)
1234
+ params.set("after", data.filters.after);
1235
+ if (data.filters.before)
1236
+ params.set("before", data.filters.before);
1237
+ if (offset > 0)
1238
+ params.set("offset", String(offset));
1239
+ return `/search?${params.toString()}`;
1240
+ };
1241
+ return `<!DOCTYPE html>
1242
+ <html lang="en">
1243
+ <head>
1244
+ <meta charset="UTF-8">
1245
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1246
+ <title>${data.query ? escapeHtml(data.query) + " - " : ""}Search - Claude Transcript Viewer</title>
1247
+ <style>
1248
+ :root {
1249
+ --bg: #1a1a2e;
1250
+ --surface: #16213e;
1251
+ --primary: #e94560;
1252
+ --text: #eee;
1253
+ --text-muted: #888;
1254
+ --border: #333;
1255
+ }
1256
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1257
+ body {
1258
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1259
+ background: var(--bg);
1260
+ color: var(--text);
1261
+ line-height: 1.6;
1262
+ }
1263
+ .header {
1264
+ background: var(--surface);
1265
+ padding: 1rem 2rem;
1266
+ border-bottom: 1px solid var(--border);
1267
+ }
1268
+ .header a { color: var(--primary); text-decoration: none; }
1269
+ .header a:hover { text-decoration: underline; }
1270
+ .search-form {
1271
+ max-width: 1200px;
1272
+ margin: 2rem auto;
1273
+ padding: 0 2rem;
1274
+ }
1275
+ .search-row {
1276
+ display: flex;
1277
+ gap: 1rem;
1278
+ margin-bottom: 1rem;
1279
+ }
1280
+ .search-row input[type="search"] {
1281
+ flex: 1;
1282
+ padding: 0.75rem 1rem;
1283
+ font-size: 1rem;
1284
+ border: 2px solid var(--border);
1285
+ border-radius: 8px;
1286
+ background: var(--surface);
1287
+ color: var(--text);
1288
+ }
1289
+ .search-row input[type="search"]:focus {
1290
+ outline: none;
1291
+ border-color: var(--primary);
1292
+ }
1293
+ .search-row button {
1294
+ padding: 0.75rem 1.5rem;
1295
+ font-size: 1rem;
1296
+ border: none;
1297
+ border-radius: 8px;
1298
+ background: var(--primary);
1299
+ color: #fff;
1300
+ cursor: pointer;
1301
+ }
1302
+ .search-row button:hover { opacity: 0.9; }
1303
+ .filters {
1304
+ display: flex;
1305
+ gap: 1rem;
1306
+ flex-wrap: wrap;
1307
+ }
1308
+ .filters select, .filters input {
1309
+ padding: 0.5rem;
1310
+ border: 1px solid var(--border);
1311
+ border-radius: 4px;
1312
+ background: var(--surface);
1313
+ color: var(--text);
1314
+ font-size: 0.875rem;
1315
+ }
1316
+ .filters label {
1317
+ color: var(--text-muted);
1318
+ font-size: 0.75rem;
1319
+ display: block;
1320
+ margin-bottom: 0.25rem;
1321
+ }
1322
+ .results-container {
1323
+ max-width: 1200px;
1324
+ margin: 0 auto;
1325
+ padding: 0 2rem 2rem;
1326
+ }
1327
+ .results-info {
1328
+ color: var(--text-muted);
1329
+ font-size: 0.875rem;
1330
+ margin-bottom: 1rem;
1331
+ }
1332
+ .search-result-item {
1333
+ background: var(--surface);
1334
+ border-radius: 8px;
1335
+ margin-bottom: 1rem;
1336
+ transition: transform 0.2s;
1337
+ }
1338
+ .search-result-item:hover {
1339
+ transform: translateX(4px);
1340
+ }
1341
+ .search-result-item a {
1342
+ display: block;
1343
+ padding: 1rem 1.5rem;
1344
+ text-decoration: none;
1345
+ color: inherit;
1346
+ }
1347
+ .search-result-item h3 {
1348
+ margin-bottom: 0.5rem;
1349
+ color: var(--text);
1350
+ }
1351
+ .result-meta {
1352
+ display: flex;
1353
+ gap: 0.5rem;
1354
+ margin-bottom: 0.5rem;
1355
+ }
1356
+ .result-meta span {
1357
+ font-size: 0.75rem;
1358
+ padding: 0.125rem 0.5rem;
1359
+ border-radius: 4px;
1360
+ }
1361
+ .result-meta .project {
1362
+ background: rgba(233, 69, 96, 0.2);
1363
+ color: var(--primary);
1364
+ }
1365
+ .result-meta .role {
1366
+ background: rgba(255, 255, 255, 0.1);
1367
+ color: var(--text-muted);
1368
+ }
1369
+ .result-meta .role.user { color: #64b5f6; }
1370
+ .result-meta .role.assistant { color: #81c784; }
1371
+ .result-snippet {
1372
+ color: var(--text-muted);
1373
+ font-size: 0.875rem;
1374
+ }
1375
+ .result-snippet strong {
1376
+ color: var(--primary);
1377
+ font-weight: normal;
1378
+ background: rgba(233, 69, 96, 0.2);
1379
+ padding: 0 2px;
1380
+ border-radius: 2px;
1381
+ }
1382
+ .pagination {
1383
+ display: flex;
1384
+ justify-content: center;
1385
+ gap: 1rem;
1386
+ margin-top: 2rem;
1387
+ }
1388
+ .pagination a {
1389
+ padding: 0.5rem 1rem;
1390
+ background: var(--surface);
1391
+ color: var(--text);
1392
+ text-decoration: none;
1393
+ border-radius: 4px;
1394
+ }
1395
+ .pagination a:hover { background: rgba(255,255,255,0.1); }
1396
+ .pagination a.disabled {
1397
+ opacity: 0.5;
1398
+ pointer-events: none;
1399
+ }
1400
+ .no-results {
1401
+ text-align: center;
1402
+ padding: 3rem;
1403
+ color: var(--text-muted);
1404
+ }
1405
+ </style>
1406
+ </head>
1407
+ <body>
1408
+ <div class="header">
1409
+ <a href="/">← Back to Home</a>
1410
+ </div>
1411
+
1412
+ <form class="search-form" method="GET" action="/search">
1413
+ <div class="search-row">
1414
+ <input type="search" name="q" value="${escapeHtml(data.query)}" placeholder="Search conversations..." autofocus />
1415
+ <button type="submit">Search</button>
1416
+ </div>
1417
+ <div class="filters">
1418
+ <div>
1419
+ <label>Project</label>
1420
+ <select name="project">
1421
+ <option value="">All projects</option>
1422
+ ${projectOptions}
1423
+ </select>
1424
+ </div>
1425
+ <div>
1426
+ <label>Role</label>
1427
+ <select name="role">
1428
+ <option value="">All roles</option>
1429
+ <option value="user"${data.filters.role === "user" ? " selected" : ""}>User</option>
1430
+ <option value="assistant"${data.filters.role === "assistant" ? " selected" : ""}>Assistant</option>
1431
+ </select>
1432
+ </div>
1433
+ <div>
1434
+ <label>After</label>
1435
+ <input type="date" name="after" value="${escapeHtml(data.filters.after || "")}" />
1436
+ </div>
1437
+ <div>
1438
+ <label>Before</label>
1439
+ <input type="date" name="before" value="${escapeHtml(data.filters.before || "")}" />
1440
+ </div>
1441
+ </div>
1442
+ </form>
1443
+
1444
+ <div class="results-container">
1445
+ ${data.query
1446
+ ? `<p class="results-info">${data.results.length} results for "${escapeHtml(data.query)}" (${data.queryTimeMs}ms)</p>`
1447
+ : `<p class="results-info">Enter a search query above</p>`}
1448
+
1449
+ ${data.results.length > 0
1450
+ ? resultItems
1451
+ : data.query
1452
+ ? `<div class="no-results"><p>No results found for "${escapeHtml(data.query)}"</p></div>`
1453
+ : ""}
1454
+
1455
+ ${data.query && (data.offset > 0 || hasMore)
1456
+ ? `
1457
+ <div class="pagination">
1458
+ <a href="${buildUrl(prevOffset)}" class="${data.offset === 0 ? "disabled" : ""}">← Previous</a>
1459
+ <a href="${buildUrl(nextOffset)}" class="${!hasMore ? "disabled" : ""}">Next →</a>
1460
+ </div>
1461
+ `
1462
+ : ""}
1463
+ </div>
1464
+ </body>
1465
+ </html>`;
1466
+ }
1467
+ // Landing page
1468
+ app.get("/", async (req, res) => {
1469
+ try {
1470
+ const db = getDatabase();
1471
+ // Get project stats
1472
+ const projects = db.prepare(`
1473
+ SELECT project, COUNT(*) as count, MAX(created_at) as last_updated
1474
+ FROM conversations
1475
+ GROUP BY project
1476
+ ORDER BY last_updated DESC
1477
+ `).all();
1478
+ // Get recent conversations
1479
+ const recentConversations = db.prepare(`
1480
+ SELECT id, project, title, created_at
1481
+ FROM conversations
1482
+ ORDER BY created_at DESC
1483
+ LIMIT 10
1484
+ `).all();
1485
+ // Get index stats
1486
+ const chunkCount = db.prepare("SELECT COUNT(*) as count FROM chunks").get();
1487
+ const html = renderLandingPage({
1488
+ projects,
1489
+ recentConversations,
1490
+ chunkCount: chunkCount.count,
1491
+ embeddingStatus: embeddingClient ? "connected" : "unavailable",
1492
+ });
1493
+ res.type("html").send(html);
1494
+ }
1495
+ catch (err) {
1496
+ // Database not initialized - show setup instructions
1497
+ res.type("html").send(renderSetupPage());
1498
+ }
1499
+ });
1500
+ function renderLandingPage(data) {
1501
+ const projectCards = data.projects
1502
+ .map((p) => {
1503
+ const archiveName = projectToArchivePath(p.project);
1504
+ return `
1505
+ <a href="/${escapeHtml(archiveName)}/" class="project-card">
1506
+ <h3>${escapeHtml(archiveName)}</h3>
1507
+ <p>${p.count} conversations</p>
1508
+ <small>Updated: ${new Date(p.last_updated).toLocaleDateString()}</small>
1509
+ </a>
1510
+ `;
1511
+ })
1512
+ .join("");
1513
+ const recentList = data.recentConversations
1514
+ .map((c) => {
1515
+ const archiveName = projectToArchivePath(c.project);
1516
+ return `
1517
+ <li>
1518
+ <a href="/${escapeHtml(archiveName)}/${escapeHtml(c.id)}/page-001.html">
1519
+ <strong>${escapeHtml(c.title?.slice(0, 60) || "Untitled")}${c.title && c.title.length > 60 ? "..." : ""}</strong>
1520
+ <span class="meta">${escapeHtml(archiveName)} - ${new Date(c.created_at).toLocaleDateString()}</span>
1521
+ </a>
1522
+ </li>
1523
+ `;
1524
+ })
1525
+ .join("");
1526
+ return `<!DOCTYPE html>
1527
+ <html lang="en">
1528
+ <head>
1529
+ <meta charset="UTF-8">
1530
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1531
+ <title>Claude Transcript Viewer</title>
1532
+ <style>
1533
+ :root {
1534
+ --bg: #1a1a2e;
1535
+ --surface: #16213e;
1536
+ --primary: #e94560;
1537
+ --text: #eee;
1538
+ --text-muted: #888;
1539
+ --border: #333;
1540
+ }
1541
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1542
+ body {
1543
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1544
+ background: var(--bg);
1545
+ color: var(--text);
1546
+ line-height: 1.6;
1547
+ padding: 2rem;
1548
+ max-width: 1200px;
1549
+ margin: 0 auto;
1550
+ }
1551
+ h1 { margin-bottom: 0.5rem; }
1552
+ .status {
1553
+ color: var(--text-muted);
1554
+ font-size: 0.875rem;
1555
+ margin-bottom: 2rem;
1556
+ }
1557
+ .status .dot {
1558
+ display: inline-block;
1559
+ width: 8px;
1560
+ height: 8px;
1561
+ border-radius: 50%;
1562
+ margin-right: 4px;
1563
+ }
1564
+ .status .dot.green { background: #4caf50; }
1565
+ .status .dot.yellow { background: #ff9800; }
1566
+ .search-container {
1567
+ margin-bottom: 2rem;
1568
+ }
1569
+ #search-input {
1570
+ width: 100%;
1571
+ padding: 1rem;
1572
+ font-size: 1.1rem;
1573
+ border: 2px solid var(--border);
1574
+ border-radius: 8px;
1575
+ background: var(--surface);
1576
+ color: var(--text);
1577
+ }
1578
+ #search-input:focus {
1579
+ outline: none;
1580
+ border-color: var(--primary);
1581
+ }
1582
+ #search-results {
1583
+ background: var(--surface);
1584
+ border: 1px solid var(--border);
1585
+ border-radius: 8px;
1586
+ margin-top: 0.5rem;
1587
+ display: none;
1588
+ }
1589
+ #search-results.visible { display: block; }
1590
+ .search-result {
1591
+ padding: 1rem;
1592
+ border-bottom: 1px solid var(--border);
1593
+ cursor: pointer;
1594
+ }
1595
+ .search-result:hover { background: rgba(255,255,255,0.05); }
1596
+ .search-result:last-child { border-bottom: none; }
1597
+ .search-result h4 { margin-bottom: 0.25rem; }
1598
+ .search-result .snippet { color: var(--text-muted); font-size: 0.875rem; }
1599
+ .search-result .snippet strong { color: var(--primary); }
1600
+ h2 { margin: 2rem 0 1rem; color: var(--text-muted); font-size: 0.875rem; text-transform: uppercase; }
1601
+ .projects {
1602
+ display: grid;
1603
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
1604
+ gap: 1rem;
1605
+ margin-bottom: 2rem;
1606
+ }
1607
+ .project-card {
1608
+ background: var(--surface);
1609
+ padding: 1.5rem;
1610
+ border-radius: 8px;
1611
+ text-decoration: none;
1612
+ color: var(--text);
1613
+ transition: transform 0.2s, box-shadow 0.2s;
1614
+ }
1615
+ .project-card:hover {
1616
+ transform: translateY(-2px);
1617
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
1618
+ }
1619
+ .project-card h3 { margin-bottom: 0.5rem; }
1620
+ .project-card p { color: var(--text-muted); }
1621
+ .project-card small { color: var(--text-muted); font-size: 0.75rem; }
1622
+ .recent-list {
1623
+ list-style: none;
1624
+ }
1625
+ .recent-list li {
1626
+ margin-bottom: 0.75rem;
1627
+ }
1628
+ .recent-list a {
1629
+ display: block;
1630
+ padding: 1rem;
1631
+ background: var(--surface);
1632
+ border-radius: 8px;
1633
+ text-decoration: none;
1634
+ color: var(--text);
1635
+ }
1636
+ .recent-list a:hover { background: rgba(255,255,255,0.05); }
1637
+ .recent-list .meta {
1638
+ display: block;
1639
+ color: var(--text-muted);
1640
+ font-size: 0.75rem;
1641
+ margin-top: 0.25rem;
1642
+ }
1643
+ </style>
1644
+ </head>
1645
+ <body>
1646
+ <h1>Claude Transcript Viewer</h1>
1647
+ <p class="status">
1648
+ <span class="dot green"></span> ${data.chunkCount.toLocaleString()} chunks indexed
1649
+ <span style="margin-left: 1rem;">
1650
+ <span class="dot ${data.embeddingStatus === "connected" ? "green" : "yellow"}"></span>
1651
+ Embeddings: ${data.embeddingStatus}
1652
+ </span>
1653
+ </p>
1654
+
1655
+ <div class="search-container">
1656
+ <input type="search" id="search-input" placeholder="Search conversations..." autocomplete="off" />
1657
+ <div id="search-results"></div>
1658
+ </div>
1659
+
1660
+ <h2>Projects</h2>
1661
+ <div class="projects">
1662
+ ${projectCards || "<p>No projects indexed yet.</p>"}
1663
+ </div>
1664
+
1665
+ <h2>Recent Conversations</h2>
1666
+ <ul class="recent-list">
1667
+ ${recentList || "<li>No conversations indexed yet.</li>"}
1668
+ </ul>
1669
+
1670
+ <script>
1671
+ const input = document.getElementById('search-input');
1672
+ const resultsContainer = document.getElementById('search-results');
1673
+ let debounceTimer;
1674
+
1675
+ input.addEventListener('input', () => {
1676
+ clearTimeout(debounceTimer);
1677
+ const q = input.value.trim();
1678
+ if (!q) {
1679
+ resultsContainer.classList.remove('visible');
1680
+ return;
1681
+ }
1682
+ debounceTimer = setTimeout(() => search(q), 200);
1683
+ });
1684
+
1685
+ async function search(q) {
1686
+ try {
1687
+ const res = await fetch('/api/search?q=' + encodeURIComponent(q) + '&limit=5');
1688
+ const data = await res.json();
1689
+
1690
+ // Clear previous results using safe DOM manipulation
1691
+ resultsContainer.textContent = '';
1692
+
1693
+ if (data.results && data.results.length > 0) {
1694
+ data.results.forEach(r => {
1695
+ const div = document.createElement('div');
1696
+ div.className = 'search-result';
1697
+ div.onclick = () => window.location.href = r.url;
1698
+
1699
+ const title = document.createElement('h4');
1700
+ title.textContent = r.title || 'Untitled';
1701
+ div.appendChild(title);
1702
+
1703
+ const snippet = document.createElement('p');
1704
+ snippet.className = 'snippet';
1705
+ // Safe: server escapes content before adding <strong> tags
1706
+ snippet.innerHTML = r.snippet;
1707
+ div.appendChild(snippet);
1708
+
1709
+ resultsContainer.appendChild(div);
1710
+ });
1711
+
1712
+ // Add "view all" link
1713
+ const viewAll = document.createElement('div');
1714
+ viewAll.className = 'search-result';
1715
+ viewAll.onclick = () => window.location.href = '/search?q=' + encodeURIComponent(q);
1716
+ const em = document.createElement('em');
1717
+ em.textContent = 'View all results for "' + q + '"';
1718
+ viewAll.appendChild(em);
1719
+ resultsContainer.appendChild(viewAll);
1720
+
1721
+ resultsContainer.classList.add('visible');
1722
+ } else {
1723
+ const noResults = document.createElement('div');
1724
+ noResults.className = 'search-result';
1725
+ const em = document.createElement('em');
1726
+ em.textContent = 'No results found';
1727
+ noResults.appendChild(em);
1728
+ resultsContainer.appendChild(noResults);
1729
+ resultsContainer.classList.add('visible');
1730
+ }
1731
+ } catch (err) {
1732
+ console.error('Search failed:', err);
1733
+ }
1734
+ }
1735
+ </script>
1736
+ </body>
1737
+ </html>`;
1738
+ }
1739
+ function renderSetupPage() {
1740
+ return `<!DOCTYPE html>
1741
+ <html lang="en">
1742
+ <head>
1743
+ <meta charset="UTF-8">
1744
+ <title>Claude Transcript Viewer - Setup</title>
1745
+ <style>
1746
+ body {
1747
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
1748
+ background: #1a1a2e;
1749
+ color: #eee;
1750
+ padding: 2rem;
1751
+ max-width: 800px;
1752
+ margin: 0 auto;
1753
+ }
1754
+ h1 { margin-bottom: 1rem; }
1755
+ pre {
1756
+ background: #16213e;
1757
+ padding: 1rem;
1758
+ border-radius: 8px;
1759
+ overflow-x: auto;
1760
+ }
1761
+ code { color: #e94560; }
1762
+ </style>
1763
+ </head>
1764
+ <body>
1765
+ <h1>Welcome to Claude Transcript Viewer</h1>
1766
+ <p>To get started, index your transcripts:</p>
1767
+ <pre><code>npm run index /path/to/transcripts ./search.db</code></pre>
1768
+ <p>Then restart the server with the database path:</p>
1769
+ <pre><code>DATABASE_PATH=./search.db npm run dev /path/to/archive</code></pre>
1770
+ </body>
1771
+ </html>`;
1772
+ }
1773
+ function escapeHtml(str) {
1774
+ return str
1775
+ .replace(/&/g, "&amp;")
1776
+ .replace(/</g, "&lt;")
1777
+ .replace(/>/g, "&gt;")
1778
+ .replace(/"/g, "&quot;")
1779
+ .replace(/'/g, "&#39;");
1780
+ }
1781
+ /**
1782
+ * Convert database project slug to archive directory name.
1783
+ * Uses the pre-built mapping from buildProjectMapping().
1784
+ */
1785
+ function projectToArchivePath(project) {
1786
+ // Check the mapping first
1787
+ const mapped = projectToArchiveMap.get(project);
1788
+ if (mapped) {
1789
+ return mapped;
1790
+ }
1791
+ // If it doesn't start with "-", it's already a simple name
1792
+ if (!project.startsWith("-")) {
1793
+ return project;
1794
+ }
1795
+ // Fallback: try suffix matching against archive directories
1796
+ // This handles cases where the mapping wasn't built yet
1797
+ const slug = project.replace(/^-/, "");
1798
+ try {
1799
+ const entries = readdirSync(ARCHIVE_DIR, { withFileTypes: true });
1800
+ let bestMatch;
1801
+ let bestMatchLen = 0;
1802
+ for (const entry of entries) {
1803
+ if (entry.isDirectory()) {
1804
+ const dir = entry.name;
1805
+ if (slug === dir || slug.endsWith(`-${dir}`)) {
1806
+ if (dir.length > bestMatchLen) {
1807
+ bestMatch = dir;
1808
+ bestMatchLen = dir.length;
1809
+ }
1810
+ }
1811
+ }
1812
+ }
1813
+ if (bestMatch) {
1814
+ // Cache for future use
1815
+ projectToArchiveMap.set(project, bestMatch);
1816
+ return bestMatch;
1817
+ }
1818
+ }
1819
+ catch {
1820
+ // Fall through to last-segment fallback
1821
+ }
1822
+ // Last resort: return the original project
1823
+ return project;
1824
+ }
1825
+ // Initialize search on startup
1826
+ initializeSearch();
1827
+ app.listen(PORT, () => {
1828
+ console.log(`
1829
+ Claude Transcript Viewer running at http://localhost:${PORT}
1830
+
1831
+ Serving archive from: ${ARCHIVE_DIR}
1832
+ Source directory: ${SOURCE_DIR || "(not configured)"}
1833
+
1834
+ Usage:
1835
+ npm run dev # Development mode with hot reload
1836
+ npm run dev -- /path/to/archive # Specify archive directory
1837
+ npm run dev -- /archive /source # Specify both archive and source directory
1838
+
1839
+ Open http://localhost:${PORT} to browse transcripts.
1840
+ `);
1841
+ // Start background archive generation and indexing after server is ready (non-blocking)
1842
+ if (SOURCE_DIR) {
1843
+ setTimeout(async () => {
1844
+ // First generate HTML archive from JSONL files
1845
+ await generateArchive();
1846
+ // Then index for search
1847
+ await startBackgroundIndexing();
1848
+ }, 1000);
1849
+ }
1850
+ });
1851
+ //# sourceMappingURL=server.js.map