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.

Snapshots capture the BEFORE state of each file the moment a task first touches it (via record_file_before_change). task-N/ therefore holds “what every file looked like just before task N changed it” — including an .absent marker for files that did not yet exist. Restoring to task T replays the earliest BEFORE recorded in any task after T, which equals the on-disk state at the end of task T.

Constant Summary collapse

ABSENT_MARKER =

Marker file written alongside a snapshot path when the original file did not exist before the task changed it. Restoring such an entry deletes the file instead of copying content back.

".clacky-absent"

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.delete_session_snapshots(session_id) ⇒ Object

Remove all snapshots for a session. Safe to call when none exist.



33
34
35
36
37
# File 'lib/clacky/agent/time_machine.rb', line 33

def self.delete_session_snapshots(session_id)
  return if session_id.to_s.empty?

  FileUtils.rm_rf(session_dir(session_id))
end

.session_dir(session_id) ⇒ Object

Snapshot directory for a single session.



28
29
30
# File 'lib/clacky/agent/time_machine.rb', line 28

def self.session_dir(session_id)
  File.join(snapshots_root, session_id.to_s)
end

.snapshots_rootObject

Root directory holding per-session file snapshots.



23
24
25
# File 'lib/clacky/agent/time_machine.rb', line 23

def self.snapshots_root
  File.join(Dir.home, ".clacky", "snapshots")
end

Instance Method Details

#active_messages(force_reasoning_content_pad: false) ⇒ Object

Filter messages to only the active task’s ancestor chain. After an undo (and especially after sending a NEW message post-undo, which forks a fresh task off the undone point) the history still holds the abandoned/sibling-branch turns. We must send the LLM only the turns on the path from the root to the active task — never undone siblings. Returns API-ready array (strips internal fields + repairs orphaned tool_calls), so this stays consistent with the normal to_api path. 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.



347
348
349
350
351
352
# File 'lib/clacky/agent/time_machine.rb', line 347

def active_messages(force_reasoning_content_pad: false)
  @history.to_api(
    force_reasoning_content_pad: force_reasoning_content_pad,
    task_chain: active_task_chain
  )
end

#get_child_tasks(task_id) ⇒ Object

Get children of a task (for branch detection)



405
406
407
# File 'lib/clacky/agent/time_machine.rb', line 405

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



549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
# File 'lib/clacky/agent/time_machine.rb', line 549

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

  chain = active_task_chain

  tasks = []
  (1..@current_task_id).to_a.reverse.take(limit).reverse.each do |task_id|
    meta = (@task_meta || {})[task_id] || {}

    summary = if meta[:title] && !meta[:title].to_s.empty?
      meta[:title]
    else
      # Best-effort fallback: scan @history for the task's first real
      # user message. Returns nothing for tasks that have already been
      # compressed out — the UI then shows "Task N".
      first = @history.to_a.find do |msg|
        msg[:role] == "user" && msg[:task_id] == task_id && !msg[:system_injected]
      end
      if first
        text = extract_message_text(first[:content]).to_s.gsub(/\s+/, " ").strip
        text.length > 60 ? "#{text[0...57]}..." : text
      else
        "Task #{task_id}"
      end
    end

    # Status relative to the ACTIVE task chain (not a linear id compare),
    # so undone/abandoned branches are flagged distinctly from the path
    # the user is currently on.
    status = if task_id == @active_task_id
      :current
    elsif chain.include?(task_id)
      :past
    else
      :undone
    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,
      started_at: meta[:started_at],
      ended_at: meta[:ended_at],
      status: status,
      has_branches: has_branches,
      change_count: task_change_count(task_id),
    }
  end

  tasks
end

#preview_restore_to_task(task_id) ⇒ Array<Hash>

Preview the file-level effect of restore_to_task_state(task_id) without touching disk. Compares the resolved restore plan against the current working-dir state and returns only files that would actually change.

Returns:

  • (Array<Hash>)
    { path:, action: “create”|“modify”|“delete” }


271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/clacky/agent/time_machine.rb', line 271

def preview_restore_to_task(task_id)
  return [] unless task_id.is_a?(Integer) && task_id >= 0

  checkpoint_latest_task_after
  plan = build_restore_plan(task_id)
  changes = []

  plan.each do |rel, decision|
    target = File.join(@working_dir, rel)
    target_exists = File.exist?(target)

    if decision[:action] == :delete
      changes << { path: rel, action: "delete" } if target_exists
    else
      src = decision[:source]
      next unless src && File.exist?(src)

      if !target_exists
        changes << { path: rel, action: "create" }
      elsif !files_equal?(src, target)
        changes << { path: rel, action: "modify" }
      end
    end
  end

  changes.sort_by { |c| c[:path] }
end

#record_file_before_change(file_path) ⇒ Object

