Skip to content

feat(storage): import Claude Code transcripts as Maka Sessions - #3435

Open
Joob1n wants to merge 4 commits into
apache:mainfrom
Joob1n:feat/claude-code-import
Open

feat(storage): import Claude Code transcripts as Maka Sessions#3435
Joob1n wants to merge 4 commits into
apache:mainfrom
Joob1n:feat/claude-code-import

Conversation

@Joob1n

@Joob1n Joob1n commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

ExternalSessionAdapter has been source-agnostic since #2500 with Codex as its only implementation. This adds Claude Code, so history in ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl can be imported.

The terminal-state problem, and why it turned out to be answerable without touching the Ledger.

#3142 records this as the hard part, and it is right about the constraint: the Ledger refuses a reconstructed terminal that no record corroborates — isTrustworthyRecoveredTerminal in runtime-ledger-repair.ts, and terminalStatus in runtime-event-backfill.ts independently. An importer either forges a recorded status it never observed, or watches every imported Run get repaired to failed. The issue proposed opening the Ledger first, as its own change.

That is not necessary, because the issue's premise does not survive the data. It states a transcript "does not record that a Turn ended — only that another one began." stop_reason is on every assistant record the Anthropic API produced, and end_turn is exactly the fact the Ledger asks for.

Measured across 1130 local root transcripts:

stop_reason count
tool_use 21697
end_turn 4026
stop_sequence 52
max_tokens 1

86.1% of turns carry a terminal stop reason. So this adapter reads recorded evidence the same way the Codex adapter reads task_complete, and runtime-ledger-repair.ts and runtime-event-backfill.ts are untouched.

The other 13.9% get no turn_state at all: 349 turns with no assistant reply, and 109 stopped at tool_use with the result never arriving. Those are genuinely unfinished — a killed process, a crash, a session still open. Reporting them as completed would put a false terminal state on one in seven imported turns, which is the outcome the Ledger's refusal exists to prevent.

What the transcript shape forces

  • Tool results arrive as type: 'user' records (9800 measured). They are the harness replying, not the human; importing them as user Turns would put the model's own tool output in the user's mouth.
  • Sub-agent transcripts are whole isSidechain files, not interleaved records — 0 mixed files across 1130 — so exclusion is per file. Importing one would present a fragment of a conversation as a conversation.
  • The directory name cannot be reversed. It encodes the cwd by replacing separators, so -Users-a-b is ambiguous between /Users/a/b and /Users/a-b. Only a record's own cwd can answer a project-scoped query.
  • Interrupt notices and isApiErrorMessage are the two other terminal facts a transcript carries; they map to aborted and failed.

Parsing primitives come from @maka/core/foreign-session rather than being reimplemented — the issue asks for this, and the reason is real: the CLI's handoff scanner and this importer disagreeing about what the user actually said would be a defect, not a cosmetic inconsistency.

Verification

Run against real data, not only fixtures. listSessions() returns 1128 sessions from 1185 local transcripts in 816ms (the difference is 55 sidechain files and a few empty ones). Converting 300 of them:

user: 1127   assistant: 2605   turn_state: 1095
tool_call: 3972   tool_result: 3972
terminal state coverage: 97.2%

tool_call and tool_result are exactly equal — every call paired with its result. That is the strongest available signal that the record walk is correct, and it is the number that would move first if the tool_result-as-user-record handling were wrong.

11 tests cover the three terminal shapes separately (completed / no-terminal / aborted / failed), tool-result pairing, sidechain exclusion, cwd matching against the mangled directory name, corrupt-line tolerance, and registry wiring. One asserts every emitted message survives decodeStoredMessage — the importer round-trips adapter output through it, so a shape this adapter invented would otherwise be rejected at persistence with the adapter unnamed.

Gates: build, lint, format:check, typecheck, check:release (49), check:stale, knip, and the CI planner tests (53). Suites: storage 860 (849 before), desktop 1023, core 576.

Not run: Windows and Linux. This is filesystem and JSON only, but the paths are exercised on macOS here.

A consequence this PR creates

The import catalog pages 16 sessions at a time and offers one filter (includeArchived). That was proportionate to what the only source produced; this PR makes it not.

source sessions on one machine
Codex 19
Claude Code 1128

59×. At 16 per page that is 71 pages of cursor paging, with no way to say which session you want. A Codex-only catalog never had enough rows for paging alone to fail — so this is something this PR introduces, not a gap it inherits.

