Packages
claude_code
0.32.2
0.36.5
0.36.4
0.36.3
0.36.2
0.36.1
0.36.0
0.35.0
0.34.0
0.33.1
0.32.2
0.32.0
0.31.0
0.30.0
0.29.0
0.28.0
0.27.0
0.26.0
0.25.0
0.24.0
0.23.0
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.0
0.10.0
0.9.0
0.8.1
0.8.0
0.7.0
0.6.0
0.5.0
0.4.0
0.3.0
0.2.0
0.1.0
Claude Agent SDK for Elixir – Build AI agents with Claude Code
Current section
Files
Jump to
Current section
Files
lib/claude_code/history.ex
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/<encoded-project-path>/<session-id>.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