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")
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_id lives 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

Instance Method Summary collapse

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



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

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



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.
  metas = files.filter_map { |f| m = (f); m && m.merge(_path: f) }
               .sort_by { |s| s[:updated_at] || s[:created_at] || "" }.reverse

  non_pinned = metas.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_dirObject



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_pathObject

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_sessionsObject

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