I have filed and claimed it as #3438 rather than fold it in here. The fix belongs to ExternalSessionQuery, which both sources share, and its real decision — the match has to reach the adapter ahead of paging, or it silently searches only the rows already fetched — deserves its own review rather than riding along with transcript parsing.

Review focus

Whether stop_reason: 'end_turn' is the right evidence for a recorded terminal state. My reading is that it is the model reporting its own turn ended — not an inference the importer made — which is what isTrustworthyRecoveredTerminal is asking for. If a reviewer reads that predicate as requiring a Maka-authored turn_state specifically, the conclusion changes and #3142's original two-step plan comes back.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — measured the transcript corpus, wrote the adapter and tests, and ran the verification above. Generated-by is on the commit. Reviewed and submitted by the contributor of record.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

Joob1n added 2 commits August 21, 2026 23:09
`ExternalSessionAdapter` has been source-agnostic since apache#2500 with Codex as
its only implementation. This adds Claude Code, so history in
`~/.claude/projects/<encoded-cwd>/<uuid>.jsonl` can be brought in.

**The terminal-state question, and why it turned out to be answerable.**

apache#3142 records that this is the hard part: the Ledger refuses a reconstructed
terminal that no record corroborates (`isTrustworthyRecoveredTerminal`, and
`terminalStatus` in `runtime-event-backfill.ts` independently), so an importer
either forges a `recorded` status it never observed or watches every imported
Run get repaired to `failed`. The issue concluded that a transcript "does not
record that a Turn ended — only that another one began", and proposed opening
the Ledger first.

That conclusion does not survive the data. `stop_reason` is on every assistant
record Anthropic's API produced, and `end_turn` is exactly the fact the Ledger
asks for. Measured across 1130 local transcripts: 4026 `end_turn`, and 86.1%
of turns carry a terminal stop reason. So this adapter reads evidence the same
way the Codex one reads `task_complete`, and the Ledger needs no change.

The remaining 13.9% are turns with no assistant reply at all (349) or stopped
at `tool_use` with the result never arriving (109). Those are genuinely
unfinished — a killed process, a crash, a session still open — and they get no
`turn_state`. Reporting them as completed would put a false terminal state on
one in seven imported turns.

**What the transcript shape forces.**

- Tool results arrive as `type: 'user'` records. They are the harness replying,
  not the human, and importing them as user Turns would put the model's own
  tool output in the user's mouth. Measured 9800 of them.
- Sub-agent transcripts are whole `isSidechain` files rather than interleaved
  records — verified 0 mixed files across 1130 — so exclusion is per file.
- The directory name encodes the cwd by replacing separators, so `-Users-a-b`
  is ambiguous between `/Users/a/b` and `/Users/a-b`. Only a record's own `cwd`
  can answer a project-scoped query.
- Interrupt notices and `isApiErrorMessage` are the two other terminal facts a
  transcript carries; they map to `aborted` and `failed`.

Parsing primitives come from `@maka/core/foreign-session` rather than being
reimplemented. The CLI's handoff scanner and this importer disagreeing about
what the user actually said would be a real defect, not a cosmetic one.

**Verified against real data, not only fixtures.** The adapter lists 1128
sessions from 1185 local transcripts in 816ms, and converting 300 of them
produces 3972 `tool_call` and 3972 `tool_result` — every call paired with its
result, which is the strongest signal the walk is correct — with 97.2% of
turns carrying a terminal state.

The hardcoded `Codex` label in the import settings page becomes a map keyed by
adapter id. That surface was the one genuinely Codex-specific piece left, and
it only becomes wrong once a second adapter exists.

Refs apache#3142

Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

Generated-by: Claude Code (Claude Opus 5)
…dapter

Read back as a reviewer rather than as the author. All three are cases where
the adapter produced something plausible instead of something true.

**`max_tokens` was treated as a completed turn.** It does report that
generation stopped — because the answer hit the output limit mid-sentence.
Calling that "completed" is the exact mistake this adapter is built to avoid,
just arriving through a different door than the one I was watching. It is rare
(1 occurrence across 1130 local transcripts) and now imports with no terminal
state, like any other turn whose ending nothing vouches for.

**A tool result with no `tool_use_id` was given a minted one.** That id cannot
match any call by construction, so the result imported as a row pointing at
nothing — a detached entry in the transcript view, which is worse than the row
being absent. Such results are now dropped, and the test asserts the stronger
property: every surviving result names a call that is present.

**Turn ids shared the message-id counter.** Harmless today and visibly odd —
ids came out `turn:0`, `turn:3` — but one edit to the emission order away from
a turn id colliding with a message id. They count separately now.