Record a file’s BEFORE state for the current task, the first time the task touches it. Call this immediately before a tool mutates the file. Subsequent calls within the same task are no-ops so the earliest state (the true “before this task” snapshot) is preserved. Made public for testing



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/clacky/agent/time_machine.rb', line 108

def record_file_before_change(file_path)
  return if @current_task_id.to_i <= 0

  full_path = File.expand_path(file_path.to_s, @working_dir)
  rel = snapshot_relative_path(full_path)
  before_dir = File.join(TimeMachine.session_dir(@session_id), "task-#{@current_task_id}", "before")
  snapshot_file = File.join(before_dir, rel)
  marker_file   = "#{snapshot_file}.#{ABSENT_MARKER}"

  # Already recorded for this task — keep the earliest capture.
  return if File.exist?(snapshot_file) || File.exist?(marker_file)

  # A fresh change to the latest task invalidates its stale AFTER checkpoint.
  @latest_after_dirty = true

  FileUtils.mkdir_p(File.dirname(snapshot_file))
  if File.exist?(full_path)
    FileUtils.cp(full_path, snapshot_file)
  else
    # File did not exist before this task — mark it so a restore deletes it.
    FileUtils.touch(marker_file)
  end
rescue StandardError
  # Snapshotting must never break the actual file operation.
end

#restore_to_task_state(task_id) ⇒ Object

Restore files to the on-disk state at the END of the given task.

History is a TREE (undo + a new message forks a sibling branch), so a linear “replay every task after T” model is wrong: a sibling branch’s files would leak in or get wrongly deleted. Instead we reconstruct T’s end state from the task tree:

* Each task owns an AFTER snapshot = the content of the files it
  touched, as they looked when that task finished.
* To rebuild "end of task T", walk T's ancestor chain (T -> root).
  For every file ever touched in the whole session, the winning
  content is the closest ancestor (starting at T) whose AFTER holds
  that file. If no ancestor on the chain ever touched it, the file
  did not exist at T and is removed.

Made public for testing

Parameters:

  • task_id (Integer)

    Target task ID



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/clacky/agent/time_machine.rb', line 188

def restore_to_task_state(task_id)
  # Freeze the task we're leaving so a later forward switch can return.
  checkpoint_latest_task_after

  plan = build_restore_plan(task_id)
  plan.each do |rel, decision|
    target = File.join(@working_dir, rel)
    if decision[:action] == :delete
      FileUtils.rm_f(target)
    else
      FileUtils.mkdir_p(File.dirname(target))
      FileUtils.cp(decision[:source], target)
    end
  end
rescue StandardError
  raise
end

#set_current_task_title(title) ⇒ Object

Update the title of the currently-active task. Used by callers that only learn the user-facing label after start_new_task has run.



90
91
92
93
94
# File 'lib/clacky/agent/time_machine.rb', line 90

def set_current_task_title(title)
  return if @active_task_id.to_i <= 0
  @task_meta[@active_task_id] ||= { started_at: Time.now.to_f, ended_at: nil }
  @task_meta[@active_task_id][:title] = truncate_task_title(title)
end

#start_new_task(title: nil) ⇒ Object

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

Parameters:

  • title (String, nil) (defaults to: nil)

    Short label for this turn (typically the user’s first message, truncated). Used by the UI to label snapshots even after the original conversation has been compressed out of @history. nil → leave unset; the UI falls back to “Task N”.



54
55
56
57
58
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
# File 'lib/clacky/agent/time_machine.rb', line 54

def start_new_task(title: nil)
  # Before the currently-active task stops being the latest, freeze its
  # end-of-task disk state into an AFTER snapshot. Without this, a task
  # that later gets superseded by a sibling branch would have no record
  # of its result, making a forward switch back to it impossible.
  checkpoint_latest_task_after

  # Close out the task we're leaving.
  if @active_task_id.to_i > 0 && @task_meta[@active_task_id]
    @task_meta[@active_task_id][:ended_at] ||= Time.now.to_f
  end

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

  @task_meta[@current_task_id] = {
    title: title ? truncate_task_title(title) : nil,
    started_at: Time.now.to_f,
    ended_at: nil,
  }

  # Claim ownership of this task for the current thread.
  # If a stale thread (e.g. a slow subagent) wakes up later it will see
  # @task_thread != Thread.current via check_stale! and self-terminate
  # before it can write to history.
  @task_thread = Thread.current

  @latest_after_dirty = true

  @current_task_id
end

#switch_to_task(target_task_id) ⇒ Object

Switch to specific task (for redo or branch switching)



389
390
391
392
393
394
395
396
397
398
399
400
401
402
# File 'lib/clacky/agent/time_machine.rb', line 389

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

#task_change_count(task_id) ⇒ Object

Cheap version of task_diff_files: just count how many distinct files this task touched, so the timeline can grey out no-op tasks without paying for a full diff walk per row.



412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
# File 'lib/clacky/agent/time_machine.rb', line 412

