Read-side access to Claude Code's on-disk session history.
Claude Code stores per-project session logs as line-delimited JSON
under ~/.claude/projects/<slug>/<session_id>.jsonl, one JSON object
per line. This module gives a typed Elixir API over those logs
without prescribing a representation for the conversation -- callers
render however they want.
Three levels of granularity:
list_projects/2-- enumerate project directories with summary metadata (session count, latest activity).list_sessions/2-- enumerate session files (all projects, or one via the:slugoption) with summary metadata.read_session/2-- parse a session into typedentry/0values.
Liberal parsing
Each line is parsed independently; malformed lines are skipped rather
than failing the whole session. Unknown entry types come through as
{:other, type_tag, raw} so callers can inspect them. Only user and
assistant get typed variants; queue-operation, attachment,
ai-title, last-prompt, and future types land in :other.
Slug encoding
Project directory names are filesystem-safe encodings of an absolute
path: every character that is not a letter or digit becomes - (so
/Users/josh/Code/foo_bar becomes -Users-josh-Code-foo-bar).
project_slug/1 is the forward derivation (canonicalize + encode);
ProjectSummary.decoded_path is a best-effort inverse that anchors on
the real filesystem to disambiguate literal hyphens in directory names.
The encoding is lossy -- a - in a slug may have been a /, ., _,
space, or a literal hyphen -- so the inverse cannot always be exact.
When decode_slug_anchored cannot confirm a decoded path against the
filesystem it sets ProjectSummary.decode_verified? to false. The
forward direction has no such ambiguity and always matches the CLI.
Example
{:ok, root} = ClaudeWrapper.History.home()
{:ok, projects} = ClaudeWrapper.History.list_projects(root)
for p <- projects do
{:ok, sessions} = ClaudeWrapper.History.list_sessions(root, slug: p.slug)
IO.puts("#{p.slug}: #{length(sessions)} sessions")
end
Summary
Types
An assistant entry's fields.
One parsed line from a session .jsonl.
Options for the listing functions.
Sort order for the listing functions.
A user entry's fields.
Functions
Use a specific path as the projects root. Useful for tests (point at a temp dir) and non-default installs.
Locate the on-disk path and project slug for a session id, searching every project directory.
Resolve the default history root, ~/.claude/projects.
List project directories with optional filter / sort / pagination.
List sessions, optionally restricted to one project via the :slug
option, with filter / sort / pagination.
Derive Claude Code's project-directory slug for a filesystem path,
matching the CLI: the path is canonicalized (best-effort symlink
resolution) and then every character that is not a letter or digit
(/, ., _, space, ...) is encoded as -.
Read one session's full entry log, searching every project directory
for <session_id>.jsonl. Malformed lines are skipped.
The configured root directory.
List sessions for a working directory, deriving its project slug via
project_slug/1. Convenience over list_sessions(h, slug: ...).
Types
@type assistant_entry() :: %{ uuid: String.t() | nil, timestamp: String.t() | nil, message: term() }
An assistant entry's fields.
@type entry() :: {:user, user_entry()} | {:assistant, assistant_entry()} | {:other, String.t(), map()}
One parsed line from a session .jsonl.
:user and :assistant carry typed fields; every other entry type
is {:other, type_tag, raw} with the raw decoded JSON map.
@type list_opt() :: {:limit, non_neg_integer() | nil} | {:offset, non_neg_integer()} | {:include_empty, boolean()} | {:sort, sort()} | {:slug, String.t() | nil}
Options for the listing functions.
:limit-- max items after sorting + offset (nil= no cap):offset-- skip the first N items (default0):include_empty-- keep projects/sessions with no activity (defaulttrue):sort--:name_asc(default) or:recency_desc:slug-- (sessions only) restrict to one project
@type sort() :: :name_asc | :recency_desc
Sort order for the listing functions.
@type t() :: %ClaudeWrapper.History{root: String.t()}
@type user_entry() :: %{ uuid: String.t() | nil, timestamp: String.t() | nil, cwd: String.t() | nil, git_branch: String.t() | nil, message: term() }
A user entry's fields.
Functions
Use a specific path as the projects root. Useful for tests (point at a temp dir) and non-default installs.
@spec find_session(t(), String.t()) :: {:ok, {String.t(), String.t()}} | {:error, ClaudeWrapper.Error.t()}
Locate the on-disk path and project slug for a session id, searching every project directory.
Returns {:ok, {path, slug}} or {:error, %ClaudeWrapper.Error{kind: :not_found}}.
@spec home() :: {:ok, t()} | {:error, ClaudeWrapper.Error.t()}
Resolve the default history root, ~/.claude/projects.
Returns {:error, %ClaudeWrapper.Error{kind: :no_home}} when the user
home cannot be determined.
@spec list_projects(t(), [list_opt()]) :: {:ok, [ClaudeWrapper.History.ProjectSummary.t()]} | {:error, ClaudeWrapper.Error.t()}
List project directories with optional filter / sort / pagination.
Returns {:ok, []} when the root directory does not exist. See
list_opt/0 for options.
@spec list_sessions(t(), [list_opt()]) :: {:ok, [ClaudeWrapper.History.SessionSummary.t()]} | {:error, ClaudeWrapper.Error.t()}
List sessions, optionally restricted to one project via the :slug
option, with filter / sort / pagination.
When no :slug is given, every project directory is unioned.
Derive Claude Code's project-directory slug for a filesystem path,
matching the CLI: the path is canonicalized (best-effort symlink
resolution) and then every character that is not a letter or digit
(/, ., _, space, ...) is encoded as -.
This is the reliable way to locate the project directory for a working
directory -- see sessions_for_path/3. Falls back to the expanded path
when it cannot be canonicalized.
iex> ClaudeWrapper.History.project_slug("/Users/josh/Code/foo_bar")
"-Users-josh-Code-foo-bar"
@spec read_session(t(), String.t()) :: {:ok, ClaudeWrapper.History.SessionLog.t()} | {:error, ClaudeWrapper.Error.t()}
Read one session's full entry log, searching every project directory
for <session_id>.jsonl. Malformed lines are skipped.
Returns {:error, %ClaudeWrapper.Error{kind: :not_found}} when no
session file matches.
The configured root directory.
@spec sessions_for_path(t(), String.t(), [list_opt()]) :: {:ok, [ClaudeWrapper.History.SessionSummary.t()]} | {:error, ClaudeWrapper.Error.t()}
List sessions for a working directory, deriving its project slug via
project_slug/1. Convenience over list_sessions(h, slug: ...).