Module: Clacky::Agent::SessionSerializer
- Included in:
- Clacky::Agent
- Defined in:
- lib/clacky/agent/session_serializer.rb
Overview
Session serialization for saving and restoring agent state Handles session data serialization and deserialization
Instance Method Summary collapse
-
#_replay_single_message(msg, ui) ⇒ Object
Render a single non-user message into the UI.
-
#extract_image_files_from_content(content) ⇒ Object
Extract images from a multipart content array and return them as file entries.
-
#extract_images_from_content(content) ⇒ Array<String>
Extract base64 data URLs from multipart content (image blocks).
-
#extract_text_from_content(content) ⇒ String
Extract text from message content (handles string and array formats).
-
#get_recent_user_messages(limit: 5) ⇒ Array<String>
Get recent user messages from conversation history.
-
#refresh_system_prompt ⇒ Object
Replace the system message in @messages with a freshly built system prompt.
-
#replay_history(ui, limit: 20, before: nil) ⇒ Hash
Replay conversation history by calling ui.show_* methods for each message.
-
#restore_session(session_data) ⇒ Object
Restore from a saved session.
-
#to_session_data(status: :success, error_message: nil) ⇒ Hash
Generate session data for saving.
Instance Method Details
#_replay_single_message(msg, ui) ⇒ Object
Render a single non-user message into the UI. Used by both the normal round-based replay and the compressed-session fallback.
390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 |
# File 'lib/clacky/agent/session_serializer.rb', line 390 def (msg, ui) return if msg[:system_injected] case msg[:role].to_s when "assistant" # Text content text = extract_text_from_content(msg[:content]).to_s.strip ui.(text, files: []) unless text.empty? # Tool calls embedded in assistant message Array(msg[:tool_calls]).each do |tc| name = tc[:name] || tc.dig(:function, :name) || "" args_raw = tc[:arguments] || tc.dig(:function, :arguments) || {} args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue args_raw) : args_raw # Special handling: request_user_feedback question is shown as an # assistant message (matching real-time behavior), not as a tool call. # Reconstruct the full formatted message including options (mirrors RequestUserFeedback#execute). if name == "request_user_feedback" question = args.is_a?(Hash) ? (args[:question] || args["question"]).to_s : "" context = args.is_a?(Hash) ? (args[:context] || args["context"]).to_s : "" = args.is_a?(Hash) ? (args[:options] || args["options"]) : nil unless question.empty? parts = [] parts << "**Context:** #{context.strip}" << "" unless context.strip.empty? parts << "**Question:** #{question.strip}" if && !.empty? parts << "" << "**Options:**" .each_with_index { |opt, i| parts << " #{i + 1}. #{opt}" } end ui.(parts.join("\n"), files: []) end else ui.show_tool_call(name, args) end end # Emit token usage stored on this message (for history replay display) ui.show_token_usage(msg[:token_usage]) if msg[:token_usage] when "user" # Anthropic-format tool results (role: user, content: array of tool_result blocks) return unless msg[:content].is_a?(Array) msg[:content].each do |blk| next unless blk.is_a?(Hash) && blk[:type] == "tool_result" ui.show_tool_result(blk[:content].to_s) end when "tool" # OpenAI-format tool result ui.show_tool_result(msg[:content].to_s) end end |
#extract_image_files_from_content(content) ⇒ Object
Extract images from a multipart content array and return them as file entries. Returns an array of { name:, mime_type:, data_url: } hashes — the same structure that the frontend sends via ‘files` in a message, and that show_user_message(files:) expects. Only includes inline data_url images (not remote URLs).
513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 |
# File 'lib/clacky/agent/session_serializer.rb', line 513 def extract_image_files_from_content(content) return [] unless content.is_a?(Array) content.each_with_index.filter_map do |block, idx| next unless block.is_a?(Hash) # OpenAI-style: { type: "image_url", image_url: { url: "data:image/png;base64,..." } } next unless block[:type] == "image_url" url = block.dig(:image_url, :url) # image_path is stored at send-time so replay can reconstruct the image from tmp path = block[:image_path] next unless url&.start_with?("data:") || path mime_type = (url || "")[/\Adata:([^;]+);/, 1] || "image/jpeg" ext = mime_type.split("/").last { name: "image_#{idx + 1}.#{ext}", mime_type: mime_type, data_url: url, path: path } end end |
#extract_images_from_content(content) ⇒ Array<String>
Extract base64 data URLs from multipart content (image blocks). Returns an empty array when there are no images or content is plain text.
467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 |
# File 'lib/clacky/agent/session_serializer.rb', line 467 def extract_images_from_content(content) return [] unless content.is_a?(Array) content.filter_map do |block| next unless block.is_a?(Hash) case block[:type].to_s when "image_url" # OpenAI format: { type: "image_url", image_url: { url: "data:image/png;base64,..." } } block.dig(:image_url, :url) when "image" # Anthropic format: { type: "image", source: { type: "base64", media_type: "image/png", data: "..." } } source = block[:source] next unless source.is_a?(Hash) && source[:type].to_s == "base64" "data:#{source[:media_type]};base64,#{source[:data]}" when "document" # Anthropic PDF document block — return a sentinel string for frontend display source = block[:source] next unless source.is_a?(Hash) && source[:media_type].to_s == "application/pdf" # Return a special marker so the frontend can render a PDF badge instead of an <img> "pdf:#{source[:data]&.then { |d| d[0, 32] }}" # prefix to identify without full payload end end end |
#extract_text_from_content(content) ⇒ String
Extract text from message content (handles string and array formats)
497 498 499 500 501 502 503 504 505 506 507 |
# File 'lib/clacky/agent/session_serializer.rb', line 497 def extract_text_from_content(content) if content.is_a?(String) content elsif content.is_a?(Array) # Extract text from content array (may contain text and images) text_parts = content.select { |c| c.is_a?(Hash) && c[:type] == "text" } text_parts.map { |c| c[:text] }.join("\n") else content.to_s end end |
#get_recent_user_messages(limit: 5) ⇒ Array<String>
Get recent user messages from conversation history
108 109 110 111 112 |
# File 'lib/clacky/agent/session_serializer.rb', line 108 def (limit: 5) @history..last(limit).map do |msg| extract_text_from_content(msg[:content]) end end |
#refresh_system_prompt ⇒ Object
Replace the system message in @messages with a freshly built system prompt. Called after restore_session so newly installed skills and any other configuration changes since the session was saved take effect immediately. If no system message exists yet (shouldn’t happen in practice), a new one is prepended so the conversation stays well-formed.
452 453 454 455 456 457 458 459 460 461 |
# File 'lib/clacky/agent/session_serializer.rb', line 452 def refresh_system_prompt # Reload skills from disk to pick up anything installed since the session was saved @skill_loader.load_all fresh_prompt = build_system_prompt @history.replace_system_prompt(fresh_prompt) rescue StandardError => e # Log and continue — a stale system prompt is better than a broken restore Clacky::Logger.warn("refresh_system_prompt failed during session restore: #{e.}") end |
#replay_history(ui, limit: 20, before: nil) ⇒ Hash
Replay conversation history by calling ui.show_* methods for each message. Supports cursor-based pagination using created_at timestamps on user messages. Each “round” starts at a user message and includes all subsequent assistant/tool messages. Compressed chunks (chunk_path on assistant messages) are transparently expanded.
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 |
# File 'lib/clacky/agent/session_serializer.rb', line 124 def replay_history(ui, limit: 20, before: nil) # Split @history into rounds, each starting at a real user message rounds = [] current_round = nil @history.to_a.each do |msg| role = msg[:role].to_s # A real user message can have either a String content or an Array content # (Array = multipart: text + image blocks). Exclude system-injected messages # and synthetic [SYSTEM] text messages. is_real_user_msg = role == "user" && !msg[:system_injected] && if msg[:content].is_a?(String) !msg[:content].start_with?("[SYSTEM]") elsif msg[:content].is_a?(Array) # Must contain at least one text or image block (not a tool_result array) msg[:content].any? { |b| b.is_a?(Hash) && %w[text image].include?(b[:type].to_s) } else false end if is_real_user_msg # Start a new round at each real user message current_round = { user_msg: msg, events: [] } rounds << current_round elsif current_round current_round[:events] << msg elsif msg[:compressed_summary] && msg[:chunk_path] # Compressed summary sitting before any user rounds — expand it from chunk md chunk_rounds = parse_chunk_md_to_rounds(msg[:chunk_path]) rounds.concat(chunk_rounds) # After expanding, treat the last chunk round as the current round so that # any orphaned assistant/tool messages that follow in session.json (belonging # to the same task whose user message was compressed into the chunk) get # appended here instead of being silently discarded. current_round = rounds.last elsif rounds.last # Orphaned non-user message with no current_round yet (e.g. recent_messages # after compression started mid-task with no leading user message). # Attach to the last known round rather than drop silently. rounds.last[:events] << msg end end # Expand any compressed_summary assistant messages sitting inside a round's events. # These occur when compression happened mid-round (rare) — expand them in-place. rounds.each do |round| round[:events].select! { |ev| !ev[:compressed_summary] } end # Apply before-cursor filter: only rounds whose user message created_at < before if before rounds = rounds.select { |r| r[:user_msg][:created_at] && r[:user_msg][:created_at] < before } end # Fallback: when the conversation was compressed and no user messages remain in the # kept slice, render the surviving assistant/tool messages directly so the user can # still see the last visible state of the chat (e.g. compressed summary + recent work). if rounds.empty? visible = @history.to_a.reject { |m| m[:role].to_s == "system" || m[:system_injected] } visible.each { |msg| (msg, ui) } return { has_more: false } end has_more = rounds.size > limit # Take the most recent `limit` rounds page = rounds.last(limit) page.each do |round| msg = round[:user_msg] raw_text = extract_text_from_content(msg[:content]) # Images: recovered from inline image_url blocks in content (carry data_url for <img> rendering) image_files = extract_image_files_from_content(msg[:content]) # Disk files (PDF, doc, etc.): stored in display_files on the user message at send time disk_files = Array(msg[:display_files]).map { |f| { name: f[:name] || f["name"], type: f[:type] || f["type"] || "file", preview_path: f[:preview_path] || f["preview_path"] } } all_files = image_files + disk_files ui.(raw_text, created_at: msg[:created_at], files: all_files) round[:events].each do |ev| # Skip system-injected messages (e.g. synthetic skill content, memory prompts) # — they are internal scaffolding and must not be shown to the user. next if ev[:system_injected] (ev, ui) end end { has_more: has_more } end |
#restore_session(session_data) ⇒ Object
Restore from a saved session
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 |
# File 'lib/clacky/agent/session_serializer.rb', line 10 def restore_session(session_data) @session_id = session_data[:session_id] @name = session_data[:name] || "" @history = MessageHistory.new(session_data[:messages] || []) @todos = session_data[:todos] || [] # Restore todos from session @iterations = session_data.dig(:stats, :total_iterations) || 0 @total_cost = session_data.dig(:stats, :total_cost_usd) || 0.0 @working_dir = session_data[:working_dir] @created_at = session_data[:created_at] @total_tasks = session_data.dig(:stats, :total_tasks) || 0 # Restore source; fall back to :manual for sessions saved before this field existed @source = (session_data[:source] || "manual").to_sym # Restore cache statistics if available @cache_stats = session_data.dig(:stats, :cache_stats) || { cache_creation_input_tokens: 0, cache_read_input_tokens: 0, total_requests: 0, cache_hit_requests: 0 } # Restore previous_total_tokens for accurate delta calculation across sessions @previous_total_tokens = session_data.dig(:stats, :previous_total_tokens) || 0 # Restore Time Machine state @task_parents = session_data.dig(:time_machine, :task_parents) || {} @current_task_id = session_data.dig(:time_machine, :current_task_id) || 0 @active_task_id = session_data.dig(:time_machine, :active_task_id) || 0 # Check if the session ended with an error. # We record the rollback intent here but do NOT truncate history immediately — # truncating at restore time causes the history replay to return empty results, # leaving the chat panel blank on first open. # Instead, the rollback is deferred: history is trimmed lazily when the user # actually sends the next message (see run() / handle_user_message). last_status = session_data.dig(:stats, :last_status) last_error = session_data.dig(:stats, :last_error) if last_status == "error" && last_error @pending_error_rollback = true end # Rebuild and refresh the system prompt so any newly installed skills # (or other configuration changes since the session was saved) are # reflected immediately — without requiring the user to create a new session. refresh_system_prompt end |
#to_session_data(status: :success, error_message: nil) ⇒ Hash
Generate session data for saving
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 |
# File 'lib/clacky/agent/session_serializer.rb', line 62 def to_session_data(status: :success, error_message: nil) stats_data = { total_tasks: @total_tasks, total_iterations: @iterations, total_cost_usd: @total_cost.round(4), duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0, last_status: status.to_s, cache_stats: @cache_stats, debug_logs: @debug_logs, previous_total_tokens: @previous_total_tokens } # Add error message if status is error stats_data[:last_error] = if status == :error && { session_id: @session_id, name: @name, created_at: @created_at, updated_at: Time.now.iso8601, working_dir: @working_dir, source: @source.to_s, # "manual" | "cron" | "channel" | "setup" agent_profile: @agent_profile&.name || "", # "general" | "coding" | custom todos: @todos, # Include todos in session data time_machine: { # Include Time Machine state task_parents: @task_parents || {}, current_task_id: @current_task_id || 0, active_task_id: @active_task_id || 0 }, config: { # NOTE: api_key and other sensitive credentials are intentionally excluded # to prevent leaking secrets into session files on disk. permission_mode: @config..to_s, enable_compression: @config.enable_compression, enable_prompt_caching: @config.enable_prompt_caching, max_tokens: @config.max_tokens, verbose: @config.verbose }, stats: stats_data, messages: @history.to_a } end |