lib/clacky/agent/message_compressor_helper.rb



# frozen_string_literal: true

module Clacky
  class Agent
    # Message compression functionality for managing conversation history
    # Handles automatic compression when token limits are exceeded
    module MessageCompressorHelper
      # Compression thresholds
      COMPRESSION_THRESHOLD = 150_000  # Trigger compression when exceeding this (in tokens)
      MESSAGE_COUNT_THRESHOLD = 200   # Trigger compression when exceeding this (in message count)
      MAX_RECENT_MESSAGES = 20  # Keep this many recent message pairs intact
      TARGET_COMPRESSED_TOKENS = 10_000  # Target size after compression
      IDLE_COMPRESSION_THRESHOLD = 20_000  # Minimum messages needed for idle compression

      # Trigger compression during idle time (user-friendly, interruptible)
      # Returns true if compression was performed, false otherwise
      def trigger_idle_compression
        # Check if we should compress (force mode) BEFORE opening any UI, so
        # "skipped" doesn't flash a spinner on screen.
        compression_context = compress_messages_if_needed(force: true)
        if compression_context.nil?
          Clacky::Logger.info(
            "Idle compression skipped",
            enable_compression: @config.enable_compression,
            previous_total_tokens: @previous_total_tokens,
            history_size: @history.size,
            idle_threshold: IDLE_COMPRESSION_THRESHOLD,
            max_recent_messages: MAX_RECENT_MESSAGES
          )
          return false
        end

        # Own the progress indicator through +with_progress+: the ensure
        # block guarantees the spinner/ticker is released even when the
        # user interrupts mid-way (AgentInterrupted from current thread)
        # or the LLM call fails. No more orphan gray tickers.
        #
        # When @ui is nil (tests / headless) we still need to run the
        # compression work — safe-navigation with a block would silently
        # skip it, so branch explicitly.
        compression_message = compression_context[:compression_message]
        @history.append(compression_message)

        run_compression = lambda do |handle|
          begin
            response = call_llm
            handle_compression_response(response, compression_context, progress: handle)
            true
          rescue Clacky::AgentInterrupted => e
            # User cancelled the idle compression — finish the quiet progress
            # slot in place so the user sees exactly what happened (rather
            # than the "Idle detected..." line being silently removed).
            final = "Idle compression cancelled: #{e.message}"
            if handle
              handle.finish(final_message: final)
            else
              @ui&.log(final, level: :info)
            end
            @history.rollback_before(compression_message)
            Clacky::Logger.info("[idle-compress] cancelled: #{e.message}")
            false
          rescue => e
            # Compression failed (most commonly: network errors after all
            # LlmCaller retries exhausted). Previously this only wrote an
            # @ui.log(:error) that was easy to miss — especially when no
            # other output followed. Now we:
            #   1. Replace the active quiet progress line with the error so
            #      the user always sees *something* where the spinner was.
            #   2. Emit a show_warning for a more prominent entry.
            #   3. Persist to Clacky::Logger so post-mortem is possible even
            #      if the terminal scrollback has rolled past.
            final = "Idle compression failed: #{e.message}"
            if handle
              handle.finish(final_message: final)
            else
              @ui&.log(final, level: :error)
            end
            @ui&.show_warning(final)
            Clacky::Logger.warn(
              "[idle-compress] failed",
              error_class: e.class.name,
              error_message: e.message,
              backtrace: e.backtrace&.first(5)
            )
            @history.rollback_before(compression_message)
            false
          end
        end

        if @ui
          result = nil
          @ui.with_progress(
            message: "Idle detected. Compressing conversation to optimize costs...",
            style: :quiet
          ) do |handle|
            result = run_compression.call(handle)
          end
          result
        else
          run_compression.call(nil)
        end
      end

      # Check if compression is needed and return compression context
      # @param force [Boolean] Force compression even if thresholds not met
      # @param pull_back_from_tail [Integer] Number of messages to temporarily pop
      #   from the tail of history before building the compression instruction.
      #   Used by the context-overflow recovery path: when the current history
      #   is already at/over the model's context window, we cannot append even
      #   a small compression instruction without overflowing. Popping K messages
      #   from the tail frees up token budget for the compression call itself.
      #
      #   Cache-preservation note: thanks to the model's two-checkpoint prompt
      #   cache (cache#A at second-to-last, cache#B at last), pulling back K=1
      #   message keeps cache#A intact — the compression LLM call still hits the
      #   cached prefix [system, m1..m(N-1)]. K>=2 sacrifices cache hits but is
      #   only used as fallback when one message isn't enough headroom.
      #
      #   The popped messages are NOT discarded — they ride along in the
      #   returned context and are reattached to the rebuilt history's tail by
      #   handle_compression_response, so recent task progress is preserved.
      # @return [Hash, nil] Compression context or nil if not needed
      def compress_messages_if_needed(force: false, pull_back_from_tail: 0)
        # Check if compression is enabled
        return nil unless @config.enable_compression

        # Use actual API-reported tokens from last request
        total_tokens = @previous_total_tokens
        message_count = @history.size

        # Force compression (for idle compression) - use lower threshold
        if force
          # Only compress if we have more than MAX_RECENT_MESSAGES + system message
          return nil unless message_count > MAX_RECENT_MESSAGES + 1
          # Also require minimum message count to make compression worthwhile
          return nil unless total_tokens >= IDLE_COMPRESSION_THRESHOLD
        else
          # Normal compression - check thresholds
          # Either: token count exceeds threshold OR message count exceeds threshold
          token_threshold_exceeded = total_tokens >= COMPRESSION_THRESHOLD
          message_count_exceeded = message_count >= MESSAGE_COUNT_THRESHOLD

          # Only compress if we exceed at least one threshold
          return nil unless token_threshold_exceeded || message_count_exceeded
        end

        # Calculate how much we need to reduce
        reduction_needed = total_tokens - TARGET_COMPRESSED_TOKENS

        # Don't compress if reduction is minimal (< 10% of current size)
        # Only apply this check when triggered by token threshold (not for force mode)
        if !force && token_threshold_exceeded && reduction_needed < (total_tokens * 0.1)
          return nil
        end

        # If only message count threshold is exceeded, force compression
        # to keep conversation history manageable

        # Calculate target size for recent messages based on compression level
        target_recent_count = calculate_target_recent_count(reduction_needed)

        # Increment compression level for progressive summarization
        @compression_level += 1

        # Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
        all_messages = @history.to_a

        # Pull back K messages from the tail (context-overflow recovery path).
        # We *physically* remove them from @history so the next call_llm
        # (which reads @history.to_api) doesn't include them in the prompt.
        # They will be reattached to the rebuilt history's tail by
        # handle_compression_response after compression succeeds. If compression
        # fails, the caller is responsible for restoring them via the returned
        # context (rollback path).
        pulled_back_messages = []
        if pull_back_from_tail > 0
          k = [pull_back_from_tail, all_messages.size - 1].min  # never pop the system message
          k.times do
            popped = @history.pop_last
            pulled_back_messages.unshift(popped) if popped
          end
          # Recompute all_messages from the now-shrunk history so downstream
          # logic (recent_messages selection, build_compression_message) sees
          # the post-pop view.
          all_messages = @history.to_a
        end

        recent_messages = get_recent_messages_with_tool_pairs(all_messages, target_recent_count)
        recent_messages = [] if recent_messages.nil?

        # Build compression instruction message (to be inserted into conversation)
        compression_message = @message_compressor.build_compression_message(all_messages, recent_messages: recent_messages)

        return nil if compression_message.nil?

        # Return compression context for agent to handle
        {
          compression_message: compression_message,
          recent_messages: recent_messages,
          pulled_back_messages: pulled_back_messages,
          original_token_count: total_tokens,
          original_message_count: @history.size,
          compression_level: @compression_level
        }
      end

      # Handle compression response and rebuild message list
      # @param response [Hash] LLM response
      # @param compression_context [Hash] context returned by +compress_messages_if_needed+
      # @param progress [#finish, nil] Owned progress handle from the caller's
      #   with_progress block. When provided, the final summary message is
      #   delivered via +progress.finish(final_message: ...)+ instead of the
      #   legacy +show_progress(phase: "done")+ — this lets +ensure+ in the
      #   caller guarantee cleanup even if this method raises mid-way.
      def handle_compression_response(response, compression_context, progress: nil)
        # Extract compressed content from response
        compressed_content = response[:content]

        # Note: Cost tracking is already handled by call_llm, no need to track again here

        # Rebuild message list with compression
        # Note: we need to remove the compression instruction message we just added
        original_messages = @history.to_a[0..-2]  # All except the last (compression instruction)

        # Archive compressed messages to a chunk MD file before discarding them.
        #
        # IMPORTANT: chunk_index and previous_chunks MUST come from disk, not from
        # message history. Each compression's rebuild_with_compression keeps only
        # ONE compressed_summary message (the new one), dropping older summaries
        # and embedding their references into the new summary's content. So
        # counting compressed_summary messages in history caps at 1 from the
        # second compression onward — causing chunk-2.md to be overwritten on
        # every subsequent compression, and losing references to chunk-1.md.
        #
        # Disk is the only durable source of truth: chunk files survive process
        # restarts, session reloads, and message rebuilds. SessionManager owns
        # all chunk file I/O (naming, writing, discovery) — we just ask it.
        sm = session_manager
        existing_chunks = sm.chunks_for_current(@session_id, @created_at)
        chunk_index = sm.next_chunk_index(@session_id, @created_at)

        # Extract topics from the LLM response to store in both the chunk MD front
        # matter and the compressed_summary message hash (for future chunk indexing).
        topics = @message_compressor.parse_topics(compressed_content)

        chunk_path = save_compressed_chunk(
          original_messages,
          compression_context[:recent_messages],
          chunk_index: chunk_index,
          compression_level: compression_context[:compression_level],
          topics: topics
        )

        # Build previous_chunks index from the disk-discovered chunks (already
        # sorted by index ascending). This gives the new summary a complete
        # chronological index of all older archives so the AI can recall any
        # past chunk via file_reader, not just the most recent one.
        previous_chunks = existing_chunks.map do |c|
          { basename: c[:basename], path: c[:path], topics: c[:topics] }
        end

        @history.replace_all(@message_compressor.rebuild_with_compression(
          compressed_content,
          original_messages: original_messages,
          recent_messages: compression_context[:recent_messages],
          chunk_path: chunk_path,
          topics: topics,
          previous_chunks: previous_chunks,
          pulled_back_messages: compression_context[:pulled_back_messages] || []
        ))

        # Reset to the estimated size of the rebuilt (small) history.
        # The compression call_llm reported the OLD large token count, so
        # @previous_total_tokens would still be above COMPRESSION_THRESHOLD —
        # without this reset the very next think() would re-trigger compression
        # immediately, causing an infinite loop (especially after image uploads
        # where base64 data inflates token counts dramatically).
        @previous_total_tokens = @history.estimate_tokens

        # Track this compression
        @compressed_summaries << {
          level: compression_context[:compression_level],
          message_count: compression_context[:original_message_count],
          timestamp: Time.now.iso8601,
          strategy: :insert_then_compress,
          chunk_path: chunk_path
        }

        # Show compression info (use estimated tokens from rebuilt history)
        compression_summary = "History compressed (~#{compression_context[:original_token_count]} -> ~#{@history.estimate_tokens} tokens, " \
          "level #{compression_context[:compression_level]})"
        if progress
          # Owned-handle path: the caller's ensure block will still call
          # handle.finish; finishing here with a final_message means that
          # later finish (with no final_message) is a no-op (idempotent).
          progress.finish(final_message: compression_summary)
        else
          @ui&.show_progress(compression_summary, progress_type: "idle_compress", phase: "done")
        end
      end

      # Get recent messages while preserving tool_calls/tool_results pairs.
      # Handles both canonical format (role: "tool") and legacy Anthropic-native
      # format (role: "user" with tool_result content blocks).
      # @param messages [Array] All messages
      # @param count [Integer] Target number of recent messages to keep
      # @return [Array] Recent messages with complete tool pairs
      def get_recent_messages_with_tool_pairs(messages, count)
        return [] if messages.nil? || messages.empty?

        messages_to_include = Set.new
        i = messages.size - 1
        messages_collected = 0

        while i >= 0 && messages_collected < count
          msg = messages[i]

          # Never include the system message — it is always prepended separately
          # by rebuild_with_compression. Including it here would cause it to appear
          # twice in the rebuilt history, inflating token counts on every compression.
          if msg[:role] == "system"
            i -= 1
            next
          end

          if messages_to_include.include?(i)
            i -= 1
            next
          end

          messages_to_include.add(i)
          messages_collected += 1

          # assistant with tool_calls → also pull in all following tool results
          if msg[:role] == "assistant" && msg[:tool_calls]&.any?
            pull_tool_results_after(messages, i, messages_to_include)
          end

          # tool result (canonical or legacy Anthropic) → also pull in its assistant
          if tool_result_message?(msg)
            pull_assistant_before(messages, i, messages_to_include) do |added|
              messages_collected += 1 if added
            end
          end

          i -= 1
        end

        recent_messages = messages_to_include.to_a.sort.map { |idx| messages[idx] }

        # Truncate large tool results to prevent token bloat
        recent_messages.map do |msg|
          truncate_tool_result(msg)
        end
      end


      # Returns true if msg is a tool result, regardless of storage format.
      # Canonical: role:"tool"  |  Legacy Anthropic-native: role:"user" + tool_result blocks
      def tool_result_message?(msg)
        MessageFormat::OpenAI.tool_result_message?(msg) ||
          MessageFormat::Anthropic.tool_result_message?(msg)
      end

      # Returns the tool_call IDs referenced in a tool result message.
      def tool_result_ids(msg)
        if MessageFormat::OpenAI.tool_result_message?(msg)
          MessageFormat::OpenAI.tool_call_ids(msg)
        else
          MessageFormat::Anthropic.tool_use_ids(msg)
        end
      end

      # Returns true if msg is a tool result that matches any of the given call IDs.
      def tool_result_for?(msg, call_ids)
        tool_result_message?(msg) && (tool_result_ids(msg) & call_ids).any?
      end

      # Mark all tool results immediately following messages[assistant_idx].
      # Stops at the first non-tool-result message.
      def pull_tool_results_after(messages, assistant_idx, include_set)
        call_ids = messages[assistant_idx][:tool_calls].map { |tc| tc[:id] }
        j = assistant_idx + 1
        while j < messages.size
          nxt = messages[j]
          if tool_result_for?(nxt, call_ids)
            include_set.add(j)
          elsif !tool_result_message?(nxt)
            break
          end
          j += 1
        end
      end

      # Walk backwards from tool_result_idx to find and mark its assistant message.
      # Also marks all sibling tool results for that assistant.
      # Yields true if the assistant was newly added (for caller to increment count).
      def pull_assistant_before(messages, tool_result_idx, include_set)
        result_ids = tool_result_ids(messages[tool_result_idx])

        j = tool_result_idx - 1
        while j >= 0
          prev = messages[j]
          if prev[:role] == "assistant" && prev[:tool_calls]&.any?
            call_ids = prev[:tool_calls].map { |tc| tc[:id] }
            if (call_ids & result_ids).any?
              newly_added = include_set.add?(j)
              yield newly_added

              # Also pull all sibling tool results for this assistant
              pull_tool_results_after(messages, j, include_set)
              break
            end
          end
          j -= 1
        end
      end

      # Truncate oversized tool result content to avoid token bloat.
      def truncate_tool_result(msg)
        if MessageFormat::OpenAI.tool_result_message?(msg) &&
            msg[:content].is_a?(String) && msg[:content].length > 2000
          msg.merge(content: msg[:content][0..2000] + "...\n[Content truncated - exceeded 2000 characters]")
        else
          msg
        end
      end

      # Lazy accessor for a SessionManager instance used by compression chunk I/O.
      # We keep this local to the helper rather than threading a manager instance
      # through the Agent constructor — Agent itself doesn't persist sessions
      # (CLI / HTTP server do that), but the compression archive lives in the
      # same directory under SessionManager's ownership.
      #
      # NOTE: Uses Clacky::SessionManager::SESSIONS_DIR by default. Tests can
      # stub that constant to point at a tmpdir.
      private def session_manager
        @session_manager ||= Clacky::SessionManager.new
      end

      # Save the messages being compressed to a chunk MD file for future recall.
      # The filesystem concerns (path, write, chmod) are delegated to SessionManager;
      # this method is responsible only for the business rules of WHAT gets archived.
      #
      # @param original_messages [Array<Hash>] All messages before compression (excluding compression instruction)
      # @param recent_messages [Array<Hash>] Recent messages being kept (to exclude from chunk)
      # @param chunk_index [Integer] Sequential chunk number
      # @param compression_level [Integer] Compression level
      # @param topics [String, nil] Short topic description for chunk index card
      # @return [String, nil] Path to saved chunk file, or nil if save failed
      def save_compressed_chunk(original_messages, recent_messages, chunk_index:, compression_level:, topics: nil)
        return nil unless @session_id && @created_at

        # Messages being compressed = original minus system message minus recent messages
        # Also exclude system-injected scaffolding (session context, memory prompts, etc.)
        # — these are internal CLI metadata and must not appear in chunk MD or WebUI history.
        # Also exclude previous compressed_summary messages: they are index cards pointing
        # to older chunk files and must NOT be embedded inside a new chunk, otherwise
        # parse_chunk_md_to_rounds would follow the nested reference and create circular
        # chunk chains (chunk-2 → chunk-1 → ... → chunk-2).
        recent_set = recent_messages.to_a
        messages_to_archive = original_messages.reject do |m|
          m[:role] == "system" || m[:system_injected] || m[:compressed_summary] || recent_set.include?(m)
        end

        return nil if messages_to_archive.empty?

        md_content = build_chunk_md(messages_to_archive,
                                    chunk_index: chunk_index,
                                    compression_level: compression_level,
                                    topics: topics)

        # Delegate filesystem concerns (path assembly, write, chmod) to SessionManager —
        # it owns the on-disk layout for sessions and their chunk archives.
        session_manager.write_chunk(@session_id, @created_at, chunk_index, md_content)
      rescue => e
        @ui&.log("Failed to save chunk MD: #{e.message}", level: :warn)
        nil
      end

      # Build markdown content from a list of messages
      # @param messages [Array<Hash>] Messages to render
      # @param chunk_index [Integer] Chunk number for metadata
      # @param compression_level [Integer] Compression level
      # @param topics [String, nil] Short topic description extracted from LLM summary
      # @return [String] Markdown content
      def build_chunk_md(messages, chunk_index:, compression_level:, topics: nil)
        lines = []

        # Front matter
        lines << "---"
        lines << "session_id: #{@session_id}"
        lines << "chunk: #{chunk_index}"
        lines << "compression_level: #{compression_level}"
        lines << "archived_at: #{Time.now.iso8601}"
        lines << "message_count: #{messages.size}"
        lines << "topics: #{topics}" if topics
        lines << "---"
        lines << ""
        lines << "# Session Chunk #{chunk_index}"
        lines << ""
        lines << "> This file contains the original conversation archived during compression."
        lines << "> Use `file_reader` to recall specific details from this conversation."
        lines << ""

        messages.each do |msg|
          role = msg[:role]
          content = msg[:content]

          case role
          when "user"
            lines << "## User"
            lines << ""
            lines << format_message_content(content)
            lines << ""
          when "assistant"
            # If this message is itself a compressed summary, annotate the header
            # so the reader knows the original conversation is in the referenced chunk
            if msg[:compressed_summary] && msg[:chunk_path]
              prev_chunk = File.basename(msg[:chunk_path])
              lines << "## Assistant [Compressed Summary — original conversation at: #{prev_chunk}]"
            else
              lines << "## Assistant"
            end
            lines << ""
            # Include tool calls summary if present
            # Format: "_Tool calls: name | {args_json}_" so replay can restore args for WebUI display.
            if msg[:tool_calls]&.any?
              tc_parts = msg[:tool_calls].map do |tc|
                name = tc.dig(:function, :name) || tc[:name] || ""
                next nil if name.empty?

                args_raw = tc.dig(:function, :arguments) || tc[:arguments] || {}
                args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue nil) : args_raw
                if args.is_a?(Hash) && !args.empty?
                  # Truncate large string values to keep chunk MD readable
                  compact = args.transform_values { |v| v.is_a?(String) && v.length > 200 ? v[0..197] + "..." : v }
                  "#{name} | #{compact.to_json}"
                else
                  name
                end
              end.compact
              lines << "_Tool calls: #{tc_parts.join("; ")}_"
              lines << ""
            end
            lines << format_message_content(content) if content
            lines << ""
          when "tool"
            tool_name = msg[:name] || "tool"
            lines << "### Tool Result: #{tool_name}"
            lines << ""
            lines << "```"
            lines << truncate_content(content.to_s, max_length: 500)
            lines << "```"
            lines << ""
          end
        end

        lines.join("\n")
      end

      # Format message content (handles string or array of content blocks)
      def format_message_content(content)
        return "" if content.nil?
        return content.to_s if content.is_a?(String)

        # Handle array of content blocks (e.g., text + images)
        if content.is_a?(Array)
          content.map do |block|
            if block.is_a?(Hash) && block[:type] == "text"
              block[:text].to_s
            else
              "[#{block[:type] || 'content'}]"
            end
          end.join("\n")
        else
          content.to_s
        end
      end

      # Truncate long content with a note
      def truncate_content(text, max_length: 500)
        return text if text.length <= max_length
        "#{text[0...max_length]}\n... [truncated, #{text.length} chars total]"
      end

      # Calculate how many recent messages to keep based on how much we need to compress
      def calculate_target_recent_count(reduction_needed)
        # We want recent messages to be around 20-30% of the total target
        # This keeps the context window useful without being too large
        tokens_per_message = 500  # Average estimate for a message with content

        # Target recent messages budget (~20% of target compressed size)
        recent_budget = (TARGET_COMPRESSED_TOKENS * 0.2).to_i
        target_messages = (recent_budget / tokens_per_message).to_i

        # Clamp to reasonable bounds
        [[target_messages, 20].max, MAX_RECENT_MESSAGES].min
      end

      # Generate hierarchical summary based on compression level
      # Level 1: Detailed summary with files, decisions, features
      # Level 2: Concise summary with key items
      # Level 3: Minimal summary (just project type)
      # Level 4+: Ultra-minimal (single line)
      def generate_hierarchical_summary(messages)
        level = @compression_level

        # Extract key information from messages
        extracted = extract_key_information(messages)

        summary_text = case level
        when 1
          generate_level1_summary(extracted)
        when 2
          generate_level2_summary(extracted)
        when 3
          generate_level3_summary(extracted)
        else
          generate_level4_summary(extracted)
        end

        {
          role: "user",
          content: "[SYSTEM][COMPRESSION LEVEL #{level}] #{summary_text}",
          system_injected: true,
          compression_level: level
        }
      end

      # Extract key information from messages for summarization
      def extract_key_information(messages)
        return empty_extraction_data if messages.nil?

        {
          # Message counts
          user_msgs: messages.count { |m| m[:role] == "user" },
          assistant_msgs: messages.count { |m| m[:role] == "assistant" },
          tool_msgs: messages.count { |m| m[:role] == "tool" },

          # Tools used
          tools_used: extract_from_messages(messages, :assistant) { |m| extract_tool_names(m[:tool_calls]) },

          # Files created/modified
          files_created: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :created) },
          files_modified: extract_from_messages(messages, :tool) { |m| filter_write_results(parse_write_result(m[:content]), :modified) },

          # Key decisions (limit to first 5)
          decisions: extract_from_messages(messages, :assistant) { |m| extract_decision_text(m[:content]) }.first(5),

          # Completed tasks (from TODO results)
          completed_tasks: extract_from_messages(messages, :tool) { |m| filter_todo_results(parse_todo_result(m[:content]), :completed) },

          # Current in-progress work
          in_progress: find_in_progress(messages),

          # Key results from shell commands
          shell_results: extract_from_messages(messages, :tool) { |m| parse_shell_result(m[:content]) }
        }
      end

      # Helper: safely extract from messages with proper nil handling
      def extract_from_messages(messages, role_filter = nil, &block)
        return [] if messages.nil?

        results = messages
          .select { |m| role_filter.nil? || m[:role] == role_filter.to_s }
          .map(&block)
          .compact

        # Flatten if we have nested arrays (from methods returning arrays of items)
        results.any? { |r| r.is_a?(Array) } ? results.flatten.uniq : results.uniq
      end

      # Helper: extract tool names from tool_calls
      def extract_tool_names(tool_calls)
        return [] unless tool_calls.is_a?(Array)
        tool_calls.map { |tc| tc.dig(:function, :name) }
      end

      # Helper: filter write results by action
      def filter_write_results(result, action)
        result && result[:action] == action ? result[:file] : nil
      end

      # Helper: filter todo results by status
      def filter_todo_results(result, status)
        result && result[:status] == status ? result[:task] : nil
      end

      # Helper: extract decision text from content (returns array of decisions or empty array)
      def extract_decision_text(content)
        return [] unless content.is_a?(String)
        return [] unless content.include?("decision") || content.include?("chose to") || content.include?("using")

        sentences = content.split(/[.!?]/).select do |s|
          s.include?("decision") || s.include?("chose") || s.include?("using") ||
          s.include?("decided") || s.include?("will use") || s.include?("selected")
        end
        sentences.map(&:strip).map { |s| s[0..100] }
      end

      # Helper: find in-progress task
      def find_in_progress(messages)
        return nil if messages.nil?

        messages.reverse_each do |m|
          if m[:role] == "tool"
            content = m[:content].to_s
            if content.include?("in progress") || content.include?("working on")
              return content[/[Tt]ODO[:\s]+(.+)/, 1]&.strip || content[/[Ww]orking[Oo]n[:\s]+(.+)/, 1]&.strip
            end
          end
        end
        nil
      end

      # Helper: empty extraction data
      def empty_extraction_data
        {
          user_msgs: 0,
          assistant_msgs: 0,
          tool_msgs: 0,
          tools_used: [],
          files_created: [],
          files_modified: [],
          decisions: [],
          completed_tasks: [],
          in_progress: nil,
          shell_results: []
        }
      end

      def parse_write_result(content)
        return nil unless content.is_a?(String)

        # Check for "Created: path" or "Updated: path" patterns
        if content.include?("Created:")
          { action: :created, file: content[/Created:\s*(.+)/, 1]&.strip }
        elsif content.include?("Updated:") || content.include?("modified")
          { action: :modified, file: content[/Updated:\s*(.+)/, 1]&.strip || content[/File written to:\s*(.+)/, 1]&.strip }
        else
          nil
        end
      end

      def parse_todo_result(content)
        return nil unless content.is_a?(String)

        if content.include?("completed")
          { status: :completed, task: content[/completed[:\s]*(.+)/i, 1]&.strip || "task" }
        elsif content.include?("added")
          { status: :added, task: content[/added[:\s]*(.+)/i, 1]&.strip || "task" }
        else
          nil
        end
      end

      def parse_shell_result(content)
        return nil unless content.is_a?(String)

        if content.include?("passed") || content.include?("success")
          "tests passed"
        elsif content.include?("failed") || content.include?("error")
          "command failed"
        elsif content =~ /bundle install|npm install|go mod download/
          "dependencies installed"
        elsif content.include?("Installed")
          content[/Installed:\s*(.+)/, 1]&.strip
        else
          nil
        end
      end

      # Level 1: Detailed summary (for first compression)
      def generate_level1_summary(data)
        parts = []

        parts << "Previous conversation summary (#{data[:user_msgs]} user requests, #{data[:assistant_msgs]} responses, #{data[:tool_msgs]} tool calls):"

        # Files created
        if data[:files_created].any?
          files_list = data[:files_created].map { |f| File.basename(f) }.join(", ")
          parts << "Created: #{files_list}"
        end

        # Files modified
        if data[:files_modified].any?
          files_list = data[:files_modified].map { |f| File.basename(f) }.join(", ")
          parts << "Modified: #{files_list}"
        end

        # Completed tasks
        if data[:completed_tasks].any?
          tasks_list = data[:completed_tasks].first(3).join(", ")
          parts << "Completed: #{tasks_list}"
        end

        # In progress
        if data[:in_progress]
          parts << "In Progress: #{data[:in_progress]}"
        end

        # Key decisions
        if data[:decisions].any?
          decisions_text = data[:decisions].map { |d| d.gsub(/\n/, " ").strip }.join("; ")
          parts << "Decisions: #{decisions_text}"
        end

        # Tools used
        if data[:tools_used].any?
          parts << "Tools: #{data[:tools_used].join(', ')}"
        end

        parts << "Continuing with recent conversation..."
        parts.join("\n")
      end

      # Level 2: Concise summary (for second compression)
      def generate_level2_summary(data)
        parts = []

        parts << "Conversation summary:"

        # Key files (limit to most important)
        all_files = (data[:files_created] + data[:files_modified]).uniq
        if all_files.any?
          key_files = all_files.first(5).map { |f| File.basename(f) }.join(", ")
          parts << "Files: #{key_files}"
        end

        # Key accomplishments
        accomplishments = []
        accomplishments << "#{data[:completed_tasks].size} tasks completed" if data[:completed_tasks].any?
        accomplishments << "#{data[:tool_msgs]} tools executed" if data[:tool_msgs] > 0
        accomplishments << "Level #{data[:completed_tasks].size + 1} progress" if data[:in_progress]

        parts << accomplishments.join(", ") if accomplishments.any?

        parts << "Recent context follows..."
        parts.join("\n")
      end

      # Level 3: Minimal summary (for third compression)
      def generate_level3_summary(data)
        parts = []

        parts << "Project progress:"

        # Just counts and key items
        all_files = (data[:files_created] + data[:files_modified]).uniq
        parts << "#{all_files.size} files modified, #{data[:completed_tasks].size} tasks done"

        if data[:in_progress]
          parts << "Currently: #{data[:in_progress]}"
        end

        parts << "See recent messages for details."
        parts.join("\n")
      end

      # Level 4: Ultra-minimal summary (for fourth+ compression)
      def generate_level4_summary(data)
        all_files = (data[:files_created] + data[:files_modified]).uniq
        "Progress: #{data[:completed_tasks].size} tasks, #{all_files.size} files. Recent: #{data[:tools_used].last(3).join(', ')}"
      end
    end
  end
end