Mintokei.AgentTranscripts
0.1.0
See the version list below for details.
dotnet add package Mintokei.AgentTranscripts --version 0.1.0
NuGet\Install-Package Mintokei.AgentTranscripts -Version 0.1.0
<PackageReference Include="Mintokei.AgentTranscripts" Version="0.1.0" />
<PackageVersion Include="Mintokei.AgentTranscripts" Version="0.1.0" />
<PackageReference Include="Mintokei.AgentTranscripts" />
paket add Mintokei.AgentTranscripts --version 0.1.0
#r "nuget: Mintokei.AgentTranscripts, 0.1.0"
#:package Mintokei.AgentTranscripts@0.1.0
#addin nuget:?package=Mintokei.AgentTranscripts&version=0.1.0
#tool nuget:?package=Mintokei.AgentTranscripts&version=0.1.0
Mintokei.AgentTranscripts
Read and write the on-disk session stores of coding-agent CLIs, normalized to the same
AgentMessage contract Mintokei.AgentEngine
emits for live sessions.
Mintokei.AgentEngine drives a CLI and reads what it says. This package reads and writes the
transcripts it leaves behind — so a conversation can be moved between CLIs and resumed there.
var claude = new ClaudeTranscriptStore();
await foreach (var s in claude.ListAsync(cwd: "/repo"))
Console.WriteLine($"{s.SessionId} {s.FirstUserMessage}");
var transcript = await claude.ReadAsync(sessionId);
// transcript.Messages is IReadOnlyList<AgentMessage> — the same type a live IAgentSession emits
var newId = await claude.WriteAsync(transcript, new TranscriptWriteOptions { Cwd = "/repo" });
// `claude --resume <newId>` now picks the conversation up
Converting between CLIs is then Read(A) → Write(B).
Transcript vs session
Deliberately not called "session": Mintokei.AgentEngine already owns AgentSession /
IAgentSession / AgentSessionSpec, meaning a live CLI conversation you can send turns to.
A StoredTranscript is the durable record of one — inert, readable long after the process
exited, and writable into a store the CLI has never run against.
SessionId stays named that on StoredTranscript, because it is the CLI's own identifier: the
value you pass to claude --resume.
Status
| CLI | Read | Write | Index |
|---|---|---|---|
| Claude Code | ✅ | ✅ | none needed — the file is the session |
| Codex | ✅ | ✅ | threads row in state_*.sqlite |
| GitHub Copilot CLI | ✅ | ✅ | sessions+turns in session-store.db |
Converting Codex → Claude Code (and back) works today:
var source = await new CodexTranscriptStore().ReadAsync(sessionId);
var newId = await new ClaudeTranscriptStore().WriteAsync(source, new TranscriptWriteOptions { Cwd = cwd });
All three read and write, in any direction.
Why AgentMessage and not a transfer DTO
A stored transcript and a live stream describe the same thing, so they produce the same type.
One normalization to maintain per CLI instead of two, and an embedder that already handles
IAgentSession.Output handles stored sessions with no new code.
AgentMessage also carries what a bespoke DTO usually forgets: ExternalId (the CLI's own
message id — the thing that makes round-tripping possible), ToolCallData.ServerName for MCP
calls, CommandExecutionData.ExitCode, and FileChangeData.Diff.
Ids are derived, not minted
A live session mints AgentMessage.Id as frames arrive. A file reader has no such moment, so
TranscriptIds.Derive produces them deterministically from the CLI's own ids. Reading the same
transcript twice yields the same Guids — otherwise every re-read looks like a fresh set of
messages to anything downstream that dedupes or resumes an interrupted import.
Reuse of the engine's parsers
Reading a Claude transcript reuses ClaudeCodeOutputParser — the same code that parses live
stream-json — because the file's user/assistant lines carry the identical message envelope.
That reuse is partial, and the boundary is worth knowing before assuming it generalises:
- Plain user turns.
ParseUserEventonly readstool_resultblocks: in a live stream the host already knows the user's turn because it sent it, so the CLI never echoes it back. In a transcript the human turns are the whole point, so the store reads those itself. - Tool calls arrive twice.
tool_useproduces an in-progress message andtool_resulta completed one, which the live sink upserts into one row byExternalId. A file reader has no sink, so the store collapses them or every tool call appears twice. - File-only line kinds —
attachment,file-history-snapshot,ai-title,last-prompt,queue-operation— never appear in the stream and are skipped. - A compaction boundary.
system/compact_boundaryis a line kind the stream has no reason to carry, so the store reads it and hands it to the parser's ownParseCompactBoundaryEvent. Without that, a moved conversation began with a summary and no sign it was one. - Questions and plans. The parser drops
AskUserQuestionandExitPlanMode/EnterPlanModedeliberately: a live stream sends each twice, once as atool_useand once as thecontrol_requestthe host must answer, and counting both duplicates them. A transcript contains nocontrol_request— it is a wire frame, never written to the file — so there thetool_useis the only record. Skipping it deleted the question outright while its answer survived as a tool namedunknown. Measured on a real session with four of them:unknown=4before,AskUserQuestion=4after.
The pattern in all four is the same, and worth naming: the parser's rules are right for the stream and wrong for the file. Anything the CLI sends twice, or sends on a channel that is never persisted, needs the store to read it instead.
Adding a store
Claude Code is the easy one: the file is the session, and claude --resume <id> finds it by
scanning. The others are not the same shape, and the engine's parsers do not transfer:
- Codex (done, and the shape of the work) — none of the engine's Codex parsing was reusable:
CodexStreamParserkeys off JSON-RPCmethod(item/completed,turn/completed), thecodex app-serverprotocol. Rollout files carry nomethodat all, usingresponse_item/event_msg/session_meta/turn_context. Two wire formats for the same conversation. Beyond the reader, it needed: skippingevent_msg(it mirrorsresponse_item, so reading both doubles every message), filteringdeveloperturns and synthetic<…>preambles that Codex regenerates each launch, joiningfunction_callto its laterfunction_call_outputbycall_id, parsing the exit code out of theexec_commandoutput header, and writing athreadsrow so the session appears in the interactive picker. - GitHub Copilot CLI (done) — the engine parses ACP
session/updatenotifications, while the store speaks Copilot's own event vocabulary, so again no reuse. The strictest to write, and it fails silently — a bad session logs to~/.copilot/logs/and exits 1 with nothing on stderr. Three things it rejects: an envelopeidthat is not a UUID; atool.execution_completewhoseresultis a JSON string rather than an object; and aworkspace.yamltimestamp in round-trip"o"format instead of2026-08-01T14:51:32.508Z. It also expectscheckpoints/,files/andresearch/beside the transcript.
Long conversations: summarising
Every hop re-ingests the whole transcript, so a long conversation can overflow the target's context window — and the cost is paid again on each hop.
var moved = transcript.SummariseIfLonger(maxMessages: 200); // untouched when it already fits
await target.WriteAsync(moved, options);
Summarise() replaces the conversation with a single briefing exchange: where it came from, the
requests in order, files touched, recent commands, and where the previous agent left off — plus the
path to the full transcript, so anything omitted can still be looked up. SummaryOptions controls
the limits, whether tool activity is included, and the wording of the header and acknowledgement.
All of that is extraction — no model, no cost, and no understanding of what mattered. When
something has read the conversation and can say what state the work is actually in, pass its
briefing as Narrative:
transcript.Summarise(new SummaryOptions { Narrative = whatTheReaderSaid });
It stands in for the closing message — two accounts of where the work got to, disagreeing, is worse
than either alone — and is labelled as somebody else's reading rather than as something that
happened. The extracted sections stay underneath, because whoever wrote it can be wrong about what
they read and a file list cannot be; IncludeFacts = false if you want only the prose.
hermod --summarise-with <profile> is this, with an agent CLI as the reader.
The briefing ends on an assistant turn on purpose. A transcript ending on a user turn reads as an
unanswered question — to the next CLI, and to TrimIncompleteTail, which would otherwise strip the
briefing that was just built.
This is lossy and is the wrong default. Move the real transcript when it fits; reach for this when the alternative is not fitting at all.
Checking it against the real CLIs
FidelityMatrixTests proves the data survives a crossing. It cannot prove an agent reads it
correctly — a file edit crosses as assistant prose rather than a tool result, and whether that
stops the next agent redoing the work is a question only a real agent answers. Nor can a unit test
see the CLIs themselves change, which is where several of these bugs came from.
scripts/live-check.sh # every case, every installed CLI
scripts/live-check.sh t6 # just that one
t0 |
a move into the CLI it came from |
t1 |
a file edit, which crosses as prose rather than a tool result |
t2 |
a failed command, whose exit status no format has a field for |
t4 |
an MCP call moved into a CLI with no such server |
t5 |
a finding a sub-agent produced |
t6 |
a run interrupted part-way, finished on another CLI |
t8 |
unicode, code fences and a 600 KB tool result |
Most plant a value that exists nowhere the target can read, so a correct answer can only come from
carried history. t6 is the exception and the one worth watching: it kills a five-file edit
part-way, marks the files already done, and fails if the next CLI overwrites them instead of
checking — the difference between continuing work and starting it again.
It spends real tokens; run it after a CLI updates, not on every commit. Not covered: --attach,
which needs a terminal to answer the CLI's startup handshake, and a conversation long enough to
trigger auto-compaction.
What survives, measured
FidelityMatrixTests writes one message of each kind into every store and reads it back, so the
table below is checked rather than claimed. A writer that starts carrying more fails those tests
with the row that moved.
| Claude Code | Codex | Copilot | |
|---|---|---|---|
| user / assistant text | ✅ | ✅ | ✅ |
| unicode, code fences, quotes | ✅ | ✅ | ✅ |
| command + output | ✅ | ✅ | ✅ |
| command exit code | ✅ | ✅ | ✅ |
| tool name, arguments, result | ✅ | ✅ | ✅ |
| tool error text | ✅ | ✅ | ✅ |
| MCP server name | ✅ mcp__server__tool |
✅ | ✅ |
| file edit with prose | ✅ as prose | ✅ as prose | ✅ as prose |
| file edit without prose | ✅ narrated | ✅ | ✅ |
| reasoning | ~ prose, marked (thinking) |
~ | ~ |
| large tool result (500 KB) | ✅ | ✅ | ✅ |
What still degrades, and why:
- A file edit becomes prose.
Edited /tmp/p/ledger.csfollowed by the diff, rather than an edit tool call the target could replay. Deliberate: a diff does not contain theold_string/new_stringan edit tool wants, and a fabricated call is a patch the next agent believes it can apply. Saying what changed is honest; guessing is not. - Reasoning becomes prose, marked
(thinking). It cannot cross as thinking — the signatures are provider-issued — but marking it stops a private doubt reading as a claim beside the answer that contradicts it. Plan,SubAgentExecution,WebSearchand a compaction boundary likewise arrive as a sentence describing what happened.
Nothing is dropped for want of prose any more; TranscriptNarration builds the sentence from
whatever the message carries when it has no Content of its own.
What does not survive a write
Reasoning, as reasoning. Not because the formats lack a field — Codex has
response_item/reasoningand Claude has thinking blocks — but because none of them replay one into model context on resume. Written and checked, all three:injected session loaded model saw it Codex response_item/reasoningwith a plaintextsummaryyes no Claude thinkingblock, empty signatureyes no Claude thinkingblock, fabricated signatureyes, no API error no That last one is the proof: an invalid signature reaching the API would be rejected, and it was not — so the block is never sent. Recorded reasoning is a record, not context. Writing into those fields would be worse than prose, because the text would vanish with the session still loading cleanly and every file-level test passing.
So reasoning crosses as an assistant message prefixed
(thinking), which is the only channel all three replay.Plangets(plan)for the same reason.Message kinds with no wire form in the target —
FileChangeandCompactBoundaryare written as assistant prose rather than dropped.TranscriptNarrationbuilds that prose from the payload when the message has noContentof its own, which is the usual case for an edit.Side channels outside the transcript — Claude's
file-history-snapshot(so undo history is lost), Codex shell snapshots, Copilot checkpoints and per-session todo databases.
Failure behaviour
These formats are undocumented and versioned. A transcript that exists but cannot be parsed
throws TranscriptStoreException rather than returning the parsable prefix — silently handing back
half a conversation is the failure that loses data without anyone noticing.
Writes create a new session and never mutate one the caller did not ask for.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Data.Sqlite (>= 10.0.2)
- Mintokei.AgentEngine (>= 0.1.0)
- SQLitePCLRaw.bundle_e_sqlite3 (>= 2.1.12)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.