Class: Clacky::MessageHistory

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/message_history.rb

Overview

MessageHistory wraps the conversation message list and exposes business-meaningful operations instead of raw array manipulation.

Internal fields (task_id, created_at, system_injected, etc.) are kept in the internal store but stripped when calling #to_api.

Constant Summary collapse

INTERNAL_FIELDS =

Fields that are internal to the agent and must not be sent to the API.

%i[
  task_id created_at system_injected session_context memory_update
  subagent_instructions subagent_result token_usage
  compressed_summary chunk_path truncated transient
].freeze

Instance Method Summary collapse

Constructor Details

#initialize(messages = []) ⇒ MessageHistory

Returns a new instance of MessageHistory.



17
18
19
# File 'lib/clacky/message_history.rb', line 17

def initialize(messages = [])
  @messages = messages.dup
end

Instance Method Details

#append(message) ⇒ Object

Append a single message hash to the history.

When appending a user message, automatically drop any trailing assistant message that has unanswered tool_calls (no tool_result follows it). This prevents API error 2013 (“tool call result does not follow tool call”) when a previous task ended before observe() could append tool results (e.g. subagent crash, interrupt, or error).



32
33
34
35
36
37
38
# File 'lib/clacky/message_history.rb', line 32

def append(message)
  if message[:role] == "user"
    drop_dangling_tool_calls!
  end
  @messages << message
  self
end

#delete_where(&block) ⇒ Object

Remove all messages matching the block in-place (e.g. cleanup_memory_messages uses reject! { m }).



72
73
74
75
# File 'lib/clacky/message_history.rb', line 72

def delete_where(&block)
  @messages.reject!(&block)
  self
end

#empty?Boolean

Returns:

  • (Boolean)


161
162
163
# File 'lib/clacky/message_history.rb', line 161

def empty?
  @messages.empty?
end

#estimate_tokensObject

Estimate total token count for all messages. Uses the ~4 chars/token heuristic (works well for English/code). Handles string content, array content blocks, and tool_calls.



168
169
170
# File 'lib/clacky/message_history.rb', line 168

def estimate_tokens
  @messages.sum { |m| estimate_message_tokens(m) }
end

#for_task(task_id) ⇒ Object

Return all messages where task_id <= given id (Time Machine support).



143
144
145
# File 'lib/clacky/message_history.rb', line 143

def for_task(task_id)
  @messages.select { |m| !m[:task_id] || m[:task_id] <= task_id }
end

#last_real_user_indexObject

Return the index of the last real (non-system-injected) user message. Used by restore_session to trim back to a clean state on error.



133
134
135
# File 'lib/clacky/message_history.rb', line 133

def last_real_user_index
  @messages.rindex { |m| m[:role] == "user" && !m[:system_injected] }
end

#last_session_context_dateObject

Return the session_date value from the most recent session_context message. Used by inject_session_context_if_needed to avoid re-injecting on the same date.



121
122
123
124
# File 'lib/clacky/message_history.rb', line 121

def last_session_context_date
  msg = @messages.reverse.find { |m| m[:session_context] }
  msg&.dig(:session_date)
end

#mutate_last_matching(predicate, &block) ⇒ Object

Mutate the last message matching the predicate lambda in-place. Used by execute_skill_with_subagent to update instruction messages.



79
80
81
82
83
# File 'lib/clacky/message_history.rb', line 79

def mutate_last_matching(predicate, &block)
  msg = @messages.reverse.find { |m| predicate.call(m) }
  block.call(msg) if msg
  self
end

#pending_tool_calls?Boolean

True when the last assistant message has tool_calls but no tool_result has been appended yet (would cause a 400 from the API).

Returns:

  • (Boolean)


108
109
110
111
112
113
114
115
116
117
# File 'lib/clacky/message_history.rb', line 108

def pending_tool_calls?
  return false if @messages.empty?

  last = @messages.last
  return false unless last[:role] == "assistant" && last[:tool_calls]&.any?

  # Check that there is no tool result message after this assistant message
  last_assistant_idx = @messages.rindex { |m| m == last }
  @messages[(last_assistant_idx + 1)..].none? { |m| m[:role] == "tool" || m[:tool_results] }
end

#pop_lastObject

Remove and return the last message.



60
61
62
# File 'lib/clacky/message_history.rb', line 60

def pop_last
  @messages.pop
end

#pop_while(&block) ⇒ Object

Remove messages from the end while the block is truthy.



65
66
67
68
# File 'lib/clacky/message_history.rb', line 65

def pop_while(&block)
  @messages.pop while !@messages.empty? && block.call(@messages.last)
  self
end

#real_user_messagesObject

Return only real (non-system-injected) user messages.



127
128
129
# File 'lib/clacky/message_history.rb', line 127

def real_user_messages
  @messages.select { |m| m[:role] == "user" && !m[:system_injected] }
end

#recent_truncation_count(n) ⇒ Object

Count how many of the last N messages have :truncated set. Used by think() to guard against infinite truncation retry loops.



149
150
151
# File 'lib/clacky/message_history.rb', line 149

def recent_truncation_count(n)
  @messages.last(n).count { |m| m[:truncated] }
end

#replace_all(new_messages) ⇒ Object

Replace the entire message list (used by compression rebuild).



54
55
56
57
# File 'lib/clacky/message_history.rb', line 54

def replace_all(new_messages)
  @messages = new_messages.dup
  self
end

#replace_system_prompt(content, **extra) ⇒ Object

Replace (or insert at head) the system prompt message. Used by session_serializer#refresh_system_prompt.



42
43
44
45
46
47
48
49
50
51
# File 'lib/clacky/message_history.rb', line 42

def replace_system_prompt(content, **extra)
  msg = { role: "system", content: content }.merge(extra)
  idx = @messages.index { |m| m[:role] == "system" }
  if idx
    @messages[idx] = msg
  else
    @messages.unshift(msg)
  end
  self
end

#rollback_before(message) ⇒ Object

Roll back the history to just before the given message object. Removes the message and anything appended after it. Used to undo a failed speculative append (e.g. compression message that errored).



94
95
96
97
98
99
100
# File 'lib/clacky/message_history.rb', line 94

def rollback_before(message)
  idx = @messages.index { |m| m.equal?(message) }
  return self unless idx

  @messages = @messages[0...idx]
  self
end

#sizeObject

─────────────────────────────────────────────Size helpers ─────────────────────────────────────────────



157
158
159
# File 'lib/clacky/message_history.rb', line 157

def size
  @messages.size
end

#subagent_instruction_messageObject

Return the message with :subagent_instructions set.



138
139
140
# File 'lib/clacky/message_history.rb', line 138

def subagent_instruction_message
  @messages.find { |m| m[:subagent_instructions] }
end

#to_aObject

Return a shallow copy of the message list, excluding transient messages. Transient messages (e.g. brand skill instructions) are valid during the current session but must not be persisted to session.json. For serialization, compression, and cloning.



186
187
188
# File 'lib/clacky/message_history.rb', line 186

def to_a
  @messages.reject { |m| m[:transient] }.dup
end

#to_apiObject

Return a clean copy of messages suitable for sending to the LLM API:

  • strips internal-only fields



178
179
180
# File 'lib/clacky/message_history.rb', line 178

def to_api
  @messages.map { |m| strip_internal_fields(m) }
end

#truncate_from(index) ⇒ Object

Remove all messages from index onward (used by restore_session on error).



86
87
88
89
# File 'lib/clacky/message_history.rb', line 86

def truncate_from(index)
  @messages = @messages[0...index]
  self
end