Class: Clacky::SessionManager
- Inherits:
-
Object
- Object
- Clacky::SessionManager
- Defined in:
- lib/clacky/session_manager.rb
Constant Summary collapse
- SESSIONS_DIR =
File.join(Dir.home, ".clacky", "sessions")
Class Method Summary collapse
-
.generate_id ⇒ Object
Generate a new unique session ID (16-char hex string).
Instance Method Summary collapse
-
#all_sessions(current_dir: nil, limit: nil) ⇒ Object
All sessions from disk, newest-first (sorted by created_at).
-
#chunks_for_current(session_id, created_at) ⇒ Array<Hash>
Discover all chunk MD files on disk for a given session.
-
#cleanup(days: 90) ⇒ Object
Delete sessions not accessed within the given number of days (default: 90).
-
#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).
-
#cleanup_trash(days: 90) ⇒ Object
Clean up soft-deleted sessions older than :days (default: 90).
-
#delete(session_id) ⇒ Object
Soft-delete: move session JSON + chunks to the session trash directory.
- #ensure_sessions_dir ⇒ Object
-
#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.
-
#fork(session_id) ⇒ Object
Fork a session: create a copy with new id, “(copy)” name suffix, and reset stats.
- #generate_filename(session_id, created_at) ⇒ Object
-
#initialize(sessions_dir: nil) ⇒ SessionManager
constructor
A new instance of SessionManager.
-
#last_saved_path ⇒ Object
Path of the last saved session file.
-
#latest_for_directory(working_dir) ⇒ Object
Return the most recent session for a given working directory, or nil.
-
#list_trash_sessions ⇒ Object
List all soft-deleted sessions (newest-first).
-
#load(session_id) ⇒ Object
Load a specific session by ID.
- #load_session_file(filepath) ⇒ Object
-
#next_chunk_index(session_id, created_at) ⇒ Object
Next unused chunk index for a session, derived from disk.
-
#permanent_delete_trash_session(session_id) ⇒ Object
Permanently delete one session from the trash — cannot be undone.
-
#restore_session(session_id) ⇒ Object
Restore a soft-deleted session back to the active sessions directory.
-
#save(session_data) ⇒ Object
Save a session.
-
#search_content(query, timeout: 5) ⇒ Object
Full-text grep over session JSON + chunk MD files.
-
#soft_delete(session_id) ⇒ Object
Soft-delete: stamp deleted_at, move JSON + chunks to sessions-trash/.
-
#write_chunk(session_id, created_at, chunk_index, md_content) ⇒ Object
Write a chunk MD file to disk.
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_id ⇒ Object
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).
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_dir ⇒ Object
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_path ⇒ Object
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_sessions ⇒ Object
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 |