def task_change_count(task_id)
  return 0 unless task_id.is_a?(Integer) && task_id > 0

  session_root = TimeMachine.session_dir(@session_id)
  before_dir = File.join(session_root, "task-#{task_id}", "before")
  after_dir  = File.join(session_root, "task-#{task_id}", "after")
  return 0 unless Dir.exist?(before_dir)
  return 0 if task_id == @current_task_id && @latest_after_dirty == true && !Dir.exist?(after_dir)

  rels = Set.new
  [before_dir, after_dir].each do |root|
    next unless Dir.exist?(root)
    Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).each do |path|
      next if File.directory?(path)
      rel = path.sub(root + "/", "").sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
      rels << rel
    end
  end
  rels.size
end

#task_diff_files(task_id) ⇒ Array<Hash>

File-level summary of changes a task introduced. Diff is task-N/before vs task-N/after (after is captured by checkpoint_latest_task_after when the task stops being the latest, so this method has no useful answer for the currently-active task — callers get an empty list back).

Returns:

  • (Array<Hash>)

    Each entry: { path:, status: “added”|“modified”|“deleted”, binary: Bool }



438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
# File 'lib/clacky/agent/time_machine.rb', line 438

def task_diff_files(task_id)
  return [] unless task_id.is_a?(Integer) && task_id > 0

  session_root = TimeMachine.session_dir(@session_id)
  before_dir = File.join(session_root, "task-#{task_id}", "before")
  after_dir  = File.join(session_root, "task-#{task_id}", "after")
  return [] unless Dir.exist?(before_dir)
  return [] if task_id == @current_task_id && @latest_after_dirty == true && !Dir.exist?(after_dir)

  rels = Set.new
  [before_dir, after_dir].each do |root|
    next unless Dir.exist?(root)
    Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).each do |path|
      next if File.directory?(path)
      rel = path.sub(root + "/", "").sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
      rels << rel
    end
  end

  rels.sort.map do |rel|
    before_file, before_absent = snapshot_paths(before_dir, rel)
    after_file,  after_absent  = snapshot_paths(after_dir,  rel)

    status = if before_absent && after_file
      "added"
    elsif before_file && after_absent
      "deleted"
    elsif before_file && after_file
      "modified"
    elsif before_file && !File.exist?(after_dir)
      # No AFTER captured (e.g. the very latest task) — still surface
      # what was touched as "modified" so the UI can list the file.
      "modified"
    else
      "modified"
    end

    binary = looks_binary?(before_file) || looks_binary?(after_file)
    { path: rel, status: status, binary: binary }
  end
end

#task_file_diff(task_id, rel_path) ⇒ Hash?

Unified diff of a single file for a task. Returns nil if either side is missing or binary. text format = “@@ … @@” patch (3-context), ready for the UI to render with a diff renderer.

Returns:

  • (Hash, nil)

    { path:, before:, after:, patch:, binary: }



484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
# File 'lib/clacky/agent/time_machine.rb', line 484

def task_file_diff(task_id, rel_path)
  return nil unless task_id.is_a?(Integer) && task_id > 0
  return nil if rel_path.to_s.include?("..")

  session_root = TimeMachine.session_dir(@session_id)
  before_dir = File.join(session_root, "task-#{task_id}", "before")
  after_dir  = File.join(session_root, "task-#{task_id}", "after")

  before_file, before_absent = snapshot_paths(before_dir, rel_path)
  after_file,  after_absent  = snapshot_paths(after_dir,  rel_path)

  before_text = before_absent ? "" : (before_file ? read_text_safe(before_file) : nil)
  after_text  = after_absent  ? "" : (after_file  ? read_text_safe(after_file)  : nil)

  if before_text.nil? && after_text.nil?
    return nil
  end

  # Detect binary on either side: bail out, the UI will render a stub.
  if (before_file && looks_binary?(before_file)) || (after_file && looks_binary?(after_file))
    return { path: rel_path, before: nil, after: nil, patch: nil, binary: true }
  end

  require "diffy" unless defined?(Diffy)
  raw = Diffy::Diff.new(before_text || "", after_text || "",
                        context: 3, include_diff_info: true).to_s(:text)
  # Strip Diffy's "--- /tmp/diffy.../before" header pair: it leaks
  # tempfile paths and adds noise the UI doesn't need.
  patch = raw.sub(/\A(?:---[^\n]*\n[^\n]*\n)/, "")

  { path: rel_path, before: before_text, after: after_text, patch: patch, binary: false }
end

#undo_last_taskObject

Undo to parent task. Task 0 represents the original pre-task state, which is reachable from task 1 thanks to its BEFORE snapshots.



372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
# File 'lib/clacky/agent/time_machine.rb', line 372

def undo_last_task
  return { success: false, message: "Already at root task" } if @active_task_id == 0

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

  restore_to_task_state(parent_id)
  @active_task_id = parent_id

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