defmodule ClaudeCode.History do @moduledoc """ Utilities for reading and parsing Claude Code session history files. Claude Code stores conversation history in JSONL files at: `~/.claude/projects//.jsonl` This module provides functions to: - Find session files by session ID - Read and parse session JSONL files - Extract conversation history (user/assistant messages) ## Session File Format Session files contain various message types: - `user` - User messages (prompts and tool results) - `assistant` - Assistant responses - `system` - System events (errors, etc.) - `summary` - Conversation summary - `file-history-snapshot` - File tracking metadata - `queue-operation` - Internal operations ## Examples # Read a session by ID {:ok, messages} = ClaudeCode.History.read_session("abc123-def456") # Get just the conversation (user/assistant messages) {:ok, conversation} = ClaudeCode.History.conversation("abc123-def456") # Read from a specific file path {:ok, messages} = ClaudeCode.History.read_file("/path/to/session.jsonl") # Find session file location {:ok, path} = ClaudeCode.History.find_session_path("abc123-def456") """ alias ClaudeCode.CLI.Parser alias ClaudeCode.Message.AssistantMessage alias ClaudeCode.Message.UserMessage @type session_id :: String.t() @type parsed_message :: AssistantMessage.t() | UserMessage.t() @claude_dir Path.expand("~/.claude") @doc """ Reads a session JSONL file by session ID and returns all entries as normalized maps. Searches through all project directories to find the session file. Returns every line as a snake_case string-keyed map, including metadata entries (summaries, queue operations, etc.) that have no SDK struct representation. Use `conversation/2` to get only user/assistant messages parsed into SDK structs. ## Options - `:project_path` - Specific project path to search in (optional) - `:claude_dir` - Override the Claude directory (default: `~/.claude`) ## Examples {:ok, entries} = ClaudeCode.History.read_session("abc123-def456") # Search in a specific project {:ok, entries} = ClaudeCode.History.read_session("abc123", project_path: "/my/project") """ @spec read_session(session_id(), keyword()) :: {:ok, [map()]} | {:error, term()} def read_session(session_id, opts \\ []) do with {:ok, path} <- find_session_path(session_id, opts) do read_file(path) end end @doc """ Reads a session JSONL file from a specific path and returns all entries as normalized maps. Returns every line as a snake_case string-keyed map, preserving all entry types (user, assistant, system, summary, queue operations, etc.). Keys are normalized from camelCase to snake_case for consistency with live CLI output. Use `conversation_from_file/1` to get only user/assistant messages as SDK structs. ## Examples {:ok, entries} = ClaudeCode.History.read_file("/path/to/session.jsonl") """ @spec read_file(Path.t()) :: {:ok, [map()]} | {:error, term()} def read_file(path) do case File.read(path) do {:ok, content} -> decode_jsonl(content) {:error, reason} -> {:error, {:file_read_error, reason, path}} end end @doc """ Extracts the conversation history from a session. Returns only user and assistant messages, parsed into SDK message structs. Other message types (system events, metadata) are excluded. ## Options Same as `read_session/2`. ## Examples {:ok, conversation} = ClaudeCode.History.conversation("abc123-def456") # Each message is a UserMessage or AssistantMessage struct Enum.each(conversation, fn %UserMessage{message: %{content: content}} -> IO.puts("User: \#{inspect(content)}") %AssistantMessage{message: %{content: content}} -> IO.puts("Assistant: \#{inspect(content)}") end) """ @spec conversation(session_id(), keyword()) :: {:ok, [parsed_message()]} | {:error, term()} def conversation(session_id, opts \\ []) do with {:ok, path} <- find_session_path(session_id, opts) do conversation_from_file(path) end end @doc """ Extracts the conversation history from a session file path. Returns only user and assistant messages, parsed into SDK message structs. ## Examples {:ok, conversation} = ClaudeCode.History.conversation_from_file("/path/to/session.jsonl") """ @spec conversation_from_file(Path.t()) :: {:ok, [parsed_message()]} | {:error, term()} def conversation_from_file(path) do with {:ok, content} <- File.read(path), {:ok, messages} <- Parser.parse_stream(content) do {:ok, Enum.filter(messages, &conversation_message?/1)} else {:error, reason} when is_atom(reason) -> {:error, {:file_read_error, reason, path}} error -> error end end @doc """ Gets the conversation summary from a session, if available. Returns the summary text or nil if no summary exists. ## Examples {:ok, "User asked about..."} = ClaudeCode.History.summary("abc123-def456") {:ok, nil} = ClaudeCode.History.summary("new-session-id") """ @spec summary(session_id(), keyword()) :: {:ok, String.t() | nil} | {:error, term()} def summary(session_id, opts \\ []) do with {:ok, entries} <- read_session(session_id, opts) do summary = Enum.find_value(entries, fn %{"type" => "summary", "summary" => text} -> text _ -> nil end) {:ok, summary} end end @doc """ Finds the file path for a session ID. Searches through all project directories in `~/.claude/projects/`. ## Options - `:project_path` - Specific project path to search in (optional) - `:claude_dir` - Override the Claude directory (default: `~/.claude`) ## Examples {:ok, "/Users/me/.claude/projects/-my-project/abc123.jsonl"} = ClaudeCode.History.find_session_path("abc123") {:error, {:session_not_found, "abc123"}} = ClaudeCode.History.find_session_path("nonexistent") """ @spec find_session_path(session_id(), keyword()) :: {:ok, Path.t()} | {:error, term()} def find_session_path(session_id, opts \\ []) do claude_dir = Keyword.get(opts, :claude_dir, @claude_dir) projects_dir = Path.join(claude_dir, "projects") case Keyword.get(opts, :project_path) do nil -> # Search all project directories search_all_projects(projects_dir, session_id) project_path -> # Search specific project encoded = encode_project_path(project_path) project_dir = Path.join(projects_dir, encoded) search_project_dir(project_dir, session_id) end end @doc """ Lists all session IDs for a project. ## Options - `:claude_dir` - Override the Claude directory (default: `~/.claude`) ## Examples {:ok, ["abc123", "def456"]} = ClaudeCode.History.list_sessions("/my/project") """ @spec list_sessions(Path.t(), keyword()) :: {:ok, [session_id()]} | {:error, term()} def list_sessions(project_path, opts \\ []) do claude_dir = Keyword.get(opts, :claude_dir, @claude_dir) projects_dir = Path.join(claude_dir, "projects") encoded = encode_project_path(project_path) project_dir = Path.join(projects_dir, encoded) case File.ls(project_dir) do {:ok, files} -> session_ids = files |> Enum.filter(&String.ends_with?(&1, ".jsonl")) |> Enum.map(&String.trim_trailing(&1, ".jsonl")) |> Enum.sort() {:ok, session_ids} {:error, reason} -> {:error, {:project_not_found, reason, project_path}} end end @doc """ Lists all projects that have session history. ## Options - `:claude_dir` - Override the Claude directory (default: `~/.claude`) ## Examples {:ok, ["/Users/me/project1", "/Users/me/project2"]} = ClaudeCode.History.list_projects() """ @spec list_projects(keyword()) :: {:ok, [Path.t()]} | {:error, term()} def list_projects(opts \\ []) do claude_dir = Keyword.get(opts, :claude_dir, @claude_dir) projects_dir = Path.join(claude_dir, "projects") case File.ls(projects_dir) do {:ok, dirs} -> paths = dirs |> Enum.filter(&File.dir?(Path.join(projects_dir, &1))) |> Enum.map(&decode_project_path/1) |> Enum.sort() {:ok, paths} {:error, reason} -> {:error, {:projects_dir_error, reason}} end end @doc """ Encodes a project path to the format used by Claude Code. Replaces `/` and `_` with `-` in the path to match the CLI's encoding. ## Examples iex> ClaudeCode.History.encode_project_path("/Users/me/project") "-Users-me-project" iex> ClaudeCode.History.encode_project_path("/Users/me/my_project") "-Users-me-my-project" """ @spec encode_project_path(Path.t()) :: String.t() def encode_project_path(path) do path |> Path.expand() |> String.replace(~r"[/_]", "-") end @doc """ Decodes an encoded project path back to a path format. Replaces `-` with `/`. Note that this encoding is lossy - if the original path contained `-` or `_` characters, they cannot be distinguished from path separators. For example, `/a/b-c`, `/a/b_c`, and `/a/b/c` all encode to `-a-b-c`. This function is primarily useful for display purposes. For matching against known paths, use `encode_project_path/1` instead. ## Examples iex> ClaudeCode.History.decode_project_path("-Users-me-project") "/Users/me/project" """ @spec decode_project_path(String.t()) :: Path.t() def decode_project_path(encoded) do String.replace(encoded, "-", "/") end # Private functions defp search_all_projects(projects_dir, session_id) do case File.ls(projects_dir) do {:ok, dirs} -> result = dirs |> Enum.filter(&File.dir?(Path.join(projects_dir, &1))) |> Enum.find_value(fn dir -> project_dir = Path.join(projects_dir, dir) case search_project_dir(project_dir, session_id) do {:ok, path} -> {:ok, path} _ -> nil end end) case result do {:ok, _} = success -> success nil -> {:error, {:session_not_found, session_id}} end {:error, reason} -> {:error, {:projects_dir_error, reason}} end end defp search_project_dir(project_dir, session_id) do direct_path = Path.join(project_dir, "#{session_id}.jsonl") if File.exists?(direct_path) do {:ok, direct_path} else search_subagents_dir(project_dir, session_id) end end defp search_subagents_dir(project_dir, session_id) do subagents_dir = Path.join([project_dir, session_id, "subagents"]) with true <- File.dir?(subagents_dir), {:ok, files} <- File.ls(subagents_dir), file when not is_nil(file) <- Enum.find(files, &String.ends_with?(&1, ".jsonl")) do {:ok, Path.join(subagents_dir, file)} else _ -> {:error, {:session_not_found, session_id}} end end defp conversation_message?(%UserMessage{}), do: true defp conversation_message?(%AssistantMessage{}), do: true defp conversation_message?(_), do: false defp decode_jsonl(content) do content |> String.split("\n", trim: true) |> Enum.with_index() |> Enum.reduce_while({:ok, []}, fn {line, index}, {:ok, acc} -> case Jason.decode(line) do {:ok, map} -> {:cont, {:ok, [Parser.normalize_keys(map) | acc]}} {:error, error} -> {:halt, {:error, {:json_decode_error, index, error}}} end end) |> case do {:ok, maps} -> {:ok, Enum.reverse(maps)} error -> error end end end