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
9 changes: 7 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ wreq = { version = "6.0.0-rc.31", default-features = false, features = ["brotli"
wreq-util = { version = "3.0.0-rc.14", features = ["emulation-compression"], optional = true }
uuid = { version = "1.23.4", features = ["v4", "v5"] }
wasm-bindgen = { version = "0.2.126", optional = true }
# Decoding the rollouts Codex compresses; the library Codex writes them with.
# Decoding the rollouts Codex compresses and the session logs DeepSeek Harness
# compresses; the library both write them with.
zstd = { version = "0.13.3", default-features = false, optional = true }

[target.'cfg(target_os = "macos")'.dependencies]
Expand Down Expand Up @@ -104,7 +105,7 @@ required-features = ["search"]
[features]
# Explicit cloud Cowork reads reuse the Claude Desktop transport.
cowork_remote = ["claude_chat"]
default = ["opencode", "hermes", "claude_chat", "cowork_remote", "chatgpt", "search", "codex_zstd"]
default = ["opencode", "hermes", "claude_chat", "cowork_remote", "chatgpt", "search", "codex_zstd", "dsh_zstd"]
# The OpenCode harness store reads OpenCode's SQLite database. The OpenCode
# codec itself is always available; only the on-disk store needs rusqlite.
opencode = ["dep:rusqlite"]
Expand Down Expand Up @@ -140,6 +141,10 @@ chatgpt = [
# leaves the compressed ones out of discovery; with it, both list and load.
# zstd is a C library, so the wasm build goes without.
codex_zstd = ["dep:zstd"]
# DeepSeek Harness compresses session logs to `session.jsonl.zstd` by default.
# Without this feature the dsh store still reads `compression: 'none'` roots
# (`session.jsonl`) and leaves compressed sessions out of discovery.
dsh_zstd = ["dep:zstd"]
# Fuzzy/substring search over transcripts (`txcript::search`). nucleo-matcher
# is pure Rust (helix's matcher) and compiles to wasm32; combine with `wasm`
# to expose the bindings.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ Each name links to its format documentation. Use the ID with `--from` and `--wit
| [Cowork](docs/formats/cowork.md) | `cowork` | Yes | Yes |
| [Grok CLI](docs/formats/grok.md) | `grok` | Yes | Yes |
| [Grok Bot](docs/formats/grok-bot.md) | `grok_bot` | Yes | Via local gateway |
| [DeepSeek Harness](docs/formats/dsh.md) | `dsh` | Yes | No |
| [fx](docs/formats/fx.md) | `fx` | Yes | Yes |
| [Antigravity](docs/formats/antigravity.md) | `antigravity` | Yes | Yes |
| [Hermes Agent](docs/formats/hermes.md) | `hermes` | Yes | No |
Expand Down
8 changes: 7 additions & 1 deletion cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,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, cowork_remote";

/// The `txcript` binary's command line.
Expand Down Expand Up @@ -1318,6 +1318,7 @@ mod style {
HarnessId::CursorDesktop => "\x1b[96m", // bright cyan
HarnessId::Grok => "\x1b[37m", // white
HarnessId::GrokBot => "\x1b[97m", // bright 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 Expand Up @@ -1919,6 +1920,11 @@ fn ensure_resumable_source(source: HarnessId, target: HarnessId) -> Result<(), S
"ChatGPT is pull-only: choose another --with harness; txcript never continues conversations in ChatGPT"
.to_string(),
)
} else if source == HarnessId::Dsh && target == HarnessId::Dsh {
Err(
"DeepSeek Harness is read-only: choose another --with harness; txcript never writes dsh session logs"
.to_string(),
)
} else {
Ok(())
}
Expand Down
1 change: 1 addition & 0 deletions docs/formats/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ than none.
| [amp.md](amp.md) | Amp (Sourcegraph) | `src/harness/amp.rs` |
| [grok.md](grok.md) | Grok CLI (xAI) | `src/harness/grok.rs` |
| [grok-bot.md](grok-bot.md) | Grok Bot (desktop assistant) | `src/harness/grok_bot.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
136 changes: 136 additions & 0 deletions docs/formats/dsh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# DeepSeek Harness (`dsh`)

DeepSeek Harness stores sessions under:

```text
$DSH_HOME/sessions/ # default ~/.dsh/sessions
--<normalized-cwd>--/ # or _no-cwd/
<encoded-id>/
session.v4.jsonl.zstd # current: concatenated checksummed Zstandard frames
session.v4.jsonl # only when compression: 'none'
session.jsonl.zstd # older generations (v0 has no `.vN`) may remain
session.lock
```

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

Each format generation has its own file name, `session[.vN].jsonl[.zstd]`,
where a missing `.vN` is version 0. When dsh opens an older session for
writing, it migrates it and publishes the new generation **beside** the old
one; the old file is kept byte-identical. The numerically highest generation is
the live log, and that is the one txcript reads. Lock files and in-flight
`.tmp` files are not logs.

Reading `.jsonl.zstd` needs txcript's `dsh_zstd` feature, on by default.
Without it (and in the wasm build), compressed sessions are left out of
discovery and loading one fails; uncompressed `.jsonl` logs still work.

## 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`, v0–v1 only) 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.

txcript reads generations 0 through 4 directly rather than migrating them.
The differences that matter to the Common projection are small:

| Version | Change that affects the reader |
| --- | --- |
| v1 | file renamed `session.v1.jsonl`; layout otherwise unchanged |
| v2 | top-level `assistant/chunk` and packed chunk rows gone (the stream is embedded in `assistant/message`); header `seedLength` replaced by `isSeeded` |
| v3 | system prompt becomes a `system/message` surface node; replace endpoints renamed `start`/`end` → `startSeq`/`endSeq` |
| v4 | `tool/result` becomes a tool-role message (no `tool-result` wrapper); message sources become direct `kind`s; `developer/message` surface node |

### Header

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

### Events

Each event is `{ type, seq, time, data, ... }`. Surface events
(`system/message`, `developer/message`, `user/message`, `assistant/message`,
`tool/result`) carry `surfaceOp` — `"append"`, or a replace op naming an
inclusive span — and, except on `assistant/message`, may carry
`sourceEventSeqs`. Log-only events (turn/step markers, `tool/call`,
`request/header`, inbox splices, delivery and title bookkeeping, …) never enter
the Common conversation.

## Common projection

| dsh event | Common representation |
| --- | --- |
| `user/message` with text parts | user text message, unless it is environment context (below) |
| `assistant/message` `reasoning` / `text` / `tool-call` | thinking / text / tool-use |
| `assistant/message` `usage` / `interrupted` | `Usage` / `StopReason::Aborted` |
| `tool/result` | user tool-result (`isError` kept), from the v4 tool-role message or the earlier wrapper block |
| `system/message`, `developer/message` | nothing, but they hold surface positions |

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 op names the inclusive span 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. System and
developer messages are surface nodes too, so a span is counted across them even
though they produce no Common message.

dsh injects its own environment into the conversation as extra `user/message`s:
runtime and sandbox snapshots, the skill catalog, instruction files, clocks.
Those sources (`runtime-context`, `skill-catalog`, `agent-instructions`,
`time-context`, `tmux-context`, `repeat-tool-reminder`, `model-selection`,
`plan-mode`; before v4, the same names as `{ kind: "plugin", plugin }`, with the
system-prompt plugin's messages standing for `runtime-context`) are not
projected — they describe dsh, not the conversation, and would mislead another
harness the session continues in. Every other source is kept, unknown ones
included: a `compact-checkpoint` message is the only remaining copy of the
history it replaced.

`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.

## Lineage

`parentSession` is the id of the session this one was seeded from. A subagent
run also records `origin: "subagent"` and becomes a Spawn `Meta.lineage`; a
user fork records no origin and becomes a Fork. dsh resumes a session under its
own id, so there is no continuation link. Lineage is filled at discovery as
well as on load.

## Store capabilities

DeepSeek Harness has no documented session-import CLI. The persistence seam
also has no delete API. The txcript dsh store is therefore **read-only**:

- `list`, `query`, `view`, and `export` work;
- a dsh session can be converted into any writable target harness;
- `save` and `delete` return an explicit read-only error;
- txcript never writes directly into `~/.dsh/sessions`.

The native resume command documented for a TUI profile is
`dsh --profile tui --resume <id>`. Cross-harness continuation is supported;
native dsh continuation is refused.

## Provenance

Open source. Layout, event vocabulary, and the per-version changes follow the
DeepSeek Harness packages `@deepseek-ai/dsh-session`,
`@deepseek-ai/dsh-session-persistence-jsonl`, and the adjacent migration
packages `@deepseek-ai/dsh-session-format-v0-to-v1` through `-v3-to-v4`
(format version 4 at 0.2.0-rc.2; the project still marks the format a
developer preview and warns of compatibility-breaking changes).

The reader was checked against sessions recorded by dsh 0.2.0-rc.2 — a
tool-using session resumed for a second turn, and a subagent run with its
parent — and against a session recorded by dsh around 2026-08-14 in format v0,
read both as v0 and after the official catalog migrated it to v4: the two
generations project to identical Common messages.

Last verified: 2026-10-15, against `@deepseek-ai/dsh` 0.2.0-rc.2.
5 changes: 3 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ txcript = "0.14"
# txcript = { version = "0.14", default-features = false }
```

Default features: `opencode` (the SQLite stores: OpenCode, both Cursors, Antigravity), `hermes`, `claude_chat`, `cowork_remote`, `chatgpt`, `search`, and `codex_zstd` (the rollouts Codex compresses to `.jsonl.zst`).
Default features: `opencode` (the SQLite stores: OpenCode, both Cursors, Antigravity), `hermes`, `claude_chat`, `cowork_remote`, `chatgpt`, `search`, `codex_zstd` (the rollouts Codex compresses to `.jsonl.zst`), and `dsh_zstd` (the session logs DeepSeek Harness compresses to `.jsonl.zstd`).

Three layers, smallest to largest:

Expand Down Expand Up @@ -225,7 +225,7 @@ writeFileSync("session.jsonl", convert(input, "codex", "claude_code"));
const common = JSON.parse(toCommon(input, "codex")); // { meta, messages }
const pi = fromCommon(JSON.stringify(common), "pi");

harnesses(); // ["claude_code","claude_chat","cowork_remote","chatgpt","codex","opencode","pi","campfire","cursor","cursor_desktop","grok","grok_bot","fx","hermes","amp","antigravity","simple","cowork"]
harnesses(); // ["claude_code","claude_chat","cowork_remote","chatgpt","codex","opencode","pi","campfire","cursor","cursor_desktop","grok","grok_bot","dsh","fx","hermes","amp","antigravity","simple","cowork"]
```

Text-in / text-out: `input` is the source harness's native session text and the result is the target's. Invalid harness names or unparseable input throw a JS `Error`.
Expand Down Expand Up @@ -255,6 +255,7 @@ const matches = JSON.parse(index.query(JSON.stringify({ pattern: "relay bug" }))
| `cursor_desktop` | JSON dump of the session's `state.vscdb` rows |
| `grok` | JSON bundle of the session directory's files |
| `grok_bot` | agent-transcript JSONL (`role`/`message` envelopes) |
| `dsh` | JSON object of the session header and its decompressed JSONL events |
| `fx` | JSON bundle of the session directory's files |
| `hermes` | `hermes sessions export` JSON object |
| `amp` | `amp threads export` JSON |
Expand Down
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