feat(storage): import Claude Code transcripts as Maka Sessions - #3435
feat(storage): import Claude Code transcripts as Maka Sessions#3435Joob1n wants to merge 4 commits into
Conversation
`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
left a comment
There was a problem hiding this comment.
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:
- normalize each Claude source identity once;
- emit one causally ordered canonical representation;
- resolve one deterministic transcript for list and read;
- 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) { |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
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)
|
All three addressed in P1 — you were right, and my first check was not. I verified the claim by counting duplicate 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:
Folding does not relocate later fragments to the first one's position. A After the fix, 1683 extra messages become 280, and those 280 are genuinely separate responses: different P2 — deterministic transcript resolution. 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 Unfinished turns now carry an explicit cutoff: 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.
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, |
Summary
ExternalSessionAdapterhas been source-agnostic since #2500 with Codex as its only implementation. This adds Claude Code, so history in~/.claude/projects/<encoded-cwd>/<uuid>.jsonlcan 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 —
isTrustworthyRecoveredTerminalinruntime-ledger-repair.ts, andterminalStatusinruntime-event-backfill.tsindependently. An importer either forges arecordedstatus it never observed, or watches every imported Run get repaired tofailed. 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_reasonis on every assistant record the Anthropic API produced, andend_turnis exactly the fact the Ledger asks for.Measured across 1130 local root transcripts:
stop_reasontool_useend_turnstop_sequencemax_tokens86.1% of turns carry a terminal stop reason. So this adapter reads recorded evidence the same way the Codex adapter reads
task_complete, andruntime-ledger-repair.tsandruntime-event-backfill.tsare untouched.The other 13.9% get no
turn_stateat all: 349 turns with no assistant reply, and 109 stopped attool_usewith 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
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.isSidechainfiles, 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.-Users-a-bis ambiguous between/Users/a/band/Users/a-b. Only a record's owncwdcan answer a project-scoped query.isApiErrorMessageare the two other terminal facts a transcript carries; they map toabortedandfailed.Parsing primitives come from
@maka/core/foreign-sessionrather 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:tool_callandtool_resultare 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.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 arecordedterminal state. My reading is that it is the model reporting its own turn ended — not an inference the importer made — which is whatisTrustworthyRecoveredTerminalis asking for. If a reviewer reads that predicate as requiring a Maka-authoredturn_statespecifically, the conclusion changes and #3142's original two-step plan comes back.AI use
Select exactly one:
Tool(s) and scope: Claude Code — measured the transcript corpus, wrote the adapter and tests, and ran the verification above.
Generated-byis on the commit. Reviewed and submitted by the contributor of record.Checklist
Does this PR entail a change in behavior?