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
6 changes: 4 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ nucleo-matcher = { version = "0.3.1", optional = true }
rusqlite = { version = "0.40.1", features = ["bundled"], optional = true }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
# Kimi Code keys each workspace directory by a SHA-256 of the working
# directory, so writing a session Kimi can find needs this on every platform,
# not just the macOS keychain path that used it before.
sha2 = "0.10.9"
thiserror = "2.0.18"
futures-util = { version = "0.3.33", optional = true }
tokio = { version = "1.52.3", default-features = false, features = ["rt", "time"], optional = true }
Expand All @@ -68,7 +72,6 @@ aes = { version = "0.8.4", optional = true }
cbc = { version = "0.1.2", features = ["alloc", "block-padding"], optional = true }
pbkdf2 = { version = "0.12.2", optional = true }
sha1 = { version = "0.10.6", optional = true }
sha2 = { version = "0.10.9", optional = true }

[dev-dependencies]
criterion = "0.8.2"
Expand Down Expand Up @@ -124,7 +127,6 @@ claude_chat = [
"dep:cbc",
"dep:pbkdf2",
"dep:sha1",
"dep:sha2",
]
# ChatGPT is a live, read-only remote store. Its codec remains available
# featureless; the feature adds read-only Codex login reuse and
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 |
| [Kimi Code](docs/formats/kimi.md) | `kimi` | Yes | Yes |
| [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
10 changes: 9 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, kimi, fx, hermes, \
amp, antigravity, simple, cowork, cowork_remote";

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

#[test]
fn kimi_can_be_continued_in_place() {
// Kimi reads whatever its session index points at, so a Kimi session
// continues into Kimi like any writable file-backed harness.
assert!(ensure_resumable_source(HarnessId::Kimi, HarnessId::Kimi).is_ok());
}

#[test]
fn resume_alias_is_accepted_for_continue_command() {
use clap::Parser;
Expand Down Expand Up @@ -1318,6 +1325,7 @@ mod style {
HarnessId::CursorDesktop => "\x1b[96m", // bright cyan
HarnessId::Grok => "\x1b[37m", // white
HarnessId::GrokBot => "\x1b[97m", // bright white
HarnessId::Kimi => "\x1b[38;5;141m", // violet
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 @@ -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` |
| [kimi.md](kimi.md) | Kimi Code (Moonshot) | `src/harness/kimi.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
163 changes: 163 additions & 0 deletions docs/formats/kimi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Kimi Code

Kimi Code CLI stores sessions under:

```text
~/.kimi-code/sessions/wd_<workspace>_<hash>/session_<uuid>/
state.json
agents/<agent-name>/wire.jsonl
```

`state.json` contains session metadata. Each agent has an append-only wire
log; `agents/main/wire.jsonl` is the main conversation. Other agent logs are
separate conversations and are not merged into the main transcript.

## Native representation

`txcript::harness::kimi::KimiSession` retains `state.json` as a JSON value and
the main wire log as a JSONL value list. Unknown Kimi event types remain in the
native body, so a native text load/render round trip does not discard events
that txcript does not understand.

The `createdAt` field is accepted as either an RFC3339 string or epoch
milliseconds. Kimi versions have emitted both forms.

### Session id and schema versions

`state.json` has two observed shapes. Schema version 2 (`"version": 2`) records
the session id in `id` and the working directory in `cwd`. Version 1 carries no
`version` marker, uses `workDir`, and has no id field at all. Kimi's own
`session_index.jsonl` calls the same value `sessionId`.

The id is therefore resolved in order: `sessionId`, `id`, then the
`session_<uuid>` segment of `agents.<name>.homedir` — an absolute path every
observed schema records. The store falls back to the session directory name.
The homedir fallback matters for `from_text` and the wasm parser, which see the
JSON without its path.

Discovery is gated on structure — a readable `state.json` plus
`agents/main/wire.jsonl` — never on the directory name, so a Kimi release that
renames its session directories still lists.

## Common projection

The following wire events become conversational blocks:

| Kimi event | Common representation |
| --- | --- |
| `context.append_message` with `role=user` | user text message |
| `content.part` with `part.type=text` | assistant text block |
| `content.part` with `part.type=think` | assistant thinking block |
| `tool.call` | assistant tool-use block |
| `tool.result` | user tool-result block |
| `context.undo` | rewinds the last `count` turns |

Tool IDs and arguments are retained. `isError` maps to the Common tool-result
error flag. A Kimi result note is appended to textual output so truncation or
permission annotations are not silently lost. Usage, timing, permission,
MCP-tool-snapshot, and step-bookkeeping events are not fabricated as messages.

### Rewound context

Kimi rewinds its context with `context.undo` after a failed or cancelled turn,
then re-sends the prompt as a fresh `turn.prompt`. Because `wire.jsonl` is
append-only, the rolled-back entries stay on disk. Replaying them would
resurrect prompts the user already retried — a session that hit ten provider
errors in a row reads back with the same prompt ten times — so the reader
applies the rewind.

`count` is measured in turns, not entries: one turn can append several
messages (a prompt plus injected reminders), and a single `count: 1` undo drops
all of them. A wire log with no `turn.prompt` markers falls back to entry
granularity.

## Lineage

`kimi fork` writes the source session's id to the forked session's
`state.json` as top-level `forkedFrom` (documented in Kimi Code's
`docs/en/configuration/data-locations.md`). txcript reads it into a Fork
`Meta.lineage`, at discovery as well as on load. Subagents are not sessions of
their own — they live under the same session's `agents/<name>/` — so there is
no spawn link to read. Writing `forkedFrom` back out is not implemented.

## Store capabilities

The store reads and writes. Kimi ships no import command, but it does not need
one: sessions are loaded from whatever `session_index.jsonl` points at, so
writing one is a matter of laying out the files Kimi expects.

### The session index

`<data root>/session_index.jsonl` is an append-only log, one JSON record per
line, sitting one level above `sessions/`:

```json
{"sessionId": "session_<uuid>", "sessionDir": "/abs/path", "workDir": "/abs/cwd"}
{"sessionId": "session_<uuid>", "deleted": true}
```

**It is the only discovery path.** Kimi does not scan the sessions directory,
so a session written without an index record is invisible to `kimi session
list` and `kimi --session`. Removal is a `deleted` tombstone rather than a
rewrite, which is how txcript's `delete` retires a session too. Before
removing anything, `delete` requires a `state.json` and refuses any path that
doesn't resolve, symlinks included, to `<sessions>/<workspace>/<session>`.

Kimi validates each record on read: `sessionDir` must be absolute, must sit
inside the sessions directory, and its last path segment must equal
`sessionId`.

### Workspace directory names

A session directory lives under `sessions/wd_<slug>_<hash>/`, where `slug` is
the working directory's last path segment — lowercased, every run of characters
outside `[a-z0-9._-]` collapsed to `-`, trimmed of leading and trailing
dashes, capped at 40 characters — and `hash` is the first 12 hex characters of
`sha256(workDir)`, with the path normalized to forward slashes and no trailing
slash.

Getting this name wrong does not hide a session, because the index points at it
directly, but it does break Kimi's own `--cwd` filtering and `kimi -c`, both of
which resolve a working directory to this exact name.

### What `save` writes

- `sessions/wd_<slug>_<hash>/<session id>/state.json`
- `sessions/wd_<slug>_<hash>/<session id>/agents/main/wire.jsonl`
- one appended record in `<data root>/session_index.jsonl`

An id that is not usable as a single path component is rejected before
anything is written. A session converted from another harness has no Kimi
`state.json`, so `save` fills in the identity fields Kimi and txcript's
directory-free `from_text` read back: `sessionId`, `workDir`, `title`,
`createdAt`, and `updatedAt`. The last one is not decorative — `kimi session
list` renders its timestamp column from `updatedAt` alone, so a session written
without it lists at the Unix epoch.

`agents.main.homedir` is rewritten rather than preserved, because it names
where the session actually is: Kimi resolves the wire log through it, and
txcript's directory-free readers recover the session id from it. A session
saved under a second root would otherwise keep pointing at the first. Saving is
therefore idempotent rather than byte-preserving on `state.json` — a
`load → save → load` round trip is stable from the first save onward.

The native Kimi resume command is `kimi --session <id>`.

## Provenance

**Reverse-engineered.** The wire protocol comes from sessions observed locally
and the CLI surface documented by `kimi --help`. The index contract and the
workspace-name derivation come from the shipped `kimi` binary's own
`readSessionIndex` and `encodeWorkDirKey`, and were confirmed end to end
against an isolated `KIMI_CODE_HOME`: a session written by txcript is listed by
`kimi session list`, and `kimi export` reports a `sessionFirstActivity` derived
from the `time` field in the written wire log — so Kimi parses the events
back, not just the directory. The derived workspace names reproduce the
existing directory names of real local sessions exactly.

The wire protocol is an implementation detail and may change between Kimi
releases; unknown events are retained to make the reader fail conservatively
rather than silently discarding native data.

Last verified: 2026-09-07, against Kimi Code 0.41.0 (wire protocol 1.5) and
real local sessions.
3 changes: 2 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
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","fx","hermes","kimi","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 @@ -257,6 +257,7 @@ const matches = JSON.parse(index.query(JSON.stringify({ pattern: "relay bug" }))
| `grok_bot` | agent-transcript JSONL (`role`/`message` envelopes) |
| `fx` | JSON bundle of the session directory's files |
| `hermes` | `hermes sessions export` JSON object |
| `kimi` | JSON object of the session's `state.json` and main `wire.jsonl` events |
| `amp` | `amp threads export` JSON |
| `antigravity` | JSON dump of the conversation database, protobuf blobs hex-encoded |
| `simple` | the [Simple](formats/simple.md) interchange JSON document |
Expand Down
Loading
Loading