Module: Clacky::Agent::TimeMachine

Included in:
Clacky::Agent
Defined in:
lib/clacky/agent/time_machine.rb

Overview

Time Machine module for task history management with undo/redo support Stores complete file snapshots (AFTER state) to support message compression

Instance Method Summary collapse

Instance Method Details

#active_messages(force_reasoning_content_pad: false) ⇒ Object

Filter messages to only show tasks up to active_task_id. This hides “future” messages when user has undone. Returns API-ready array (strips internal fields + handles orphaned tool_calls). Made public for testing

Parameters:

  • force_reasoning_content_pad (Boolean) (defaults to: false)

    forwarded to MessageHistory, enables one-shot pad-and-retry for thinking-mode providers that require reasoning_content on every assistant message.



100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/clacky/agent/time_machine.rb', line 100

def active_messages(force_reasoning_content_pad: false)
  if @active_task_id == @current_task_id
    return @history.to_api(force_reasoning_content_pad: force_reasoning_content_pad)
  end

  stripped = @history.for_task(@active_task_id).map do |msg|
    msg.reject { |k, _| MessageHistory::INTERNAL_FIELDS.include?(k) }
  end
  # Apply the same reasoning_content padding rule used by to_api so
  # Time Machine replays satisfy thinking-mode providers after a
  # 400 retry.
  MessageHistory.pad_reasoning_content_if_needed(stripped, force: force_reasoning_content_pad)
end

#get_child_tasks(task_id) ⇒ Object

Get children of a task (for branch detection)



146
147
148
# File 'lib/clacky/agent/time_machine.rb', line 146

def get_child_tasks(task_id)
  @task_parents.select { |_, parent| parent == task_id }.keys
end

#get_task_history(limit: 10) ⇒ Array<Hash>

Get task history with summaries for UI display

Parameters:

  • limit (Integer) (defaults to: 10)

    Maximum number of recent tasks to return

Returns:

  • (Array<Hash>)

    Task history with metadata



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
# File 'lib/clacky/agent/time_machine.rb', line 153

def get_task_history(limit: 10)
  return [] if @current_task_id == 0

  tasks = []
  (1..@current_task_id).to_a.reverse.take(limit).reverse.each do |task_id|
    # Find first user message for this task
    first_user_msg = @history.to_a.find do |msg|
      msg[:task_id] == task_id && msg[:role] == "user"
    end

    summary = if first_user_msg
      content = extract_message_text(first_user_msg[:content])
      # Truncate to 60 characters (including "...")
      content.length > 60 ? "#{content[0...57]}..." : content
    else
      "Task #{task_id}"
    end

    # Determine task status
    status = if task_id == @active_task_id
      :current
    elsif task_id < @active_task_id
      :past
    else
      :future
    end

    # Check if task has branches (multiple children)
    children = get_child_tasks(task_id)
    has_branches = children.length > 1

    tasks << {
      task_id: task_id,
      summary: summary,
      status: status,
      has_branches: has_branches
    }
  end

  tasks
end

#restore_to_task_state(task_id) ⇒ Object

Restore files to the state at given task Made public for testing

Parameters:

  • task_id (Integer)

    Target task ID



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
# File 'lib/clacky/agent/time_machine.rb', line 59

def restore_to_task_state(task_id)
  # Collect all modified files from task 1 to target task
  files_to_restore = {}
  
  (1..task_id).each do |tid|
    snapshot_dir = File.join(
      Dir.home,
      ".clacky",
      "snapshots",
      @session_id,
      "task-#{tid}"
    )
    
    next unless Dir.exist?(snapshot_dir)
    
    Dir.glob(File.join(snapshot_dir, "**", "*")).each do |snapshot_file|
      next if File.directory?(snapshot_file)
      
      relative_path = snapshot_file.sub(snapshot_dir + "/", "")
      files_to_restore[relative_path] = snapshot_file
    end
  end
  
  # Restore files
  files_to_restore.each do |relative_path, snapshot_file|
    target_file = File.join(@working_dir, relative_path)
    FileUtils.mkdir_p(File.dirname(target_file))
    FileUtils.cp(snapshot_file, target_file)
  end
rescue StandardError => e
  # Silently handle errors in tests
  raise
end

#save_modified_files_snapshot(modified_files) ⇒ Object

Save snapshots of modified files (AFTER state) Made public for testing

Parameters:

  • modified_files (Array<String>)

    List of file paths that were modified



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
# File 'lib/clacky/agent/time_machine.rb', line 29

def save_modified_files_snapshot(modified_files)
  return if modified_files.nil? || modified_files.empty?

  snapshot_dir = File.join(
    Dir.home,
    ".clacky",
    "snapshots",
    @session_id,
    "task-#{@current_task_id}"
  )
  FileUtils.mkdir_p(snapshot_dir)

  modified_files.each do |file_path|
    next unless File.exist?(file_path)

    # Save file content to snapshot
    relative_path = file_path.start_with?(@working_dir) ?
      file_path.sub(@working_dir + "/", "") : File.basename(file_path)
    
    snapshot_file = File.join(snapshot_dir, relative_path)
    FileUtils.mkdir_p(File.dirname(snapshot_file))
    FileUtils.cp(file_path, snapshot_file)
  end
rescue StandardError => e
  # Silently handle errors in tests
end

#start_new_taskObject

Start a new task and establish parent relationship Made public for testing



17
18
19
20
21
22
23
24
# File 'lib/clacky/agent/time_machine.rb', line 17

def start_new_task
  parent_id = @active_task_id
  @current_task_id += 1
  @active_task_id = @current_task_id
  @task_parents[@current_task_id] = parent_id

  @current_task_id
end

#switch_to_task(target_task_id) ⇒ Object

Switch to specific task (for redo or branch switching)



130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/clacky/agent/time_machine.rb', line 130

def switch_to_task(target_task_id)
  if target_task_id > @current_task_id || target_task_id < 1
    return { success: false, message: "Invalid task ID: #{target_task_id}" }
  end

  restore_to_task_state(target_task_id)
  @active_task_id = target_task_id

  {
    success: true,
    message: "⏩ Switched to task #{target_task_id}",
    task_id: target_task_id
  }
end

#undo_last_taskObject

Undo to parent task



115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/clacky/agent/time_machine.rb', line 115

def undo_last_task
  parent_id = @task_parents[@active_task_id]
  return { success: false, message: "Already at root task" } if parent_id.nil? || parent_id == 0

  restore_to_task_state(parent_id)
  @active_task_id = parent_id

  {
    success: true,
    message: "⏪ Undone to task #{parent_id}",
    task_id: parent_id
  }
end