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.



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

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.



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

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


147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/clacky/session_manager.rb', line 147

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



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/clacky/session_manager.rb', line 97

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.



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

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 sessions by created_at; delete the rest. Returns count of deleted sessions.



184
185
186
187
188
189
190
191
192
# File 'lib/clacky/session_manager.rb', line 184

def cleanup_by_count(keep:)
  sessions = all_sessions # already sorted newest-first
  return 0 if sessions.size <= keep

  sessions[keep..].each do |session|
    filepath = File.join(@sessions_dir, generate_filename(session[:session_id], session[:created_at]))
    _hard_delete_session_with_chunks(filepath) if File.exist?(filepath)
  end.size
end

#cleanup_trash(days: 90) ⇒ Object

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



223
224
225
226
# File 'lib/clacky/session_manager.rb', line 223

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.



56
57
58
# File 'lib/clacky/session_manager.rb', line 56

def delete(session_id)
  soft_delete(session_id)
end

#ensure_sessions_dirObject



229
230
231
# File 'lib/clacky/session_manager.rb', line 229

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
}


69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/clacky/session_manager.rb', line 69

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

#generate_filename(session_id, created_at) ⇒ Object



233
234
235
# File 'lib/clacky/session_manager.rb', line 233

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.



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

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.



162
163
164
# File 'lib/clacky/session_manager.rb', line 162

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

#list_trash_sessionsObject

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



211
212
213
214
# File 'lib/clacky/session_manager.rb', line 211

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.



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

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



289
290
291
292
293
# File 'lib/clacky/session_manager.rb', line 289

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.



123
124
125
126
# File 'lib/clacky/session_manager.rb', line 123

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.



217
218
219
220
# File 'lib/clacky/session_manager.rb', line 217

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.



205
206
207
208
# File 'lib/clacky/session_manager.rb', line 205

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.



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

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

#soft_delete(session_id) ⇒ Object

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



199
200
201
202
# File 'lib/clacky/session_manager.rb', line 199

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).



131
132
133
134
135
136
137
138
139
140
141
# File 'lib/clacky/session_manager.rb', line 131

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