Re-ran against the real corpus after the fixes: 3972 `tool_call` and 3972
`tool_result` across 300 sessions, terminal distribution unchanged. The
corrections touch only the edges they were meant to.

Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

Generated-by: Claude Code (Claude Opus 5)

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding Claude Code through the existing ExternalSessionAdapter and Host import seams. The overall architecture is the right one: source-specific parsing stays in the adapter, canonical persistence and lifecycle stay with the Host, and the Desktop change is limited to presenting the second source. The recorded end_turn evidence, synthetic-user filtering, and tool-result handling are also well motivated.

I found one issue that should be addressed before approval: Claude JSONL records need an identity-normalization pass before canonical messages are emitted. Normal resume/recovery transcripts can repeat the same source record or split one assistant response across non-consecutive records. Persisting those lines independently duplicates prompts, turns, and tool identities, which can make the imported history unreliable for continuation.

The two P2 comments are non-blocking follow-ups: make duplicate transcript-file resolution deterministic, and align unfinished source turns with the Host's actual terminal materialization. I intentionally left timestamp polish, catalog scan cost, and path-normalization improvements out of the blocking scope.

A clean final shape would be:

  1. normalize each Claude source identity once;
  2. emit one causally ordered canonical representation;
  3. resolve one deterministic transcript for list and read;
  4. let the Host own the final imported-snapshot terminal fact.

Once the duplicate-source-identity issue is fixed with the focused repeated-record fixture, I would be comfortable approving this revision.

AI-assisted review disclosure: OpenAI Codex assisted with the exact-head analysis of transcript identity, Host Ledger materialization, source resolution, and simplification opportunities. I verified the cited code paths, live PR state, and severity calibration before posting.

turn = undefined;
};

for (const record of records) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Normalize Claude source identity before emitting canonical messages

This loop currently treats every JSONL line as a distinct semantic event. In normal Claude resume and recovery histories, the same record.uuid can be written more than once, while fragments sharing one assistant message.id can recur non-consecutively across a tool_result. Real transcripts therefore cause this walk to emit duplicate user prompts, turns, and identical tool_call ids.

Once those duplicates are persisted as canonical Maka history, the imported Session can be misleading or invalid for continuation. Deleting and importing it again on this head produces the same result, so this is not recoverable without changing the converter.

Could we make source normalization a single precondition of conversion: de-duplicate repeated records by record.uuid, fold assistant fragments by message.id even when a tool result appears between them, and emit each semantic block exactly once in its original causal order? A focused regression should contain both a repeated UUID and a non-consecutive shared message.id. Equal call/result counts are not sufficient here because both sides can be duplicated together.

This is the only issue in my review that should be addressed before approval.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8316b773c.

My first check was the wrong one — I counted duplicate tool_call ids, found zero, and nearly replied that it did not reproduce. The converter mints a fresh id per record, so that count could never have found anything; your comment had already ruled it out and I ran it anyway. Measuring responses instead: 1683 more assistant messages than responses across the 30 affected transcripts here.

Records now de-duplicate on record.uuid, and a response's prose is emitted once per message.id. Folding does not relocate later fragments — a tool_use after a tool_result was issued after it — so prose folds and calls stay in place. Checked the corpus first: text and thinking each appear at most once per message.id.

1683 extra messages became 280, and those 280 are genuinely separate responses. Regression covers both shapes, each verified to fail when its own normalization is reverted.


