Class: Clacky::SessionManager

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

Constant Summary collapse

SESSIONS_DIR =
File.join(Dir.home, ".clacky", "sessions")

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(sessions_dir: nil) ⇒ SessionManager

Returns a new instance of SessionManager.



20
21
22
23
# File 'lib/clacky/session_manager.rb', line 20

def initialize(sessions_dir: nil)
  @sessions_dir = sessions_dir || SESSIONS_DIR
  ensure_sessions_dir
end

Class Method Details

.generate_idObject

Generate a new unique session ID (16-char hex string). This is the single authoritative source for session IDs — all components (Agent, SessionRegistry) should receive an ID generated here rather than creating their own.



16
17
18
# File 'lib/clacky/session_manager.rb', line 16

def self.generate_id
  SecureRandom.hex(8)
end

Instance Method Details

#all_sessions(current_dir: nil, limit: nil) ⇒ Object

All sessions from disk, newest-first (sorted by created_at). Optional filters:

current_dir: (String) if given, sessions matching working_dir come first
limit:       (Integer) max number of sessions to return


169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/clacky/session_manager.rb', line 169

def all_sessions(current_dir: nil, limit: nil)
  sessions = Dir.glob(File.join(@sessions_dir, "*.json")).filter_map do |filepath|
    load_session_file(filepath)
  end.sort_by { |s| s[:created_at] || "" }.reverse

  if current_dir
    current_sessions = sessions.select { |s| s[:working_dir] == current_dir }
    other_sessions   = sessions.reject { |s| s[:working_dir] == current_dir }
    sessions = current_sessions + other_sessions
  end

  limit ? sessions.first(limit) : sessions
end

#chunks_for_current(session_id, created_at) ⇒ Array<Hash>

Discover all chunk MD files on disk for a given session. Returns them sorted by chunk index ascending (oldest first).

Parameters:

  • session_id (String)

    full session id (or at least first 8 chars)

  • created_at (String)

    ISO-8601 timestamp used in the base filename

Returns:

  • (Array<Hash>)

    each with :index, :path, :basename, :topics



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/clacky/session_manager.rb', line 119

def chunks_for_current(session_id, created_at)
  return [] unless session_id && created_at

  base = chunk_base_name(session_id, created_at)
  pattern = File.join(@sessions_dir, "#{base}-chunk-*.md")

  Dir.glob(pattern).filter_map do |path|
    basename = File.basename(path)
    # Extract integer index from "<base>-chunk-<N>.md"
    m = basename.match(/-chunk-(\d+)\.md\z/)
    next nil unless m

    {
      index: m[1].to_i,
      path: path,
      basename: basename,
      topics: read_chunk_topics(path)
    }
  end.sort_by { |c| c[:index] }
end

#cleanup(days: 90) ⇒ Object

Delete sessions not accessed within the given number of days (default: 90). Returns count of deleted sessions.



288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/clacky/session_manager.rb', line 288

def cleanup(days: 90)
  cutoff = Time.now - (days * 24 * 60 * 60)
  deleted = 0
  Dir.glob(File.join(@sessions_dir, "*.json")).each do |filepath|
    session = load_session_file(filepath)
    next unless session
    if Time.parse(session[:updated_at]) < cutoff
      _hard_delete_session_with_chunks(filepath)
      deleted += 1
    end
  end
  deleted
end

#cleanup_by_count(keep:) ⇒ Object

Keep only the most recent N non-pinned sessions by created_at; the rest are soft-deleted (moved to the session trash, recoverable). Pinned sessions are never deleted and do not count toward the cap. Returns count of soft-deleted sessions.



306
307
308
309
310
311
312
313
# File 'lib/clacky/session_manager.rb', line 306

def cleanup_by_count(keep:)
  non_pinned = all_sessions.reject { |s| s[:pinned] } # already sorted newest-first
  return 0 if non_pinned.size <= keep

  victims = non_pinned[keep..]
  victims.each { |session| soft_delete(session[:session_id]) }
  victims.size
end

#cleanup_trash(days: 90) ⇒ Object

Clean up soft-deleted sessions older than :days (default: 90).



