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_messagesObject

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



97
98
99
100
101
102
103
# File 'lib/clacky/agent/time_machine.rb', line 97

def active_messages
  return @history.to_api if @active_task_id == @current_task_id

  @history.for_task(@active_task_id).map do |msg|
    msg.reject { |k, _| MessageHistory::INTERNAL_FIELDS.include?(k) }
  end
end

#get_child_tasks(task_id) ⇒ Object

Get children of a task (for branch detection)



137
138
139
# File 'lib/clacky/agent/time_machine.rb', line 137

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



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

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)



121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/clacky/agent/time_machine.rb', line 121

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



106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/clacky/agent/time_machine.rb', line 106

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