Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ wreq-util = { version = "3.0.0-rc.14", features = ["emulation-compression"], opt
uuid = { version = "1.23.4", features = ["v4", "v5"] }
wasm-bindgen = { version = "0.2.126", optional = true }

# DeepSeek Harness stores session logs as concatenated Zstandard frames by
# default. The codec's text form is uncompressed JSON; only the native store
# decoder needs this, and wasm builds never open those files.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
zstd = "0.13.3"

[target.'cfg(target_os = "macos")'.dependencies]
aes = { version = "0.8.4", optional = true }
cbc = { version = "0.1.2", features = ["alloc", "block-padding"], optional = true }
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ txcript maps each harness's native transcript format through a typed common mode

## Highlights

- **16 harnesses, one model**: every format converts through `Transcript<Common>`, so adding a harness connects it to all the others.
- **17 harnesses, one model**: every format converts through `Transcript<Common>`, so adding a harness connects it to all the others.
- **A format for everyone else**: agents txcript has never heard of emit the documented [Simple](docs/formats/simple.md) interchange JSON — a file or a stream, handed to txcript directly — and their transcripts continue in any supported harness.
- **Byte-lossless round-trips**: loading and saving a session in its own format reproduces it exactly.
- **Continue anywhere**: `txcript continue <id> --with <harness>` rewrites a session into another harness's native format and launches it. The original is never modified.
Expand All @@ -71,6 +71,7 @@ flowchart LR
common <--> cursor["Cursor CLI"]
common <--> cursordesktop["Cursor desktop"]
common <--> grok["Grok CLI"]
common <--> dsh["DeepSeek Harness"]
common <--> fx["fx"]
common <--> antigravity["Antigravity"]
simple["Simple (any agent)"] --> common
Expand All @@ -93,6 +94,7 @@ Discovery, listing, search, and `view` work for every harness with a backing sto
| [Cursor CLI](https://cursor.com/cli) | `cursor` | `~/.cursor/chats/` | SQLite | ⇄ | ✓ | [spec](docs/formats/cursor.md) |
| [Cursor desktop](https://cursor.com) | `cursor_desktop` | `<Cursor User dir>/globalStorage/` | SQLite | ⇄ | ✓ | [spec](docs/formats/cursor-desktop.md) |
| [Grok CLI](https://github.com/xai-org/grok-build) | `grok` | `~/.grok/sessions/` | JSON session dir | ⇄ | ✓ | [spec](docs/formats/grok.md) |
| [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | `dsh` | `~/.dsh/sessions/` | zstd JSONL event log | ⇄ | ✓ | [spec](docs/formats/dsh.md) |
| [fx](https://fx.sh) | `fx` | `~/.fx/sessions/` | event-log session dir | ⇄ | ✓ | [spec](docs/formats/fx.md) |
| Hermes Agent | `hermes` | `~/.hermes/state.db` | SQLite | → | — <sup>3</sup> | [spec](docs/formats/hermes.md) |
| [Amp](https://ampcode.com) | `amp` | `~/.local/share/amp/threads/` | thread JSON | → | — <sup>1</sup> | [spec](docs/formats/amp.md) |
Expand All @@ -109,6 +111,20 @@ Discovery, listing, search, and `view` work for every harness with a backing sto

<sup>5</sup> ChatGPT is a live, pull-only source. Like Claude Chat reuses Claude Desktop, explicitly selecting `--from chatgpt` automatically reuses the ChatGPT login managed by Codex at `CODEX_HOME/auth.json` or `~/.codex/auth.json`; the account may differ from the one signed in through a browser. txcript only reads that credential file and never refreshes or rewrites it. Aggregate discovery does not contact ChatGPT, while an exact conversation UUID can be read directly without enumerating the account. txcript only reads: it refuses save, delete, same-harness continue, and `--with chatgpt`. ChatGPT has no supported conversation API, so this access may change or be restricted. ChatGPT data-export archives are not supported.

### DeepSeek Harness

Sessions are discovered from `$DSH_HOME/sessions` (default `~/.dsh/sessions`).
dsh ships no session import command, but it finds sessions by walking that
root, so txcript writes the layout it scans for.

It also validates what it finds, and three of its checks fail the whole listing
rather than skipping one session: the first Zstandard frame must decode to
exactly the header line, the header's id and cwd must name the path it was
found at, and a root must not mix `.jsonl` with `.jsonl.zstd`. Writes reproduce
dsh's own derivation rather than approximating it, and were verified by running
the official persistence backend against a txcript-written root. See
[`docs/formats/dsh.md`](docs/formats/dsh.md).

## Install

**CLI** (installs the `txcript` binary):
Expand Down
10 changes: 9 additions & 1 deletion cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ pub mod mcp;
mod pager;
mod view;

pub const HARNESSES: &str = "harnesses: claude_code, claude_chat, chatgpt, codex, opencode, pi, campfire, cursor, cursor_desktop, grok, fx, hermes, \
pub const HARNESSES: &str = "harnesses: claude_code, claude_chat, chatgpt, codex, opencode, pi, campfire, cursor, cursor_desktop, grok, dsh, fx, hermes, \
amp, antigravity, simple, cowork";

/// The `txcript` binary's command line.
Expand Down Expand Up @@ -927,6 +927,13 @@ mod identity_tests {
assert!(error.contains("pull-only"));
}

#[test]
fn dsh_can_be_continued_in_place() {
// Only the two pull-only remote sources are refused here. dsh is a
// local store txcript writes, so it takes the normal write path.
assert!(ensure_resumable_source(HarnessId::Dsh, HarnessId::Dsh).is_ok());
}

#[test]
fn resume_alias_is_accepted_for_continue_command() {
use clap::Parser;
Expand Down Expand Up @@ -1069,6 +1076,7 @@ mod style {
HarnessId::Cursor => "\x1b[34m", // blue
HarnessId::CursorDesktop => "\x1b[96m", // bright cyan
HarnessId::Grok => "\x1b[37m", // white
HarnessId::Dsh => "\x1b[38;5;43m", // teal
HarnessId::Fx => "\x1b[38;5;39m", // azure
HarnessId::Hermes => "\x1b[93m", // bright yellow
HarnessId::Amp => "\x1b[95m", // bright magenta
Expand Down
1 change: 1 addition & 0 deletions docs/formats/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ than none.
| [cursor-desktop.md](cursor-desktop.md) | Cursor desktop (IDE app) | `src/harness/cursor_desktop.rs` |
| [amp.md](amp.md) | Amp (Sourcegraph) | `src/harness/amp.rs` |
| [grok.md](grok.md) | Grok CLI (xAI) | `src/harness/grok.rs` |
| [dsh.md](dsh.md) | DeepSeek Harness (`dsh`) | `src/harness/dsh.rs` |
| [fx.md](fx.md) | fx (Vercel) | `src/harness/fx.rs` |
| [hermes.md](hermes.md) | Hermes Agent | `src/harness/hermes.rs` |
| [antigravity.md](antigravity.md) | Antigravity (Google) | `src/harness/antigravity.rs` |
Expand Down
133 changes: 133 additions & 0 deletions docs/formats/dsh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# DeepSeek Harness (`dsh`)

DeepSeek Harness stores sessions under:

```text
$DSH_HOME/sessions/ # default ~/.dsh/sessions
--<normalized-cwd>--/ # or _no-cwd/
<encoded-id>/
session.jsonl.zstd # default: concatenated checksummed Zstandard frames
session.jsonl # only when compression: 'none'
```

Home resolution is configured path, then `$DSH_HOME`, then `~/.dsh`. An empty
`$DSH_HOME` is treated as unset.

## Native representation

`txcript::harness::dsh::DshSession` retains the first JSONL line as `header`
and every subsequent line as raw JSON values. Packed storage rows
(`text-chunks`, `reasoning-chunks`, `tool-call-chunks`) and unknown event
types stay in the native body so a text load/render round trip does not drop
bookkeeping the Common projection does not understand.

The official on-disk format version is `0`. There is no migration; txcript
still loads the native body when the version field differs.

### Header

The first line is tagged `type: "session"` and carries `version`, `id`,
`createdAt` (epoch milliseconds), optional `cwd`, `parentSession`,
`seedLength`, `origin`, `delegationDepth`, and `agentPreset`.

### Events

Each event is `{ type, seq, time, data, ... }`. Surface events
(`user/message`, `assistant/message`, `tool/result`) may also carry
`surfaceOp` (`"append"` or `{ op: "replace", start, end }`) and
`sourceEventSeqs`. Log-only events (turn/step markers, `assistant/chunk`,
`request/header`, packed chunk rows, …) never enter the Common conversation.

## Common projection

| dsh event | Common representation |
| --- | --- |
| `user/message` with text parts | user text message |
| `assistant/message` `reasoning` / `text` / `tool-call` | thinking / text / tool-use |
| `assistant/message` `usage` / `interrupted` | `Usage` / `StopReason::Aborted` |
| `tool/result` | user tool-result (`isError` kept) |

The ordered surface is rebuilt before projection. Surface nodes are tracked by
event `seq`, because log-only events sit between them and a node's seq is not
its surface position. A `replace` `surfaceOp` names the inclusive **seq** range
it shadows — both endpoints must be on the current surface — and substitutes
the replacing node for that whole run; a range txcript cannot resolve shadows
nothing. Packed chunk rows and `assistant/chunk` stream events are ignored for
Common because the assembled `assistant/message` already carries the step.

`usage` maps `inputTokens`/`outputTokens` and the optional
`cacheReadTokens`/`cacheWriteTokens` onto Common's `Usage`. `reasoningTokens`
has no Common counterpart and survives in the native body only. Shadowed
surface nodes likewise stay in the native body, so a text round trip keeps the
full log even though Common shows the model-visible surface.

## Store capabilities

The store reads and writes. dsh ships no session-import command, but it does
not need one: it discovers sessions by walking its root, so writing one is a
matter of reproducing the layout it scans for.

What makes that exact rather than approximate is dsh's validation. Three of its
checks fail the **entire** listing rather than skipping the one bad session, so
each is a hard requirement on the writer:

| Check | Requirement |
| --- | --- |
| `assertZstdHeaderFrame` | the first Zstandard frame decodes to exactly one line — the header |
| `assertStoredIdentity` | the header's own `id` and `cwd` name the path the log was found at |
| `checkRootEncoding` | one root never mixes `.jsonl` with `.jsonl.zstd` |

A duplicate session id across two project directories is rejected the same way.

### Layout derivation

- **Project directory** — `--<key>--`, where `key` collapses each run of `/`,
`\`, or `:` to a single `-`, keeps `[A-Za-z0-9._-]`, escapes every other
UTF-16 code unit as `~XXXX`, strips leading dashes, falls back to `root` if
nothing remains, and truncates to 251 characters. A session with no cwd goes
under `_no-cwd`.
- **Session directory** — the id under the same `~XXXX` escape, with `.` and
`..` special-cased whole. This is what contains a traversing id: escaping the
separators turns `../../evil` into the literal directory
`..~002F..~002Fevil`.

Escaping operates on UTF-16 code units, not Unicode scalars, which is what
makes it injective over lone surrogates.

### What `save` writes

`<root>/<project>/<encoded id>/session.jsonl.zstd` — a checksummed Zstandard
frame holding the header line, then one holding the event lines. The header is
stamped with the id and cwd that built the path, because a copy given a new
identity would otherwise keep pointing at the original's.

The physical encoding follows whatever the root already uses; only an empty
root falls back to dsh's own default of `zstd`. Re-saving a session whose cwd
changed removes the copy under the old project directory, since leaving it
would be the duplicate-id corruption above.

`delete` removes the session directory. dsh's persistence seam has no delete
API, but it also keeps no index — an absent directory is simply not scanned.

The native resume command documented for a TUI profile is
`dsh --profile tui --resume <id>`.

## Provenance

Open source. Layout and event vocabulary follow the DeepSeek Harness
packages `@deepseek-ai/dsh-session` and
`@deepseek-ai/dsh-session-persistence-jsonl` (session format version 0,
developer preview; the project warns of compatibility-breaking changes).
The path derivation, frame layout, and validation rules above are that
package's own `encodeSegment`, `projectKey`, `encodeMaterialization`, and
`listArtifacts`.

The reader was checked against a local `session.jsonl.zstd` written by dsh
around 2026-08-14. The writer was checked by running the official backend's
`listArtifacts` and `loadStored` against a txcript-written root: it lists the
session and decodes all 1148 of its event records. Compressing the log as one
frame instead of two — which txcript itself still reads — makes that same check
fail with dsh's `first frame is not exactly one header line`.

Last verified: 2026-09-07, against `@deepseek-ai/dsh-session-persistence-jsonl`
0.0.1-rc.1.
9 changes: 8 additions & 1 deletion examples/search_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

use std::time::Instant;

use txcript::harness::{campfire, claude_code, codex, cursor, grok, pi};
use txcript::harness::{campfire, claude_code, codex, cursor, dsh, grok, pi};
use txcript::search::{DocKey, Index, Origin, Query};
use txcript::{Codec, Common, HarnessId, Store, Transcript};

Expand Down Expand Up @@ -62,6 +62,13 @@ fn main() {
&mut loaded,
&mut failed,
);
load_files::<dsh::Dsh, _>(
HarnessId::Dsh,
dsh::DshStore::default_root(),
&mut index,
&mut loaded,
&mut failed,
);

let build = started.elapsed();
println!(
Expand Down
Loading
Loading