项目文件夹

文件
wehub-resource-sync 9f2e1cd2af
Ruby / Ruby 4.0.5 (push) Failing after 5m13s
Ruby / Ruby 3.3.4 (push) Failing after 5m43s
Ruby / Ruby 2.6.8 (push) Failing after 16m2s
chore: import upstream snapshot with attribution
2026-07-13 12:33:07 +08:00

251 行
12 KiB
Ruby

# frozen_string_literal: true
module Clacky
# Message compressor using Insert-then-Compress strategy
#
# New Strategy: Instead of creating a separate API call for compression,
# we insert a compression instruction into the current conversation flow.
# This allows us to reuse the existing cache (system prompt + tools) and
# only pay for processing the new compression instruction.
#
# Flow:
# 1. Agent detects compression threshold is reached
# 2. Compressor builds a compression instruction message
# 3. Agent inserts this message and calls LLM (with cache reuse!)
# 4. LLM returns compressed summary
# 5. Compressor rebuilds message list: system + summary + recent messages
# 6. Agent continues with new message list (cache will rebuild from here)
#
# Benefits:
# - Compression call reuses existing cache (huge token savings)
# - Only one cache rebuild after compression (vs two with old approach)
#
class MessageCompressor
COMPRESSION_PROMPT = <<~PROMPT.freeze
═══════════════════════════════════════════════════════════════
CRITICAL: TASK CHANGE - MEMORY COMPRESSION MODE
═══════════════════════════════════════════════════════════════
The conversation above has ENDED. You are now in MEMORY COMPRESSION MODE.
CRITICAL INSTRUCTIONS - READ CAREFULLY:
1. This is NOT a continuation of the conversation
2. DO NOT respond to any requests in the conversation above
3. DO NOT call ANY tools or functions
4. DO NOT use tool_calls in your response
5. Your response MUST be PURE TEXT ONLY
YOUR ONLY TASK: Create a comprehensive summary of the conversation above.
REQUIRED RESPONSE FORMAT:
First output a <topics> line listing 3-6 key topic phrases (comma-separated, concise).
Then output a <continues_previous> line: "true" if this conversation is a direct
continuation of the SAME task/topic as the PREVIOUS chunk shown below, "false" if it
has moved on to a different task or topic. If there is no previous chunk, output "false".
Then output the full summary wrapped in <summary> tags.
Example format:
<topics>Rails setup, database config, deploy pipeline, Tailwind CSS</topics>
<continues_previous>false</continues_previous>
<summary>
...full summary text...
</summary>
Focus on:
- User's explicit requests and intents
- Key technical concepts and code changes
- Files examined and modified
- Errors encountered and fixes applied
- Current work status and pending tasks
Begin your response NOW. Remember: PURE TEXT only, starting with <topics> then
<continues_previous> then <summary>.
PROMPT
def initialize(client, model: nil)
@client = client
@model = model
end
# Generate compression instruction message to be inserted into conversation
# This enables cache reuse by using the same API call with tools
#
# SIMPLIFIED APPROACH:
# - Don't duplicate conversation history in the compression message
# - LLM can already see all messages, just ask it to compress
# - Keep the instruction small for better cache efficiency
#
# @param messages [Array<Hash>] Original conversation messages
# @param recent_messages [Array<Hash>] Recent messages to keep uncompressed (optional)
# @param previous_topics [String, nil] Topics of the most recent chunk on disk,
# shown to the LLM so it can decide whether the current conversation is a
# continuation (drives the <continues_previous> output for chunk merging).
# @return [Hash] Compression instruction message to insert, or nil if nothing to compress
def build_compression_message(messages, recent_messages: [], previous_topics: nil)
# Get messages to compress (exclude system message and recent messages)
messages_to_compress = messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
# If nothing to compress, return nil
return nil if messages_to_compress.empty?
content = COMPRESSION_PROMPT
if previous_topics && !previous_topics.strip.empty?
content = "#{COMPRESSION_PROMPT}\n\nPREVIOUS CHUNK TOPICS (for <continues_previous> judgement): #{previous_topics}"
end
{
role: "user",
content: content,
system_injected: true
}
end
# Parse LLM response and rebuild message list with compression
# @param compressed_content [String] The compressed summary from LLM
# @param original_messages [Array<Hash>] Original messages before compression
# @param recent_messages [Array<Hash>] Recent messages to preserve
# @param chunk_path [String, nil] Path to the archived chunk MD file (if saved)
# @param pulled_back_messages [Array<Hash>] Messages temporarily popped from the
# tail of @history before the compression LLM call (to free up token budget so
# the compression call itself doesn't overflow context). These are NOT discarded —
# they are reattached to the tail of the rebuilt history so recent task progress
# is preserved. Default: [] (normal compression path doesn't need this).
# @return [Array<Hash>] Rebuilt message list: system + compressed + recent + pulled_back
def rebuild_with_compression(compressed_content, original_messages:, recent_messages:, chunk_path: nil, topics: nil, previous_chunks: [], pulled_back_messages: [])
# Find and preserve system message
system_msg = original_messages.find { |m| m[:role] == "system" }
# Parse the compressed result, embedding previous chunk references so the
# new summary carries a complete index of all older archives. This avoids
# keeping all prior compressed_summary messages in active history while
# still giving the AI a path to find old conversations via file_reader.
parsed_messages = parse_compressed_result(compressed_content,
chunk_path: chunk_path,
topics: topics,
previous_chunks: previous_chunks)
# If parsing fails or returns empty, raise error
if parsed_messages.nil? || parsed_messages.empty?
raise "LLM compression failed: unable to parse compressed messages"
end
# Return system message + compressed messages + recent messages + pulled_back messages.
# Strip any system messages from recent_messages as a safety net —
# get_recent_messages_with_tool_pairs already excludes them, but this
# guard ensures we never end up with duplicate system prompts even if
# the caller passes an unfiltered list.
#
# pulled_back_messages: messages that were temporarily popped from the tail
# of @history before the compression LLM call (to free up token budget so
# the compression call itself doesn't overflow context). They are reattached
# here to preserve recent task progress.
safe_recent = recent_messages.reject { |m| m[:role] == "system" }
safe_pulled_back = pulled_back_messages.reject { |m| m[:role] == "system" }
[system_msg, *parsed_messages, *safe_recent, *safe_pulled_back].compact
end
# Parse topics tag from compressed content.
# Returns the topics string if found, nil otherwise.
# e.g. "<topics>Rails setup, database config</topics>" → "Rails setup, database config"
def parse_topics(content)
return nil if content.nil? || content.to_s.empty?
m = content.to_s.match(/<topics>(.*?)<\/topics>/m)
m ? m[1].strip : nil
end
# Parse the <continues_previous> tag. Returns true only when the LLM
# explicitly says "true"; missing tag or any other value → false.
# This conservative default ensures we never merge unless the model is sure.
def parse_continues_previous(content)
return false if content.nil? || content.to_s.empty?
m = content.to_s.match(/<continues_previous>(.*?)<\/continues_previous>/m)
m ? m[1].strip.downcase == "true" : false
end
def parse_compressed_result(result, chunk_path: nil, topics: nil, previous_chunks: [])
# Return the compressed result as a single user message (role: "user").
#
# Why role:"user" instead of "assistant":
# When all original user messages get archived into the chunk during compression
# (e.g. a long single-turn `/slash` task), the rebuilt history can end up as
# `system → assistant(summary) → assistant(tool_calls) → tool → …` with NO user
# message anywhere. Strict providers (notably DeepSeek V4 thinking mode) reject
# this as a malformed turn structure with a misleading
# "reasoning_content must be passed back" 400 error.
#
# Marking it as a user message gives the conversation a valid turn boundary.
# `system_injected: true` ensures the UI's replay_history still hides it from
# the chat panel (the real-user filter excludes system_injected messages), while
# INTERNAL_FIELDS in MessageHistory strips the marker before the API payload is
# built — so DeepSeek/OpenAI/Anthropic only see a plain `{role:"user", content:…}`.
#
# The `compressed_summary: true` flag is preserved so that replay_history still
# routes this message through the chunk-expansion path (which keys off that flag,
# not the role).
#
# @param topics [String, nil] Short topic description extracted from <topics> tag
# @param previous_chunks [Array<Hash>] Info about older chunk files
# Each hash: { basename:, path:, topics: }
content = result.to_s.strip
if content.empty?
[]
else
# Strip out the <topics> and <continues_previous> blocks — they're
# metadata for chunk handling, not for AI context.
content_without_topics = content.gsub(/<topics>.*?<\/topics>\n*/m, "")
.gsub(/<continues_previous>.*?<\/continues_previous>\n*/m, "")
.strip
# Build previous chunks index section — links to older chunk files so the AI
# can find earlier conversations without keeping all prior compressed_summary
# messages in the active history. Shows newest chunks first (reverse order),
# capped at 10 to keep the message size bounded.
previous_chunks_section = ""
if previous_chunks.any?
max_visible = 10
visible = previous_chunks.last(max_visible).reverse
older_count = previous_chunks.size - visible.size
previous_chunks_section = "\n\n---\n📁 **Previous chunks (newest first):**\n"
visible.each do |pc|
topic_str = pc[:topics] ? " #{pc[:topics]}" : ""
previous_chunks_section += "- `#{pc[:basename]}`#{topic_str}\n"
end
if older_count > 0
oldest = previous_chunks.first
previous_chunks_section += "- ... and #{older_count} older chunks back to `#{oldest[:basename]}`\n"
end
previous_chunks_section += "_Use `file_reader` to recall details from these chunks._"
end
# Inject chunk anchor so AI knows where to find original conversation for THIS chunk
anchor = ""
if chunk_path
anchor = "\n\n---\n📁 **Current chunk archived at:** `#{chunk_path}`\n" \
"_Use `file_reader` tool to recall details from this chunk._"
end
# Prefix lets the model recognise this is injected context, not a user utterance.
# Order: summary → previous chunks → current anchor (chronological)
framed_content = "[Compressed conversation summary — previous turns archived]\n\n" \
"#{content_without_topics}" \
"#{previous_chunks_section}" \
"#{anchor}"
[{
role: "user",
content: framed_content,
compressed_summary: true,
chunk_path: chunk_path,
topics: topics,
system_injected: true
}]
end
end
end
end