@arcreflex/agent-transcripts 0.1.5 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,611 @@
1
+ /**
2
+ * Render index.html for browsing transcripts.
3
+ *
4
+ * Generates a standalone index page with:
5
+ * - Session list with titles and dates
6
+ * - Client-side filtering
7
+ * - Data embedded inline (works with file://)
8
+ */
9
+
10
+ import type { TranscriptsIndex } from "./utils/provenance.ts";
11
+ import { escapeHtml } from "./utils/html.ts";
12
+
13
+ // ============================================================================
14
+ // Styles - Terminal Chronicle Theme (Index)
15
+ // ============================================================================
16
+
17
+ const INDEX_STYLES = `
18
+ /* ============================================================================
19
+ Agent Transcripts Index - Terminal Chronicle Theme
20
+ ============================================================================ */
21
+
22
+ @import url('https://fonts.googleapis.com/css2?family=Berkeley+Mono:wght@400;500&family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500&display=swap');
23
+
24
+ :root {
25
+ /* Typography */
26
+ --font-mono: 'Berkeley Mono', 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', Consolas, monospace;
27
+ --font-body: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
28
+
29
+ /* Dark theme */
30
+ --bg: #0d0d0d;
31
+ --bg-elevated: #141414;
32
+ --bg-surface: #1a1a1a;
33
+ --fg: #e4e4e4;
34
+ --fg-secondary: #a3a3a3;
35
+ --muted: #666666;
36
+ --border: #2a2a2a;
37
+ --border-subtle: #222222;
38
+
39
+ /* Accent */
40
+ --accent: #f59e0b;
41
+ --accent-dim: #b45309;
42
+ --accent-glow: rgba(245, 158, 11, 0.15);
43
+
44
+ /* Cards */
45
+ --card-bg: var(--bg-elevated);
46
+ --card-hover: var(--bg-surface);
47
+ --card-border: var(--border);
48
+ --card-border-hover: #3a3a3a;
49
+
50
+ /* Links */
51
+ --link: #60a5fa;
52
+ --link-hover: #93c5fd;
53
+
54
+ /* Input */
55
+ --input-bg: var(--bg-elevated);
56
+ --input-border: var(--border);
57
+ --input-focus: var(--accent);
58
+
59
+ /* Effects */
60
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
61
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
62
+ }
63
+
64
+ @media (prefers-color-scheme: light) {
65
+ :root {
66
+ --bg: #fafafa;
67
+ --bg-elevated: #ffffff;
68
+ --bg-surface: #f5f5f5;
69
+ --fg: #171717;
70
+ --fg-secondary: #525252;
71
+ --muted: #a3a3a3;
72
+ --border: #e5e5e5;
73
+ --border-subtle: #f0f0f0;
74
+
75
+ --accent: #d97706;
76
+ --accent-dim: #92400e;
77
+ --accent-glow: rgba(217, 119, 6, 0.1);
78
+
79
+ --card-bg: var(--bg-elevated);
80
+ --card-hover: var(--bg-surface);
81
+ --card-border: var(--border);
82
+ --card-border-hover: #d4d4d4;
83
+
84
+ --link: #2563eb;
85
+ --link-hover: #1d4ed8;
86
+
87
+ --input-bg: var(--bg-elevated);
88
+ --input-border: var(--border);
89
+ --input-focus: var(--accent);
90
+
91
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
92
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
93
+ }
94
+ }
95
+
96
+ *, *::before, *::after { box-sizing: border-box; }
97
+
98
+ html {
99
+ font-size: 15px;
100
+ -webkit-font-smoothing: antialiased;
101
+ -moz-osx-font-smoothing: grayscale;
102
+ }
103
+
104
+ body {
105
+ font-family: var(--font-body);
106
+ background: var(--bg);
107
+ color: var(--fg);
108
+ line-height: 1.6;
109
+ margin: 0;
110
+ padding: 0;
111
+ min-height: 100vh;
112
+ }
113
+
114
+ .index-container {
115
+ max-width: 54rem;
116
+ margin: 0 auto;
117
+ padding: 2.5rem 2rem 4rem;
118
+ position: relative;
119
+ }
120
+
121
+ /* Subtle accent bar */
122
+ .index-container::before {
123
+ content: '';
124
+ position: fixed;
125
+ left: 0;
126
+ top: 0;
127
+ bottom: 0;
128
+ width: 2px;
129
+ background: linear-gradient(
130
+ 180deg,
131
+ transparent 0%,
132
+ var(--accent-dim) 15%,
133
+ var(--accent) 50%,
134
+ var(--accent-dim) 85%,
135
+ transparent 100%
136
+ );
137
+ opacity: 0.6;
138
+ }
139
+
140
+ a {
141
+ color: var(--link);
142
+ text-decoration: none;
143
+ transition: color 0.15s ease;
144
+ }
145
+
146
+ a:hover {
147
+ color: var(--link-hover);
148
+ }
149
+
150
+ /* ============================================================================
151
+ Header
152
+ ============================================================================ */
153
+
154
+ header {
155
+ margin-bottom: 2rem;
156
+ padding-bottom: 1.25rem;
157
+ border-bottom: 1px solid var(--border);
158
+ }
159
+
160
+ header h1 {
161
+ font-family: var(--font-mono);
162
+ font-weight: 500;
163
+ font-size: 1.25rem;
164
+ line-height: 1.4;
165
+ margin: 0 0 0.5rem 0;
166
+ color: var(--fg);
167
+ display: flex;
168
+ align-items: baseline;
169
+ gap: 0.5rem;
170
+ }
171
+
172
+ header h1::before {
173
+ content: '~';
174
+ color: var(--accent);
175
+ font-weight: 600;
176
+ }
177
+
178
+ .subtitle {
179
+ font-family: var(--font-mono);
180
+ color: var(--muted);
181
+ font-size: 0.75rem;
182
+ letter-spacing: 0.02em;
183
+ }
184
+
185
+ /* ============================================================================
186
+ Search
187
+ ============================================================================ */
188
+
189
+ .search-bar {
190
+ margin-bottom: 1.5rem;
191
+ }
192
+
193
+ .search-bar input {
194
+ width: 100%;
195
+ padding: 0.75rem 1rem;
196
+ font-family: var(--font-mono);
197
+ font-size: 0.8125rem;
198
+ border: 1px solid var(--input-border);
199
+ border-radius: 4px;
200
+ background: var(--input-bg);
201
+ color: var(--fg);
202
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
203
+ }
204
+
205
+ .search-bar input::placeholder {
206
+ color: var(--muted);
207
+ }
208
+
209
+ .search-bar input:focus {
210
+ outline: none;
211
+ border-color: var(--input-focus);
212
+ box-shadow: 0 0 0 2px var(--accent-glow);
213
+ }
214
+
215
+ /* ============================================================================
216
+ Session List
217
+ ============================================================================ */
218
+
219
+ .session-list {
220
+ list-style: none;
221
+ padding: 0;
222
+ margin: 0;
223
+ }
224
+
225
+ .session-item {
226
+ margin-bottom: 0.625rem;
227
+ }
228
+
229
+ .session-link {
230
+ display: block;
231
+ padding: 0.875rem 1rem;
232
+ background: var(--card-bg);
233
+ border: 1px solid var(--card-border);
234
+ border-radius: 4px;
235
+ transition: all 0.15s ease;
236
+ }
237
+
238
+ .session-link:hover {
239
+ background: var(--card-hover);
240
+ border-color: var(--card-border-hover);
241
+ text-decoration: none;
242
+ box-shadow: var(--shadow-sm);
243
+ }
244
+
245
+ .session-title {
246
+ font-family: var(--font-mono);
247
+ font-weight: 500;
248
+ font-size: 0.875rem;
249
+ margin-bottom: 0.25rem;
250
+ color: var(--fg);
251
+ display: flex;
252
+ align-items: baseline;
253
+ gap: 0.5rem;
254
+ }
255
+
256
+ .session-title::before {
257
+ content: '>';
258
+ color: var(--accent);
259
+ font-size: 0.75rem;
260
+ opacity: 0.7;
261
+ }
262
+
263
+ .session-meta {
264
+ font-family: var(--font-mono);
265
+ font-size: 0.6875rem;
266
+ color: var(--muted);
267
+ margin-left: 1rem;
268
+ }
269
+
270
+ .session-preview {
271
+ font-size: 0.8125rem;
272
+ color: var(--fg-secondary);
273
+ margin-top: 0.375rem;
274
+ margin-left: 1rem;
275
+ overflow: hidden;
276
+ text-overflow: ellipsis;
277
+ white-space: nowrap;
278
+ line-height: 1.5;
279
+ }
280
+
281
+ .no-results {
282
+ text-align: center;
283
+ font-family: var(--font-mono);
284
+ color: var(--muted);
285
+ padding: 3rem;
286
+ font-size: 0.875rem;
287
+ }
288
+
289
+ .hidden {
290
+ display: none;
291
+ }
292
+
293
+ /* ============================================================================
294
+ Session Groups
295
+ ============================================================================ */
296
+
297
+ .session-group {
298
+ margin-bottom: 1.5rem;
299
+ }
300
+
301
+ .group-header {
302
+ font-family: var(--font-mono);
303
+ font-size: 0.6875rem;
304
+ color: var(--muted);
305
+ padding: 0.5rem 0;
306
+ border-bottom: 1px solid var(--border-subtle);
307
+ margin-bottom: 0.5rem;
308
+ letter-spacing: 0.02em;
309
+ }
310
+
311
+ .group-sessions {
312
+ list-style: none;
313
+ padding: 0;
314
+ margin: 0;
315
+ }
316
+
317
+ /* ============================================================================
318
+ Scrollbar
319
+ ============================================================================ */
320
+
321
+ ::-webkit-scrollbar {
322
+ width: 6px;
323
+ height: 6px;
324
+ }
325
+
326
+ ::-webkit-scrollbar-track {
327
+ background: var(--border-subtle);
328
+ }
329
+
330
+ ::-webkit-scrollbar-thumb {
331
+ background: var(--muted);
332
+ border-radius: 3px;
333
+ }
334
+
335
+ ::-webkit-scrollbar-thumb:hover {
336
+ background: var(--fg-secondary);
337
+ }
338
+
339
+ /* ============================================================================
340
+ Responsive
341
+ ============================================================================ */
342
+
343
+ @media (max-width: 640px) {
344
+ html {
345
+ font-size: 14px;
346
+ }
347
+
348
+ .index-container {
349
+ padding: 1.5rem 1rem 3rem;
350
+ }
351
+
352
+ .index-container::before {
353
+ display: none;
354
+ }
355
+
356
+ header h1 {
357
+ font-size: 1.125rem;
358
+ }
359
+
360
+ .session-link {
361
+ padding: 0.75rem;
362
+ }
363
+ }
364
+ `;
365
+
366
+ // ============================================================================
367
+ // Client-side JavaScript
368
+ // ============================================================================
369
+
370
+ const INDEX_SCRIPT = `
371
+ (function() {
372
+ const searchInput = document.getElementById('search');
373
+ const sessionList = document.getElementById('sessions');
374
+ const items = sessionList.querySelectorAll('.session-item');
375
+ const groups = sessionList.querySelectorAll('.session-group');
376
+ const noResults = document.getElementById('no-results');
377
+
378
+ searchInput.addEventListener('input', function() {
379
+ const query = this.value.toLowerCase().trim();
380
+ let visibleCount = 0;
381
+
382
+ items.forEach(function(item) {
383
+ const title = item.dataset.title.toLowerCase();
384
+ const preview = item.dataset.preview.toLowerCase();
385
+ const matches = !query || title.includes(query) || preview.includes(query);
386
+
387
+ if (matches) {
388
+ item.classList.remove('hidden');
389
+ visibleCount++;
390
+ } else {
391
+ item.classList.add('hidden');
392
+ }
393
+ });
394
+
395
+ // Hide groups with no visible items
396
+ groups.forEach(function(group) {
397
+ const visibleItems = group.querySelectorAll('.session-item:not(.hidden)');
398
+ group.classList.toggle('hidden', visibleItems.length === 0);
399
+ });
400
+
401
+ if (visibleCount === 0 && query) {
402
+ noResults.classList.remove('hidden');
403
+ } else {
404
+ noResults.classList.add('hidden');
405
+ }
406
+ });
407
+ })();
408
+ `;
409
+
410
+ // ============================================================================
411
+ // Helpers
412
+ // ============================================================================
413
+
414
+ function truncate(text: string, maxLen: number): string {
415
+ if (text.length <= maxLen) return text;
416
+ return text.slice(0, maxLen).trim() + "...";
417
+ }
418
+
419
+ function formatDate(isoString: string): string {
420
+ try {
421
+ const date = new Date(isoString);
422
+ return date.toLocaleDateString("en-US", {
423
+ year: "numeric",
424
+ month: "short",
425
+ day: "numeric",
426
+ hour: "2-digit",
427
+ minute: "2-digit",
428
+ });
429
+ } catch {
430
+ return isoString;
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Format a date range compactly, e.g., "1/25 4:00-6:30"
436
+ */
437
+ function formatDateRange(startIso: string, endIso?: string): string {
438
+ try {
439
+ const start = new Date(startIso);
440
+ const end = endIso ? new Date(endIso) : start;
441
+
442
+ const month = start.getMonth() + 1;
443
+ const day = start.getDate();
444
+ const startTime = start.toLocaleTimeString("en-US", {
445
+ hour: "numeric",
446
+ minute: "2-digit",
447
+ hour12: false,
448
+ });
449
+
450
+ if (!endIso || start.getTime() === end.getTime()) {
451
+ return `${month}/${day} ${startTime}`;
452
+ }
453
+
454
+ const endTime = end.toLocaleTimeString("en-US", {
455
+ hour: "numeric",
456
+ minute: "2-digit",
457
+ hour12: false,
458
+ });
459
+
460
+ // If same day, just show time range
461
+ if (
462
+ start.getDate() === end.getDate() &&
463
+ start.getMonth() === end.getMonth()
464
+ ) {
465
+ return `${month}/${day} ${startTime}–${endTime}`;
466
+ }
467
+
468
+ // Different days
469
+ const endMonth = end.getMonth() + 1;
470
+ const endDay = end.getDate();
471
+ return `${month}/${day} ${startTime} – ${endMonth}/${endDay} ${endTime}`;
472
+ } catch {
473
+ return startIso;
474
+ }
475
+ }
476
+
477
+ export interface SessionEntry {
478
+ filename: string;
479
+ title: string;
480
+ firstUserMessage: string;
481
+ date: string; // ISO timestamp for sorting/display
482
+ endDate: string; // ISO timestamp for time range display
483
+ messageCount: number;
484
+ cwd?: string; // optional since not all adapters may provide it
485
+ }
486
+
487
+ // ============================================================================
488
+ // Main Renderer
489
+ // ============================================================================
490
+
491
+ export interface RenderIndexOptions {
492
+ title?: string;
493
+ }
494
+
495
+ /**
496
+ * Render index.html from a list of session entries.
497
+ */
498
+ export function renderIndexFromSessions(
499
+ sessions: SessionEntry[],
500
+ options: RenderIndexOptions = {},
501
+ ): string {
502
+ const { title = "Agent Transcripts" } = options;
503
+
504
+ // Filter out empty sessions and sort by date (newest first)
505
+ const filtered = sessions.filter((s) => s.messageCount > 0);
506
+ const sorted = [...filtered].sort(
507
+ (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
508
+ );
509
+
510
+ // Group by cwd
511
+ const groups = new Map<string, SessionEntry[]>();
512
+ for (const session of sorted) {
513
+ const key = session.cwd || "(unknown)";
514
+ const group = groups.get(key) || [];
515
+ group.push(session);
516
+ groups.set(key, group);
517
+ }
518
+
519
+ // Sort groups by most recent session
520
+ const sortedGroups = [...groups.entries()].sort((a, b) => {
521
+ const aDate = new Date(a[1][0].date).getTime();
522
+ const bDate = new Date(b[1][0].date).getTime();
523
+ return bDate - aDate;
524
+ });
525
+
526
+ // Build grouped session list HTML
527
+ const groupsHtml = sortedGroups
528
+ .map(([cwd, groupSessions]) => {
529
+ const sessionsHtml = groupSessions
530
+ .map((session) => {
531
+ const preview = truncate(session.firstUserMessage, 120);
532
+ const dateRange = formatDateRange(session.date, session.endDate);
533
+ const msgCount = session.messageCount ?? "?";
534
+ return `
535
+ <li class="session-item" data-title="${escapeHtml(session.title)}" data-preview="${escapeHtml(session.firstUserMessage)}" data-cwd="${escapeHtml(cwd)}">
536
+ <a href="${escapeHtml(session.filename)}" class="session-link">
537
+ <div class="session-title">${escapeHtml(session.title)}</div>
538
+ <div class="session-meta">${msgCount} msgs · ${escapeHtml(dateRange)}</div>
539
+ ${preview ? `<div class="session-preview">${escapeHtml(preview)}</div>` : ""}
540
+ </a>
541
+ </li>`;
542
+ })
543
+ .join("");
544
+
545
+ const cwdDisplay = cwd === "(unknown)" ? cwd : cwd.replace(/^\//, "");
546
+ return `
547
+ <li class="session-group" data-cwd="${escapeHtml(cwd)}">
548
+ <div class="group-header">${escapeHtml(cwdDisplay)}</div>
549
+ <ul class="group-sessions">
550
+ ${sessionsHtml}
551
+ </ul>
552
+ </li>`;
553
+ })
554
+ .join("");
555
+
556
+ return `<!DOCTYPE html>
557
+ <html lang="en">
558
+ <head>
559
+ <meta charset="UTF-8">
560
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
561
+ <title>${escapeHtml(title)}</title>
562
+ <style>${INDEX_STYLES}</style>
563
+ </head>
564
+ <body>
565
+ <div class="index-container">
566
+ <header>
567
+ <h1>${escapeHtml(title)}</h1>
568
+ <p class="subtitle">${sorted.length} session${sorted.length !== 1 ? "s" : ""}</p>
569
+ </header>
570
+
571
+ <div class="search-bar">
572
+ <input type="text" id="search" placeholder="/ filter sessions..." autocomplete="off">
573
+ </div>
574
+
575
+ <ul id="sessions" class="session-list">
576
+ ${groupsHtml}
577
+ </ul>
578
+
579
+ <div id="no-results" class="no-results hidden">
580
+ No matching sessions found.
581
+ </div>
582
+ </div>
583
+
584
+ <script>${INDEX_SCRIPT}</script>
585
+ </body>
586
+ </html>`;
587
+ }
588
+
589
+ /**
590
+ * Render index.html from transcripts.json data.
591
+ * Convenience wrapper around renderIndexFromSessions.
592
+ */
593
+ export function renderIndex(
594
+ index: TranscriptsIndex,
595
+ options: RenderIndexOptions = {},
596
+ ): string {
597
+ const sessions: SessionEntry[] = Object.entries(index.entries)
598
+ .filter(([filename]) => filename.endsWith(".html"))
599
+ .map(([filename, entry]) => ({
600
+ filename,
601
+ title:
602
+ entry.title || truncate(entry.firstUserMessage, 80) || entry.sessionId,
603
+ firstUserMessage: entry.firstUserMessage,
604
+ date: entry.startTime,
605
+ endDate: entry.endTime,
606
+ messageCount: entry.messageCount,
607
+ cwd: entry.cwd,
608
+ }));
609
+
610
+ return renderIndexFromSessions(sessions, options);
611
+ }