344
345
346
347
# File 'lib/clacky/session_manager.rb', line 344

def cleanup_trash(days: 90)
  require_relative "tools/trash_manager"
  Clacky::Tools::TrashManager.empty_trash_sessions(sessions_dir: @sessions_dir, days: days)
end

#delete(session_id) ⇒ Object

Soft-delete: move session JSON + chunks to the session trash directory. Returns true if found and moved, false if not found.



78
79
80
# File 'lib/clacky/session_manager.rb', line 78

def delete(session_id)
  soft_delete(session_id)
end

#ensure_sessions_dirObject



350
351
352
# File 'lib/clacky/session_manager.rb', line 350

def ensure_sessions_dir
  FileUtils.mkdir_p(@sessions_dir) unless Dir.exist?(@sessions_dir)
end

#files_for(session_id) ⇒ Object

Return the on-disk files associated with a session: the main JSON file and any “base-chunk-*.md” archive files. Used by the export / download endpoint so the UI can bundle everything a user may need for debugging. Returns nil if the session is not found, or a Hash:

{
  session:   Hash,        # the loaded session metadata
  json_path: String,      # absolute path to session.json
  chunks:    [String]     # sorted absolute paths to chunk *.md files
}


91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/clacky/session_manager.rb', line 91

def files_for(session_id)
  session = all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
  return nil unless session

  json_path = File.join(@sessions_dir, generate_filename(session[:session_id], session[:created_at]))
  return nil unless File.exist?(json_path)

  base   = File.basename(json_path, ".json")
  chunks = Dir.glob(File.join(@sessions_dir, "#{base}-chunk-*.md")).sort

  { session: session, json_path: json_path, chunks: chunks }
end

#fork(session_id) ⇒ Object

Fork a session: create a copy with new id, “(copy)” name suffix, and reset stats. Returns the forked session data hash, or nil if the original is not found.



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/clacky/session_manager.rb', line 57

def fork(session_id)
  original = load(session_id)
  return nil unless original

  forked = original.dup
  forked[:session_id]  = self.class.generate_id
  forked[:created_at]  = Time.now.iso8601
  forked[:updated_at]  = Time.now.iso8601
  forked[:pinned]      = false
  forked[:name]        = "#{original[:name] || "Unnamed session"} (copy)"
  forked[:stats] = (original[:stats] || {}).merge(
    total_tasks: 0, total_iterations: 0, total_cost_usd: 0.0,
    last_status: nil, last_error: nil
  )

  save(forked)
  forked
end

#generate_filename(session_id, created_at) ⇒ Object



354
355
356
# File 'lib/clacky/session_manager.rb', line 354

def generate_filename(session_id, created_at)
  "#{chunk_base_name(session_id, created_at)}.json"
end

#last_saved_pathObject

Path of the last saved session file.



46
47
48
# File 'lib/clacky/session_manager.rb', line 46

def last_saved_path
  @last_saved_path
end

#latest_for_directory(working_dir) ⇒ Object

Return the most recent session for a given working directory, or nil.



282
283
284
# File 'lib/clacky/session_manager.rb', line 282

def latest_for_directory(working_dir)
  all_sessions(current_dir: working_dir).first
end

#list_trash_sessionsObject

List all soft-deleted sessions (newest-first).



332
333
334
335
# File 'lib/clacky/session_manager.rb', line 332

def list_trash_sessions
  require_relative "tools/trash_manager"
  Clacky::Tools::TrashManager.list_trash_sessions(sessions_dir: @sessions_dir)
end

#load(session_id) ⇒ Object

Load a specific session by ID. Returns nil if not found.



51
52
53
# File 'lib/clacky/session_manager.rb', line 51

def load(session_id)
  all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
end

#load_session_file(filepath) ⇒ Object



410
411
412
413
414
# File 'lib/clacky/session_manager.rb', line 410

def load_session_file(filepath)
  JSON.parse(File.read(filepath), symbolize_names: true)
rescue JSON::ParserError, Errno::ENOENT
  nil
end

#next_chunk_index(session_id, created_at) ⇒ Object