async readSession(sessionId: string): Promise<ExternalMakaSession> {
assertSafeSessionId(sessionId);
const file = (await this.#transcriptFiles()).find(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Resolve duplicate transcript stems consistently between list and read

A Claude Session UUID can legitimately appear under more than one project directory after a workspace move, resume, or recovery. listSessions() currently exposes each file with the same source id, while this .find() reads whichever copy happens to appear first. The user can therefore select a newer summary but import an older or shorter transcript carrying the same UUID.

The clean final state is one resolver shared by both listing and reading. It should either select one canonical valid transcript per UUID deterministically — normally the newest continuation — or, if separate forks are intentionally user-visible, give each one a path-qualified opaque source id. It should also verify that the records' sessionId agrees with the filename stem.

A fixture with the same UUID in two project directories and divergent contents would pin the chosen policy. I consider this non-blocking because the source remains intact and the affected import can be deleted, but the current selection is not deterministic from the user's perspective.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8316b773c. listSessions and readSession now resolve through one map keyed by session id — newest mtime wins, path breaks the tie — so the selection no longer depends on directory iteration order.

I did not add the two-directory fixture: no duplicate stems exist in the 1185 local transcripts, so I would have been pinning a policy against data I could not observe. The resolver is deterministic either way; say the word if you want the fixture regardless.


const closeTurn = (): void => {
if (!turn) return;
// No terminal `turn_state` is emitted for a turn nothing corroborates.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Give an incomplete imported snapshot a Host-owned terminal outcome

Leaving out turn_state does not preserve an unfinished turn in the final system. During transcript materialization, deriveTurnRecords() infers a completed turn, the Ledger rejects the recovered terminal because no recorded state corroborates it, and the repair path persists failed / missing_terminal_event.

That is reachable for ordinary unanswered prompts, a final tool_use, and max_tokens. The damage is limited to the imported copy, but the resulting internal-corruption failure is not the source fact this adapter intended to represent.

Could the final state define one explicit imported-snapshot cutoff at the Host transcript-materialization seam — for example a cancelled/aborted terminal with an external_session_snapshot source — while keeping end_turn, interrupt, and API-error evidence authoritative? An end-to-end test through HostExternalSessionCoordinator and Ledger materialization is important here; the adapter-only assertion cannot observe the repair that changes the status.

This is non-blocking in my review, but it should not be described as “no terminal state” until the Host result matches that claim.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8316b773c, and you were right that I should not have described it as "no terminal state".

Traced it: without a turn_state, deriveTurnRecords falls back to inferLegacyTurnStatus, which returns completed for any turn holding an assistant message (session.ts:1250) and marks it inferred; the Ledger refuses it and the repair writes failed / missing_terminal_event. A transcript that was merely cut short imported as internal corruption.

Unfinished turns now carry aborted with abortSource: 'external_session_snapshot'. Terminal coverage across 1128 local sessions goes 97.2% → 100%.

The end-to-end test is in 82287ab56 — I had said I would add it if wanted, which dressed up a decision made without checking; runtime-ledger-repair.test.ts already had the harness. Both directions are pinned: the cutoff materializes to cancelled, and the same turn without it materializes to failed / missing_terminal_event.

Joob1n added 2 commits August 22, 2026 00:52
Three findings from @Astro-Han's review. The blocking one was right, and my
first attempt to check it was wrong in a way worth recording.

**P1 — a transcript line is not a semantic event.**

I verified the claim by counting duplicate `tool_call` ids in the output and
found zero, which nearly became a reply saying the concern did not reproduce.
That check could not have found anything: the converter mints a fresh id per
record, so duplicates are impossible by construction. The review had already
named this — "equal call/result counts are not sufficient here." Measuring the
right thing showed the damage: across the 30 affected transcripts, 1683
assistant messages more than there were responses. One reply rendered as four.

Two shapes cause it, both ordinary in resume and recovery histories:

- A record can be written twice (3 repeats across 1130 local transcripts).
  Records now de-duplicate on `record.uuid`.
- One response is split across records sharing `message.id`, and the pieces
  can be separated by the `tool_result` of a call an earlier piece made (374
  occurrences across 30 transcripts). Its prose is now emitted once.

Folding does not move the later fragments to the first one's position. A
`tool_use` that arrives after a result was issued after it, and relocating it
would invert cause and effect — so prose folds and calls stay where the log
put them. Verified against the corpus first: `text` and `thinking` each appear
at most once per `message.id`, so emitting them once loses nothing.

Result on the affected transcripts: 1683 extra assistant messages became 280,
and the remainder are genuinely separate responses — different `message.id`,
different text, one per model reply after a tool returned.

**P2 — one transcript per session id, chosen the same way twice.**

`listSessions` exposed every file and `readSession` took whichever `.find()`
reached first, so a user could select one summary and import another. Both now
resolve through one map keyed by session id: newest mtime wins, path breaks a
tie, so the choice does not depend on directory iteration order.

**P2 — "no terminal state" was not what the system did with it.**

Leaving `turn_state` out does not preserve "unfinished". `deriveTurnRecords`
falls back to `inferLegacyTurnStatus`, which answers `completed` for any turn
holding an assistant message (`session.ts:1250`) and marks it `inferred`; the
Ledger then refuses that uncorroborated terminal and the repair path persists
`failed / missing_terminal_event`. A transcript that was merely cut short
would have imported as internal corruption.

An unfinished turn is now recorded as what it is — `aborted` with
`abortSource: 'external_session_snapshot'`, distinguishable from a user Stop
or a provider abort. `end_turn`, interrupt notices and API errors keep their
own evidence. Terminal coverage across 1128 local sessions goes from 97.2% to
100%, with the 32 newly covered turns landing in the snapshot-cutoff bucket
rather than in the Ledger's repair path.

Tool calls and results remain exactly paired at 3972 each.

Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

Generated-by: Claude Code (Claude Opus 5)
…alization

@Astro-Han asked for an end-to-end test through Ledger materialization, and I
said in review that I had not added one. That was a decision made without
checking: `runtime-ledger-repair.test.ts` already builds the stores, calls
`materializeTranscriptLedger`, and asserts the resulting run — the harness I
needed was sitting there. The cost I declined to pay was imagined.

Two tests, and the pair is the point:

- A turn carrying `aborted` with `abortSource: 'external_session_snapshot'`
  materializes to a `cancelled` run. The Ledger accepts it because the
  terminal is recorded rather than reconstructed.
- The same turn with no terminal state at all materializes to `failed /
  missing_terminal_event`.

The second is what the adapter used to produce. It now pins the reason the
first exists, so the justification cannot quietly stop being true — and it
turns "the Ledger repairs this to failed" from something I repeated out of a
review comment into something that runs.

Neither is observable from the adapter, which is exactly why the review asked
for this seam: an adapter assertion can show which `turn_state` was emitted
and nothing about what happens to it afterwards.

Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

Generated-by: Claude Code (Claude Opus 5)
@Joob1n

Joob1n commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

All three addressed in 8316b773c.

P1 — you were right, and my first check was not.

I verified the claim by counting duplicate tool_call ids in the output, found zero, and nearly replied that it did not reproduce. That check could not have found anything: the converter mints a fresh id per record, so duplicates are impossible by construction. Your comment had already ruled it out — "equal call/result counts are not sufficient here" — and I ran it anyway.

Measuring the right thing showed it plainly. Across the 30 affected transcripts on this machine: 1683 more assistant messages than there were responses. One reply rendering as four.

Both shapes are now normalized before conversion:

  • Records de-duplicate on record.uuid — 3 repeats across 1130 local transcripts.
  • One response's prose is emitted once, keyed by message.id — 374 split occurrences across 30 transcripts.

Folding does not relocate later fragments to the first one's position. A tool_use arriving after a tool_result was issued after it, so moving it earlier would invert cause and effect: prose folds, calls stay where the log put them. I checked the corpus before choosing that rule — text and thinking each appear at most once per message.id, so emitting them once loses nothing.

After the fix, 1683 extra messages become 280, and those 280 are genuinely separate responses: different message.id, different text, one per model reply after a tool returned. Regression covers both a repeated UUID and a non-consecutive shared message.id, and I verified each fails when its own normalization is reverted.

P2 — deterministic transcript resolution. listSessions exposed every file while readSession took whichever .find() reached first. Both now resolve through one map keyed by session id: newest mtime wins, path breaks the tie, so the choice no longer depends on directory iteration order.

P2 — "no terminal state" was not what the system did with it, and you are right that I should not have described it that way.

I traced it: without a turn_state, deriveTurnRecords falls back to inferLegacyTurnStatus, which returns completed for any turn holding an assistant message (session.ts:1250) and marks it inferred; the Ledger refuses that uncorroborated terminal and the repair path persists failed / missing_terminal_event. A transcript that was merely cut short would have imported as internal corruption — the opposite of the source fact I meant to preserve.

Unfinished turns now carry an explicit cutoff: aborted with abortSource: 'external_session_snapshot', distinguishable from a user Stop or a provider abort. end_turn, interrupt notices and API errors keep their own evidence and are unchanged. Terminal coverage across 1128 local sessions goes 97.2% → 100%, with the 32 newly covered turns landing in the snapshot bucket rather than in the repair path.

On the end-to-end test you asked for. I first wrote that I had not added one and would if you wanted it — which dressed up a decision I had made without checking. runtime-ledger-repair.test.ts already builds the stores, calls materializeTranscriptLedger, and asserts the resulting run; the harness was there and the cost I declined to pay was imagined. It is in 82287ab56, as a pair:

  • a turn carrying aborted / external_session_snapshot materializes to a cancelled run — the Ledger accepts a recorded terminal;
  • the same turn with no terminal state materializes to failed / missing_terminal_event.

The second pins what the adapter used to produce, so the reason the first exists cannot quietly stop being true. Neither is observable from the adapter, which is your point about the seam.

Tool calls and results remain exactly paired at 3972 each. Gates: build, lint, format:check, typecheck, check:release; storage 865, desktop 1030, ui 187, core 587, runtime 2996/3001 (the five failures are spawn rg ENOENT on a machine with no ripgrep binary).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants