All files / web/src/lib/character useConversationCompaction.ts

0% Statements 0/239
0% Branches 0/1
0% Functions 0/1
0% Lines 0/239

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               
'use client'

/**
 * Async conversation compaction — summarizes the head of a conversation
 * in the background so API calls use a compact [summary, ...tail] view
 * instead of the full message history.
 *
 * The summarization is:
 * - Triggered when the head grows past a threshold
 * - Non-blocking — the current send uses whatever is cached
 * - Incremental — each summary builds on the previous one
 * - Shared — both text chat and voice use the same cached summary
 */

import { useState, useEffect, useRef, useCallback } from 'react'
import type { ChatMessage } from './types'

export interface CompactionState {
  /** Cached summary of older messages (null if not yet summarized) */
  headSummary: string | null
  /** Index in the full messages array up to which the summary covers (exclusive) */
  coversUpTo: number
}

export interface UseConversationCompactionReturn extends CompactionState {
  /**
   * Build the compacted message array for API consumption.
   * Returns [summaryMessage, ...tailMessages] if a summary is cached,
   * or the full messages array if not.
   */
  compactForApi: (messages: ChatMessage[]) => Array<{ role: string; content: string }>
  /**
   * Build compacted conversation lines for voice session history injection.
   * Returns formatted text suitable for sendSystemMessage().
   */
  compactForVoice: (messages: ChatMessage[]) => string
  /** Ref indicating whether a summarization request is currently in-flight */
  isSummarizingRef: React.RefObject<boolean>
  /** Manually trigger compaction of all messages before the given index */
  manualCompactUpTo: (index: number) => void
}

/** How many recent messages to keep verbatim in the tail */
const TAIL_SIZE = 15

/** How many new head messages must accumulate before re-summarizing */
const GROWTH_THRESHOLD = 10

/** Minimum total messages before compaction kicks in */
const MIN_MESSAGES = TAIL_SIZE + GROWTH_THRESHOLD

export function useConversationCompaction(
  messages: ChatMessage[]
): UseConversationCompactionReturn {
  const [headSummary, setHeadSummary] = useState<string | null>(null)
  const [coversUpTo, setCoversUpTo] = useState(0)
  const isSummarizingRef = useRef(false)
  // Track the message count at which we last triggered summarization
  // to avoid re-triggering on every render
  const lastTriggerCountRef = useRef(0)

  useEffect(() => {
    if (messages.length < MIN_MESSAGES) return
    if (isSummarizingRef.current) return

    const headSize = messages.length - TAIL_SIZE
    const newHeadMessages = headSize - coversUpTo
    if (newHeadMessages < GROWTH_THRESHOLD) return
    // Don't re-trigger for the same message count
    if (messages.length === lastTriggerCountRef.current) return

    lastTriggerCountRef.current = messages.length
    isSummarizingRef.current = true

    const messagesToSummarize = messages.slice(coversUpTo, headSize)
    const apiMessages = messagesToSummarize
      .filter((m) => !m.isError)
      .map((m) => ({
        role: m.role,
        content: m.isEvent ? `[Event: ${m.content}]` : m.content,
      }))

    // Fire and forget — result arrives async, next send uses it
    const summarize = async () => {
      try {
        console.log(
          '[compaction] summarizing %d messages (coversUpTo=%d, headSize=%d)',
          apiMessages.length,
          coversUpTo,
          headSize
        )
        const res = await fetch('/api/chat/summarize', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            messages: apiMessages,
            previousSummary: headSummary ?? undefined,
          }),
        })

        if (!res.ok) {
          console.warn('[compaction] summarization failed:', res.status)
          return
        }

        const data = await res.json()
        if (data.summary) {
          console.log(
            '[compaction] summary ready (%d chars), covers up to index %d',
            data.summary.length,
            headSize
          )
          setHeadSummary(data.summary)
          setCoversUpTo(headSize)
        }
      } catch (err) {
        console.warn('[compaction] summarization error:', err)
      } finally {
        isSummarizingRef.current = false
      }
    }

    summarize()
    // Deliberately using messages.length (not messages) to avoid re-triggering
    // on every content change. The lastTriggerCountRef guard prevents duplicates.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [messages.length, coversUpTo, headSummary])

  const compactForApi = useCallback(
    (msgs: ChatMessage[]): Array<{ role: string; content: string }> => {
      const source =
        headSummary && coversUpTo > 0
          ? [
              {
                role: 'user' as const,
                content: `[CONVERSATION SUMMARY — this covers the earlier part of the conversation:]\n${headSummary}\n[END SUMMARY]`,
                isError: false,
                isEvent: false,
              } as ChatMessage,
              ...msgs.slice(coversUpTo),
            ]
          : msgs

      return source
        .filter((m) => !m.isError)
        .map((m) => ({
          role: m.role,
          content: m.isEvent ? `[CONSTRUCTION EVENT: ${m.content}]` : m.content,
        }))
    },
    [headSummary, coversUpTo]
  )

  const compactForVoice = useCallback(
    (msgs: ChatMessage[]): string => {
      const preamble = headSummary ? `[Summary of earlier conversation: ${headSummary}]\n\n` : ''

      const tail = (headSummary && coversUpTo > 0 ? msgs.slice(coversUpTo) : msgs).filter(
        (m) => !m.isError
      )

      const lines = tail
        .map((m) => {
          if (m.isEvent) return `[Event: ${m.content}]`
          return `${m.role === 'user' ? 'Student' : 'Euclid'}: ${m.content}`
        })
        .join('\n')

      return `${preamble}${lines}`
    },
    [headSummary, coversUpTo]
  )

  const manualCompactUpTo = useCallback(
    (index: number) => {
      if (isSummarizingRef.current) return
      if (index <= 0 || index > messages.length) return

      isSummarizingRef.current = true
      const messagesToSummarize = messages.slice(0, index)
      const apiMessages = messagesToSummarize
        .filter((m) => !m.isError)
        .map((m) => ({
          role: m.role,
          content: m.isEvent ? `[Event: ${m.content}]` : m.content,
        }))

      const summarize = async () => {
        try {
          console.log(
            '[compaction] manual compact up to index %d (%d messages)',
            index,
            apiMessages.length
          )
          const res = await fetch('/api/chat/summarize', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              messages: apiMessages,
              previousSummary: undefined,
            }),
          })

          if (!res.ok) {
            console.warn('[compaction] manual summarization failed:', res.status)
            return
          }

          const data = await res.json()
          if (data.summary) {
            console.log(
              '[compaction] manual summary ready (%d chars), covers up to index %d',
              data.summary.length,
              index
            )
            setHeadSummary(data.summary)
            setCoversUpTo(index)
          }
        } catch (err) {
          console.warn('[compaction] manual summarization error:', err)
        } finally {
          isSummarizingRef.current = false
        }
      }

      summarize()
    },
    [messages]
  )

  return {
    headSummary,
    coversUpTo,
    compactForApi,
    compactForVoice,
    isSummarizingRef,
    manualCompactUpTo,
  }
}