Next unused chunk index for a session, derived from disk. This is the ONLY correct way to compute the next chunk index —counting compressed_summary messages in history caps at 1 after the second compression (rebuild keeps only the latest summary) and in-memory counters reset on process restart.



145
146
147
148
# File 'lib/clacky/session_manager.rb', line 145

def next_chunk_index(session_id, created_at)
  existing = chunks_for_current(session_id, created_at)
  (existing.map { |c| c[:index] }.max || 0) + 1
end

#permanent_delete_trash_session(session_id) ⇒ Object

Permanently delete one session from the trash — cannot be undone.



338
339
340
341
# File 'lib/clacky/session_manager.rb', line 338

def permanent_delete_trash_session(session_id)
  require_relative "tools/trash_manager"
  Clacky::Tools::TrashManager.permanent_delete_trash_session(session_id, sessions_dir: @sessions_dir)
end

#restore_session(session_id) ⇒ Object

Restore a soft-deleted session back to the active sessions directory.



326
327
328
329
# File 'lib/clacky/session_manager.rb', line 326

def restore_session(session_id)
  require_relative "tools/trash_manager"
  Clacky::Tools::TrashManager.restore_session(session_id, sessions_dir: @sessions_dir)
end

#save(session_data) ⇒ Object

Save a session. Returns the file path.



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/clacky/session_manager.rb', line 26

def save(session_data)
  filename = generate_filename(session_data[:session_id], session_data[:created_at])
  filepath = File.join(@sessions_dir, filename)

  File.write(filepath, JSON.pretty_generate(session_data))
  FileUtils.chmod(0o600, filepath)

  @last_saved_path = filepath

  # Keep only the most recent 200 sessions (best-effort, never block save)
  begin
    cleanup_by_count(keep: 200)
  rescue Exception # rubocop:disable Lint/RescueException
    # Cleanup is non-critical; swallow all errors (including AgentInterrupted)
  end

  filepath
end

#search_content(query, timeout: 5) ⇒ Object

Full-text grep over session JSON + chunk MD files. Case-sensitive: BSD grep -i is ~30x slower; Chinese has no case. Returns Hash<short_id String => snippet String> (snippet around the first match).



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
# File 'lib/clacky/session_manager.rb', line 186

def search_content(query, timeout: 5)
  q = query.to_s
  return {} if q.strip.length < 2

  files = Dir.glob(File.join(@sessions_dir, "*.json")) +
          Dir.glob(File.join(@sessions_dir, "*-chunk-*.md"))
  return {} if files.empty?

  result = {}
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
  each_grep_batch(files) do |batch|
    remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
    break if remaining <= 0
    out = run_with_timeout({ "LC_ALL" => "C" },
                           "grep", "-H", "-F", "-m", "1", "--",
                           q, *batch,
                           timeout: remaining)
    next unless out
    out.each_line do |line|
      path, _, rest = line.chomp.partition(":")
      next if path.empty? || rest.empty?
      sid = extract_short_id(File.basename(path))
      next unless sid
      next if result.key?(sid)
      result[sid] = build_snippet(rest, q)
    end
  end
  result
end

#soft_delete(session_id) ⇒ Object

Soft-delete: stamp deleted_at, move JSON + chunks to sessions-trash/.



320
321
322
323
# File 'lib/clacky/session_manager.rb', line 320

def soft_delete(session_id)
  require_relative "tools/trash_manager"
  Clacky::Tools::TrashManager.soft_delete_session(session_id, sessions_dir: @sessions_dir)
end

#write_chunk(session_id, created_at, chunk_index, md_content) ⇒ Object

Write a chunk MD file to disk. Returns the absolute path. Caller is responsible for generating the MD content — this method only handles filesystem concerns (path assembly, write, chmod).



153
154
155
156
157
158
159
160
161
162
163
# File 'lib/clacky/session_manager.rb', line 153

def write_chunk(session_id, created_at, chunk_index, md_content)
  return nil unless session_id && created_at

  base = chunk_base_name(session_id, created_at)
  chunk_path = File.join(@sessions_dir, "#{base}-chunk-#{chunk_index}.md")

  File.write(chunk_path, md_content)
  FileUtils.chmod(0o600, chunk_path)

  chunk_path
end