session-read
Tier 2 · domain. Depends on locator and agent-protocol.
npm install @titan-design/session-readThe problem it solves
Claude Code writes every session to ~/.claude/projects/**/*.jsonl, one JSON object per line, in a format that is rich and undocumented. Anything that wants to learn from those sessions starts by re-deriving the same parse: which line is a turn, which is a tool result, what file did that Bash command touch, which repo was it in.
This turns a transcript into typed events, each carrying the byte range of the line that produced it. It stores nothing. What you do with the events is session-graph's job, or your own.
When to reach for it
You want to analyse Claude Code sessions and you do not want the storage opinion that session-graph brings. Session analytics, a custom aggregation, a one-off report.
Example
Verified against 0.2.0, over a two-line transcript.
import { extractTranscript, readTranscriptEvents } from "@titan-design/session-read";
// Stream events from a watermark
for await (const event of readTranscriptEvents(path, { fromByteOffset: watermark })) {
// event.kind: 'session' | 'branch' | 'edge' | 'fact' | 'span' | 'turn' | 'request' | …
}
// Or fold a chunk into one delta of rows
const delta = await extractTranscript(path, { fromByteOffset: 0 });
delta.sessions; // [{ sessionId: 's-1', gitBranch: 'main', … }]
delta.requests; // [{ requestId: 'req_1', model: 'claude-opus-5', inputTokens: 120, outputTokens: 40, … }], one per line; dedupe on requestId
delta.lastByteOffset; // 542
delta.prefixHash; // hash of the bytes consumed, for rewrite detectionDiscovery, including subagent sidechains:
import { discoverTranscripts } from "@titan-design/session-read";
const found = await discoverTranscripts(); // defaults to ~/.claude/projects
// [{ projectDir, absolutePath, displayPath, subagentId? }, …]The event model
SessionEvent is a discriminated union on kind:
fact (one per line, typed by event), span (search text for prompt / assistant_response / tool_input / tool_result), session (descriptive fields and turn/commit/push deltas), turn, usage (per-model tokens with an estimated thinking share), phase, human_edit, file_checkpoint, pr, pr_merge, pr_create, review_verdict (below), branch, file, task, subagent, subagent_transcript, artifact, and edge (session:… touched file:… and friends; vocabulary in RELATIONS).
File refs and code ids
A touched edge points at fileRef(repo, path): file:<repo>/<path>, or file:<abs path> when the file is outside any working tree. Two functions turn either form back into a (repo, path) pair, and that path is the code-graph file id:
toRepoRelative(absPath): { repo, path }resolves an absolute path by its nearest.git, in posix form. A path under a removed.worktrees/<name>/resolves to the same path a live worktree gives, so one file gets one ref. A path outside any repo keeps its absolute form with a null repo.parseFileRef(ref): { repo, path } | nullis the inverse offileRef. It splits at the first/and strips a.worktrees/<name>/prefix that an older ingest stored. A null repo means plain text, with no code link. A ref that is notfile:returns null.
import { parseFileRef } from "@titan-design/session-read";
parseFileRef("file:demo/.worktrees/feature-b/src/app.ts"); // { repo: "demo", path: "src/app.ts" }
parseFileRef("file:/srv/notes.md"); // { repo: null, path: "/srv/notes.md" }Only the default .worktrees base is recognised. Refs stored before 0.11 keep their leaked prefix in the graph; parseFileRef normalises them at read time.
Review verdicts
parseReviewVerdicts(text) reads a chat_send message for approve / changes-requested verdicts, without storing any of the message. A verdict line either contains the word verdict followed (after up to three punctuation characters and any whitespace) by a token, or begins, after optional markdown, with APPROVE, APPROVED, CHANGES REQUESTED or REQUEST CHANGES. Tokens are case-insensitive and their words may be joined by a space, underscore or hyphen: APPROVE, APPROVED and LGTM mean approve; CHANGES REQUESTED, REQUEST CHANGES, REQUESTED CHANGES, NEEDS CHANGES, BLOCKED and BLOCKING mean changes requested.
PR references come from the verdict line, or the message's first line when the verdict line names none, in precedence order: a github.com/<owner>/<repo>/pull/<n> URL or an <owner>/<repo>#<n> pair give an exact repo; <repo> #<n> or <repo> PR #<n> keep the word before the number as a repoHint, but only when that word is not a verdict token (approve, blocked, …) or a common filler (pr, see, on, the, …) — a rejected candidate yields no hint, and the search never looks further left; PR #<n>, PR <n>, pull request #<n> and a bare #<n> carry neither. One line can name several PRs, each becoming its own match. Verdicts dedupe on the pair (repo ?? repoHint?.toLowerCase() ?? "", number): two references to the same number in different repos are two verdicts, but when a later verdict line names the same pair, it replaces the earlier one.
Every scan is a single bounded pass over the line, so a pathological single-line message still parses in linear time.
import { parseReviewVerdicts } from "@titan-design/session-read";
parseReviewVerdicts("Verdict: CHANGES REQUESTED on acme/widgets#248");
// [{ verdict: "changes_requested", repo: "acme/widgets", repoHint: null, number: 248 }]readToolUse emits one review_verdict event per match for a tool whose name ends in __chat_send, adding toolUseId, ordinal (the verdict's index in that message's parsed list) and cwdRepo (repoForCwd of the call's cwd). The event carries only the parsed fields, never the message text. Resolving repo/repoHint against known PRs and filtering by the sender's profile happen downstream, in session-graph.
Verdict block
parseVerdictBlock(text) reads the strict block a reviewer sends when a merge hangs on it:
Verdict: MERGE
PR: octo/demo#12
Head: 0123456789abcdef0123456789abcdef01234567It returns { ok: true, verdict: "MERGE" | "FIX_FIRST", repo, pr, head, lineOffset, closer?, ciPolicy? } or { ok: false, reason }, where lineOffset is the zero-based line of the Verdict: line. It fails closed. The three lines must be consecutive and exact: Verdict: is MERGE or FIX_FIRST in upper case, PR: is owner/name#n (GitHub's [A-Za-z0-9._-], no .git suffix, no URL, n a positive integer without leading zeros), and Head: is exactly 40 lowercase hex characters with nothing after it. A FIX_FIRST block may add one optional fourth line, Closer: yes|no (is this head closer to MERGE than the last reviewed one), directly after Head:. It is returned as closer. On MERGE, with any other value, duplicated anywhere visible, or anywhere but directly after Head:, it is ignored and the block parses as it would without it. A MERGE block may add a different optional fourth line directly after Head:, CI-policy: unchanged or CI-policy: weakened <keys> (TP-2407), where the keys are comma-and-space separated, each at most once, from the closed set CI_POLICY_KEYS: required-check, permissions, pull_request_target, secrets, ruleset and other. It is returned as ciPolicy ({ kind: "unchanged" } or { kind: "weakened", keys }). Unlike the block lines, the label is looked for on every raw line, fenced, indented, commented out or not: a line is labelled when, after NFKC, lower case and the removal of every non-alphanumeric character and any leading list number, it starts cipolicy (**CI-policy:**, ## CI-policy:, | CI-policy |, CI/policy:, CI-policy weakened). With no labelled line, ciPolicy is undefined. A labelled line whose rest folds to weakened and known keys reads weakened wherever it sits. unchanged counts only as the one labelled line, exact and visible directly after Head:. Any other labelled line returns { kind: "malformed" }: an unknown or repeated key, an empty list, a trailing note, a decorated, hidden or misplaced unchanged, or a second one. Across several labelled lines the strictest reading wins: weakened (keys merged) over malformed over unchanged. Shepherd's merge guard gates a weakened or malformed line on any PR. On FIX_FIRST or WAIT the line is ignored. Text that may be someone else's only makes the parser stricter. A line is read only when indented 0 to 3 spaces (a tab counts 4, and any other character trim strips, such as U+00A0, counts 1), since a deeper line is an indented code block. Trailing whitespace and CRLF are harmless. A quoted line (> Verdict: MERGE) is not a block. A fence opens on 3 or more backticks or tildes, also after list or > markers, and closes only on a bare line of the same character at least as long, as in CommonMark, and an unclosed fence hides the rest of the message. Lines that start inside an HTML comment are skipped up to a line holding --> with no <!-- after its last -->. A Status: line is ignored. Any visible line starting Verdict: is a second block and refuses the message, even when identical. Refusal reasons are no_block, multiple_blocks, bad_verdict, missing_pr_line, bad_pr, missing_head_line and bad_head. It shares no grammar with parseReviewVerdicts.
visibleLines(text) is the visibility pass the parser runs first: one entry per line, its trimmed text, or null when the line is fenced, inside an HTML comment or indented as code. It is exported so other parsers of a reviewer's message (review-panel's parseFollowUps) agree with the verdict on what is visible.
Audit events
Eight more kinds feed cost and context audits. Each extends the event base with blockIndex: the position of the block within the line's content, or 0 for a whole-line event. They fold into their own TranscriptDelta lists. EXTRACT_VERSION (now 8) is bumped whenever a classification rule changes, so a store can tell stale rows apart and re-index. Version 8 added the channel tag's broadcast flag to inbound; session-graph's backfillFacets re-extracts the inbound rows of every transcript indexed under 7 or earlier.
| kind | list | emitted for |
|---|---|---|
request | requests | every assistant line with usage, keyed by requestId or else message.id. A response split over two lines repeats its usage, so the store dedupes on the key. Cache creation is split into 5m and 1h. Supersedes the deprecated usage. |
tool_call | toolCalls | each tool_use block, with family and mcpServer from toolFamily, and inputChars |
inbound | inbound | each delivering record: every user line, and each queued_command attachment (a message delivered mid-loop). cause is a WakeCause from classifyInbound. delivery is turn_start, mid_loop or tool_result. A channel message carries originServer, fromName, msgId and broadcast (true when the tag carries broadcast="true", false for a direct message, null for an arrival with no channel tag). A tool result carries the toolUseId of its first tool_result block; rollup joins it to the tool_call for the tool name. contentHash is the sha-1 of the first 512 characters. promptSource is the record's own promptSource (typed, system, sdk for a headless turn), null when absent. |
context_block | contextBlocks | the characters that entered context, by ContextSource. A user record gives one row for its text, labelled by its wake cause (human, channel, compaction_summary, or system_reminder for other injected text). It also gives one tool_result row per tool_result block, and one image row per image block. An assistant line gives one row per text, thinking and tool_use block. An attachment gives one row (skill_listing, or attachment with attachmentType) only when its string content is 256 characters or more (MIN_ATTACHMENT_CHARS). isMedia marks base64 images, whose chars is the encoded length. |
compaction | compactions | a compact_boundary system line: trigger, tokens before and after, dropped tokens, duration |
queue_op | queueOps | a queue-operation line. enqueue is when a message arrived during a busy turn; rollup pairs it with the matching inbound by contentHash. |
signal | signals | a recognisable act in one tool_use block (provisional vocabulary, below) |
cost_state | costStates | a cost-state line: totalCostUsd and the raw model usage |
SignalKind values, each read from a single block:
chat_sendis an agent-chatchat_send, with the recipient asdetail.status_reportis achat_sendwhose text hasStatus: DONE,DONE_WITH_CONCERNS,BLOCKEDorNEEDS_*, with the status word asdetail.commit,pushandpr_mergecome from a Bash command, viaparseGitIntent.pr_mergecarries the PR number.pr_createis a Bash simple command that matchesgh pr create, not aparseGitIntentresult.task_wrapisactive-work wraporrecordin Bash, or aSkillcall whose skill isactive-work.task_doneisactive-work task done, with the task id asdetail.doc_writtenis aWriteto a.mdpath.agent_spawnis anAgentorTaskcall, or an agent-chatagent_spawn.file_readis aReadcall andfile_writeis a write-tool call (including notebook edits). Both carry the repo-relative path asdetail; build trees matched byIGNORED_PATHemit nothing.
The classifiers are exported for reuse: classifyInbound, toolFamily, toolUseSignals, bashSignals, sourceForCause, and the shared INJECTED_MARKERS list.
Why incremental reading is safe
Every rule in LineReader is stateless across lines except the last-seen timestamp. A record such as cost-state carries none of its own and takes the enclosing line's. A caller resuming mid-file with LineReader directly must pass initialTs, the last timestamp before its start offset. readTranscriptEvents does this for you, recovering it with a backward lookback from the start offset. With that, reading incrementally from a watermark and rebuilding the whole file produce the same events, so an index can resume without ever re-deriving history.
EventFolder then merges a chunk's events into a TranscriptDelta with rules chosen so chunk boundaries cannot change the answer: timestamps min/max, counters sum, gitBranch and seedPrompt first-wins, aiTitle / cwd / cliVersion last-wins, startType from the earliest line carrying an entrypoint, ref-keyed rows deduplicated.
Transcripts mirrored from another host
claudeTranscriptRoots reads CLAUDE_TRANSCRIPT_MIRRORS as well as this machine's own config dirs. Each entry is <host>=<dir>, and entries are joined by the path delimiter (:). Every <dir>/<account> with a projects dir becomes a root whose account is the directory name and whose host is the label, so a mirror laid out as <dir>/<account>/projects/... needs one entry for all its accounts:
CLAUDE_TRANSCRIPT_MIRRORS="mac=~/mac-transcripts"discoverAllTranscripts stamps each mirrored transcript with that host; a transcript from this machine has no host at all. A missing mirror dir adds no roots, and an entry without a valid <host>= label throws. Mirrors are appended whether or not CLAUDE_CONFIG_DIRS is set.
Do not add a mirror to CLAUDE_CONFIG_DIRS. That variable also names the accounts anthropic-account polls for credentials and usage, and a mirror is a copy of transcripts, not a config dir anyone can run Claude Code with. A caller that turns a root back into a config dir should skip any root with a host.
A mirrored session usually exists on this machine too, under the same session id. The graph treats both files as transcripts of that one session; see session-graph.
Gotchas
Pass subagentId when reading a sidechain. Lines in <session>/subagents/agent-<id>.jsonl carry the parent's sessionId. Taking that at face value files the child's work under the parent. Given subagentId, the reader gives the child its own identity and emits the spawned edge instead.
Attribution is deliberately conservative. Files and branches are attributed to the nearest .git ancestor of the path or the command's effective cwd (cd … and git -C … are honoured), named from the origin remote. Anything outside a working tree stays unattributed rather than guessed.
A DiscoveredTranscript is not just a path. It is { projectDir, absolutePath, displayPath, subagentId? }, and displayPath (the ~-relative form) is the key the watermark table stores. Hand-building one with the wrong shape is the fastest way to get a confusing NOT NULL failure downstream.
Assigned task of a spawned session
assignedTaskIds({ agentName, brief, isKnown }) reads which task a spawned session was given, from the agent name and the full spawn brief. isKnown is the caller's task store: an id counts only if it returns true. The result is { taskIds, source }, with up to three ids in order.
import { assignedTaskIds } from "@titan-design/session-read";
assignedTaskIds({ agentName: "factory-tp422", brief, isKnown });
// { taskIds: ["TP-422"], source: "name" }source says which rule found the ids. The name wins over the brief. name-over-brief marks a name whose ids the brief does not repeat. brief-anchor is a line that begins with TASK, ASSIGNMENT, YOUR TASK, IMPLEMENT or GOAL; ids in parentheses on it are references, not assignments. brief-paragraph is the first known id near the start of the assignment. none is a definite answer that nothing links. More than three ids in one source is a list, and that source yields nothing.
The orientation block is skipped. agent-chat prepends an orientation that lists every open task. orientationEnd(brief) returns where the assignment starts: 0 when there is no orientation, null when there is one whose end cannot be found. Such a brief links nothing rather than guess. The block's format is owned by agent-chat, and the tests pin today's headings and truncation marker. When agent-chat recorded the block's length (agent_spawned meta briefing_chars), pass it as orientationEnd(brief, briefingChars) or as briefingChars to assignedTaskIds: the assignment then starts at the first non-blank line after that offset, whatever the headings say. An out-of-range value falls back to the heading rules. Every search reads a bounded window, so a 100 KB brief costs the same as a short one.
Where it came from
active-work's session miner (the AW-23 line handler). The writer, rollups, PR reconciliation, quarantine, and scheduler stayed behind as storage concerns and became session-graph.
Normalized decoder contracts
SessionFormatDecoder defines streaming observations with conversation-scoped IDs, source bytes and semantic subrecord selectors. Stateful formats can replay prefixes or resume validated checkpoints. Usage distinguishes response deltas from snapshots.
discoverCodexSources({ namespace, codexHome }), readCodexObservations(source) and readCodexText(locator) implement the initial Codex rollout path. Source line hashes protect readback; prefix replay preserves model, usage and projection state across chunks. The existing Claude reader remains available. See the contract decision for compatibility and migration.
codexHome() honors $CODEX_HOME and falls back to ~/.codex. Discovery and readback separate absence from failure. A missing directory yields no sources, and a stale locator (file gone or truncated, or a line that no longer matches its hash or parses) reads back as null. Any other I/O error, such as EACCES, rejects discoverTranscripts, discoverAllTranscripts, discoverCodexSources, readClaudeText, readCodexText and readSessionSourceText.
Tool calls from raw lines
decodeClaudeToolLines(lines) decodes raw Claude transcript lines synchronously, with no source descriptor. It skips blank and malformed lines, ignores session identity and does not dedupe a repeated call id; a block without a call id is skipped.
import { decodeClaudeToolLines } from "@titan-design/session-read";
const results = new Map<string, unknown>();
const calls = [];
for (const line of decodeClaudeToolLines(rawLines)) {
if (line.kind === "tool_result") results.set(line.callId, line.output);
else calls.push(line);
}Graph-free consumer views
The normalized reader dispatches both Claude and Codex source descriptors through readSessionObservations. findClaudeSessionSource supports exact conversation lookup, while readRecentSessionTurns provides an independently bounded tail with explicit truncation and unknown-field reporting. Whole-source prefix replay and bounded tail reading have different guarantees.
For a readback of what an agent said, pass projection: "text". It keeps only user and assistant text and applies maxTurns after tool traffic is dropped. claudeSourceFromPath(path, namespace) builds the descriptor when the caller already holds a transcript path. The package README has the recipe and measured window sizes.
SessionSummaryAccumulator and summarizeSession expose observed spans, tools and usage without a database. SessionUsageAccumulator is shared with graph queries. It selects measurements with agent-protocol's foldUsage, then groups them by model; a null token count in a group makes that group's sum null.
Session identity mismatches throw SessionIdentityError. A Claude transcript record or Codex rollout record that belongs to a different native session, or a sidechain that names more than one parent session, throws SessionIdentityError from both the decoders and the recent-turn readers — a TypeError subclass with a stable code field ("foreign_native_session" or "multiple_parent_sessions"). Existing instanceof TypeError catches and message-prefix matches keep working; a consumer that wants to tell this apart from option-validation TypeErrors (which stay plain) can now check instanceof SessionIdentityError and read .code. session-graph quarantines a source on this error, as it does on TranscriptParseError, because both describe the file's contents rather than a fault in the caller.