+
-
+
+
+
+
+
+
+
+
+ ...
+
+
+
+
{{ error }}
+
+
+
+ Sin mensajes en esta sesión
+
+
+
+
-
- {{ entry.role === 'user' ? 'Tú' : agent.uiConfig?.shortLabel || 'AG' }}
+
+ {{ msg.role === 'user' ? 'Tú' : agent.uiConfig?.shortLabel || 'AG' }}
+
+ {{ formatSessionTime(msg.timestamp) }}
+
+ {{ msg.toolCalls.length }} tools
- {{ entry.timestamp }}
-
-
-
{{ entry.content }}
+
{{ truncateContent(msg.content) }}
+
+
+
+ {{ shortModel(stats.model) }}
+ ·
+ {{ formatDuration(stats.duration) }}
+ ·
+ {{ formatTokens(stats.totalInput + stats.totalOutput) }}
+ ·
+ {{ stats.toolCallCount }} tools
+
@@ -115,8 +235,53 @@ const mockEntries: ConversationEntry[] = [
border-radius: 8px;
}
+/* Session Pills */
+.session-pills {
+ display: flex;
+ gap: 6px;
+ overflow-x: auto;
+ padding-bottom: 8px;
+ margin-bottom: 8px;
+ scrollbar-width: none;
+}
+
+.session-pills::-webkit-scrollbar {
+ display: none;
+}
+
+.session-pill {
+ flex-shrink: 0;
+ font-size: 10px;
+ font-weight: 600;
+ padding: 3px 8px;
+ border-radius: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.04);
+ color: rgba(255, 255, 255, 0.5);
+ cursor: pointer;
+ transition: all 0.15s ease;
+ white-space: nowrap;
+}
+
+.session-pill:hover {
+ background: rgba(255, 255, 255, 0.08);
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.session-pill.active {
+ background: rgba(139, 92, 246, 0.15);
+ border-color: rgba(139, 92, 246, 0.3);
+ color: rgba(139, 92, 246, 0.9);
+}
+
+.pill-count {
+ opacity: 0.6;
+ margin-left: 2px;
+}
+
+/* Message List */
.history-list {
- max-height: 300px;
+ max-height: 220px;
overflow-y: auto;
display: flex;
flex-direction: column;
@@ -168,8 +333,13 @@ const mockEntries: ConversationEntry[] = [
color: rgba(255, 255, 255, 0.25);
}
-.method-icon {
- color: rgba(255, 255, 255, 0.25);
+.tool-badge {
+ font-size: 9px;
+ font-weight: 600;
+ padding: 1px 5px;
+ border-radius: 4px;
+ color: rgba(245, 158, 11, 0.9);
+ background: rgba(245, 158, 11, 0.12);
}
.entry-content {
@@ -178,8 +348,57 @@ const mockEntries: ConversationEntry[] = [
color: rgba(255, 255, 255, 0.7);
}
+/* Stats Bar */
+.stats-bar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+ font-family: monospace;
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.35);
+}
+
+.stats-sep {
+ opacity: 0.4;
+}
+
+/* States */
+.history-loading {
+ padding: 16px 0;
+ text-align: center;
+ color: rgba(255, 255, 255, 0.3);
+ font-size: 12px;
+}
+
+.loading-dots {
+ animation: pulse 1s ease-in-out infinite;
+}
+
+.history-error {
+ padding: 12px 0;
+ text-align: center;
+ color: rgba(239, 68, 68, 0.7);
+ font-size: 11px;
+}
+
+.history-empty {
+ padding: 16px 0;
+ text-align: center;
+ color: rgba(255, 255, 255, 0.25);
+ font-size: 11px;
+ font-style: italic;
+}
+
@keyframes slide-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 0.3; }
+ 50% { opacity: 1; }
+}
diff --git a/frontend/src/components/agent/InputSettings.vue b/frontend/src/components/agent/InputSettings.vue
new file mode 100644
index 0000000..ba4894d
--- /dev/null
+++ b/frontend/src/components/agent/InputSettings.vue
@@ -0,0 +1,328 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/agent/PromptBar.vue b/frontend/src/components/agent/PromptBar.vue
index 76bc555..ea1e15b 100644
--- a/frontend/src/components/agent/PromptBar.vue
+++ b/frontend/src/components/agent/PromptBar.vue
@@ -1,8 +1,11 @@
@@ -187,7 +211,8 @@ onBeforeUnmount(() => {
-
-
-
{{ displayedText }}
+
+
+
+ {{ voice.error.value }}
+
+
+
+
+ {{ voice.animatedTranscript.value }}
+ {{ voice.interimTranscript.value }}
|
@@ -64,10 +74,16 @@ onBeforeUnmount(() => {
.transcript-header {
display: flex;
align-items: center;
- gap: 8px;
+ justify-content: space-between;
margin-bottom: 8px;
}
+.transcript-header-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
.rec-dot {
width: 8px;
height: 8px;
@@ -85,16 +101,67 @@ onBeforeUnmount(() => {
letter-spacing: 0.5px;
}
+.mode-badge {
+ font-size: 9px;
+ font-weight: 700;
+ padding: 2px 5px;
+ border-radius: 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.mode-badge.whisper {
+ background: rgba(139, 92, 246, 0.2);
+ color: rgba(139, 92, 246, 0.9);
+ border: 1px solid rgba(139, 92, 246, 0.3);
+}
+
+.mode-badge.webspeech {
+ background: rgba(59, 130, 246, 0.2);
+ color: rgba(59, 130, 246, 0.9);
+ border: 1px solid rgba(59, 130, 246, 0.3);
+}
+
+.stop-btn {
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(239, 68, 68, 0.15);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: 6px;
+ color: #ef4444;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.stop-btn:hover {
+ background: rgba(239, 68, 68, 0.25);
+}
+
.transcript-body {
font-size: 13px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.85);
+ min-height: 20px;
}
.transcript-text {
white-space: pre-wrap;
}
+.interim-text {
+ color: rgba(255, 255, 255, 0.4);
+ font-style: italic;
+}
+
+.transcript-error {
+ font-size: 12px;
+ color: rgba(239, 68, 68, 0.8);
+ padding: 4px 0;
+}
+
.blink-cursor {
color: rgba(255, 255, 255, 0.7);
animation: cursor-blink 0.8s step-end infinite;
diff --git a/frontend/src/composables/useVoiceCapture.ts b/frontend/src/composables/useVoiceCapture.ts
new file mode 100644
index 0000000..72e9c40
--- /dev/null
+++ b/frontend/src/composables/useVoiceCapture.ts
@@ -0,0 +1,806 @@
+import { ref, watch, type Ref } from 'vue'
+import { endpoints } from '../config/endpoints'
+
+// Web Speech API types (not in default TS lib)
+interface SpeechRecognitionEvent extends Event {
+ resultIndex: number
+ results: SpeechRecognitionResultList
+}
+
+interface SpeechRecognitionErrorEvent extends Event {
+ error: string
+ message?: string
+}
+
+interface SpeechRecognition extends EventTarget {
+ continuous: boolean
+ interimResults: boolean
+ lang: string
+ onresult: ((event: SpeechRecognitionEvent) => void) | null
+ onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
+ onend: (() => void) | null
+ start(): void
+ stop(): void
+ abort(): void
+}
+
+export type VoiceMode = 'webspeech' | 'whisper'
+export type WhisperStatus = 'offline' | 'loading' | 'ready'
+
+export interface VoiceCapture {
+ // State
+ isRecording: Ref
+ transcript: Ref
+ interimTranscript: Ref
+ animatedTranscript: Ref
+ error: Ref
+ voiceMode: Ref
+ whisperStatus: Ref
+ audioDevices: Ref
+ selectedDeviceId: Ref
+ isAndroid: Ref
+ lastAudioUrl: Ref
+ isPlayingAudio: Ref
+ // Actions
+ startRecording: () => void
+ stopRecording: () => void
+ toggleWhisperMode: () => Promise
+ checkWhisperStatus: () => Promise
+ loadAudioDevices: () => Promise
+ selectMicrophone: (deviceId: string) => void
+ playLastAudio: () => void
+ init: () => Promise
+ cleanup: () => void
+ clearTranscript: () => void
+}
+
+export function useVoiceCapture(options?: {
+ onNotification?: (message: string, type: 'info' | 'success' | 'error', duration?: number) => void
+}): VoiceCapture {
+ const notify = options?.onNotification || (() => {})
+
+ // ====== State ======
+ const isRecording = ref(false)
+ const transcript = ref('')
+ const interimTranscript = ref('')
+ const animatedTranscript = ref('')
+ const error = ref('')
+ const voiceMode = ref('webspeech')
+ const whisperStatus = ref('offline')
+ const audioDevices = ref([])
+ const selectedDeviceId = ref('')
+ const isAndroid = ref(false)
+
+ // Audio debug & save
+ const lastAudioUrl = ref('')
+ const isPlayingAudio = ref(false)
+
+ // ====== Internal state ======
+ let recognition: SpeechRecognition | null = null
+ let lastProcessedResult = ''
+
+ // Typing animation
+ let typingTimeout: number | null = null
+ let lastAnimatedLength = 0
+
+ // Whisper
+ const WHISPER_WS_URL = endpoints.whisper
+ let whisperSocket: WebSocket | null = null
+ let mediaRecorder: MediaRecorder | null = null
+ let audioChunks: Blob[] = []
+ let chunkInterval: number | null = null
+ const CHUNK_INTERVAL_MS = 3000
+ let mediaStream: MediaStream | null = null
+ let supportedMimeType = 'audio/webm;codecs=opus'
+
+ // Audio playback debug
+ let audioElement: HTMLAudioElement | null = null
+ let recordingStartTime = 0
+
+ // ====== Mobile / Audio Format ======
+
+ function checkMobile() {
+ const ua = navigator.userAgent
+ isAndroid.value = /Android/i.test(ua)
+ }
+
+ function detectAudioFormat(): string {
+ const formats = [
+ 'audio/webm;codecs=opus',
+ 'audio/webm',
+ 'audio/mp4',
+ 'audio/mp4;codecs=mp4a.40.2',
+ 'audio/aac',
+ 'audio/ogg;codecs=opus',
+ 'audio/wav'
+ ]
+ for (const format of formats) {
+ if (MediaRecorder.isTypeSupported(format)) {
+ console.log(`[VoiceCapture] Using audio format: ${format}`)
+ return format
+ }
+ }
+ console.warn('[VoiceCapture] No preferred format supported, using default')
+ return ''
+ }
+
+ // ====== Web Speech API ======
+
+ function initRecognition(): SpeechRecognition | null {
+ const SpeechRecognitionCtor = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
+ if (!SpeechRecognitionCtor) {
+ error.value = 'Speech recognition not supported in this browser'
+ return null
+ }
+
+ const rec: SpeechRecognition = new SpeechRecognitionCtor()
+ rec.continuous = !isAndroid.value
+ rec.interimResults = true
+ rec.lang = 'es-419'
+
+ if (isAndroid.value) {
+ console.log('[VoiceCapture] Android detected - using non-continuous mode')
+ }
+
+ rec.onresult = (event: SpeechRecognitionEvent) => {
+ let interim = ''
+ let final = ''
+
+ for (let i = event.resultIndex; i < event.results.length; i++) {
+ const result = event.results[i]
+ if (!result || !result[0]) continue
+ if (result.isFinal) {
+ final += result[0].transcript + ' '
+ } else {
+ interim += result[0].transcript
+ }
+ }
+
+ if (final) {
+ const trimmedFinal = final.trim()
+ if (isAndroid.value && lastProcessedResult && trimmedFinal.startsWith(lastProcessedResult.trim())) {
+ const newPart = trimmedFinal.slice(lastProcessedResult.trim().length).trim()
+ if (newPart) {
+ transcript.value += newPart + ' '
+ lastProcessedResult = trimmedFinal
+ }
+ } else {
+ transcript.value += final
+ lastProcessedResult = trimmedFinal
+ }
+ }
+ interimTranscript.value = interim
+ }
+
+ rec.onerror = (event: SpeechRecognitionErrorEvent) => {
+ // no-speech and aborted are transient — don't kill the session
+ if (event.error === 'no-speech' || event.error === 'aborted') {
+ console.log('[VoiceCapture] Transient error:', event.error, '(will auto-restart)')
+ return
+ }
+ console.error('[VoiceCapture] Recognition error:', event.error)
+ if (event.error === 'not-allowed') {
+ error.value = 'Microphone access denied'
+ } else {
+ error.value = `Error: ${event.error}`
+ }
+ isRecording.value = false
+ }
+
+ rec.onend = () => {
+ if (isRecording.value && voiceMode.value === 'webspeech') {
+ if (isAndroid.value) {
+ isRecording.value = false
+ console.log('[VoiceCapture] Android session ended - tap mic to continue')
+ } else {
+ rec.start()
+ }
+ }
+ }
+
+ return rec
+ }
+
+ // ====== Whisper Functions ======
+
+ async function checkWhisperStatusFn(updateLoading = true): Promise {
+ try {
+ const res = await fetch('/api/whisper/status')
+ const data = await res.json()
+ if (data.enabled) {
+ voiceMode.value = 'whisper'
+ }
+ if (data.running) {
+ whisperStatus.value = 'ready'
+ } else if (updateLoading && (data.starting || false)) {
+ whisperStatus.value = 'loading'
+ } else if (!data.running) {
+ if (voiceMode.value === 'whisper' && !data.starting) {
+ whisperStatus.value = 'offline'
+ }
+ }
+ return data
+ } catch {
+ voiceMode.value = 'webspeech'
+ whisperStatus.value = 'offline'
+ return null
+ }
+ }
+
+ async function pollWhisperStatus(): Promise {
+ const maxAttempts = 60
+ let attempts = 0
+
+ while (attempts < maxAttempts) {
+ await new Promise(resolve => setTimeout(resolve, 2000))
+ attempts++
+
+ try {
+ const status = await checkWhisperStatusFn(false)
+ if (!status) continue
+
+ if (status.starting) {
+ console.log(`[VoiceCapture] Still starting... (${attempts * 2}s)`)
+ continue
+ }
+
+ if (status.running && status.enabled) {
+ console.log('[VoiceCapture] Server ready!')
+ notify('Whisper GPU ready!', 'success')
+ connectWhisperSocket()
+ whisperStatus.value = 'ready'
+ return
+ }
+
+ console.log('[VoiceCapture] Server failed to start')
+ notify('Whisper server failed to start', 'error')
+ whisperStatus.value = 'offline'
+ return
+ } catch (e) {
+ console.error('[VoiceCapture] Polling error:', e)
+ }
+ }
+
+ notify('Whisper server timeout', 'error')
+ whisperStatus.value = 'offline'
+ }
+
+ function connectWhisperSocket() {
+ if (whisperStatus.value !== 'ready') {
+ console.log('[VoiceCapture] Whisper not ready, skipping connection')
+ return
+ }
+ if (whisperSocket?.readyState === WebSocket.OPEN) return
+
+ console.log('[VoiceCapture] Connecting to Whisper at:', WHISPER_WS_URL)
+ whisperSocket = new WebSocket(WHISPER_WS_URL)
+
+ const connectionTimeout = setTimeout(() => {
+ if (whisperSocket && whisperSocket.readyState !== WebSocket.OPEN) {
+ console.error('[VoiceCapture] Whisper connection timeout (10s)')
+ whisperSocket.close()
+ whisperStatus.value = 'offline'
+ }
+ }, 10000)
+
+ whisperSocket.onopen = () => {
+ clearTimeout(connectionTimeout)
+ console.log('[VoiceCapture] Whisper WebSocket connected')
+ whisperStatus.value = 'ready'
+ }
+
+ whisperSocket.onmessage = (event) => {
+ try {
+ const msg = JSON.parse(event.data)
+
+ if (msg.type === 'ready') {
+ console.log('[VoiceCapture] Whisper ready:', msg.model, msg.device)
+ whisperStatus.value = 'ready'
+ } else if (msg.type === 'transcription') {
+ if (msg.success && msg.text) {
+ const fullText = msg.text.trim()
+ if (msg.partial) {
+ transcript.value = fullText + ' '
+ interimTranscript.value = ''
+ } else {
+ transcript.value = fullText + ' '
+ interimTranscript.value = ''
+ console.log(`[VoiceCapture] WHISPER-GPU (${msg.model}/${msg.device}):`, fullText)
+ }
+ } else if (msg.error) {
+ error.value = msg.error
+ console.error('[VoiceCapture] Whisper error:', msg.error)
+ }
+ }
+ } catch (e) {
+ console.error('[VoiceCapture] Whisper message error:', e)
+ }
+ }
+
+ whisperSocket.onclose = () => {
+ console.log('[VoiceCapture] Whisper WebSocket closed')
+ whisperStatus.value = 'offline'
+ }
+
+ whisperSocket.onerror = (e) => {
+ console.error('[VoiceCapture] Whisper WebSocket error:', e)
+ whisperStatus.value = 'offline'
+ }
+ }
+
+ function disconnectWhisperSocket() {
+ if (whisperSocket) {
+ whisperSocket.close()
+ whisperSocket = null
+ }
+ whisperStatus.value = 'offline'
+ }
+
+ async function startWhisperRecording() {
+ if (!whisperSocket || whisperSocket.readyState !== WebSocket.OPEN) {
+ console.warn('[VoiceCapture] Whisper socket not connected, attempting to connect...')
+ connectWhisperSocket()
+ await new Promise(resolve => setTimeout(resolve, 500))
+
+ if (!whisperSocket || whisperSocket.readyState !== WebSocket.OPEN) {
+ error.value = 'Whisper server not connected'
+ notify('Whisper not connected. Try toggling GPU mode.', 'error')
+ return
+ }
+ }
+
+ try {
+ const audioConstraints: MediaTrackConstraints = {
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ ...(selectedDeviceId.value ? { deviceId: { exact: selectedDeviceId.value } } : {})
+ }
+
+ mediaStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints })
+
+ const recorderOptions: MediaRecorderOptions = {}
+ if (supportedMimeType) {
+ recorderOptions.mimeType = supportedMimeType
+ }
+
+ mediaRecorder = new MediaRecorder(mediaStream, recorderOptions)
+ console.log(`[VoiceCapture] MediaRecorder using: ${mediaRecorder.mimeType}`)
+
+ audioChunks = []
+
+ mediaRecorder.ondataavailable = (event) => {
+ if (event.data.size > 0) {
+ audioChunks.push(event.data)
+ }
+ }
+
+ audioChunks = []
+ mediaRecorder.start(100)
+ isRecording.value = true
+ recordingStartTime = Date.now()
+ console.log(`[VoiceCapture] Whisper recording started`)
+
+ // Permission granted via user gesture — reload devices with labels
+ loadAudioDevices(true)
+
+ chunkInterval = window.setInterval(() => {
+ if (audioChunks.length > 0 && whisperSocket?.readyState === WebSocket.OPEN) {
+ sendAudioChunk(false)
+ }
+ }, CHUNK_INTERVAL_MS)
+ } catch (e: any) {
+ error.value = `Microphone error: ${e.message}`
+ console.error('[VoiceCapture] Microphone error:', e)
+ }
+ }
+
+ function sendAudioChunk(isFinal: boolean) {
+ if (audioChunks.length === 0) return
+
+ const mimeType = mediaRecorder?.mimeType || supportedMimeType || 'audio/webm'
+ const audioBlob = new Blob(audioChunks, { type: mimeType })
+
+ if (audioBlob.size < 5000) {
+ if (isFinal) audioChunks = []
+ return
+ }
+
+ if (isFinal) {
+ audioChunks = []
+ saveAudioForPlayback(audioBlob)
+ }
+
+ const reader = new FileReader()
+ reader.onloadend = () => {
+ const base64 = (reader.result as string).split(',')[1]
+ if (whisperSocket?.readyState === WebSocket.OPEN) {
+ whisperSocket.send(JSON.stringify({
+ type: 'transcribe',
+ audio: base64,
+ language: 'es',
+ partial: !isFinal
+ }))
+ }
+ }
+ reader.readAsDataURL(audioBlob)
+ }
+
+ function stopWhisperRecording() {
+ if (chunkInterval) {
+ clearInterval(chunkInterval)
+ chunkInterval = null
+ }
+
+ if (audioChunks.length > 0) {
+ sendAudioChunk(true)
+ }
+
+ if (mediaRecorder && mediaRecorder.state !== 'inactive') {
+ mediaRecorder.stop()
+ }
+
+ if (mediaStream) {
+ mediaStream.getTracks().forEach(track => track.stop())
+ mediaStream = null
+ }
+
+ isRecording.value = false
+ }
+
+ // ====== Audio Save & Debug Playback ======
+
+ function currentMicName(): string {
+ if (!selectedDeviceId.value) return 'Default'
+ const device = audioDevices.value.find(d => d.deviceId === selectedDeviceId.value)
+ return device?.label || 'Microphone'
+ }
+
+ function saveAudioForPlayback(blob: Blob) {
+ if (lastAudioUrl.value) {
+ URL.revokeObjectURL(lastAudioUrl.value)
+ }
+ lastAudioUrl.value = URL.createObjectURL(blob)
+ saveRecordingToBackend(blob)
+ }
+
+ async function saveRecordingToBackend(blob: Blob) {
+ try {
+ const duration_ms = Date.now() - recordingStartTime
+ const reader = new FileReader()
+
+ reader.onloadend = async () => {
+ const base64 = (reader.result as string).split(',')[1]
+ const response = await fetch('/api/recordings', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ audio: base64,
+ transcription: transcript.value.trim(),
+ microphone: currentMicName(),
+ duration_ms
+ })
+ })
+ const data = await response.json()
+ if (data.success) {
+ console.log(`[VoiceCapture] Recording saved: ${data.filename} (${(data.size / 1024).toFixed(1)} KB)`)
+ } else {
+ console.error('[VoiceCapture] Failed to save recording:', data.error)
+ }
+ }
+
+ reader.readAsDataURL(blob)
+ } catch (e) {
+ console.error('[VoiceCapture] Error saving recording:', e)
+ }
+ }
+
+ function playLastAudio() {
+ if (!lastAudioUrl.value) return
+
+ if (isPlayingAudio.value && audioElement) {
+ audioElement.pause()
+ audioElement.currentTime = 0
+ isPlayingAudio.value = false
+ return
+ }
+
+ audioElement = new Audio(lastAudioUrl.value)
+ audioElement.onplay = () => { isPlayingAudio.value = true }
+ audioElement.onended = () => { isPlayingAudio.value = false }
+ audioElement.onpause = () => { isPlayingAudio.value = false }
+ audioElement.play().catch(e => {
+ console.error('[VoiceCapture] Failed to play audio:', e)
+ isPlayingAudio.value = false
+ })
+ }
+
+ // ====== Parallel Audio Capture (for Web Speech mode) ======
+
+ async function startAudioCapture() {
+ try {
+ const audioConstraints: MediaTrackConstraints = {
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ ...(selectedDeviceId.value ? { deviceId: { exact: selectedDeviceId.value } } : {})
+ }
+
+ mediaStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints })
+
+ const recorderOptions: MediaRecorderOptions = {}
+ if (supportedMimeType) {
+ recorderOptions.mimeType = supportedMimeType
+ }
+
+ mediaRecorder = new MediaRecorder(mediaStream, recorderOptions)
+ audioChunks = []
+
+ mediaRecorder.ondataavailable = (event) => {
+ if (event.data.size > 0) {
+ audioChunks.push(event.data)
+ }
+ }
+
+ mediaRecorder.start(100)
+ recordingStartTime = Date.now()
+ console.log(`[VoiceCapture] Audio capture started (${mediaRecorder.mimeType})`)
+
+ // Permission is now granted via user gesture — reload devices with labels
+ loadAudioDevices(true)
+ } catch (e: any) {
+ console.error('[VoiceCapture] Audio capture error:', e)
+ }
+ }
+
+ function stopAudioCapture() {
+ if (mediaRecorder && mediaRecorder.state !== 'inactive') {
+ mediaRecorder.stop()
+ }
+
+ // Build final blob and save
+ if (audioChunks.length > 0) {
+ const mimeType = mediaRecorder?.mimeType || supportedMimeType || 'audio/webm'
+ const audioBlob = new Blob(audioChunks, { type: mimeType })
+ audioChunks = []
+ if (audioBlob.size > 1000) {
+ saveAudioForPlayback(audioBlob)
+ }
+ }
+
+ if (mediaStream) {
+ mediaStream.getTracks().forEach(track => track.stop())
+ mediaStream = null
+ }
+ }
+
+ // ====== Public Recording API ======
+
+ function startRecording() {
+ error.value = ''
+
+ if (voiceMode.value === 'whisper' && whisperStatus.value === 'ready') {
+ startWhisperRecording()
+ } else {
+ if (!recognition) {
+ recognition = initRecognition()
+ }
+ if (recognition) {
+ try {
+ recognition.start()
+ isRecording.value = true
+ // Capture raw audio in parallel for save/debug playback
+ startAudioCapture()
+ if (isAndroid.value) {
+ notify('Android: Tap mic again to continue recording', 'info', 3000)
+ }
+ } catch (e) {
+ console.error('[VoiceCapture] Failed to start:', e)
+ }
+ }
+ }
+ }
+
+ function stopRecording() {
+ if (voiceMode.value === 'whisper') {
+ stopWhisperRecording()
+ } else {
+ if (recognition) {
+ recognition.stop()
+ }
+ stopAudioCapture()
+ isRecording.value = false
+ }
+ interimTranscript.value = ''
+ }
+
+ async function toggleWhisperMode() {
+ if (whisperStatus.value === 'loading') return
+
+ whisperStatus.value = 'loading'
+ error.value = ''
+
+ if (voiceMode.value !== 'whisper') {
+ notify('Starting Whisper GPU server...', 'info', 10000)
+ }
+
+ try {
+ const res = await fetch('/api/whisper/toggle', { method: 'POST' })
+ const data = await res.json()
+
+ if (data.starting) {
+ console.log('[VoiceCapture] Server starting, polling...')
+ voiceMode.value = 'whisper'
+ await pollWhisperStatus()
+ return
+ }
+
+ if (data.enabled) {
+ voiceMode.value = 'whisper'
+ whisperStatus.value = data.running ? 'ready' : 'offline'
+ if (data.running) {
+ notify('Whisper GPU ready!', 'success')
+ connectWhisperSocket()
+ }
+ } else {
+ voiceMode.value = 'webspeech'
+ whisperStatus.value = 'offline'
+ notify('Using Web Speech API', 'info')
+ disconnectWhisperSocket()
+ }
+ } catch (e: any) {
+ error.value = 'Failed to toggle Whisper'
+ notify('Error starting Whisper server', 'error')
+ console.error('[VoiceCapture] Whisper toggle error:', e)
+ whisperStatus.value = 'offline'
+ }
+ }
+
+ // ====== Microphone ======
+
+ async function loadAudioDevices(skipPermissionRequest = false) {
+ try {
+ if (!skipPermissionRequest) {
+ // Request permission to get device labels
+ const tempStream = await navigator.mediaDevices.getUserMedia({ audio: true })
+ tempStream.getTracks().forEach(track => track.stop())
+ }
+
+ const devices = await navigator.mediaDevices.enumerateDevices()
+ audioDevices.value = devices.filter(d => d.kind === 'audioinput')
+
+ if (!selectedDeviceId.value && audioDevices.value.length > 0) {
+ selectedDeviceId.value = audioDevices.value[0]?.deviceId || ''
+ }
+ console.log(`[VoiceCapture] Found ${audioDevices.value.length} audio devices`)
+ } catch (e) {
+ console.error('[VoiceCapture] Failed to enumerate devices:', e)
+ }
+ }
+
+ function selectMicrophone(deviceId: string) {
+ selectedDeviceId.value = deviceId
+ if (isRecording.value) {
+ stopRecording()
+ setTimeout(() => startRecording(), 100)
+ }
+ }
+
+ // ====== Typing Animation ======
+
+ function animateTyping(targetText: string) {
+ if (typingTimeout) {
+ clearTimeout(typingTimeout)
+ typingTimeout = null
+ }
+
+ if (targetText.length < animatedTranscript.value.length) {
+ animatedTranscript.value = targetText
+ lastAnimatedLength = targetText.length
+ return
+ }
+
+ const startIndex = lastAnimatedLength
+
+ function typeNext(index: number) {
+ if (index <= targetText.length) {
+ animatedTranscript.value = targetText.substring(0, index)
+ lastAnimatedLength = index
+ if (index < targetText.length) {
+ const delay = 15 + Math.random() * 10
+ typingTimeout = window.setTimeout(() => typeNext(index + 1), delay)
+ }
+ }
+ }
+
+ typeNext(startIndex)
+ }
+
+ watch(transcript, (newVal) => {
+ animateTyping(newVal)
+ })
+
+ // ====== Transcript ======
+
+ function clearTranscript() {
+ transcript.value = ''
+ interimTranscript.value = ''
+ animatedTranscript.value = ''
+ lastAnimatedLength = 0
+ lastProcessedResult = ''
+ if (typingTimeout) {
+ clearTimeout(typingTimeout)
+ typingTimeout = null
+ }
+ }
+
+ // ====== Lifecycle ======
+
+ async function init() {
+ recognition = initRecognition()
+ checkMobile()
+ supportedMimeType = detectAudioFormat()
+ // Only enumerate without getUserMedia — no user gesture here
+ // Devices will get full labels after first recording (user gesture)
+ await loadAudioDevices(true)
+
+ const status = await checkWhisperStatusFn()
+ if (status?.starting) {
+ console.log('[VoiceCapture] Server is starting, resuming polling...')
+ pollWhisperStatus()
+ } else if (voiceMode.value === 'whisper' && whisperStatus.value === 'ready') {
+ connectWhisperSocket()
+ } else if (voiceMode.value === 'whisper' && whisperStatus.value !== 'ready') {
+ console.log('[VoiceCapture] Whisper was enabled but server not running, disabling')
+ voiceMode.value = 'webspeech'
+ }
+ }
+
+ function cleanup() {
+ stopRecording()
+ recognition = null
+ disconnectWhisperSocket()
+ if (chunkInterval) clearInterval(chunkInterval)
+ if (typingTimeout) clearTimeout(typingTimeout)
+ if (mediaStream) {
+ mediaStream.getTracks().forEach(track => track.stop())
+ mediaStream = null
+ }
+ if (audioElement) {
+ audioElement.pause()
+ audioElement = null
+ }
+ if (lastAudioUrl.value) {
+ URL.revokeObjectURL(lastAudioUrl.value)
+ lastAudioUrl.value = ''
+ }
+ isPlayingAudio.value = false
+ }
+
+ return {
+ // State
+ isRecording,
+ transcript,
+ interimTranscript,
+ animatedTranscript,
+ error,
+ voiceMode,
+ whisperStatus,
+ audioDevices,
+ selectedDeviceId,
+ isAndroid,
+ lastAudioUrl,
+ isPlayingAudio,
+ // Actions
+ startRecording,
+ stopRecording,
+ toggleWhisperMode,
+ checkWhisperStatus: checkWhisperStatusFn,
+ loadAudioDevices,
+ selectMicrophone,
+ playLastAudio,
+ init,
+ cleanup,
+ clearTranscript
+ }
+}
diff --git a/frontend/src/types/agent.ts b/frontend/src/types/agent.ts
index 939d34f..c15fb92 100644
--- a/frontend/src/types/agent.ts
+++ b/frontend/src/types/agent.ts
@@ -46,3 +46,21 @@ export interface ConversationEntry {
timestamp: string
method: 'text' | 'voice'
}
+
+export interface TranscriptSession {
+ id: string
+ startTime: string
+ messageCount: number
+ model: string
+}
+
+export interface TranscriptMessage {
+ uuid: string
+ role: 'user' | 'assistant'
+ content: string
+ timestamp: string
+ isMeta: boolean
+ tokens?: { input: number; output: number }
+ toolCalls?: string[]
+ hasThinking: boolean
+}
diff --git a/server/routes/index.ts b/server/routes/index.ts
index f8bf082..b487f20 100644
--- a/server/routes/index.ts
+++ b/server/routes/index.ts
@@ -20,6 +20,7 @@ import {
handleAgentsPlugins, handleAgentsMcpJson,
handleAgentsConfigPermissions, handleAgentsConfigHooks, handleAgentsConfigMcp
} from './agents'
+import { handleTranscript, handleTranscriptSessions } from './transcript'
export async function handleRequest(req: Request): Promise {
const url = new URL(req.url)
@@ -277,6 +278,20 @@ export async function handleRequest(req: Request): Promise {
return handleGitFile(url)
}
+ // Transcript
+ if (path === '/api/transcript/sessions' && req.method === 'GET') {
+ return handleTranscriptSessions()
+ }
+
+ if (path === '/api/transcript/latest' && req.method === 'GET') {
+ return handleTranscript(req, url, 'latest')
+ }
+
+ const transcriptMatch = path.match(/^\/api\/transcript\/([a-f0-9-]+)$/)
+ if (transcriptMatch && req.method === 'GET') {
+ return handleTranscript(req, url, transcriptMatch[1])
+ }
+
// Agents
if (path === '/api/agents' && req.method === 'GET') {
return handleAgents(req)
diff --git a/server/routes/transcript.ts b/server/routes/transcript.ts
new file mode 100644
index 0000000..1347caf
--- /dev/null
+++ b/server/routes/transcript.ts
@@ -0,0 +1,81 @@
+import { jsonResponse, errorResponse } from '../utils/cors'
+import { getTranscriptAnalysis, listSessions } from '../services/transcript-engine'
+import type { TranscriptAnalysis } from '../services/transcript-engine'
+
+export function handleTranscriptSessions(): Response {
+ const sessions = listSessions()
+ return jsonResponse(sessions)
+}
+
+export function handleTranscript(req: Request, url: URL, sessionId: string): Response {
+ if (req.method !== 'GET') return errorResponse('Method not allowed', 405)
+
+ const resolvedId = sessionId === 'latest' ? undefined : sessionId
+ const analysis = getTranscriptAnalysis(resolvedId)
+
+ if (!analysis) {
+ return errorResponse('Transcript not found', 404)
+ }
+
+ // Section filtering
+ const section = url.searchParams.get('section')
+ if (section) {
+ return handleSection(analysis, section)
+ }
+
+ // Full response (exclude thinking by default)
+ const includeThinking = url.searchParams.get('includeThinking') === 'true'
+ if (!includeThinking) {
+ return jsonResponse({
+ ...analysis,
+ messages: analysis.messages.filter(m => !m.isMeta)
+ })
+ }
+
+ return jsonResponse({
+ ...analysis,
+ messages: analysis.messages.filter(m => !m.isMeta)
+ })
+}
+
+function handleSection(analysis: TranscriptAnalysis, section: string): Response {
+ switch (section) {
+ case 'messages':
+ return jsonResponse({
+ sessionId: analysis.sessionId,
+ messages: analysis.messages.filter(m => !m.isMeta)
+ })
+ case 'tokens':
+ return jsonResponse({
+ sessionId: analysis.sessionId,
+ tokens: analysis.tokens
+ })
+ case 'tools':
+ return jsonResponse({
+ sessionId: analysis.sessionId,
+ tools: analysis.tools
+ })
+ case 'stats':
+ return jsonResponse({
+ sessionId: analysis.sessionId,
+ stats: analysis.stats,
+ model: analysis.model,
+ version: analysis.version,
+ duration: analysis.duration,
+ startTime: analysis.startTime,
+ endTime: analysis.endTime
+ })
+ case 'files':
+ return jsonResponse({
+ sessionId: analysis.sessionId,
+ filesModified: analysis.filesModified
+ })
+ case 'subagents':
+ return jsonResponse({
+ sessionId: analysis.sessionId,
+ subagents: analysis.subagents
+ })
+ default:
+ return errorResponse(`Unknown section: ${section}. Valid: messages, tokens, tools, stats, files, subagents`, 400)
+ }
+}
diff --git a/server/services/transcript-engine.ts b/server/services/transcript-engine.ts
new file mode 100644
index 0000000..c3ad802
--- /dev/null
+++ b/server/services/transcript-engine.ts
@@ -0,0 +1,574 @@
+// Transcript Engine - Parses Claude Code JSONL transcripts
+// Module-level state pattern (like terminal.ts, torch-handler.ts)
+
+import { existsSync, readFileSync, statSync, readdirSync } from 'fs'
+import { join } from 'path'
+import { homedir } from 'os'
+import { WORKING_DIR } from '../config'
+
+// ── Types ──
+
+export interface TranscriptAnalysis {
+ sessionId: string
+ model: string
+ version: string
+ gitBranch: string
+ cwd: string
+ startTime: string
+ endTime: string
+ duration: number
+ messages: TranscriptMessage[]
+ tokens: {
+ totalInput: number
+ totalOutput: number
+ totalCacheRead: number
+ totalCacheCreation: number
+ byTurn: TurnTokens[]
+ }
+ tools: {
+ summary: Record
+ calls: ToolCall[]
+ }
+ filesModified: string[]
+ subagents: SubagentInfo[]
+ summaries: string[]
+ stats: {
+ messageCount: number
+ userMessageCount: number
+ assistantMessageCount: number
+ toolCallCount: number
+ thinkingBlocks: number
+ errors: number
+ }
+}
+
+export interface TranscriptMessage {
+ uuid: string
+ role: 'user' | 'assistant'
+ content: string
+ timestamp: string
+ isMeta: boolean
+ tokens?: { input: number; output: number }
+ toolCalls?: string[]
+ hasThinking: boolean
+}
+
+export interface ToolCall {
+ name: string
+ input: unknown
+ output?: string
+ timestamp: string
+ isError: boolean
+}
+
+export interface TurnTokens {
+ turnIndex: number
+ input: number
+ output: number
+ cacheRead: number
+ cacheCreation: number
+ model: string
+}
+
+export interface SubagentInfo {
+ agentId: string
+ prompt: string
+ timestamp: string
+}
+
+export interface SessionInfo {
+ id: string
+ startTime: string
+ messageCount: number
+ model: string
+}
+
+// ── Module-level cache ──
+
+const cache = new Map()
+
+// ── Project hash ──
+
+function getProjectHash(): string {
+ // C:\Users\jodar\agent-ui → C--Users-jodar-agent-ui
+ return WORKING_DIR.replace(/[\\/]/g, '-').replace(/:/g, '-')
+}
+
+function getProjectDir(): string {
+ return join(homedir(), '.claude', 'projects', getProjectHash())
+}
+
+// ── Path resolution ──
+
+function resolveTranscriptPath(sessionId?: string): string | null {
+ const projectDir = getProjectDir()
+ if (!existsSync(projectDir)) return null
+
+ if (sessionId && sessionId !== 'latest') {
+ const filePath = join(projectDir, `${sessionId}.jsonl`)
+ return existsSync(filePath) ? filePath : null
+ }
+
+ // Find most recent by mtime
+ try {
+ const files = readdirSync(projectDir)
+ .filter(f => f.endsWith('.jsonl'))
+ .map(f => {
+ const fullPath = join(projectDir, f)
+ return { name: f, path: fullPath, mtime: statSync(fullPath).mtimeMs }
+ })
+ .sort((a, b) => b.mtime - a.mtime)
+
+ return files.length > 0 ? files[0].path : null
+ } catch {
+ return null
+ }
+}
+
+function sessionIdFromPath(filePath: string): string {
+ const basename = filePath.split(/[\\/]/).pop() || ''
+ return basename.replace('.jsonl', '')
+}
+
+// ── Helpers ──
+
+function truncate(str: string, maxLen: number): string {
+ if (!str || str.length <= maxLen) return str || ''
+ return str.slice(0, maxLen) + '...'
+}
+
+function extractText(content: any): string {
+ if (typeof content === 'string') {
+ return content.replace(/<[^>]+>/g, '').trim()
+ }
+ if (Array.isArray(content)) {
+ return content
+ .filter((c: any) => c.type === 'text')
+ .map((c: any) => c.text || '')
+ .join('\n')
+ .replace(/<[^>]+>/g, '')
+ .trim()
+ }
+ return ''
+}
+
+// ── JSONL parsing ──
+
+interface ParsedLine {
+ type: string
+ data: any
+}
+
+function parseTranscriptFile(filePath: string): ParsedLine[] {
+ const content = readFileSync(filePath, 'utf8')
+ const rawLines = content.trim().split('\n')
+ const lines: ParsedLine[] = []
+
+ for (const line of rawLines) {
+ if (!line.trim()) continue
+ try {
+ const obj = JSON.parse(line)
+ lines.push({ type: obj.type, data: obj })
+ } catch {
+ // Skip unparseable lines
+ }
+ }
+
+ return lines
+}
+
+// ── Build analysis from parsed lines ──
+
+function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAnalysis {
+ let sessionId = fileSessionId
+ let model = ''
+ let version = ''
+ let gitBranch = ''
+ let cwd = ''
+ let startTime = ''
+ let endTime = ''
+
+ const messages: TranscriptMessage[] = []
+ const toolCalls: ToolCall[] = []
+ const filesModified = new Set()
+ const subagents: SubagentInfo[] = []
+ const summaries: string[] = []
+ const turnTokens: TurnTokens[] = []
+
+ let totalInput = 0
+ let totalOutput = 0
+ let totalCacheRead = 0
+ let totalCacheCreation = 0
+ let thinkingBlocks = 0
+ let errors = 0
+
+ // Track assistant message chunks by message.id (streaming chunks share the same id)
+ const assistantChunks = new Map()
+
+ // Track tool results by tool_use_id
+ const toolResults = new Map()
+
+ // ── First pass: collect all data ──
+ for (const { type, data } of lines) {
+ // Extract metadata from first message that has it
+ if (data.sessionId && !sessionId) sessionId = data.sessionId
+ if (data.version && !version) version = data.version
+ if (data.gitBranch && !gitBranch) gitBranch = data.gitBranch
+ if (data.cwd && !cwd) cwd = data.cwd
+
+ // Track time bounds
+ if (data.timestamp) {
+ if (!startTime || data.timestamp < startTime) startTime = data.timestamp
+ if (!endTime || data.timestamp > endTime) endTime = data.timestamp
+ }
+
+ switch (type) {
+ case 'user': {
+ const msg = data.message
+ if (!msg) break
+
+ // Collect tool results (user messages contain tool_result blocks)
+ if (Array.isArray(msg.content)) {
+ for (const block of msg.content) {
+ if (block.type === 'tool_result') {
+ const resultText = typeof block.content === 'string'
+ ? block.content
+ : Array.isArray(block.content)
+ ? block.content.map((c: any) => c.text || '').join('\n')
+ : ''
+ toolResults.set(block.tool_use_id, {
+ content: truncate(resultText, 300),
+ isError: !!block.is_error
+ })
+ if (block.is_error) errors++
+ }
+ }
+ }
+
+ // Add as user message (skip meta, skip tool-result-only messages)
+ const isMeta = !!data.isMeta
+ const text = extractText(msg.content)
+ const hasToolResult = Array.isArray(msg.content) &&
+ msg.content.some((c: any) => c.type === 'tool_result')
+
+ if (text && !hasToolResult) {
+ messages.push({
+ uuid: data.uuid || '',
+ role: 'user',
+ content: text,
+ timestamp: data.timestamp || '',
+ isMeta,
+ hasThinking: false
+ })
+ }
+ break
+ }
+
+ case 'assistant': {
+ const msg = data.message
+ if (!msg || msg.role !== 'assistant') break
+
+ const msgId = msg.id || data.uuid
+ if (!model && msg.model) model = msg.model
+
+ let chunk = assistantChunks.get(msgId)
+ if (!chunk) {
+ chunk = {
+ uuid: data.uuid || '',
+ timestamp: data.timestamp || '',
+ model: msg.model || '',
+ textParts: [],
+ toolNames: [],
+ hasThinking: false,
+ usage: null,
+ pendingToolCalls: []
+ }
+ assistantChunks.set(msgId, chunk)
+ }
+
+ // Take latest usage (streaming chunks repeat usage, last is most accurate)
+ if (msg.usage) chunk.usage = msg.usage
+
+ // Process content blocks (each JSONL line typically has one block)
+ if (Array.isArray(msg.content)) {
+ for (const block of msg.content) {
+ if (block.type === 'text' && block.text?.trim()) {
+ chunk.textParts.push(block.text)
+ } else if (block.type === 'thinking') {
+ chunk.hasThinking = true
+ thinkingBlocks++
+ } else if (block.type === 'tool_use') {
+ chunk.toolNames.push(block.name)
+ chunk.pendingToolCalls.push({
+ name: block.name,
+ input: block.input,
+ id: block.id,
+ timestamp: data.timestamp || ''
+ })
+ }
+ }
+ }
+ break
+ }
+
+ case 'progress': {
+ if (data.data?.type === 'agent_progress' && data.data.agentId) {
+ const existing = subagents.find(s => s.agentId === data.data.agentId)
+ if (!existing) {
+ subagents.push({
+ agentId: data.data.agentId,
+ prompt: truncate(data.data.prompt || '', 200),
+ timestamp: data.timestamp || ''
+ })
+ }
+ }
+ break
+ }
+
+ case 'file-history-snapshot': {
+ const backups = data.snapshot?.trackedFileBackups
+ if (backups && typeof backups === 'object') {
+ for (const filePath of Object.keys(backups)) {
+ filesModified.add(filePath)
+ }
+ }
+ break
+ }
+
+ case 'summary': {
+ const summaryText = data.summary || data.message?.content
+ if (summaryText) {
+ summaries.push(truncate(
+ typeof summaryText === 'string' ? summaryText : JSON.stringify(summaryText),
+ 1000
+ ))
+ }
+ break
+ }
+ }
+ }
+
+ // ── Second pass: assemble assistant messages and finalize tool calls ──
+ let turnIndex = 0
+ for (const [, chunk] of assistantChunks) {
+ const text = chunk.textParts.join('\n').trim()
+
+ if (text || chunk.toolNames.length > 0) {
+ const msgTokens = chunk.usage
+ ? { input: chunk.usage.input_tokens || 0, output: chunk.usage.output_tokens || 0 }
+ : undefined
+
+ messages.push({
+ uuid: chunk.uuid,
+ role: 'assistant',
+ content: text || `[Tool calls: ${chunk.toolNames.join(', ')}]`,
+ timestamp: chunk.timestamp,
+ isMeta: false,
+ tokens: msgTokens,
+ toolCalls: chunk.toolNames.length > 0 ? chunk.toolNames : undefined,
+ hasThinking: chunk.hasThinking
+ })
+ }
+
+ // Finalize tool calls with results
+ for (const tc of chunk.pendingToolCalls) {
+ const result = toolResults.get(tc.id)
+ const inputStr = typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input)
+ toolCalls.push({
+ name: tc.name,
+ input: truncate(inputStr, 500),
+ output: result?.content,
+ timestamp: tc.timestamp,
+ isError: result?.isError || false
+ })
+ }
+
+ // Token tracking per turn
+ if (chunk.usage) {
+ const u = chunk.usage
+ const input = u.input_tokens || 0
+ const output = u.output_tokens || 0
+ const cacheRead = u.cache_read_input_tokens || 0
+ const cacheCreation = u.cache_creation_input_tokens || 0
+
+ totalInput += input
+ totalOutput += output
+ totalCacheRead += cacheRead
+ totalCacheCreation += cacheCreation
+
+ turnTokens.push({
+ turnIndex: turnIndex++,
+ input,
+ output,
+ cacheRead,
+ cacheCreation,
+ model: chunk.model
+ })
+ }
+ }
+
+ // Sort messages chronologically
+ messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
+
+ // Build tool summary
+ const toolSummary: Record = {}
+ for (const tc of toolCalls) {
+ toolSummary[tc.name] = (toolSummary[tc.name] || 0) + 1
+ }
+
+ // Extract files from Edit/Write tool calls
+ for (const tc of toolCalls) {
+ if (['Edit', 'Write', 'NotebookEdit'].includes(tc.name) && tc.input) {
+ try {
+ const input = typeof tc.input === 'string' ? JSON.parse(tc.input) : tc.input
+ if (input.file_path) filesModified.add(input.file_path)
+ if (input.notebook_path) filesModified.add(input.notebook_path)
+ } catch { /* skip */ }
+ }
+ }
+
+ const duration = startTime && endTime
+ ? new Date(endTime).getTime() - new Date(startTime).getTime()
+ : 0
+
+ const userMsgCount = messages.filter(m => m.role === 'user').length
+ const assistantMsgCount = messages.filter(m => m.role === 'assistant').length
+
+ return {
+ sessionId,
+ model,
+ version,
+ gitBranch,
+ cwd,
+ startTime,
+ endTime,
+ duration,
+ messages,
+ tokens: {
+ totalInput,
+ totalOutput,
+ totalCacheRead,
+ totalCacheCreation,
+ byTurn: turnTokens
+ },
+ tools: {
+ summary: toolSummary,
+ calls: toolCalls
+ },
+ filesModified: [...filesModified],
+ subagents,
+ summaries,
+ stats: {
+ messageCount: messages.length,
+ userMessageCount: userMsgCount,
+ assistantMessageCount: assistantMsgCount,
+ toolCallCount: toolCalls.length,
+ thinkingBlocks,
+ errors
+ }
+ }
+}
+
+// ── Exported API ──
+
+export function getTranscriptAnalysis(sessionId?: string): TranscriptAnalysis | null {
+ const filePath = resolveTranscriptPath(sessionId)
+ if (!filePath) return null
+
+ const sid = sessionIdFromPath(filePath)
+
+ try {
+ const stat = statSync(filePath)
+ const mtime = stat.mtimeMs
+
+ // Return cached if file hasn't changed
+ const cached = cache.get(sid)
+ if (cached && cached.lastModified === mtime) {
+ return cached.analysis
+ }
+
+ // Full parse
+ const lines = parseTranscriptFile(filePath)
+ const analysis = buildAnalysis(lines, sid)
+
+ cache.set(sid, { analysis, lastModified: mtime })
+ return analysis
+ } catch (e) {
+ console.error('[transcript-engine] Error parsing transcript:', e)
+ return null
+ }
+}
+
+export function listSessions(): SessionInfo[] {
+ const projectDir = getProjectDir()
+ if (!existsSync(projectDir)) return []
+
+ try {
+ const files = readdirSync(projectDir)
+ .filter(f => f.endsWith('.jsonl'))
+ .map(f => {
+ const fullPath = join(projectDir, f)
+ const stat = statSync(fullPath)
+ return { name: f, path: fullPath, mtime: stat.mtimeMs }
+ })
+ .sort((a, b) => b.mtime - a.mtime)
+
+ return files.map(f => {
+ const sid = f.name.replace('.jsonl', '')
+
+ // Try cache first for quick metadata
+ const cached = cache.get(sid)
+ if (cached && cached.lastModified === f.mtime) {
+ return {
+ id: sid,
+ startTime: cached.analysis.startTime,
+ messageCount: cached.analysis.stats.messageCount,
+ model: cached.analysis.model
+ }
+ }
+
+ // Quick scan: read first few lines for metadata without full parse
+ try {
+ const content = readFileSync(f.path, 'utf8')
+ const firstLines = content.split('\n').slice(0, 20)
+ let startTime = ''
+ let model = ''
+ let lineCount = content.split('\n').filter(l => l.trim()).length
+
+ for (const line of firstLines) {
+ if (!line.trim()) continue
+ try {
+ const obj = JSON.parse(line)
+ if (obj.timestamp && !startTime) startTime = obj.timestamp
+ if (obj.type === 'assistant' && obj.message?.model && !model) {
+ model = obj.message.model
+ }
+ } catch { /* skip */ }
+ }
+
+ return {
+ id: sid,
+ startTime,
+ messageCount: lineCount,
+ model
+ }
+ } catch {
+ return { id: sid, startTime: '', messageCount: 0, model: '' }
+ }
+ })
+ } catch {
+ return []
+ }
+}