Packages
claude_code
0.33.1
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/lite_reader.ex
# Reads session file metadata using only head/tail reads for fast extraction.
#
# Matches the Python SDK's `_read_session_lite` + `_extract_first_prompt_from_head`
# approach: reads the first and last 64KB of a session file to extract metadata
# without parsing the full JSONL content.
defmodule ClaudeCode.History.LiteReader do
@moduledoc false
alias ClaudeCode.History.SessionInfo
@lite_read_buf_size 65_536
@uuid_re ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
# Patterns matching auto-generated or system messages to skip when
# looking for the first meaningful user prompt.
@skip_first_prompt_re ~r/^(?:<local-command-stdout>|<session-start-hook>|<tick>|<goal>|\[Request interrupted by user[^\]]*\]|\s*<ide_opened_file>[\s\S]*<\/ide_opened_file>\s*$|\s*<ide_selection>[\s\S]*<\/ide_selection>\s*$)/
@command_name_re ~r/<command-name>(.*?)<\/command-name>/
@doc """
Reads metadata from a session JSONL file using head/tail reads.
Returns `{:ok, %SessionInfo{}}` or `{:error, reason}`.
"""
@spec read_metadata(Path.t()) :: {:ok, SessionInfo.t()} | {:error, term()}
def read_metadata(path) do
filename = Path.basename(path, ".jsonl")
case validate_uuid(filename) do
nil -> {:error, :invalid_uuid}
session_id -> do_read_metadata(path, session_id)
end
end
@doc """
Validates a string as a UUID. Returns the string if valid, nil otherwise.
"""
@spec validate_uuid(String.t()) :: String.t() | nil
def validate_uuid(maybe_uuid) do
if Regex.match?(@uuid_re, maybe_uuid), do: maybe_uuid
end
# -- Private ----------------------------------------------------------------
defp do_read_metadata(path, session_id) do
case read_lite(path) do
{:ok, %{head: head, tail: tail, mtime: mtime, size: size}} ->
# Check first line for sidechain
first_line = head |> String.split("\n", parts: 2) |> hd()
if sidechain?(first_line) do
{:error, :sidechain}
else
build_session_info(session_id, head, tail, mtime, size)
end
{:error, _} = error ->
error
end
end
defp build_session_info(session_id, head, tail, mtime, size) do
custom_title = extract_last_json_string_field(tail, "customTitle")
first_prompt = extract_first_prompt_from_head(head)
summary = custom_title || extract_last_json_string_field(tail, "summary") || first_prompt
# Skip metadata-only sessions (no title, no summary, no prompt)
if is_nil(summary) or summary == "" do
{:error, :no_summary}
else
git_branch =
extract_last_json_string_field(tail, "gitBranch") ||
extract_json_string_field(head, "gitBranch")
cwd = extract_json_string_field(head, "cwd")
{:ok,
%SessionInfo{
session_id: session_id,
summary: summary,
last_modified: mtime,
file_size: size,
custom_title: custom_title,
first_prompt: first_prompt,
git_branch: git_branch,
cwd: cwd
}}
end
end
defp read_lite(path) do
case :file.open(path, [:read, :binary, :raw]) do
{:ok, fd} ->
try do
read_lite_from_fd(fd, path)
after
:file.close(fd)
end
{:error, reason} ->
{:error, reason}
end
end
defp read_lite_from_fd(fd, path) do
with {:ok, file_info} <- :file.read_file_info(path),
{:ok, head} when byte_size(head) > 0 <- :file.read(fd, @lite_read_buf_size) do
size = elem(file_info, 1)
mtime = file_info_to_epoch_ms(file_info)
tail = read_tail(fd, head, size)
{:ok, %{head: head, tail: tail, mtime: mtime, size: size}}
else
{:ok, _empty} -> {:error, :empty_file}
:eof -> {:error, :empty_file}
{:error, reason} -> {:error, reason}
end
end
defp file_info_to_epoch_ms(file_info) do
mtime_secs = :calendar.datetime_to_gregorian_seconds(elem(file_info, 5))
unix_epoch = :calendar.datetime_to_gregorian_seconds({{1970, 1, 1}, {0, 0, 0}})
(mtime_secs - unix_epoch) * 1000
end
defp read_tail(_fd, head, size) when size <= @lite_read_buf_size, do: head
defp read_tail(fd, head, size) do
tail_offset = max(0, size - @lite_read_buf_size)
{:ok, _} = :file.position(fd, tail_offset)
case :file.read(fd, @lite_read_buf_size) do
{:ok, tail_bytes} -> tail_bytes
_ -> head
end
end
defp sidechain?(line) do
String.contains?(line, "\"isSidechain\":true") or
String.contains?(line, "\"isSidechain\": true")
end
@doc false
# Exposed for testing
def extract_first_prompt_from_head(head) do
command_fallback = nil
head
|> String.split("\n")
|> Enum.reduce_while(command_fallback, fn line, cmd_fallback ->
case extract_prompt_from_line(line, cmd_fallback) do
{:found, prompt} -> {:halt, {:found, prompt}}
{:command, new_fallback} -> {:cont, new_fallback}
:skip -> {:cont, cmd_fallback}
end
end)
|> case do
{:found, prompt} -> prompt
nil -> nil
fallback when is_binary(fallback) -> fallback
end
end
defp extract_prompt_from_line(line, cmd_fallback) do
if skip_line?(line) do
:skip
else
parse_user_prompt(line, cmd_fallback)
end
end
defp skip_line?(line) do
not user_type_line?(line) or
String.contains?(line, "\"tool_result\"") or
contains_bool_field?(line, "isMeta") or
contains_bool_field?(line, "isCompactSummary")
end
defp user_type_line?(line) do
String.contains?(line, ~s("type":"user")) or
String.contains?(line, ~s("type": "user"))
end
defp contains_bool_field?(line, field) do
String.contains?(line, ~s("#{field}":true)) or
String.contains?(line, ~s("#{field}": true))
end
defp parse_user_prompt(line, cmd_fallback) do
case Jason.decode(line) do
{:ok, %{"type" => "user", "message" => %{"content" => content}}} ->
extract_prompt_from_content(content, cmd_fallback)
_ ->
:skip
end
end
defp extract_prompt_from_content(content, cmd_fallback) when is_binary(content) do
check_prompt_text(content, cmd_fallback)
end
defp extract_prompt_from_content(content, cmd_fallback) when is_list(content) do
texts =
Enum.flat_map(content, fn
%{"type" => "text", "text" => text} when is_binary(text) -> [text]
_ -> []
end)
texts
|> Enum.reduce_while(cmd_fallback, fn text, fallback ->
case check_prompt_text(text, fallback) do
{:found, _} = found -> {:halt, found}
{:command, new_fallback} -> {:cont, new_fallback}
:skip -> {:cont, fallback}
end
end)
|> case do
{:found, _} = found -> found
other -> if other == cmd_fallback, do: :skip, else: {:command, other}
end
end
defp extract_prompt_from_content(_content, _cmd_fallback), do: :skip
defp check_prompt_text(raw, cmd_fallback) do
result = raw |> String.replace("\n", " ") |> String.trim()
cond do
result == "" ->
:skip
Regex.match?(@command_name_re, result) ->
case Regex.run(@command_name_re, result) do
[_, cmd_name] ->
new_fallback = if is_nil(cmd_fallback), do: cmd_name, else: cmd_fallback
{:command, new_fallback}
_ ->
:skip
end
Regex.match?(@skip_first_prompt_re, result) ->
:skip
String.length(result) > 200 ->
truncated = result |> String.slice(0, 200) |> String.trim_trailing()
{:found, truncated <> "\u2026"}
true ->
{:found, result}
end
end
@doc """
Extracts the first occurrence of a JSON string field without full parsing.
Looks for `"key":"value"` or `"key": "value"` patterns.
"""
@spec extract_json_string_field(String.t(), String.t()) :: String.t() | nil
def extract_json_string_field(text, key) do
patterns = [~s("#{key}":"), ~s("#{key}": ")]
Enum.find_value(patterns, fn pattern ->
case :binary.match(text, pattern) do
{pos, len} ->
value_start = pos + len
extract_json_string_value(text, value_start)
:nomatch ->
nil
end
end)
end
@doc """
Extracts the last occurrence of a JSON string field without full parsing.
"""
@spec extract_last_json_string_field(String.t(), String.t()) :: String.t() | nil
def extract_last_json_string_field(text, key) do
patterns = [~s("#{key}":"), ~s("#{key}": ")]
Enum.reduce(patterns, nil, fn pattern, last_value ->
find_all_occurrences(text, pattern, 0, last_value)
end)
end
defp find_all_occurrences(text, pattern, search_from, last_value) do
case :binary.match(text, pattern, [{:scope, {search_from, byte_size(text) - search_from}}]) do
{pos, len} ->
value_start = pos + len
case extract_json_string_value(text, value_start) do
nil ->
find_all_occurrences(text, pattern, value_start, last_value)
value ->
find_all_occurrences(text, pattern, value_start, value)
end
:nomatch ->
last_value
end
end
defp extract_json_string_value(text, start) do
text_size = byte_size(text)
do_extract_json_string_value(text, start, text_size, start)
end
defp do_extract_json_string_value(text, pos, text_size, value_start) when pos < text_size do
case :binary.at(text, pos) do
?\\ ->
do_extract_json_string_value(text, pos + 2, text_size, value_start)
?" ->
raw = binary_part(text, value_start, pos - value_start)
unescape_json_string(raw)
_ ->
do_extract_json_string_value(text, pos + 1, text_size, value_start)
end
end
defp do_extract_json_string_value(_text, _pos, _text_size, _value_start), do: nil
defp unescape_json_string(raw) do
if String.contains?(raw, "\\") do
case Jason.decode(~s("#{raw}")) do
{:ok, str} when is_binary(str) -> str
_ -> raw
end
else
raw
end
end
end