class Clacky::SessionManager

def self.generate_id

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

def all_sessions(current_dir: nil, limit: nil)

limit: (Integer) max number of sessions to return
current_dir: (String) if given, sessions matching working_dir come first
Optional filters:
All sessions from disk, newest-first (sorted by created_at).
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

def chunk_base_name(session_id, created_at)

I/O stays consistent with the session filename.
chunk-N.md archive files. Kept as a single source of truth so chunk
Base name (without extension) shared by a session's .json file and its
def chunk_base_name(session_id, created_at)
me = Time.parse(created_at).strftime("%Y-%m-%d-%H-%M-%S")
id = session_id[0..7]
etime}-#{short_id}"

def chunks_for_current(session_id, created_at)

Returns:
  • (Array) - each with :index, :path, :basename, :topics

Parameters:
  • created_at (String) -- ISO-8601 timestamp used in the base filename
  • session_id (String) -- full session id (or at least first 8 chars)
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

def cleanup(days: 90)

Returns count of deleted sessions.
Delete sessions not accessed within the given number of days (default: 90).
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
      delete_session_with_chunks(filepath)
      deleted += 1
    end
  end
  deleted
end

def cleanup_by_count(keep:)

Returns count of deleted sessions.
Keep only the most recent N sessions by created_at; delete the rest.
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]))
    delete_session_with_chunks(filepath) if File.exist?(filepath)
  end.size
end

def delete(session_id)

Returns true if found and deleted, false if not found.
Physical delete — removes disk file + associated chunk files.
def delete(session_id)
  session = all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
  return false unless session
  filepath = File.join(@sessions_dir, generate_filename(session[:session_id], session[:created_at]))
  delete_session_with_chunks(filepath)
  true
end

def delete_session_with_chunks(json_filepath)

Delete a session JSON file and all its associated chunk MD files.
def delete_session_with_chunks(json_filepath)
  File.delete(json_filepath) if File.exist?(json_filepath)
  base = File.basename(json_filepath, ".json")
  Dir.glob(File.join(@sessions_dir, "#{base}-chunk-*.md")).each { |f| File.delete(f) }
end

def ensure_sessions_dir

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

def files_for(session_id)

}
chunks: [String] # sorted absolute paths to chunk *.md files
json_path: String, # absolute path to session.json
session: Hash, # the loaded session metadata
{
Returns nil if the session is not found, or a Hash:
endpoint so the UI can bundle everything a user may need for debugging.
and any "{base}-chunk-*.md" archive files. Used by the export / download
Return the on-disk files associated with a session: the main JSON file
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

def generate_filename(session_id, created_at)

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

def initialize(sessions_dir: nil)

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

def last_saved_path

Path of the last saved session file.
def last_saved_path
  @last_saved_path
end

def latest_for_directory(working_dir)

Return the most recent session for a given working directory, or nil.
def latest_for_directory(working_dir)
  all_sessions(current_dir: working_dir).first
end

def load(session_id)

Load a specific session by ID. Returns nil if not found.
def load(session_id)
  all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
end

def load_session_file(filepath)

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

def next_chunk_index(session_id, created_at)

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

def read_chunk_topics(path)

Returns nil if the file is missing, unreadable, or has no topics.
want to read megabytes of archived conversation just to grab one line.
Only scans the first ~20 lines — front matter is tiny and we don't
Read the `topics:` field from a chunk MD file's YAML-like front matter.
def read_chunk_topics(path)
 nil unless File.exist?(path)
= []
pen(path, "r") do |f|
imes do
ne = f.gets
eak if line.nil?
nes << line
nt_matter = false
each do |line|
pped = line.strip
tripped == "---"
eak if in_front_matter
_front_matter = true
xt
 unless in_front_matter
m = stripped.match(/\Atopics:\s*(.+)\z/))
pics = m[1].strip
turn topics.empty? ? nil : topics

def save(session_data)

Save a session. Returns the file path.
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

def write_chunk(session_id, created_at, chunk_index, md_content)

only handles filesystem concerns (path assembly, write, chmod).
Caller is responsible for generating the MD content — this method
Write a chunk MD file to disk. Returns the absolute path.
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