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")
- GROUPED_SOURCES =
%w[cron ext].freeze
- METADATA_HEADER_BYTES =
Read only the small scalar header of a session file without parsing the (potentially multi-MB) messages / time_machine payload. Session JSON is written with a stable field order, so the fields we care about here (session_id, pinned, created_at, updated_at, source) always sit in the first few hundred bytes, before any large array/object.
project_idlives much later and is intentionally NOT read — callers that need it fall back to load_session_file for the rare candidate that requires it.Returns a Hash with symbol keys, or nil if the header can't be read.
16 * 1024
Class Method Summary collapse
-
.cleanup_orphan_snapshots(sessions_dir: SESSIONS_DIR, snapshots_root: nil) ⇒ Object
Remove Time Machine snapshots that no longer belong to any known session.
-
.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 last activity / updated_at, falling back to created_at for legacy sessions without updated_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:, grouped_keep: 200) ⇒ 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_file_trash(days: 8) ⇒ Object
Keep file-trash on the same rolling window as sessions-trash.
-
#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
- #load_session_metadata(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.
-
#read_chunk(chunk_path) ⇒ Object
Read the raw markdown of a chunk file.
-
#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/.
-
#split_chunk_md(raw) ⇒ Object
Split raw chunk markdown into [front_matter_hash, body_string].
-
#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.
22 23 24 25 |
# File 'lib/clacky/session_manager.rb', line 22 def initialize(sessions_dir: nil) @sessions_dir = sessions_dir || SESSIONS_DIR ensure_sessions_dir end |
Class Method Details
.cleanup_orphan_snapshots(sessions_dir: SESSIONS_DIR, snapshots_root: nil) ⇒ Object
Remove Time Machine snapshots that no longer belong to any known session. Snapshots are keyed by full session_id; session files are named by the 8-char id prefix, so a snapshot dir is an orphan when its prefix matches no active or trashed session file. Returns the count of removed dirs.
528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 |
# File 'lib/clacky/session_manager.rb', line 528 def self.cleanup_orphan_snapshots(sessions_dir: SESSIONS_DIR, snapshots_root: nil) snapshots_root ||= File.join(Dir.home, ".clacky", "snapshots") return 0 unless Dir.exist?(snapshots_root) require_relative "utils/trash_directory" known = _session_id_prefixes(File.join(sessions_dir, "*.json")) trash_dir = Clacky::TrashDirectory.sessions_trash_dir known += _session_id_prefixes(File.join(trash_dir, "*.json")) if Dir.exist?(trash_dir) known = known.to_set removed = 0 Dir.children(snapshots_root).each do |name| dir = File.join(snapshots_root, name) next unless File.directory?(dir) next if known.include?(name[0, 8]) FileUtils.rm_rf(dir) removed += 1 end removed end |
.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.
18 19 20 |
# File 'lib/clacky/session_manager.rb', line 18 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 last activity / updated_at, falling back to created_at for legacy sessions without updated_at). Optional filters:
current_dir: (String) if given, sessions matching working_dir come first
limit: (Integer) max number of sessions to return
215 216 217 218 219 220 221 222 223 224 225 226 227 |
# File 'lib/clacky/session_manager.rb', line 215 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[:updated_at] || 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).
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 |
# File 'lib/clacky/session_manager.rb', line 137 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.
334 335 336 337 338 339 340 341 342 343 344 345 346 |
# File 'lib/clacky/session_manager.rb', line 334 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:, grouped_keep: 200) ⇒ 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.
352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 |
# File 'lib/clacky/session_manager.rb', line 352 def cleanup_by_count(keep:, grouped_keep: 200) # Fast path: if total session files can't exceed any single group's cap, # skip the scan entirely. min() is correct because groups are mutually # exclusive — any single group's count ≤ non_pinned ≤ file_count. files = Dir.glob(File.join(@sessions_dir, "*.json")) return 0 if files.size <= [keep, grouped_keep].min # Read only the header of each file (cheap) instead of parsing the whole # multi-MB payload. The header carries everything we need to rank and # group sessions except project_id, which is resolved lazily below. = files.filter_map { |f| m = (f); m && m.merge(_path: f) } .sort_by { |s| s[:updated_at] || s[:created_at] || "" }.reverse non_pinned = .reject { |s| s[:pinned] } groups = non_pinned.group_by { |s| group_key_for(s) } victims = groups.flat_map do |source, sessions| sessions[(source == "regular" ? keep : grouped_keep)..] || [] end victims.each { |session| soft_delete(session[:session_id]) } victims.size end |
#cleanup_file_trash(days: 8) ⇒ Object
Keep file-trash on the same rolling window as sessions-trash.
418 419 420 421 |
# File 'lib/clacky/session_manager.rb', line 418 def cleanup_file_trash(days: 8) require_relative "tools/trash_manager" Clacky::Tools::TrashManager.cleanup_files_trash(days: days) end |
#cleanup_trash(days: 90) ⇒ Object
Clean up soft-deleted sessions older than :days (default: 90).
424 425 426 427 |
# File 'lib/clacky/session_manager.rb', line 424 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.
96 97 98 |
# File 'lib/clacky/session_manager.rb', line 96 def delete(session_id) soft_delete(session_id) end |
#ensure_sessions_dir ⇒ Object
430 431 432 |
# File 'lib/clacky/session_manager.rb', line 430 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
}
109 110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/clacky/session_manager.rb', line 109 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.
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/clacky/session_manager.rb', line 75 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
434 435 436 |
# File 'lib/clacky/session_manager.rb', line 434 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.
64 65 66 |
# File 'lib/clacky/session_manager.rb', line 64 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.
328 329 330 |
# File 'lib/clacky/session_manager.rb', line 328 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).
406 407 408 409 |
# File 'lib/clacky/session_manager.rb', line 406 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.
69 70 71 |
# File 'lib/clacky/session_manager.rb', line 69 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
490 491 492 493 494 |
# File 'lib/clacky/session_manager.rb', line 490 def load_session_file(filepath) JSON.parse(File.read(filepath), symbolize_names: true) rescue JSON::ParserError, Errno::ENOENT nil end |
#load_session_metadata(filepath) ⇒ Object
506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 |
# File 'lib/clacky/session_manager.rb', line 506 def (filepath) head = File.open(filepath, "r") { |f| f.read(METADATA_HEADER_BYTES) } return nil unless head sid = head[/"session_id"\s*:\s*"([^"]*)"/, 1] return nil unless sid { session_id: sid, pinned: head[/"pinned"\s*:\s*(true|false)/, 1] == "true", created_at: head[/"created_at"\s*:\s*"([^"]*)"/, 1], updated_at: head[/"updated_at"\s*:\s*"([^"]*)"/, 1], source: head[/"source"\s*:\s*"([^"]*)"/, 1] } rescue 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.
163 164 165 166 |
# File 'lib/clacky/session_manager.rb', line 163 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.
412 413 414 415 |
# File 'lib/clacky/session_manager.rb', line 412 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 |
#read_chunk(chunk_path) ⇒ Object
Read the raw markdown of a chunk file. Returns nil if missing.
184 185 186 187 |
# File 'lib/clacky/session_manager.rb', line 184 def read_chunk(chunk_path) return nil unless chunk_path && File.exist?(chunk_path) File.read(chunk_path) end |
#restore_session(session_id) ⇒ Object
Restore a soft-deleted session back to the active sessions directory.
400 401 402 403 |
# File 'lib/clacky/session_manager.rb', line 400 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.
28 29 30 31 32 33 34 35 36 37 38 39 40 |
# File 'lib/clacky/session_manager.rb', line 28 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 run_cleanup_async 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).
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 |
# File 'lib/clacky/session_manager.rb', line 232 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/.
394 395 396 397 |
# File 'lib/clacky/session_manager.rb', line 394 def soft_delete(session_id) require_relative "tools/trash_manager" Clacky::Tools::TrashManager.soft_delete_session(session_id, sessions_dir: @sessions_dir) end |
#split_chunk_md(raw) ⇒ Object
Split raw chunk markdown into [front_matter_hash, body_string]. front_matter_hash preserves insertion order; body is everything after the closing "---". Returns [nil, raw] when there is no leading block.
192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 |
# File 'lib/clacky/session_manager.rb', line 192 def split_chunk_md(raw) return [nil, raw.to_s] unless raw.to_s.start_with?("---") fm_end = raw.index("\n---\n", 4) return [nil, raw] unless fm_end fm_text = raw[4...fm_end] body = raw[(fm_end + 5)..] || "" fm = {} fm_text.each_line do |line| k, _, v = line.chomp.partition(":") next if k.strip.empty? fm[k.strip] = v.strip end [fm, body] 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).
171 172 173 174 175 176 177 178 179 180 181 |
# File 'lib/clacky/session_manager.rb', line 171 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 |