ClaudeWrapper.History (ClaudeWrapper v0.8.1)

Copy Markdown View Source

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 :slug option) with summary metadata.
  • read_session/2 -- parse a session into typed entry/0 values.

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.

t()

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

assistant_entry()

@type assistant_entry() :: %{
  uuid: String.t() | nil,
  timestamp: String.t() | nil,
  message: term()
}

An assistant entry's fields.

entry()

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

list_opt()

@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 (default 0)
  • :include_empty -- keep projects/sessions with no activity (default true)
  • :sort -- :name_asc (default) or :recency_desc
  • :slug -- (sessions only) restrict to one project

sort()

@type sort() :: :name_asc | :recency_desc

Sort order for the listing functions.

t()

@type t() :: %ClaudeWrapper.History{root: String.t()}

user_entry()

@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

at(path)

@spec at(String.t()) :: t()

Use a specific path as the projects root. Useful for tests (point at a temp dir) and non-default installs.

find_session(h, session_id)

@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}}.

home()

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

list_projects(h, opts \\ [])

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

list_sessions(h, opts \\ [])

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

project_slug(path)

@spec project_slug(String.t()) :: String.t()

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"

read_session(h, session_id)

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

root(history)

@spec root(t()) :: String.t()

The configured root directory.

sessions_for_path(h, cwd, opts \\ [])

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