diff --git a/src/web/frontend/src/components/SessionDigestPanel.tsx b/src/web/frontend/src/components/SessionDigestPanel.tsx
new file mode 100644
index 0000000..d61a53c
--- /dev/null
+++ b/src/web/frontend/src/components/SessionDigestPanel.tsx
@@ -0,0 +1,530 @@
+import React, { useState, useCallback } from 'react';
+import {
+ SessionDigestData,
+ DigestTab,
+ DigestBeadCompletion,
+ DigestFileModification,
+ DigestErrorOccurrence,
+ DigestWorkerSummary,
+ ErrorCategory,
+} from '../types';
+
+interface SessionDigestPanelProps {
+ visible: boolean;
+ onClose: () => void;
+}
+
+function formatDuration(ms: number): string {
+ if (ms < 1000) return `${ms}ms`;
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
+ if (ms < 3600000) {
+ const mins = Math.floor(ms / 60000);
+ const secs = Math.floor((ms % 60000) / 1000);
+ return `${mins}m ${secs}s`;
+ }
+ const hours = Math.floor(ms / 3600000);
+ const mins = Math.floor((ms % 3600000) / 60000);
+ return `${hours}h ${mins}m`;
+}
+
+function formatAsMarkdown(d: SessionDigestData): string {
+ const lines: string[] = [];
+
+ lines.push('# Session Digest');
+ lines.push('');
+ lines.push(`**Session ID:** ${d.sessionId}`);
+ lines.push(`**Start Time:** ${new Date(d.startTime).toLocaleString()}`);
+ lines.push(`**End Time:** ${new Date(d.endTime).toLocaleString()}`);
+ lines.push(`**Duration:** ${formatDuration(d.durationMs)}`);
+ lines.push('');
+
+ lines.push('## Statistics');
+ lines.push('');
+ lines.push('| Metric | Value |');
+ lines.push('|--------|-------|');
+ lines.push(`| Total Events | ${d.stats.totalEvents} |`);
+ lines.push(`| Total Workers | ${d.stats.totalWorkers} |`);
+ lines.push(`| Total Beads | ${d.stats.totalBeads} |`);
+ lines.push(`| Total Files | ${d.stats.totalFiles} |`);
+ lines.push(`| Total Errors | ${d.stats.totalErrors} |`);
+ lines.push('');
+
+ if (d.cost.totalTokens > 0) {
+ lines.push('## Cost Breakdown');
+ lines.push('');
+ lines.push(`- **Input Tokens:** ${d.cost.inputTokens.toLocaleString()}`);
+ lines.push(`- **Output Tokens:** ${d.cost.outputTokens.toLocaleString()}`);
+ lines.push(`- **Total Tokens:** ${d.cost.totalTokens.toLocaleString()}`);
+ lines.push(`- **Estimated Cost:** $${d.cost.estimatedCostUsd.toFixed(4)}`);
+ lines.push('');
+ }
+
+ lines.push('## Completed Beads');
+ lines.push('');
+ if (d.beadsCompleted.length === 0) {
+ lines.push('_No beads completed_');
+ } else {
+ lines.push('| Bead ID | Worker | Completed At | Duration |');
+ lines.push('|---------|--------|--------------|----------|');
+ for (const bead of d.beadsCompleted) {
+ const time = new Date(bead.completedAt).toLocaleString();
+ const duration = bead.durationMs ? formatDuration(bead.durationMs) : '-';
+ lines.push(`| ${bead.beadId} | ${bead.workerId.slice(0, 8)} | ${time} | ${duration} |`);
+ }
+ }
+ lines.push('');
+
+ lines.push('## Files Modified');
+ lines.push('');
+ if (d.filesModified.length === 0) {
+ lines.push('_No files modified_');
+ } else {
+ lines.push('| Path | Modifications | Workers |');
+ lines.push('|------|---------------|---------|');
+ for (const file of d.filesModified) {
+ lines.push(`| \`${file.path}\` | ${file.modifications} | ${file.workers.length} |`);
+ }
+ }
+ lines.push('');
+
+ lines.push('## Errors');
+ lines.push('');
+ if (d.errors.length === 0) {
+ lines.push('_No errors encountered_');
+ } else {
+ lines.push('| Time | Category | Worker | Message |');
+ lines.push('|------|----------|--------|---------|');
+ for (const err of d.errors) {
+ const time = new Date(err.timestamp).toLocaleTimeString();
+ const msg = err.message.slice(0, 50).replace(/\n/g, ' ');
+ lines.push(`| ${time} | ${err.category} | ${err.workerId.slice(0, 8)} | ${msg} |`);
+ }
+ }
+ lines.push('');
+
+ lines.push('## Worker Summary');
+ lines.push('');
+ lines.push('| Worker ID | Beads | Files | Errors | Active Time |');
+ lines.push('|-----------|-------|-------|--------|-------------|');
+ for (const worker of d.workers) {
+ lines.push(`| ${worker.workerId.slice(0, 8)} | ${worker.beadsCompleted} | ${worker.filesModified} | ${worker.errorsEncountered} | ${formatDuration(worker.activeTimeMs)} |`);
+ }
+ lines.push('');
+
+ lines.push('---');
+ lines.push(`*Generated by FABRIC at ${new Date().toLocaleString()}*`);
+
+ return lines.join('\n');
+}
+
+function formatAsText(d: SessionDigestData): string {
+ const lines: string[] = [];
+
+ lines.push('SESSION DIGEST');
+ lines.push('='.repeat(50));
+ lines.push('');
+ lines.push(`Session ID: ${d.sessionId}`);
+ lines.push(`Start Time: ${new Date(d.startTime).toLocaleString()}`);
+ lines.push(`End Time: ${new Date(d.endTime).toLocaleString()}`);
+ lines.push(`Duration: ${formatDuration(d.durationMs)}`);
+ lines.push('');
+
+ lines.push('STATISTICS');
+ lines.push('-'.repeat(30));
+ lines.push(`Total Events: ${d.stats.totalEvents}`);
+ lines.push(`Total Workers: ${d.stats.totalWorkers}`);
+ lines.push(`Total Beads: ${d.stats.totalBeads}`);
+ lines.push(`Total Files: ${d.stats.totalFiles}`);
+ lines.push(`Total Errors: ${d.stats.totalErrors}`);
+ lines.push(`Avg Events/Worker: ${d.stats.avgEventsPerWorker.toFixed(1)}`);
+ lines.push(`Avg Beads/Worker: ${d.stats.avgBeadsPerWorker.toFixed(1)}`);
+ lines.push('');
+
+ if (d.cost.totalTokens > 0) {
+ lines.push('COST BREAKDOWN');
+ lines.push('-'.repeat(30));
+ lines.push(`Input Tokens: ${d.cost.inputTokens.toLocaleString()}`);
+ lines.push(`Output Tokens: ${d.cost.outputTokens.toLocaleString()}`);
+ lines.push(`Total Tokens: ${d.cost.totalTokens.toLocaleString()}`);
+ lines.push(`Estimated Cost: $${d.cost.estimatedCostUsd.toFixed(4)}`);
+ lines.push('');
+ }
+
+ lines.push('COMPLETED BEADS');
+ lines.push('-'.repeat(30));
+ for (const bead of d.beadsCompleted) {
+ const time = new Date(bead.completedAt).toLocaleString();
+ const duration = bead.durationMs ? ` (${formatDuration(bead.durationMs)})` : '';
+ lines.push(`${bead.beadId} by ${bead.workerId.slice(0, 8)} at ${time}${duration}`);
+ }
+ if (d.beadsCompleted.length === 0) lines.push('No beads completed');
+ lines.push('');
+
+ lines.push('FILES MODIFIED');
+ lines.push('-'.repeat(30));
+ for (const file of d.filesModified) {
+ lines.push(`${file.path} (${file.modifications} mods by ${file.workers.length} workers)`);
+ }
+ if (d.filesModified.length === 0) lines.push('No files modified');
+ lines.push('');
+
+ lines.push('ERRORS');
+ lines.push('-'.repeat(30));
+ for (const err of d.errors) {
+ const time = new Date(err.timestamp).toLocaleTimeString();
+ lines.push(`[${err.category.toUpperCase()}] ${time} ${err.workerId.slice(0, 8)}: ${err.message.slice(0, 100)}`);
+ }
+ if (d.errors.length === 0) lines.push('No errors encountered');
+ lines.push('');
+
+ lines.push('WORKER SUMMARY');
+ lines.push('-'.repeat(30));
+ for (const worker of d.workers) {
+ lines.push(`${worker.workerId}`);
+ lines.push(` Beads: ${worker.beadsCompleted} Files: ${worker.filesModified} Errors: ${worker.errorsEncountered} Events: ${worker.totalEvents}`);
+ lines.push(` Active: ${formatDuration(worker.activeTimeMs)}`);
+ }
+ if (d.workers.length === 0) lines.push('No workers');
+ lines.push('');
+
+ lines.push('---');
+ lines.push(`Generated by FABRIC at ${new Date().toLocaleString()}`);
+
+ return lines.join('\n');
+}
+
+function downloadFile(content: string, filename: string, mimeType: string) {
+ const blob = new Blob([content], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
+
+// ── Tab content renderers ────────────────────────────────────────────────────
+
+const SummaryTab: React.FC<{ d: SessionDigestData }> = ({ d }) => (
+
+
+
Session Info
+
+
+ | Session ID | {d.sessionId} |
+ | Start Time | {new Date(d.startTime).toLocaleString()} |
+ | End Time | {new Date(d.endTime).toLocaleString()} |
+ | Duration | {formatDuration(d.durationMs)} |
+
+
+
+
+
+
Statistics
+
+
+ | Total Events | {d.stats.totalEvents.toLocaleString()} |
+ | Total Workers | {d.stats.totalWorkers} |
+ | Total Beads | {d.stats.totalBeads} |
+ | Total Files | {d.stats.totalFiles} |
+ | Total Errors | {d.stats.totalErrors} |
+ | Avg Events/Worker | {d.stats.avgEventsPerWorker.toFixed(1)} |
+ | Avg Beads/Worker | {d.stats.avgBeadsPerWorker.toFixed(1)} |
+
+
+
+
+ {d.cost.totalTokens > 0 && (
+
+
Cost Breakdown
+
+
+ | Input Tokens | {d.cost.inputTokens.toLocaleString()} |
+ | Output Tokens | {d.cost.outputTokens.toLocaleString()} |
+ | Total Tokens | {d.cost.totalTokens.toLocaleString()} |
+ | Estimated Cost | ${d.cost.estimatedCostUsd.toFixed(4)} |
+
+
+
+ )}
+
+
+
Completed Work
+
+ {d.beadsCompleted.length} beads
+ {d.filesModified.length} files
+ {d.workers.length} workers
+ {d.errors.length > 0 && {d.errors.length} errors}
+
+
+
+);
+
+const BeadsTab: React.FC<{ beads: DigestBeadCompletion[] }> = ({ beads }) => {
+ if (beads.length === 0) {
+ return
No beads completed in this session
;
+ }
+ const sorted = [...beads].sort((a, b) => b.completedAt - a.completedAt);
+ return (
+
+
+
+
+ | Bead ID |
+ Worker |
+ Completed At |
+ Duration |
+
+
+
+ {sorted.map((bead, i) => (
+
+ | {bead.beadId} |
+ {bead.workerId.slice(0, 8)} |
+ {new Date(bead.completedAt).toLocaleTimeString()} |
+ {bead.durationMs ? formatDuration(bead.durationMs) : '-'} |
+
+ ))}
+
+
+
+ );
+};
+
+const FilesTab: React.FC<{ files: DigestFileModification[] }> = ({ files }) => {
+ if (files.length === 0) {
+ return
No files modified in this session
;
+ }
+ const sorted = [...files].sort((a, b) => b.modifications - a.modifications);
+ return (
+
+
+
+
+ | Path |
+ Mods |
+ Workers |
+ Tools |
+
+
+
+ {sorted.map((file, i) => {
+ const heat = file.modifications >= 10 ? 'digest-red'
+ : file.modifications >= 5 ? 'digest-yellow'
+ : file.modifications >= 3 ? 'digest-cyan'
+ : 'digest-green';
+ return (
+
+ | {file.path} |
+ {file.modifications} |
+ {file.workers.length} |
+ {file.tools.join(', ')} |
+
+ );
+ })}
+
+
+
+ );
+};
+
+const ErrorsTab: React.FC<{ errors: DigestErrorOccurrence[] }> = ({ errors }) => {
+ if (errors.length === 0) {
+ return
No errors encountered in this session
;
+ }
+ const sorted = [...errors].sort((a, b) => b.timestamp - a.timestamp);
+ return (
+
+ {sorted.map((err, i) => (
+
+
+ [{err.category.toUpperCase()}]
+ {new Date(err.timestamp).toLocaleTimeString()}
+ {err.workerId.slice(0, 8)}
+
+
+ {err.message.slice(0, 200)}{err.message.length > 200 ? '...' : ''}
+
+ {err.fingerprint && (
+
fp: {err.fingerprint}
+ )}
+
+ ))}
+
+ );
+};
+
+const WorkersTab: React.FC<{ workers: DigestWorkerSummary[] }> = ({ workers }) => {
+ if (workers.length === 0) {
+ return
No workers in this session
;
+ }
+ const sorted = [...workers].sort((a, b) => b.beadsCompleted - a.beadsCompleted);
+ return (
+
+ {sorted.map((worker, i) => (
+
+
{worker.workerId}
+
+ Beads: {worker.beadsCompleted}
+ Files: {worker.filesModified}
+ Errors: {worker.errorsEncountered}
+ Events: {worker.totalEvents}
+ Active: {formatDuration(worker.activeTimeMs)}
+
+
+
+ {new Date(worker.firstActivity).toLocaleTimeString()} – {new Date(worker.lastActivity).toLocaleTimeString()}
+
+
+
+ ))}
+
+ );
+};
+
+// ── Main Component ────────────────────────────────────────────────────────────
+
+const TABS: Array<{ key: DigestTab; label: string }> = [
+ { key: 'summary', label: 'Summary' },
+ { key: 'beads', label: 'Beads' },
+ { key: 'files', label: 'Files' },
+ { key: 'errors', label: 'Errors' },
+ { key: 'workers', label: 'Workers' },
+];
+
+const SessionDigestPanel: React.FC
= ({ visible, onClose }) => {
+ const [digest, setDigest] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [activeTab, setActiveTab] = useState('summary');
+ const [exportStatus, setExportStatus] = useState(null);
+
+ const generateDigest = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch('/api/digest');
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data: SessionDigestData = await res.json();
+ setDigest(data);
+ setActiveTab('summary');
+ } catch (err) {
+ setError(String(err));
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const handleExport = useCallback((format: 'json' | 'markdown' | 'text') => {
+ if (!digest) return;
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const base = `session-digest-${timestamp}`;
+
+ let content: string;
+ let filename: string;
+ let mime: string;
+
+ switch (format) {
+ case 'json':
+ content = JSON.stringify(digest, null, 2);
+ filename = `${base}.json`;
+ mime = 'application/json';
+ break;
+ case 'markdown':
+ content = formatAsMarkdown(digest);
+ filename = `${base}.md`;
+ mime = 'text/markdown';
+ break;
+ case 'text':
+ content = formatAsText(digest);
+ filename = `${base}.txt`;
+ mime = 'text/plain';
+ break;
+ }
+
+ downloadFile(content, filename, mime);
+ setExportStatus(`Exported ${filename}`);
+ setTimeout(() => setExportStatus(null), 3000);
+ }, [digest]);
+
+ if (!visible) return null;
+
+ return (
+
+
+
Session Digest
+
+
+
+
+
+
+ {error && (
+
Failed to generate digest: {error}
+ )}
+
+ {!digest && !loading && !error && (
+
+
Click Generate Digest to summarize the current session.
+
+ )}
+
+ {digest && (
+ <>
+
+ Session: {digest.sessionId.slice(0, 20)}…
+ Duration: {formatDuration(digest.durationMs)}
+ Events: {digest.stats.totalEvents.toLocaleString()}
+ Workers: {digest.stats.totalWorkers}
+
+
+
+ {TABS.map(tab => (
+
+ ))}
+
+
+
+ {activeTab === 'summary' && }
+ {activeTab === 'beads' && }
+ {activeTab === 'files' && }
+ {activeTab === 'errors' && }
+ {activeTab === 'workers' && }
+
+
+
+
+ Export:
+
+
+
+
+ {exportStatus &&
{exportStatus}}
+
+ >
+ )}
+
+ );
+};
+
+export default SessionDigestPanel;
diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css
index fee05e2..db73f0f 100644
--- a/src/web/frontend/src/index.css
+++ b/src/web/frontend/src/index.css
@@ -1131,6 +1131,12 @@ body {
border-radius: 2px;
}
+.conversation-turn-activity-highlight {
+ background: rgba(33, 150, 243, 0.12);
+ outline: 2px solid rgba(33, 150, 243, 0.4);
+ transition: background 0.3s, outline-color 0.3s;
+}
+
/* ============================================
Session Replay Component Styles
============================================ */
@@ -6918,3 +6924,371 @@ body {
font-weight: 600;
}
+/* ── Session Digest ──────────────────────────────────────────────────────── */
+
+.digest-toggle {
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+ padding: 0.3rem 0.6rem;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ cursor: pointer;
+ color: var(--text-secondary);
+ transition: all 0.2s;
+}
+
+.digest-toggle:hover,
+.digest-toggle.active {
+ border-color: var(--info);
+ color: var(--info);
+}
+
+.digest-toggle-icon { font-size: 0.9rem; }
+.digest-toggle-label { font-size: 0.78rem; }
+
+.digest-panel {
+ grid-column: 1 / -1;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ max-height: 60vh;
+ animation: fadeIn 0.15s ease-out;
+}
+
+.digest-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ background: var(--bg-tertiary);
+ border-bottom: 1px solid var(--border-color);
+ flex-shrink: 0;
+}
+
+.digest-header h3 {
+ font-size: 0.9375rem;
+ font-weight: 600;
+ margin: 0;
+ color: var(--text-primary);
+}
+
+.digest-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.digest-generate-btn {
+ padding: 0.3rem 0.75rem;
+ background: var(--info);
+ color: #fff;
+ border: none;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.2s;
+}
+
+.digest-generate-btn:hover:not(:disabled) { opacity: 0.85; }
+.digest-generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+
+.digest-fetch-error {
+ padding: 0.75rem 1rem;
+ color: var(--error);
+ font-size: 0.85rem;
+ background: rgba(var(--error-rgb, 239,68,68), 0.08);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.digest-placeholder {
+ padding: 2rem;
+ text-align: center;
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+.digest-meta {
+ display: flex;
+ gap: 1.25rem;
+ padding: 0.5rem 1rem;
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ background: var(--bg-primary);
+ border-bottom: 1px solid var(--border-color);
+ flex-shrink: 0;
+ flex-wrap: wrap;
+}
+
+.digest-tabs {
+ display: flex;
+ gap: 0;
+ border-bottom: 1px solid var(--border-color);
+ flex-shrink: 0;
+}
+
+.digest-tab {
+ padding: 0.5rem 1rem;
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--text-secondary);
+ font-size: 0.82rem;
+ cursor: pointer;
+ transition: all 0.15s;
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.digest-tab:hover { color: var(--text-primary); }
+.digest-tab.active {
+ color: var(--info);
+ border-bottom-color: var(--info);
+ font-weight: 600;
+}
+
+.digest-tab-badge {
+ background: var(--error);
+ color: #fff;
+ font-size: 0.65rem;
+ padding: 0.1rem 0.35rem;
+ border-radius: 8px;
+ font-weight: 700;
+}
+
+.digest-tab-badge-green {
+ background: var(--success);
+}
+
+.digest-content {
+ flex: 1;
+ overflow-y: auto;
+}
+
+.digest-tab-content {
+ padding: 1rem;
+}
+
+.digest-section {
+ margin-bottom: 1.25rem;
+}
+
+.digest-section h4 {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin: 0 0 0.5rem 0;
+}
+
+.digest-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.85rem;
+}
+
+.digest-table td {
+ padding: 0.25rem 0.5rem;
+ color: var(--text-secondary);
+}
+
+.digest-table td:first-child {
+ width: 45%;
+ color: var(--text-muted, var(--text-secondary));
+ font-weight: 500;
+}
+
+.digest-value { color: var(--text-primary) !important; }
+.digest-error { color: var(--error) !important; }
+.digest-cost { color: var(--success) !important; }
+.digest-green { color: var(--success) !important; }
+.digest-cyan { color: var(--info) !important; }
+.digest-yellow { color: var(--warning) !important; }
+.digest-red { color: var(--error) !important; }
+.digest-purple { color: #a78bfa !important; }
+.digest-muted { color: var(--text-secondary) !important; }
+.digest-mono { font-family: monospace; font-size: 0.8rem; }
+.digest-success { color: var(--success) !important; }
+
+.digest-summary-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.digest-badge {
+ padding: 0.2rem 0.6rem;
+ border-radius: 12px;
+ font-size: 0.78rem;
+ font-weight: 600;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ color: var(--text-secondary);
+}
+
+.digest-badge-green { border-color: var(--success); color: var(--success); }
+.digest-badge-cyan { border-color: var(--info); color: var(--info); }
+.digest-badge-red { border-color: var(--error); color: var(--error); }
+.digest-badge-default { border-color: var(--border-color); }
+
+.digest-data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.82rem;
+}
+
+.digest-data-table th {
+ text-align: left;
+ padding: 0.4rem 0.6rem;
+ color: var(--text-secondary);
+ font-weight: 600;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.digest-data-table td {
+ padding: 0.35rem 0.6rem;
+ color: var(--text-primary);
+ border-bottom: 1px solid var(--border-color);
+ vertical-align: top;
+}
+
+.digest-data-table tr:last-child td { border-bottom: none; }
+.digest-data-table tr:hover td { background: var(--hover-bg); }
+
+.digest-path {
+ max-width: 300px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.digest-count { font-weight: 700; }
+
+.digest-error-card {
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-left: 3px solid var(--error);
+ border-radius: 4px;
+ padding: 0.6rem 0.75rem;
+ margin-bottom: 0.5rem;
+}
+
+.digest-error-header {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+ margin-bottom: 0.4rem;
+ font-size: 0.8rem;
+}
+
+.digest-error-category {
+ color: var(--error);
+ font-weight: 700;
+ font-size: 0.75rem;
+}
+
+.digest-error-message {
+ font-size: 0.83rem;
+ color: var(--text-primary);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.digest-fingerprint {
+ font-size: 0.72rem;
+ color: var(--text-secondary);
+ margin-top: 0.25rem;
+ font-family: monospace;
+}
+
+.digest-empty {
+ padding: 2rem;
+ text-align: center;
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+.digest-worker-card {
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ padding: 0.6rem 0.75rem;
+ margin-bottom: 0.5rem;
+}
+
+.digest-worker-id {
+ font-family: monospace;
+ font-size: 0.82rem;
+ color: var(--info);
+ font-weight: 600;
+ margin-bottom: 0.35rem;
+}
+
+.digest-worker-stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ font-size: 0.82rem;
+ color: var(--text-secondary);
+ margin-bottom: 0.25rem;
+}
+
+.digest-worker-time {
+ font-size: 0.75rem;
+}
+
+.digest-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 1rem;
+ border-top: 1px solid var(--border-color);
+ background: var(--bg-tertiary);
+ flex-shrink: 0;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.digest-export-buttons {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.digest-export-label {
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ margin-right: 0.25rem;
+}
+
+.digest-export-btn {
+ padding: 0.25rem 0.6rem;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.digest-export-btn:hover {
+ border-color: var(--info);
+ color: var(--info);
+}
+
+.digest-export-status {
+ font-size: 0.78rem;
+ color: var(--success);
+}
+
diff --git a/src/web/frontend/src/types.ts b/src/web/frontend/src/types.ts
index 918cb6b..9454cff 100644
--- a/src/web/frontend/src/types.ts
+++ b/src/web/frontend/src/types.ts
@@ -515,6 +515,71 @@ export interface SimilarError {
// Conversation Transcript Types
// ============================================
+// ============================================
+// Session Digest Types
+// ============================================
+
+export interface DigestBeadCompletion {
+ beadId: string;
+ workerId: string;
+ completedAt: number;
+ durationMs?: number;
+}
+
+export interface DigestFileModification {
+ path: string;
+ modifications: number;
+ workers: string[];
+ tools: string[];
+}
+
+export interface DigestErrorOccurrence {
+ message: string;
+ category: ErrorCategory;
+ workerId: string;
+ timestamp: number;
+ fingerprint?: string;
+}
+
+export interface DigestWorkerSummary {
+ workerId: string;
+ beadsCompleted: number;
+ filesModified: number;
+ errorsEncountered: number;
+ totalEvents: number;
+ activeTimeMs: number;
+ firstActivity: number;
+ lastActivity: number;
+}
+
+export interface SessionDigestData {
+ sessionId: string;
+ startTime: number;
+ endTime: number;
+ durationMs: number;
+ beadsCompleted: DigestBeadCompletion[];
+ filesModified: DigestFileModification[];
+ errors: DigestErrorOccurrence[];
+ workers: DigestWorkerSummary[];
+ cost: {
+ totalTokens: number;
+ inputTokens: number;
+ outputTokens: number;
+ estimatedCostUsd: number;
+ };
+ stats: {
+ totalEvents: number;
+ totalWorkers: number;
+ totalBeads: number;
+ totalFiles: number;
+ totalErrors: number;
+ avgEventsPerWorker: number;
+ avgBeadsPerWorker: number;
+ };
+}
+
+export type DigestTab = 'summary' | 'beads' | 'files' | 'errors' | 'workers';
+
export type ConversationTurnRole = 'system' | 'user' | 'assistant' | 'tool';
export interface ConversationTurn {
diff --git a/src/web/server.ts b/src/web/server.ts
index 9d82801..da0fb18 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -13,11 +13,13 @@ import { createSocket } from 'dgram';
import { WebSocketServer, WebSocket } from 'ws';
import { LogEvent, EventFilter, CrossReferenceEntityType, CrossReferenceRelationship, DagOptions, BeadStatus } from '../types.js';
import { InMemoryEventStore } from '../store.js';
+import { SemanticNarrativeGenerator } from '../semanticNarrative.js';
import { refreshDependencyGraph, getDagStats } from '../tui/dagUtils.js';
import { normalizeToLogEvent, EventDeduplicator } from '../normalizer.js';
import { computeFleetAnalytics } from '../analytics.js';
import { createOtlpHttpRouter } from '../otlpHttpReceiver.js';
import { ServerMetrics } from '../serverMetrics.js';
+import { SessionDigestGenerator, formatDigestAsMarkdown } from '../sessionDigest.js';
/** Maximum payload size for POST requests (64KB) */
const MAX_PAYLOAD_SIZE = 64 * 1024;
@@ -731,6 +733,19 @@ export function createWebServer(options: WebServerOptions): WebServer {
}
});
+ app.get('/api/digest', (req: Request, res: Response) => {
+ try {
+ const generator = new SessionDigestGenerator(store);
+ const opts: Record = {};
+ if (req.query.startTime) opts.startTime = Number(req.query.startTime);
+ if (req.query.endTime) opts.endTime = Number(req.query.endTime);
+ const digest = generator.generateDigest(opts);
+ res.json(digest);
+ } catch (err) {
+ res.status(500).json({ error: String(err) });
+ }
+ });
+
// Serve static frontend files
const staticPath = join(__dirname, 'public');
app.use(express.static(staticPath));