From 4dd713364378335e405e92703da9cec7d64068d0 Mon Sep 17 00:00:00 2001 From: zzyking Date: Wed, 26 Aug 2026 01:18:01 +0800 Subject: [PATCH 1/6] feat: add read-only Kimi Code harness --- README.md | 1 + cli/src/lib.rs | 8 +- docs/formats/README.md | 1 + docs/formats/kimi.md | 94 ++++ docs/usage.md | 3 +- src/harness/kimi.rs | 784 +++++++++++++++++++++++++++++ src/harness/mod.rs | 1 + src/lib.rs | 2 +- src/local.rs | 15 +- src/transcript.rs | 6 +- src/wasm.rs | 4 +- tests/integration/cross_harness.rs | 10 +- tests/integration/kimi.rs | 341 +++++++++++++ tests/integration/main.rs | 1 + 14 files changed, 1264 insertions(+), 7 deletions(-) create mode 100644 docs/formats/kimi.md create mode 100644 src/harness/kimi.rs create mode 100644 tests/integration/kimi.rs diff --git a/README.md b/README.md index 9727819..29e2336 100644 --- a/README.md +++ b/README.md @@ -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 | 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 | diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 30feb8b..27d774a 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -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. @@ -1318,6 +1318,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 @@ -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::Kimi && target == HarnessId::Kimi { + Err( + "Kimi Code is read-only: choose another --with harness; txcript never writes undocumented Kimi session files" + .to_string(), + ) } else { Ok(()) } diff --git a/docs/formats/README.md b/docs/formats/README.md index 633fdde..4afb4f5 100644 --- a/docs/formats/README.md +++ b/docs/formats/README.md @@ -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` | diff --git a/docs/formats/kimi.md b/docs/formats/kimi.md new file mode 100644 index 0000000..b4b3bc5 --- /dev/null +++ b/docs/formats/kimi.md @@ -0,0 +1,94 @@ +# Kimi Code + +Kimi Code CLI stores sessions under: + +```text +~/.kimi-code/sessions/wd__/session_/ + state.json + agents//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_` segment of `agents..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. + +## Store capabilities + +Kimi Code has no documented session import or deletion command. The txcript +Kimi store is therefore **read-only**: + +- `list`, `query`, `view`, and `export` work; +- a Kimi session can be converted into any writable target harness; +- `save` and `delete` return an explicit read-only error; +- txcript never writes directly into `~/.kimi-code/sessions`. + +The native Kimi resume command is `kimi --session `. Cross-harness +continuation is supported; native Kimi continuation is refused because the +store cannot safely create a Kimi session without an official import contract. + +## Provenance + +The format is based on the Kimi Code CLI wire protocol observed in local +sessions and the public CLI interface documented by `kimi --help`. 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. diff --git a/docs/usage.md b/docs/usage.md index e9ad6c8..43c8d44 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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`. @@ -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 | diff --git a/src/harness/kimi.rs b/src/harness/kimi.rs new file mode 100644 index 0000000..9534e35 --- /dev/null +++ b/src/harness/kimi.rs @@ -0,0 +1,784 @@ +//! Kimi Code CLI sessions: `~/.kimi-code/sessions/wd_*/session_*/`. +//! +//! Kimi keeps session metadata in `state.json` and the append-only event stream +//! for each agent in `agents//wire.jsonl`. The main conversation is the +//! `agents/main` stream. Kimi has no documented session import or deletion +//! interface, so [`KimiStore`] is deliberately read-only: sessions can be +//! discovered, loaded, searched, exported, and converted into another +//! harness, but txcript never writes an undocumented Kimi session. +//! +//! The native body retains both JSON files as raw JSON. This makes loading and +//! rendering a session lossless even when Kimi adds bookkeeping events that +//! txcript does not understand. The Common projection interprets user messages, +//! assistant text/reasoning, tool calls, and tool results. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::common::{Block, Message, Meta, Role, StopReason, Tool, ToolOutput}; +use crate::error::{Error, Result}; +use crate::harness::jsonl; +use crate::transcript::{Codec, Common, Discovered, Harness, Saved, Store, TextCodec, Transcript}; + +/// The Kimi Code CLI harness marker. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Kimi; + +impl Harness for Kimi { + const NAME: &'static str = "kimi"; + type Body = KimiSession; +} + +/// The two JSON documents that make up the native main-agent session. +/// +/// `state` is the contents of `state.json`; `wire` is the parsed JSONL from +/// `agents/main/wire.jsonl`. Keeping them raw is intentional: Kimi's wire +/// protocol has many bookkeeping event types and they must not be discarded by +/// a native load/render round trip. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct KimiSession { + pub state: Value, + #[serde(default)] + pub wire: Vec, +} + +impl TextCodec for Kimi { + fn from_text(text: &str) -> Result> { + let body: KimiSession = serde_json::from_str(text)?; + let meta = meta_from_body(&body); + Ok(Transcript::new(meta, body)) + } + + fn to_text(transcript: &Transcript) -> Result { + Ok(serde_json::to_string_pretty(&transcript.body)?) + } +} + +impl Codec for Kimi { + fn to_common(transcript: &Transcript) -> Result> { + Ok(Transcript::new( + transcript.meta.clone(), + wire_to_messages(&transcript.body.wire, transcript.meta.timestamp), + )) + } + + fn from_common(transcript: &Transcript) -> Result> { + Ok(Transcript::new( + transcript.meta.clone(), + body_from_common(transcript), + )) + } +} + +/// Read-only access to Kimi Code's session directories. +#[derive(Debug, Clone)] +pub struct KimiStore { + pub sessions_dir: PathBuf, +} + +impl KimiStore { + pub fn new(path: impl Into) -> Self { + Self { + sessions_dir: path.into(), + } + } + + /// Resolve `$KIMI_HOME/sessions`, falling back to + /// `~/.kimi-code/sessions`. + #[must_use] + pub fn default_root() -> Option { + std::env::var_os("KIMI_HOME") + .filter(|v| !v.is_empty()) + .map(|home| Self::new(PathBuf::from(home).join("sessions"))) + .or_else(|| { + super::home_dir().map(|home| Self::new(home.join(".kimi-code").join("sessions"))) + }) + } +} + +impl Store for KimiStore { + type H = Kimi; + type Ref = PathBuf; + + fn discover(&self) -> Result>> { + let mut found = Vec::new(); + let Ok(entries) = fs::read_dir(&self.sessions_dir) else { + return Ok(found); + }; + for workspace in entries.flatten().map(|entry| entry.path()) { + if !workspace.is_dir() { + continue; + } + let Ok(sessions) = fs::read_dir(&workspace) else { + continue; + }; + for session_dir in sessions.flatten().map(|entry| entry.path()) { + if !session_dir.is_dir() { + continue; + } + let state_path = session_dir.join("state.json"); + let wire_path = session_dir.join("agents").join("main").join("wire.jsonl"); + if !state_path.is_file() || !wire_path.is_file() { + continue; + } + let Ok(body) = load_body(&session_dir) else { + continue; + }; + let mut meta = meta_from_body(&body); + if meta.id.is_empty() { + meta.id = session_id_from_path(&session_dir); + } + found.push(Discovered { + meta, + reference: session_dir, + }); + } + } + Ok(found) + } + + fn load(&self, reference: &PathBuf) -> Result> { + let body = load_body(reference)?; + let mut meta = meta_from_body(&body); + if meta.id.is_empty() { + meta.id = session_id_from_path(reference); + } + Ok(Transcript::new(meta, body)) + } + + fn save(&self, _transcript: &Transcript) -> Result> { + Err(read_only_error()) + } + + fn delete(&self, _reference: &PathBuf) -> Result<()> { + Err(read_only_error()) + } + + fn fingerprints(&self, refs: &[PathBuf]) -> Result> { + let mut output = HashMap::with_capacity(refs.len()); + for reference in refs { + let state = file_fingerprint(&reference.join("state.json")); + let wire = file_fingerprint(&reference.join("agents").join("main").join("wire.jsonl")); + output.insert( + reference.to_string_lossy().into_owned(), + format!("{state}:{wire}"), + ); + } + Ok(output) + } +} + +fn read_only_error() -> Error { + Error::Unconvertible { + harness: Kimi::NAME, + detail: "Kimi Code session storage is read-only in txcript; Kimi has no documented session import or delete command".to_string(), + } +} + +fn load_body(reference: &Path) -> Result { + let state: Value = serde_json::from_str(&fs::read_to_string(reference.join("state.json"))?)?; + let wire = jsonl::parse(&fs::read_to_string( + reference.join("agents").join("main").join("wire.jsonl"), + )?); + Ok(KimiSession { state, wire }) +} + +/// Last-resort id: the session directory name, as every other file-backed +/// store does. Discovery already rejects anything without a `state.json` and a +/// main wire log, so the name is a label, not a filter — a Kimi release that +/// renames its directories must not make sessions vanish from `list`. +fn session_id_from_path(path: &Path) -> String { + jsonl::file_id(path) +} + +fn file_fingerprint(path: &Path) -> String { + let Ok(metadata) = fs::metadata(path) else { + return String::new(); + }; + let modified = metadata + .modified() + .ok() + .map(|time| { + DateTime::::from(time) + .timestamp_nanos_opt() + .unwrap_or_default() + }) + .unwrap_or_default(); + format!("{}:{modified}", metadata.len()) +} + +fn meta_from_body(body: &KimiSession) -> Meta { + let state = body.state.as_object(); + let string = |key: &str| { + state + .and_then(|object| object.get(key)) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(String::from) + }; + let id = string("sessionId") + .or_else(|| string("id")) + .or_else(|| id_from_agent_homedir(&body.state)) + .unwrap_or_default(); + let timestamp = state + .and_then(|object| object.get("createdAt")) + .and_then(parse_timestamp) + .or_else(|| first_event_timestamp(&body.wire)) + .unwrap_or(DateTime::::UNIX_EPOCH); + let title = string("title").filter(|title| title != "New Session"); + let model = body.wire.iter().find_map(|event| { + (event.get("type") == Some(&Value::String("llm.request".to_string()))) + .then(|| event.get("model").and_then(Value::as_str).map(String::from)) + .flatten() + }); + Meta { + id, + timestamp, + cwd: string("workDir").or_else(|| string("cwd")), + git_branch: string("gitBranch").or_else(|| string("git_branch")), + title, + cli_version: string("cliVersion").or_else(|| string("cli_version")), + model, + // Kimi's state.json and wire log carry no parent/fork/spawn signal. + lineage: None, + } +} + +/// Recover the session id from `agents..homedir`. +/// +/// Kimi's `state.json` gained a top-level `id` in schema version 2; version 1 +/// carries no id field at all. Every version does record each agent's absolute +/// home directory, which lives inside the `session_` directory, so the +/// path is the only id a bare version-1 `state.json` carries. Without this the +/// text codec — and the wasm parser built on it — would silently drop the id +/// for older sessions. The store still falls back to the directory name. +fn id_from_agent_homedir(state: &Value) -> Option { + let agents = state.get("agents")?.as_object()?; + let homedir = agents + .get("main") + .into_iter() + .chain(agents.values()) + .find_map(|agent| agent.get("homedir").and_then(Value::as_str))?; + homedir + .split(['/', '\\']) + .find(|segment| segment.starts_with("session_")) + .map(String::from) +} + +fn parse_timestamp(value: &Value) -> Option> { + if let Some(text) = value.as_str() { + return text.parse().ok(); + } + value.as_i64().and_then(DateTime::from_timestamp_millis) +} + +fn first_event_timestamp(events: &[Value]) -> Option> { + events.iter().find_map(|event| { + event.get("time").and_then(parse_timestamp).or_else(|| { + event + .get("event") + .and_then(|inner| inner.get("time")) + .and_then(parse_timestamp) + }) + }) +} + +fn event_time(event: &Value, fallback: DateTime) -> DateTime { + event + .get("time") + .and_then(parse_timestamp) + .or_else(|| { + event + .get("event") + .and_then(|inner| inner.get("time")) + .and_then(parse_timestamp) + }) + .unwrap_or(fallback) +} + +#[derive(Default)] +struct MessageBuilder { + messages: Vec, + assistant: Vec, + assistant_time: Option>, + assistant_model: Option, + results: Vec, + result_time: Option>, + tool_sequence: usize, + /// Message count at each `turn.prompt`, so `context.undo` can rewind whole + /// turns rather than individual messages. + turn_starts: Vec, +} + +impl MessageBuilder { + fn flush_assistant(&mut self, fallback: DateTime) { + if self.assistant.is_empty() { + return; + } + let has_tool = self + .assistant + .iter() + .any(|block| matches!(block, Block::ToolUse { .. })); + self.messages.push(Message { + role: Role::Assistant, + content: std::mem::take(&mut self.assistant), + timestamp: self.assistant_time.take().unwrap_or(fallback), + model: self.assistant_model.take(), + stop_reason: Some(if has_tool { + StopReason::ToolUse + } else { + StopReason::EndTurn + }), + usage: None, + }); + } + + fn flush_results(&mut self, fallback: DateTime) { + if self.results.is_empty() { + return; + } + self.messages.push(Message { + role: Role::User, + content: std::mem::take(&mut self.results), + timestamp: self.result_time.take().unwrap_or(fallback), + model: None, + stop_reason: None, + usage: None, + }); + } + + fn add_assistant(&mut self, block: Block, timestamp: DateTime, model: Option) { + self.flush_results(timestamp); + self.assistant_time.get_or_insert(timestamp); + if model.is_some() { + self.assistant_model = model; + } + self.assistant.push(block); + } + + fn add_result(&mut self, block: Block, timestamp: DateTime) { + self.flush_assistant(timestamp); + self.result_time.get_or_insert(timestamp); + self.results.push(block); + } + + fn add_user(&mut self, blocks: Vec, timestamp: DateTime) { + self.flush_assistant(timestamp); + self.flush_results(timestamp); + if !blocks.is_empty() { + self.messages.push(Message { + role: Role::User, + content: blocks, + timestamp, + model: None, + stop_reason: None, + usage: None, + }); + } + } + + fn finish(mut self, fallback: DateTime) -> Vec { + self.flush_assistant(fallback); + self.flush_results(fallback); + self.messages + } + + /// Record a turn boundary from `turn.prompt`. + fn begin_turn(&mut self, fallback: DateTime) { + self.flush_assistant(fallback); + self.flush_results(fallback); + self.turn_starts.push(self.messages.len()); + } + + /// Apply a Kimi `context.undo`: rewind the last `count` turns. + /// + /// Kimi rewinds its context after a failed or cancelled turn, then re-sends + /// the prompt as a fresh turn. `wire.jsonl` is append-only, so the + /// rolled-back entries stay on disk; replaying them would resurrect prompts + /// the user already retried. `count` is measured in turns, not entries: a + /// single turn can append several messages, and one `count: 1` undo drops + /// all of them. Logs without `turn.prompt` markers fall back to entry + /// granularity. Pending buffers are flushed first because a half-assembled + /// turn belongs to the range being rewound. + fn undo(&mut self, count: usize, fallback: DateTime) { + self.flush_assistant(fallback); + self.flush_results(fallback); + if self.turn_starts.is_empty() { + let keep = self.messages.len().saturating_sub(count); + self.messages.truncate(keep); + return; + } + let rewind_to = self.turn_starts.len().saturating_sub(count); + let keep = self.turn_starts.get(rewind_to).copied().unwrap_or(0); + self.turn_starts.truncate(rewind_to); + self.messages.truncate(keep); + } +} + +#[allow(clippy::too_many_lines)] +fn wire_to_messages(wire: &[Value], fallback: DateTime) -> Vec { + let mut builder = MessageBuilder::default(); + let mut model: Option = None; + for event in wire { + match event.get("type").and_then(Value::as_str) { + Some("llm.request") => { + model = event.get("model").and_then(Value::as_str).map(String::from); + } + Some("turn.prompt") => builder.begin_turn(event_time(event, fallback)), + Some("context.undo") => { + let count = + usize::try_from(event.get("count").and_then(Value::as_u64).unwrap_or(1)) + .unwrap_or(usize::MAX); + builder.undo(count, event_time(event, fallback)); + } + Some("context.append_message") => { + let Some(message) = event.get("message") else { + continue; + }; + if message.get("role").and_then(Value::as_str) != Some("user") { + continue; + } + let blocks = message + .get("content") + .and_then(Value::as_array) + .map(|content| { + content + .iter() + .filter_map(|part| { + if part.get("type").and_then(Value::as_str) != Some("text") { + return None; + } + Some(Block::Text { + text: part + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }) + }) + .collect() + }) + .unwrap_or_default(); + builder.add_user(blocks, event_time(event, fallback)); + } + Some("context.append_loop_event") => { + let Some(inner) = event.get("event") else { + continue; + }; + let timestamp = event_time(event, fallback); + match inner.get("type").and_then(Value::as_str) { + Some("content.part") => { + let Some(part) = inner.get("part") else { + continue; + }; + match part.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = part.get("text").and_then(Value::as_str) { + builder.add_assistant( + Block::Text { + text: text.to_string(), + }, + timestamp, + model.clone(), + ); + } + } + Some("think") => { + if let Some(text) = part.get("think").and_then(Value::as_str) { + builder.add_assistant( + Block::Thinking { + text: text.to_string(), + signature: None, + encrypted: None, + }, + timestamp, + model.clone(), + ); + } + } + _ => {} + } + } + Some("tool.call") => { + builder.tool_sequence += 1; + let id = inner.get("toolCallId").and_then(Value::as_str).map_or_else( + || format!("kimi-tool-{}", builder.tool_sequence), + String::from, + ); + let name = inner + .get("name") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let input = inner.get("args").cloned().unwrap_or(Value::Null); + builder.add_assistant( + Block::ToolUse { + id, + tool: Tool::from_canonical(name, input), + }, + timestamp, + model.clone(), + ); + } + Some("tool.result") => { + let id = inner + .get("toolCallId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let result = inner.get("result").cloned().unwrap_or(Value::Null); + let (content, is_error) = tool_result(result); + builder.add_result( + Block::ToolResult { + tool_use_id: id, + content, + is_error, + }, + timestamp, + ); + } + _ => {} + } + } + _ => {} + } + } + builder.finish(fallback) +} + +fn tool_result(result: Value) -> (ToolOutput, bool) { + let Some(object) = result.as_object() else { + return (tool_output(result), false); + }; + let is_error = object + .get("isError") + .and_then(Value::as_bool) + .unwrap_or(false); + let mut output = object.get("output").cloned().unwrap_or(Value::Null); + if let Some(note) = object.get("note").and_then(Value::as_str) + && let Some(text) = output.as_str() + { + output = Value::String(format!("{text}\n\n[kimi note: {note}]")); + } + (tool_output(output), is_error) +} + +fn tool_output(value: Value) -> ToolOutput { + match value { + Value::String(text) => ToolOutput::Text(text), + other => ToolOutput::Json(other), + } +} + +/// Namespace for the bookkeeping ids txcript has to synthesize when rendering a +/// Kimi wire log. Kimi itself uses random v4 uuids; deriving v5 ids keeps +/// rendering deterministic so the same transcript always produces the same +/// bytes. +const NS: Uuid = Uuid::from_bytes([ + 0x7b, 0x2c, 0x41, 0xd6, 0x8f, 0x53, 0x4a, 0x19, 0xb0, 0x64, 0x3e, 0xa7, 0x15, 0xc8, 0x92, 0x0d, +]); + +/// The wire protocol version txcript renders. Observed in Kimi Code sessions; +/// the reader does not depend on it. +const PROTOCOL_VERSION: &str = "1.5"; + +fn body_from_common(transcript: &Transcript) -> KimiSession { + let timestamp = transcript.meta.timestamp.timestamp_millis(); + let state = json!({ + "sessionId": transcript.meta.id, + "createdAt": timestamp, + "title": transcript.meta.title, + "workDir": transcript.meta.cwd, + }); + let mut wire = vec![ + json!({"type": "metadata", "protocol_version": PROTOCOL_VERSION, "created_at": timestamp}), + ]; + for (index, message) in transcript.body.iter().enumerate() { + let time = message.timestamp.timestamp_millis(); + if message.role == Role::User { + let mut content = Vec::new(); + for block in &message.content { + if let Block::Text { text } = block { + content.push(json!({"type": "text", "text": text})); + } + } + if !content.is_empty() { + wire.push(json!({"type": "context.append_message", "time": time, "message": {"role": "user", "content": content, "toolCalls": []}})); + } + for block in &message.content { + if let Block::ToolResult { + tool_use_id, + content, + is_error, + } = block + { + let output = match content { + ToolOutput::Text(text) => Value::String(text.clone()), + ToolOutput::Json(value) => value.clone(), + }; + wire.push(json!({"type": "context.append_loop_event", "time": time, "event": {"type": "tool.result", "toolCallId": tool_use_id, "result": {"output": output, "isError": is_error}}})); + } + } + } else { + let step_uuid = Uuid::new_v5( + &NS, + format!("{}:{index}:step", transcript.meta.id).as_bytes(), + ) + .to_string(); + wire.push(json!({"type": "llm.request", "time": time, "model": message.model})); + wire.push(json!({"type": "context.append_loop_event", "time": time, "event": {"type": "step.begin", "uuid": step_uuid, "turnId": "0", "step": 1}})); + for block in &message.content { + match block { + Block::Text { text } => wire.push(json!({"type": "context.append_loop_event", "time": time, "event": {"type": "content.part", "part": {"type": "text", "text": text}}})), + Block::Thinking { text, .. } => wire.push(json!({"type": "context.append_loop_event", "time": time, "event": {"type": "content.part", "part": {"type": "think", "think": text}}})), + Block::ToolUse { id, tool } => { + let (name, input) = tool.to_canonical(); + wire.push(json!({"type": "context.append_loop_event", "time": time, "event": {"type": "tool.call", "toolCallId": id, "name": name, "args": input}})); + } + Block::ToolResult { .. } | Block::Image { .. } | Block::Artifact { .. } => {} + } + } + wire.push(json!({"type": "context.append_loop_event", "time": time, "event": {"type": "step.end", "finishReason": "stop"}})); + } + } + KimiSession { state, wire } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_both_kimi_timestamp_shapes() { + assert_eq!( + parse_timestamp(&json!(1_785_766_971_574_i64)) + .unwrap() + .timestamp_millis(), + 1_785_766_971_574_i64 + ); + assert!(parse_timestamp(&json!("2026-08-03T14:22:52.557Z")).is_some()); + } + + #[test] + fn tool_result_keeps_note_and_error() { + let (content, error) = + tool_result(json!({"output": "boom", "note": "truncated", "isError": true})); + assert!(error); + assert!(matches!(content, ToolOutput::Text(text) if text.contains("truncated"))); + } + + #[test] + fn session_id_is_recovered_from_every_state_schema() { + let meta = |state: Value| { + meta_from_body(&KimiSession { + state, + wire: Vec::new(), + }) + .id + }; + // Schema version 2 records the id directly. + assert_eq!( + meta(json!({"version": 2, "id": "session_abc", "cwd": "/repo"})), + "session_abc" + ); + // Version 1 has no id field; the agent home directory is the only + // copy a bare state.json carries. + assert_eq!( + meta(json!({ + "workDir": "/repo", + "agents": {"main": {"homedir": "/home/u/.kimi-code/sessions/wd_repo_1/session_abc/agents/main"}} + })), + "session_abc" + ); + // Kimi's own index calls the field sessionId. + assert_eq!(meta(json!({"sessionId": "session_abc"})), "session_abc"); + assert_eq!(meta(json!({"workDir": "/repo"})), ""); + } + + #[test] + fn context_undo_rewinds_whole_turns() { + let user = |text: &str, time: i64| { + json!({"type": "context.append_message", "time": time, + "message": {"role": "user", "content": [{"type": "text", "text": text}]}}) + }; + // Kimi's retry shape: one turn appends the prompt *and* a system + // reminder, fails, and is rewound with `count: 1`. Counting entries + // instead of turns would leave the prompt behind and duplicate it. + let wire = vec![ + json!({"type": "turn.prompt", "time": 1_i64}), + user("continue", 2_i64), + user("", 3_i64), + json!({"type": "turn.ended", "turnId": 6, "reason": "failed"}), + json!({"type": "context.undo", "count": 1, "time": 4_i64}), + json!({"type": "turn.prompt", "time": 5_i64}), + user("continue", 6_i64), + user("", 7_i64), + ]; + let messages = wire_to_messages(&wire, DateTime::::UNIX_EPOCH); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].timestamp.timestamp_millis(), 6_i64); + } + + #[test] + fn context_undo_rewinds_several_turns_at_once() { + let user = |text: &str, time: i64| { + json!({"type": "context.append_message", "time": time, + "message": {"role": "user", "content": [{"type": "text", "text": text}]}}) + }; + let wire = vec![ + json!({"type": "turn.prompt", "time": 1_i64}), + user("keep me", 1_i64), + json!({"type": "turn.prompt", "time": 2_i64}), + user("first retry", 2_i64), + json!({"type": "turn.prompt", "time": 3_i64}), + user("second retry", 3_i64), + json!({"type": "context.undo", "count": 2, "time": 4_i64}), + ]; + let messages = wire_to_messages(&wire, DateTime::::UNIX_EPOCH); + assert_eq!(messages.len(), 1); + assert!(matches!( + &messages[0].content[0], + Block::Text { text } if text == "keep me" + )); + } + + #[test] + fn context_undo_without_turn_markers_falls_back_to_entries() { + let user = |text: &str, time: i64| { + json!({"type": "context.append_message", "time": time, + "message": {"role": "user", "content": [{"type": "text", "text": text}]}}) + }; + let wire = vec![ + user("continue", 1_786_970_050_554_i64), + json!({"type": "context.undo", "count": 1, "time": 1_786_970_609_991_i64}), + user("continue", 1_786_970_613_673_i64), + ]; + let messages = wire_to_messages(&wire, DateTime::::UNIX_EPOCH); + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].timestamp.timestamp_millis(), + 1_786_970_613_673_i64 + ); + } + + #[test] + fn context_undo_rewinds_a_pending_assistant_turn() { + let wire = vec![ + json!({"type": "context.append_message", "time": 1_i64, + "message": {"role": "user", "content": [{"type": "text", "text": "hi"}]}}), + json!({"type": "context.append_loop_event", "time": 2_i64, + "event": {"type": "content.part", "part": {"type": "text", "text": "half"}}}), + json!({"type": "context.undo", "count": 1, "time": 3_i64}), + ]; + let messages = wire_to_messages(&wire, DateTime::::UNIX_EPOCH); + // No turn markers, so entry granularity: the half-assembled assistant + // turn is the most recent entry and the user prompt survives. + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].role, Role::User); + } +} diff --git a/src/harness/mod.rs b/src/harness/mod.rs index b550c47..25166cc 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -21,6 +21,7 @@ pub mod fx; pub mod grok; pub mod grok_bot; pub mod hermes; +pub mod kimi; pub mod opencode; pub mod pi; pub mod simple; diff --git a/src/lib.rs b/src/lib.rs index a340523..925b9a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ //! Typed conversion for coding-agent session transcripts. //! //! Claude Code, Claude Chat, Cowork, Codex, `OpenCode`, pi, Campfire, Cursor, -//! Grok, Hermes, Amp, and Antigravity record similar conversation data in +//! Grok, Kimi, Hermes, Amp, and Antigravity record similar conversation data in //! different stores. This crate maps each format through [`Transcript`] and //! converts with [`convert::`](convert): `A` -> [`Common`] -> `B`. //! diff --git a/src/local.rs b/src/local.rs index 4c8dd57..9d19613 100644 --- a/src/local.rs +++ b/src/local.rs @@ -23,7 +23,7 @@ use chrono::{DateTime, Utc}; use crate::common::{ArtifactSource, Block, Meta}; use crate::harness::{ - amp, antigravity, campfire, claude_code, codex, cowork, cursor, fx, grok, grok_bot, pi, + amp, antigravity, campfire, claude_code, codex, cowork, cursor, fx, grok, grok_bot, kimi, pi, }; #[cfg(feature = "chatgpt")] @@ -131,6 +131,8 @@ pub fn discover_with(mut on_store: impl FnMut(HarnessId, usize)) -> Vec grok_bot::GrokBotStore::default_root(), &mut out, ); + on_store(HarnessId::Kimi, out.len()); + scan(HarnessId::Kimi, kimi::KimiStore::default_root(), &mut out); on_store(HarnessId::Fx, out.len()); scan(HarnessId::Fx, fx::FxStore::default_root(), &mut out); on_store(HarnessId::Amp, out.len()); @@ -379,6 +381,7 @@ impl Session { (HarnessId::Cursor, Locator::Path(p)) => go(cursor::CursorStore::default_root(), p), (HarnessId::Grok, Locator::Path(p)) => go(grok::GrokStore::default_root(), p), (HarnessId::GrokBot, Locator::Path(p)) => go(grok_bot::GrokBotStore::default_root(), p), + (HarnessId::Kimi, Locator::Path(p)) => go(kimi::KimiStore::default_root(), p), (HarnessId::Fx, Locator::Path(p)) => go(fx::FxStore::default_root(), p), (HarnessId::Amp, Locator::Path(p)) => go(amp::AmpStore::default_root(), p), (HarnessId::Antigravity, Locator::Path(p)) => { @@ -438,6 +441,7 @@ impl Session { (HarnessId::Cursor, Locator::Path(p)) => go(cursor::CursorStore::default_root(), p), (HarnessId::Grok, Locator::Path(p)) => go(grok::GrokStore::default_root(), p), (HarnessId::GrokBot, Locator::Path(p)) => go(grok_bot::GrokBotStore::default_root(), p), + (HarnessId::Kimi, Locator::Path(p)) => go(kimi::KimiStore::default_root(), p), (HarnessId::Fx, Locator::Path(p)) => go(fx::FxStore::default_root(), p), (HarnessId::Amp, Locator::Path(p)) => go(amp::AmpStore::default_root(), p), (HarnessId::Antigravity, Locator::Path(p)) => { @@ -507,6 +511,7 @@ pub fn fingerprints(sessions: &[Session]) -> Vec { HarnessId::Cursor => group.files(cursor::CursorStore::default_root()), HarnessId::Grok => group.files(grok::GrokStore::default_root()), HarnessId::GrokBot => group.files(grok_bot::GrokBotStore::default_root()), + HarnessId::Kimi => group.files(kimi::KimiStore::default_root()), HarnessId::Fx => group.files(fx::FxStore::default_root()), HarnessId::Amp => group.files(amp::AmpStore::default_root()), HarnessId::Antigravity => group.files(antigravity::AntigravityStore::default_root()), @@ -843,6 +848,13 @@ pub fn write_with( common, |s| s.sessions_dir, ), + // Kimi has no documented session import command. Sessions convert + // *from* Kimi, never into it. + HarnessId::Kimi => Err(Error::Unconvertible { + harness: "kimi", + detail: "Kimi Code session storage is read-only and Kimi has no documented session import command; sessions can be converted from Kimi, but not continued into it" + .to_string(), + }), // Hermes's state.db is read-only in txcript and Hermes has no // session-import command. Sessions convert *from* Hermes, never // into it. @@ -1119,6 +1131,7 @@ pub fn resume_command(harness: HarnessId, id: &str) -> (String, Vec) { // the session is in the Agents sidebar. HarnessId::CursorDesktop => ("cursor".into(), Vec::new()), HarnessId::Grok => ("grok".into(), vec!["--resume".into(), id]), + HarnessId::Kimi => ("kimi".into(), vec!["--session".into(), id]), HarnessId::Fx => ("fx".into(), vec!["--resume".into(), id]), HarnessId::Hermes => ("hermes".into(), vec!["--resume".into(), id]), HarnessId::Amp => ("amp".into(), vec!["threads".into(), "continue".into(), id]), diff --git a/src/transcript.rs b/src/transcript.rs index c89ba1e..91c4c24 100644 --- a/src/transcript.rs +++ b/src/transcript.rs @@ -458,6 +458,7 @@ pub enum HarnessId { GrokBot, Fx, Hermes, + Kimi, Amp, Antigravity, Simple, @@ -465,7 +466,7 @@ pub enum HarnessId { } impl HarnessId { - pub const ALL: [HarnessId; 18] = [ + pub const ALL: [HarnessId; 19] = [ HarnessId::ClaudeCode, HarnessId::ClaudeChat, HarnessId::CoworkRemote, @@ -480,6 +481,7 @@ impl HarnessId { HarnessId::GrokBot, HarnessId::Fx, HarnessId::Hermes, + HarnessId::Kimi, HarnessId::Amp, HarnessId::Antigravity, HarnessId::Simple, @@ -504,6 +506,7 @@ impl HarnessId { HarnessId::GrokBot => "grok_bot", HarnessId::Fx => "fx", HarnessId::Hermes => "hermes", + HarnessId::Kimi => "kimi", HarnessId::Amp => "amp", HarnessId::Antigravity => "antigravity", HarnessId::Simple => "simple", @@ -550,6 +553,7 @@ impl FromStr for HarnessId { } "fx" | "fx_cli" | "fx-cli" | "fxcli" | "vercel_fx" | "vercel-fx" => Ok(HarnessId::Fx), "hermes" | "hermes_agent" | "hermes-agent" | "hermesagent" => Ok(HarnessId::Hermes), + "kimi" | "kimi_code" | "kimi-code" | "kimicode" => Ok(HarnessId::Kimi), "amp" | "ampcode" | "amp_code" | "amp-code" => Ok(HarnessId::Amp), "antigravity" | "agy" | "antigravity_cli" | "antigravity-cli" | "anti-gravity" => { Ok(HarnessId::Antigravity) diff --git a/src/wasm.rs b/src/wasm.rs index e1bbb6f..2033196 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -15,7 +15,7 @@ use wasm_bindgen::prelude::*; use crate::common; use crate::harness::{ amp, antigravity, campfire, chatgpt, claude_chat, claude_code, codex, cowork, cowork_remote, - cursor, cursor_desktop, fx, grok, grok_bot, hermes, opencode, pi, simple, + cursor, cursor_desktop, fx, grok, grok_bot, hermes, kimi, opencode, pi, simple, }; use crate::transcript::{Codec, Common, HarnessId, TextCodec, Transcript}; @@ -217,6 +217,7 @@ fn parse_to_common(harness: HarnessId, text: &str) -> crate::Result go::(text), HarnessId::Simple => go::(text), HarnessId::Cowork => go::(text), + HarnessId::Kimi => go::(text), } } @@ -243,6 +244,7 @@ fn render_from_common(harness: HarnessId, common: &Transcript) -> crate: HarnessId::Antigravity => go::(common), HarnessId::Simple => go::(common), HarnessId::Cowork => go::(common), + HarnessId::Kimi => go::(common), } } diff --git a/tests/integration/cross_harness.rs b/tests/integration/cross_harness.rs index 7a80ba5..41bb092 100644 --- a/tests/integration/cross_harness.rs +++ b/tests/integration/cross_harness.rs @@ -7,7 +7,7 @@ use chrono::{DateTime, Utc}; use txcript::common; use txcript::harness::{ amp, antigravity, campfire, claude_code, codex, cowork, cursor, cursor_desktop, fx, grok, - grok_bot, hermes, opencode, pi, simple, + grok_bot, hermes, kimi, opencode, pi, simple, }; use txcript::{Codec, Common, Transcript, convert}; @@ -194,6 +194,7 @@ fn sample_with_raw_tool(tool_name: &str) -> Transcript { Transcript::new(meta, body) } +#[allow(clippy::too_many_lines)] fn assert_cycle(common: &Transcript, context: &str) { let expected = signature(common); @@ -268,6 +269,13 @@ fn assert_cycle(common: &Transcript, context: &str) { "{context}: fx" ); + let kimi = convert::(&fx).unwrap(); + assert_eq!( + signature(&kimi::Kimi::to_common(&kimi).unwrap()), + expected, + "{context}: kimi" + ); + let hermes = convert::(&fx).unwrap(); assert_eq!( signature(&hermes::Hermes::to_common(&hermes).unwrap()), diff --git a/tests/integration/kimi.rs b/tests/integration/kimi.rs new file mode 100644 index 0000000..b5a4600 --- /dev/null +++ b/tests/integration/kimi.rs @@ -0,0 +1,341 @@ +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +//! Integration tests for the Kimi Code harness. + +use chrono::{DateTime, Utc}; +use serde_json::json; +use txcript::common; +use txcript::harness::kimi; +use txcript::{Codec, Common, Store, TextCodec}; + +fn ts(s: &str) -> DateTime { + s.parse().unwrap() +} + +fn event(inner: &serde_json::Value, time: i64) -> serde_json::Value { + json!({"type": "context.append_loop_event", "time": time, "event": inner.clone()}) +} + +fn sample_body() -> kimi::KimiSession { + kimi::KimiSession { + state: json!({ + "createdAt": "2026-01-02T03:04:05.000Z", + "title": "Parser work", + "workDir": "/repo" + }), + wire: vec![ + json!({"type": "metadata", "protocol_version": "1.4"}), + json!({"type": "llm.request", "model": "kimi-k2", "time": 1_767_323_045_000_i64}), + json!({"type": "context.append_message", "time": 1_767_323_046_000_i64, + "message": {"role": "user", "content": [{"type": "text", "text": "edit the file"}]}}), + event( + &json!({"type": "content.part", "part": {"type": "think", "think": "inspect it"}}), + 1_767_323_047_000_i64, + ), + event( + &json!({"type": "content.part", "part": {"type": "text", "text": "On it."}}), + 1_767_323_047_000_i64, + ), + event( + &json!({"type": "tool.call", "toolCallId": "call-1", "name": "Edit", + "args": {"file_path": "/repo/a.rs", "old_string": "old", "new_string": "new"}}), + 1_767_323_047_000_i64, + ), + event( + &json!({"type": "tool.result", "toolCallId": "call-1", + "result": {"output": "done", "isError": false}}), + 1_767_323_048_000_i64, + ), + event( + &json!({"type": "content.part", "part": {"type": "text", "text": "finished"}}), + 1_767_323_049_000_i64, + ), + // An unknown event must remain in the native body. + json!({"type": "future.kimi.event", "payload": {"v": 1}}), + ], + } +} + +#[test] +fn metadata_and_messages_are_converted() { + let body = sample_body(); + let meta = common::Meta { + id: "abc".into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: Some("/repo".into()), + git_branch: None, + title: Some("Parser work".into()), + cli_version: None, + model: Some("kimi-k2".into()), + lineage: None, + }; + let native = txcript::Transcript::new(meta, body); + // Build through TextCodec so the test exercises the public native text API. + let text = kimi::Kimi::to_text(&native).unwrap(); + let parsed = kimi::Kimi::from_text(&text).unwrap(); + assert_eq!(parsed.meta.cwd.as_deref(), Some("/repo")); + assert_eq!(parsed.meta.title.as_deref(), Some("Parser work")); + assert_eq!(parsed.meta.model.as_deref(), Some("kimi-k2")); + + let common = kimi::Kimi::to_common(&parsed).unwrap(); + assert_eq!(common.body.len(), 4); + assert!(matches!( + common.body[1].content[0], + common::Block::Thinking { .. } + )); + assert!(matches!( + common.body[1].content[2], + common::Block::ToolUse { .. } + )); + assert!(matches!( + common.body[2].content[0], + common::Block::ToolResult { .. } + )); + assert_eq!(common.body[3].timestamp, ts("2026-01-02T03:04:09.000Z")); +} + +#[test] +fn text_codec_recovers_the_id_without_a_directory() { + // The store can fall back to the session directory name, but `from_text` + // (and the wasm parser on top of it) only sees the JSON. Schema version 2 + // carries `id`; version 1 only carries the agent home directory. + let mut v2 = sample_body(); + v2.state = json!({"version": 2, "id": "session_abc", "cwd": "/repo", + "createdAt": "2026-01-02T03:04:05.000Z"}); + let rendered = kimi::Kimi::to_text(&txcript::Transcript::new( + common::Meta { + id: "session_abc".into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: Some("/repo".into()), + git_branch: None, + title: None, + cli_version: None, + model: None, + lineage: None, + }, + v2, + )) + .unwrap(); + assert_eq!( + kimi::Kimi::from_text(&rendered).unwrap().meta.id, + "session_abc" + ); + + let mut v1 = sample_body(); + v1.state = json!({"workDir": "/repo", "createdAt": "2026-01-02T03:04:05.000Z", + "agents": {"main": {"homedir": "/home/u/.kimi-code/sessions/wd_repo_1/session_abc/agents/main"}}}); + let rendered = kimi::Kimi::to_text(&txcript::Transcript::new( + common::Meta { + id: "session_abc".into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: Some("/repo".into()), + git_branch: None, + title: None, + cli_version: None, + model: None, + lineage: None, + }, + v1, + )) + .unwrap(); + assert_eq!( + kimi::Kimi::from_text(&rendered).unwrap().meta.id, + "session_abc" + ); +} + +#[test] +fn native_text_round_trip_retains_unknown_wire_events() { + let body = sample_body(); + let meta = common::Meta { + id: "session-id".into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: Some("/repo".into()), + git_branch: None, + title: Some("Parser work".into()), + cli_version: None, + model: Some("kimi-k2".into()), + lineage: None, + }; + let transcript = txcript::Transcript::new(meta, body.clone()); + let text = kimi::Kimi::to_text(&transcript).unwrap(); + let parsed = kimi::Kimi::from_text(&text).unwrap(); + assert_eq!(parsed.body, body); + assert!(text.contains("future.kimi.event")); +} + +#[test] +fn read_only_store_discovers_and_refuses_writes() { + let root = tempfile::tempdir().unwrap(); + let session = root.path().join("wd_repo_hash").join("session_abc"); + write_session(&session, &sample_body()); + let body = sample_body(); + + let store = kimi::KimiStore::new(root.path()); + let found = store.discover().unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].meta.id, "session_abc"); + assert_eq!(found[0].meta.timestamp, ts("2026-01-02T03:04:05.000Z")); + + let loaded = store.load(&found[0].reference).unwrap(); + assert_eq!(loaded.body, body); + assert!(store.save(&loaded).is_err()); + assert!(store.delete(&found[0].reference).is_err()); +} + +#[test] +fn discovery_does_not_depend_on_the_directory_name() { + // Discovery is gated on structure — a state.json and a main wire log — + // exactly like every other file-backed store. A Kimi release that renames + // its session directories must not make sessions disappear from `list`. + let root = tempfile::tempdir().unwrap(); + write_session( + &root.path().join("workspace-1").join("conversation-7"), + &sample_body(), + ); + let found = kimi::KimiStore::new(root.path()).discover().unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].meta.id, "conversation-7"); +} + +fn write_session(dir: &std::path::Path, body: &kimi::KimiSession) { + std::fs::create_dir_all(dir.join("agents/main")).unwrap(); + std::fs::write( + dir.join("state.json"), + serde_json::to_string(&body.state).unwrap(), + ) + .unwrap(); + std::fs::write( + dir.join("agents/main/wire.jsonl"), + body.wire + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + + "\n", + ) + .unwrap(); +} + +#[test] +fn epoch_millisecond_created_at_is_supported() { + let mut body = sample_body(); + body.state["createdAt"] = json!(1_767_323_045_000_i64); + let native = txcript::Transcript::new( + common::Meta { + id: "abc".into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: Some("/repo".into()), + git_branch: None, + title: None, + cli_version: None, + model: None, + lineage: None, + }, + body, + ); + let text = kimi::Kimi::to_text(&native).unwrap(); + let parsed = kimi::Kimi::from_text(&text).unwrap(); + assert_eq!(parsed.meta.timestamp, ts("2026-01-02T03:04:05.000Z")); +} + +fn common_sample() -> txcript::Transcript { + let meta = common::Meta { + id: "abc".into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: Some("/repo".into()), + git_branch: None, + title: Some("Parser work".into()), + cli_version: None, + model: Some("kimi-k2".into()), + lineage: None, + }; + let body = vec![ + common::Message { + role: common::Role::User, + content: vec![common::Block::Text { + text: "edit the file".into(), + }], + timestamp: ts("2026-01-02T03:04:06.000Z"), + model: None, + stop_reason: None, + usage: None, + }, + common::Message { + role: common::Role::Assistant, + content: vec![common::Block::Text { + text: "On it.".into(), + }], + timestamp: ts("2026-01-02T03:04:07.000Z"), + model: Some("kimi-k2".into()), + stop_reason: Some(common::StopReason::EndTurn), + usage: None, + }, + ]; + txcript::Transcript::new(meta, body) +} + +#[test] +fn assistant_timestamps_survive_a_common_round_trip() { + let native = kimi::Kimi::from_common(&common_sample()).unwrap(); + let back = kimi::Kimi::to_common(&native).unwrap(); + assert_eq!(back.body[0].timestamp, ts("2026-01-02T03:04:06.000Z")); + // Assistant turns are rendered as loop events; they must carry their own + // `time` or every assistant message collapses onto the session timestamp. + assert_eq!(back.body[1].timestamp, ts("2026-01-02T03:04:07.000Z")); +} + +#[test] +fn rendering_is_deterministic() { + let common = common_sample(); + let first = kimi::Kimi::to_text(&kimi::Kimi::from_common(&common).unwrap()).unwrap(); + let second = kimi::Kimi::to_text(&kimi::Kimi::from_common(&common).unwrap()).unwrap(); + assert_eq!(first, second); +} + +#[test] +fn context_undo_removes_the_retried_prompt() { + let mut body = sample_body(); + body.wire + .push(json!({"type": "turn.prompt", "time": 1_767_323_050_000_i64})); + body.wire.push( + json!({"type": "context.append_message", "time": 1_767_323_050_000_i64, + "message": {"role": "user", "content": [{"type": "text", "text": "continue"}]}}), + ); + body.wire + .push(json!({"type": "turn.ended", "turnId": 1, "reason": "failed"})); + body.wire + .push(json!({"type": "context.undo", "count": 1, "time": 1_767_323_051_000_i64})); + body.wire + .push(json!({"type": "turn.prompt", "time": 1_767_323_052_000_i64})); + body.wire.push( + json!({"type": "context.append_message", "time": 1_767_323_052_000_i64, + "message": {"role": "user", "content": [{"type": "text", "text": "continue"}]}}), + ); + + let native = txcript::Transcript::new( + common::Meta { + id: "abc".into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: None, + git_branch: None, + title: None, + cli_version: None, + model: None, + lineage: None, + }, + body, + ); + let converted = kimi::Kimi::to_common(&native).unwrap(); + let prompts = converted + .body + .iter() + .filter(|message| { + matches!( + message.content.first(), + Some(common::Block::Text { text }) if text == "continue" + ) + }) + .count(); + assert_eq!(prompts, 1, "the rolled-back prompt must not be replayed"); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 4eb9cce..e0ae031 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -20,6 +20,7 @@ mod fx; mod grok; mod grok_bot; mod hermes; +mod kimi; mod opencode; mod path_safety; mod pi; From d099d97041c8b5acf9dbc75fd9ce0e709249864f Mon Sep 17 00:00:00 2001 From: zzyking Date: Mon, 7 Sep 2026 20:13:10 +0800 Subject: [PATCH 2/6] fix(kimi): preserve tool-result notes and mixed user block order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings: - A Kimi tool result's `note` — a truncation or permission annotation — was only retained when `output` was a string. For an object, array, or null output the note was silently dropped. Text output still takes the note as a suffix; every other shape now keeps its structure and carries the note beside it. - Encoding a user message in two passes emitted all text before all tool results, so a Common message holding `[ToolResult, Text]` round-tripped through Kimi as `[Text, ToolResult]`. Blocks are now emitted in one ordered pass, coalescing adjacent text and flushing it ahead of each tool result. - The README diagram showed `common <--> kimi`, contradicting the table and the read-only store. It is now `kimi --> common`, alongside the other source-only harnesses. --- src/harness/kimi.rs | 157 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 131 insertions(+), 26 deletions(-) diff --git a/src/harness/kimi.rs b/src/harness/kimi.rs index 9534e35..fe0838b 100644 --- a/src/harness/kimi.rs +++ b/src/harness/kimi.rs @@ -559,12 +559,18 @@ fn tool_result(result: Value) -> (ToolOutput, bool) { .get("isError") .and_then(Value::as_bool) .unwrap_or(false); - let mut output = object.get("output").cloned().unwrap_or(Value::Null); - if let Some(note) = object.get("note").and_then(Value::as_str) - && let Some(text) = output.as_str() - { - output = Value::String(format!("{text}\n\n[kimi note: {note}]")); - } + let output = object.get("output").cloned().unwrap_or(Value::Null); + let Some(note) = object.get("note").and_then(Value::as_str) else { + return (tool_output(output), is_error); + }; + // A note annotates the result — truncation, a permission decision — so it + // must survive whatever shape `output` has. Text takes it as a suffix; + // every other shape (object, array, null) keeps its structure and carries + // the note beside it rather than losing it. + let output = match output { + Value::String(text) => Value::String(format!("{text}\n\n[kimi note: {note}]")), + other => json!({"output": other, "note": note}), + }; (tool_output(output), is_error) } @@ -587,6 +593,15 @@ const NS: Uuid = Uuid::from_bytes([ /// the reader does not depend on it. const PROTOCOL_VERSION: &str = "1.5"; +/// Emit the text accumulated so far as one user entry, if any. +fn flush_user_text(pending: &mut Vec, wire: &mut Vec, time: i64) { + if pending.is_empty() { + return; + } + let content = std::mem::take(pending); + wire.push(json!({"type": "context.append_message", "time": time, "message": {"role": "user", "content": content, "toolCalls": []}})); +} + fn body_from_common(transcript: &Transcript) -> KimiSession { let timestamp = transcript.meta.timestamp.timestamp_millis(); let state = json!({ @@ -601,29 +616,33 @@ fn body_from_common(transcript: &Transcript) -> KimiSession { for (index, message) in transcript.body.iter().enumerate() { let time = message.timestamp.timestamp_millis(); if message.role == Role::User { - let mut content = Vec::new(); - for block in &message.content { - if let Block::Text { text } = block { - content.push(json!({"type": "text", "text": text})); - } - } - if !content.is_empty() { - wire.push(json!({"type": "context.append_message", "time": time, "message": {"role": "user", "content": content, "toolCalls": []}})); - } + // One ordered pass: emitting all text first and all tool results + // second would reorder a mixed message, so adjacent text is + // coalesced into one entry and a tool result flushes whatever text + // precedes it. + let mut pending = Vec::new(); for block in &message.content { - if let Block::ToolResult { - tool_use_id, - content, - is_error, - } = block - { - let output = match content { - ToolOutput::Text(text) => Value::String(text.clone()), - ToolOutput::Json(value) => value.clone(), - }; - wire.push(json!({"type": "context.append_loop_event", "time": time, "event": {"type": "tool.result", "toolCallId": tool_use_id, "result": {"output": output, "isError": is_error}}})); + match block { + Block::Text { text } => pending.push(json!({"type": "text", "text": text})), + Block::ToolResult { + tool_use_id, + content, + is_error, + } => { + flush_user_text(&mut pending, &mut wire, time); + let output = match content { + ToolOutput::Text(text) => Value::String(text.clone()), + ToolOutput::Json(value) => value.clone(), + }; + wire.push(json!({"type": "context.append_loop_event", "time": time, "event": {"type": "tool.result", "toolCallId": tool_use_id, "result": {"output": output, "isError": is_error}}})); + } + Block::Thinking { .. } + | Block::ToolUse { .. } + | Block::Image { .. } + | Block::Artifact { .. } => {} } } + flush_user_text(&mut pending, &mut wire, time); } else { let step_uuid = Uuid::new_v5( &NS, @@ -672,6 +691,92 @@ mod tests { assert!(matches!(content, ToolOutput::Text(text) if text.contains("truncated"))); } + #[test] + fn tool_result_keeps_note_for_every_output_shape() { + // A note must not depend on `output` being a string. + let note_of = |result: Value| match tool_result(result).0 { + ToolOutput::Json(value) => value.get("note").and_then(Value::as_str).map(String::from), + ToolOutput::Text(text) => text.contains("truncated").then(|| "truncated".to_string()), + }; + assert_eq!( + note_of(json!({"output": {"x": 1}, "note": "truncated"})).as_deref(), + Some("truncated") + ); + assert_eq!( + note_of(json!({"output": null, "note": "truncated"})).as_deref(), + Some("truncated") + ); + assert_eq!( + note_of(json!({"output": [1, 2], "note": "truncated"})).as_deref(), + Some("truncated") + ); + // The structured output itself is preserved alongside the note. + let ToolOutput::Json(value) = tool_result(json!({"output": {"x": 1}, "note": "n"})).0 + else { + panic!("structured output should stay structured"); + }; + assert_eq!(value.get("output"), Some(&json!({"x": 1}))); + // No note means no wrapper. + assert!(matches!( + tool_result(json!({"output": {"x": 1}})).0, + ToolOutput::Json(value) if value == json!({"x": 1}) + )); + } + + #[test] + fn mixed_user_blocks_keep_their_order() { + // `[ToolResult, Text]` must not come back as `[Text, ToolResult]`. + let message = Message { + role: Role::User, + content: vec![ + Block::ToolResult { + tool_use_id: "c1".to_string(), + content: ToolOutput::Text("result".to_string()), + is_error: false, + }, + Block::Text { + text: "after".to_string(), + }, + ], + timestamp: DateTime::::UNIX_EPOCH, + model: None, + stop_reason: None, + usage: None, + }; + let meta = Meta { + id: "session_probe".to_string(), + timestamp: DateTime::::UNIX_EPOCH, + cwd: None, + git_branch: None, + title: None, + cli_version: None, + model: None, + lineage: None, + }; + let transcript = Transcript::new(meta, vec![message]); + let body = body_from_common(&transcript); + + let kinds: Vec<&str> = body + .wire + .iter() + .filter_map(|entry| match entry.get("type").and_then(Value::as_str) { + Some("context.append_message") => Some("text"), + Some("context.append_loop_event") => entry + .get("event") + .and_then(|event| event.get("type")) + .and_then(Value::as_str), + _ => None, + }) + .collect(); + assert_eq!(kinds, vec!["tool.result", "text"]); + + // And the order survives a full round trip through Common. + let back = wire_to_messages(&body.wire, DateTime::::UNIX_EPOCH); + let blocks: Vec<&Block> = back.iter().flat_map(|m| m.content.iter()).collect(); + assert!(matches!(blocks[0], Block::ToolResult { .. })); + assert!(matches!(blocks[1], Block::Text { text } if text == "after")); + } + #[test] fn session_id_is_recovered_from_every_state_schema() { let meta = |state: Value| { From 396f94f2657f716cfe407ce707f1a1b2a3557987 Mon Sep 17 00:00:00 2001 From: zzyking Date: Thu, 15 Oct 2026 15:42:50 +0800 Subject: [PATCH 3/6] feat(kimi): read `forkedFrom` as fork lineage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `kimi fork` records the source session's id in the forked session's `state.json` as top-level `forkedFrom`. Fill `Meta::lineage` from it as a Fork, at discovery as well as on load, so forked Kimi sessions link back to their source the way pi, Codex and OpenCode sessions already do. Subagents are not sessions of their own in Kimi — they live under the same session's `agents//` — so there is no spawn link to read. --- docs/formats/kimi.md | 9 +++++++++ src/harness/kimi.rs | 11 ++++++++--- tests/integration/kimi.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/docs/formats/kimi.md b/docs/formats/kimi.md index b4b3bc5..dfdab1b 100644 --- a/docs/formats/kimi.md +++ b/docs/formats/kimi.md @@ -71,6 +71,15 @@ 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//` — so there is +no spawn link to read. Writing `forkedFrom` back out is not implemented. + ## Store capabilities Kimi Code has no documented session import or deletion command. The txcript diff --git a/src/harness/kimi.rs b/src/harness/kimi.rs index fe0838b..08c3f10 100644 --- a/src/harness/kimi.rs +++ b/src/harness/kimi.rs @@ -21,7 +21,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use uuid::Uuid; -use crate::common::{Block, Message, Meta, Role, StopReason, Tool, ToolOutput}; +use crate::common::{Block, Lineage, Message, Meta, Relation, Role, StopReason, Tool, ToolOutput}; use crate::error::{Error, Result}; use crate::harness::jsonl; use crate::transcript::{Codec, Common, Discovered, Harness, Saved, Store, TextCodec, Transcript}; @@ -245,8 +245,13 @@ fn meta_from_body(body: &KimiSession) -> Meta { title, cli_version: string("cliVersion").or_else(|| string("cli_version")), model, - // Kimi's state.json and wire log carry no parent/fork/spawn signal. - lineage: None, + // `kimi fork` records the source session's id as `forkedFrom`. + // Subagents live inside the same session directory rather than as + // sessions of their own, so there is no spawn link to read. + lineage: string("forkedFrom").map(|parent| Lineage { + parent, + relation: Relation::Fork, + }), } } diff --git a/tests/integration/kimi.rs b/tests/integration/kimi.rs index b5a4600..3bfbfcf 100644 --- a/tests/integration/kimi.rs +++ b/tests/integration/kimi.rs @@ -198,6 +198,32 @@ fn discovery_does_not_depend_on_the_directory_name() { assert_eq!(found[0].meta.id, "conversation-7"); } +#[test] +fn forked_from_becomes_fork_lineage() { + // `kimi fork` records the source session's id as `forkedFrom`. + let mut body = sample_body(); + body.state["forkedFrom"] = json!("session_parent"); + let root = tempfile::tempdir().unwrap(); + write_session(&root.path().join("wd_x").join("session_child"), &body); + + let found = kimi::KimiStore::new(root.path()).discover().unwrap(); + let lineage = found[0].meta.lineage.as_ref().expect("fork lineage"); + assert_eq!(lineage.parent, "session_parent"); + assert_eq!(lineage.relation, common::Relation::Fork); + + let text = serde_json::to_string(&body).unwrap(); + let parsed = ::from_text(&text).unwrap(); + assert_eq!(parsed.meta.lineage, found[0].meta.lineage); +} + +#[test] +fn no_forked_from_means_no_lineage() { + let root = tempfile::tempdir().unwrap(); + write_session(&root.path().join("wd_x").join("session_a"), &sample_body()); + let found = kimi::KimiStore::new(root.path()).discover().unwrap(); + assert!(found[0].meta.lineage.is_none()); +} + fn write_session(dir: &std::path::Path, body: &kimi::KimiSession) { std::fs::create_dir_all(dir.join("agents/main")).unwrap(); std::fs::write( From b729b1aa153c55ca3b2655239bc11a74ba8b3d61 Mon Sep 17 00:00:00 2001 From: zzyking Date: Mon, 7 Sep 2026 20:44:20 +0800 Subject: [PATCH 4/6] feat: make the Kimi Code store writable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kimi ships no import command, but it does not need one: it discovers sessions solely through `session_index.jsonl`, so writing one is a matter of laying out the files it already reads. `save` writes the session directory, the main agent's wire log, and one index record; `delete` appends a `deleted` tombstone rather than rewriting the append-only log. Workspace directory names are derived the way Kimi derives them — `wd__`. Getting this wrong would not hide a session, since the index points at it directly, but it would break `--cwd` filtering and `kimi -c`, which resolve a working directory to this exact name. The derivation reproduces the names of real local session directories byte for byte. A converted session has no Kimi `state.json`, so `save` fills in what Kimi and txcript's directory-free readers need. `updatedAt` matters more than it looks: `kimi session list` renders its timestamp column from that field alone, and 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 — a copy saved under a second root would otherwise point back at the first. Verified end to end against an isolated `KIMI_CODE_HOME`: a session written by txcript is listed by `kimi session list` with the right timestamp, and `kimi export` reports a `sessionFirstActivity` derived from the written wire log, so Kimi parses the events back rather than just finding the directory. `sha2` moves from a macOS-only optional dependency to an unconditional one, since the workspace key needs it on every platform. --- Cargo.toml | 6 +- README.md | 2 +- cli/src/lib.rs | 12 +- docs/formats/kimi.md | 86 ++++++++++-- src/harness/kimi.rs | 172 +++++++++++++++++++++-- src/local.rs | 14 +- tests/integration/kimi.rs | 225 +++++++++++++++++++++++++++++- tests/integration/path_safety.rs | 3 +- tests/integration/properties.rs | 3 +- tests/integration/store_delete.rs | 16 ++- 10 files changed, 490 insertions(+), 49 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5d9f64d..9937585 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } @@ -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" @@ -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 diff --git a/README.md b/README.md index 29e2336..0b91aca 100644 --- a/README.md +++ b/README.md @@ -117,7 +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 | No | +| [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 | diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 27d774a..39efce6 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -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; @@ -1920,11 +1927,6 @@ 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::Kimi && target == HarnessId::Kimi { - Err( - "Kimi Code is read-only: choose another --with harness; txcript never writes undocumented Kimi session files" - .to_string(), - ) } else { Ok(()) } diff --git a/docs/formats/kimi.md b/docs/formats/kimi.md index dfdab1b..58bd970 100644 --- a/docs/formats/kimi.md +++ b/docs/formats/kimi.md @@ -82,22 +82,80 @@ no spawn link to read. Writing `forkedFrom` back out is not implemented. ## Store capabilities -Kimi Code has no documented session import or deletion command. The txcript -Kimi store is therefore **read-only**: +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. -- `list`, `query`, `view`, and `export` work; -- a Kimi session can be converted into any writable target harness; -- `save` and `delete` return an explicit read-only error; -- txcript never writes directly into `~/.kimi-code/sessions`. +### The session index -The native Kimi resume command is `kimi --session `. Cross-harness -continuation is supported; native Kimi continuation is refused because the -store cannot safely create a Kimi session without an official import contract. +`/session_index.jsonl` is an append-only log, one JSON record per +line, sitting one level above `sessions/`: + +```json +{"sessionId": "session_", "sessionDir": "/abs/path", "workDir": "/abs/cwd"} +{"sessionId": "session_", "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. + +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__/`, 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__//state.json` +- `sessions/wd__//agents/main/wire.jsonl` +- one appended record in `/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 `. ## Provenance -The format is based on the Kimi Code CLI wire protocol observed in local -sessions and the public CLI interface documented by `kimi --help`. 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. +**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. diff --git a/src/harness/kimi.rs b/src/harness/kimi.rs index 08c3f10..b88f3af 100644 --- a/src/harness/kimi.rs +++ b/src/harness/kimi.rs @@ -2,10 +2,13 @@ //! //! Kimi keeps session metadata in `state.json` and the append-only event stream //! for each agent in `agents//wire.jsonl`. The main conversation is the -//! `agents/main` stream. Kimi has no documented session import or deletion -//! interface, so [`KimiStore`] is deliberately read-only: sessions can be -//! discovered, loaded, searched, exported, and converted into another -//! harness, but txcript never writes an undocumented Kimi session. +//! `agents/main` stream. +//! +//! [`KimiStore`] reads and writes. Kimi ships no import command, but it does +//! not need one: it loads whatever `session_index.jsonl` points at, so writing +//! a session means laying out the two files and appending one index record. +//! The index is the only discovery path — a session on disk that is missing +//! from it does not exist as far as Kimi is concerned. //! //! The native body retains both JSON files as raw JSON. This makes loading and //! rendering a session lossless even when Kimi adds bookkeeping events that @@ -19,10 +22,11 @@ use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::common::{Block, Lineage, Message, Meta, Relation, Role, StopReason, Tool, ToolOutput}; -use crate::error::{Error, Result}; +use crate::error::Result; use crate::harness::jsonl; use crate::transcript::{Codec, Common, Discovered, Harness, Saved, Store, TextCodec, Transcript}; @@ -76,7 +80,7 @@ impl Codec for Kimi { } } -/// Read-only access to Kimi Code's session directories. +/// Read/write access to Kimi Code's session directories. #[derive(Debug, Clone)] pub struct KimiStore { pub sessions_dir: PathBuf, @@ -89,6 +93,32 @@ impl KimiStore { } } + /// `session_index.jsonl` sits in Kimi's data root, one level above + /// `sessions/`. + fn index_path(&self) -> Option { + self.sessions_dir + .parent() + .map(|home| home.join("session_index.jsonl")) + } + + /// Append one record to Kimi's session index, creating it if needed. + fn append_index(&self, record: &Value) -> Result<()> { + let Some(path) = self.index_path() else { + return Ok(()); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut line = serde_json::to_string(record)?; + line.push('\n'); + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + std::io::Write::write_all(&mut file, line.as_bytes())?; + Ok(()) + } + /// Resolve `$KIMI_HOME/sessions`, falling back to /// `~/.kimi-code/sessions`. #[must_use] @@ -152,12 +182,51 @@ impl Store for KimiStore { Ok(Transcript::new(meta, body)) } - fn save(&self, _transcript: &Transcript) -> Result> { - Err(read_only_error()) + fn save(&self, transcript: &Transcript) -> Result> { + let id = if transcript.meta.id.is_empty() { + format!("session_{}", Uuid::new_v4()) + } else { + transcript.meta.id.clone() + }; + super::checked_id_component(Kimi::NAME, &id)?; + let work_dir = transcript.meta.cwd.clone().unwrap_or_default(); + + let session_dir = self.sessions_dir.join(workspace_key(&work_dir)).join(&id); + let agent_dir = session_dir.join("agents").join("main"); + fs::create_dir_all(&agent_dir)?; + + let mut state = transcript.body.state.clone(); + ensure_state_identity(&mut state, &transcript.meta, &id, &agent_dir); + fs::write( + session_dir.join("state.json"), + serde_json::to_string_pretty(&state)?, + )?; + fs::write( + agent_dir.join("wire.jsonl"), + jsonl::render(&transcript.body.wire)?, + )?; + + // Kimi finds sessions only through the index; a session that is on + // disk but absent from it does not exist as far as the CLI is + // concerned. + self.append_index(&json!({ + "sessionId": id, + "sessionDir": session_dir.to_string_lossy(), + "workDir": work_dir, + }))?; + + Ok(Saved { + id, + reference: session_dir, + }) } - fn delete(&self, _reference: &PathBuf) -> Result<()> { - Err(read_only_error()) + fn delete(&self, reference: &PathBuf) -> Result<()> { + let id = session_id_from_path(reference); + fs::remove_dir_all(reference)?; + // The index is append-only, so a removal is a tombstone record rather + // than a rewrite — matching how Kimi itself retires a session. + self.append_index(&json!({"sessionId": id, "deleted": true})) } fn fingerprints(&self, refs: &[PathBuf]) -> Result> { @@ -174,10 +243,85 @@ impl Store for KimiStore { } } -fn read_only_error() -> Error { - Error::Unconvertible { - harness: Kimi::NAME, - detail: "Kimi Code session storage is read-only in txcript; Kimi has no documented session import or delete command".to_string(), +/// Kimi's workspace directory name: `wd__`, where +/// the slug is the working directory's last path segment, lowercased with +/// every run of non-`[a-z0-9._-]` characters collapsed to `-`, trimmed of +/// leading and trailing dashes and capped at 40 characters. +/// +/// Getting this wrong does not hide a session — the index still points at it — +/// but it breaks Kimi's own `--cwd` filtering and `kimi -c`, which resolve a +/// working directory to this exact name. +fn workspace_key(work_dir: &str) -> String { + const MAX_SLUG: usize = 40; + const HEX: &[u8; 16] = b"0123456789abcdef"; + + let normalized = work_dir.replace('\\', "/"); + let normalized = normalized.trim_end_matches('/'); + let base = normalized.rsplit('/').next().unwrap_or(normalized); + + let mut slug = String::with_capacity(base.len()); + let mut pending_dash = false; + for ch in base.chars() { + let ch = ch.to_ascii_lowercase(); + if ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '_' | '-') { + if pending_dash && !slug.is_empty() { + slug.push('-'); + } + pending_dash = false; + slug.push(ch); + if slug.len() >= MAX_SLUG { + break; + } + } else { + pending_dash = true; + } + } + let slug = slug.trim_matches('-'); + + let digest = ::digest(normalized.as_bytes()); + let mut hash = String::with_capacity(12); + for &byte in digest.iter().take(6) { + hash.push(char::from(HEX[(byte >> 4) as usize])); + hash.push(char::from(HEX[(byte & 0x0f) as usize])); + } + format!("wd_{slug}_{hash}") +} + +/// Make the written `state.json` self-describing. Kimi's own loader and +/// txcript's directory-free `from_text` both read the id back out of it; +/// `updatedAt` is what Kimi renders a session's time from (without it the CLI +/// lists the session at the epoch); and `agents..homedir` is an absolute +/// path only the store can fill in. +/// +/// Every field but the id is filled in only when absent, so a native session's +/// own bookkeeping survives a load/save round trip untouched. +fn ensure_state_identity(state: &mut Value, meta: &Meta, id: &str, agent_dir: &Path) { + let Some(object) = state.as_object_mut() else { + return; + }; + object.insert("sessionId".to_string(), json!(id)); + if let Some(cwd) = &meta.cwd { + object.entry("workDir").or_insert_with(|| json!(cwd)); + } + if let Some(title) = &meta.title { + object.entry("title").or_insert_with(|| json!(title)); + } + let millis = meta.timestamp.timestamp_millis(); + object.entry("createdAt").or_insert_with(|| json!(millis)); + object.entry("updatedAt").or_insert_with(|| json!(millis)); + // `homedir` is bound to where the session actually is, so it is rewritten + // rather than preserved: a session saved under a different root must not + // keep pointing at the old one. + let main = json!({ + "homedir": agent_dir.to_string_lossy(), + "type": "main", + "parentAgentId": Value::Null, + }); + match object.entry("agents").or_insert_with(|| json!({})) { + Value::Object(agents) => { + agents.insert("main".to_string(), main); + } + other => *other = json!({"main": main}), } } diff --git a/src/local.rs b/src/local.rs index 9d19613..5ac4475 100644 --- a/src/local.rs +++ b/src/local.rs @@ -848,13 +848,13 @@ pub fn write_with( common, |s| s.sessions_dir, ), - // Kimi has no documented session import command. Sessions convert - // *from* Kimi, never into it. - HarnessId::Kimi => Err(Error::Unconvertible { - harness: "kimi", - detail: "Kimi Code session storage is read-only and Kimi has no documented session import command; sessions can be converted from Kimi, but not continued into it" - .to_string(), - }), + HarnessId::Kimi => go( + kimi::KimiStore::default_root(), + kimi::KimiStore::new, + root, + common, + |s| s.sessions_dir, + ), // Hermes's state.db is read-only in txcript and Hermes has no // session-import command. Sessions convert *from* Hermes, never // into it. diff --git a/tests/integration/kimi.rs b/tests/integration/kimi.rs index 3bfbfcf..c4d0007 100644 --- a/tests/integration/kimi.rs +++ b/tests/integration/kimi.rs @@ -165,7 +165,7 @@ fn native_text_round_trip_retains_unknown_wire_events() { } #[test] -fn read_only_store_discovers_and_refuses_writes() { +fn store_discovers_and_loads_a_session() { let root = tempfile::tempdir().unwrap(); let session = root.path().join("wd_repo_hash").join("session_abc"); write_session(&session, &sample_body()); @@ -179,8 +179,6 @@ fn read_only_store_discovers_and_refuses_writes() { let loaded = store.load(&found[0].reference).unwrap(); assert_eq!(loaded.body, body); - assert!(store.save(&loaded).is_err()); - assert!(store.delete(&found[0].reference).is_err()); } #[test] @@ -224,6 +222,227 @@ fn no_forked_from_means_no_lineage() { assert!(found[0].meta.lineage.is_none()); } +/// Kimi resolves its data root first and `sessions/` beneath it, so a store +/// rooted at `/sessions` puts the session index at ``. +fn kimi_home() -> tempfile::TempDir { + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(home.path().join("sessions")).unwrap(); + home +} + +fn saveable(id: &str, cwd: &str) -> txcript::Transcript { + let mut body = sample_body(); + // Shaped like a real state.json, which always carries a creation time. + body.state = json!({ + "sessionId": id, + "workDir": cwd, + "title": "Parser work", + "createdAt": 1_767_323_045_000_i64 + }); + txcript::Transcript::new( + common::Meta { + id: id.into(), + timestamp: ts("2026-01-02T03:04:05.000Z"), + cwd: Some(cwd.into()), + git_branch: None, + title: Some("Parser work".into()), + cli_version: None, + model: None, + lineage: None, + }, + body, + ) +} + +#[test] +fn save_writes_the_layout_kimi_discovers() { + // Kimi keys a workspace directory by `wd__` + // and finds sessions only through `session_index.jsonl` — there is no + // directory-scan fallback, so a save that skips the index is invisible. + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + let saved = store.save(&saveable("session_abc", "/repo")).unwrap(); + + assert_eq!(saved.id, "session_abc"); + let expected = home + .path() + .join("sessions") + .join("wd_repo_816fc349d3fa") + .join("session_abc"); + assert_eq!(saved.reference, expected); + assert!(expected.join("state.json").is_file()); + assert!(expected.join("agents/main/wire.jsonl").is_file()); + + let index = std::fs::read_to_string(home.path().join("session_index.jsonl")).unwrap(); + let entry: serde_json::Value = serde_json::from_str(index.trim()).unwrap(); + assert_eq!(entry["sessionId"], json!("session_abc")); + assert_eq!(entry["workDir"], json!("/repo")); + assert_eq!(entry["sessionDir"], json!(expected.to_str().unwrap())); +} + +#[test] +fn saved_session_is_discovered_and_loads_back() { + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + let original = saveable("session_abc", "/repo"); + store.save(&original).unwrap(); + + let found = store.discover().unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].meta.id, "session_abc"); + + let loaded = store.load(&found[0].reference).unwrap(); + // The wire log is carried through verbatim. + assert_eq!(loaded.body.wire, original.body.wire); + // `state` is normalized on the way out — `agents.main.homedir` names where + // the session actually landed — so the round-trip contract is that the + // normalization is a fixed point, not that it is a no-op. + let again = store.save(&loaded).unwrap(); + assert_eq!(store.load(&again.reference).unwrap().body, loaded.body); +} + +#[test] +fn save_points_the_agent_homedir_at_where_the_session_landed() { + // Kimi resolves an agent's wire log through `agents..homedir`, and + // txcript's directory-free readers recover the session id from it. Saving + // into a different root has to rewrite it, or the copy points at the + // original. + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + let saved = store.save(&saveable("session_abc", "/repo")).unwrap(); + + let elsewhere = kimi_home(); + let other = kimi::KimiStore::new(elsewhere.path().join("sessions")); + let moved = other.save(&store.load(&saved.reference).unwrap()).unwrap(); + + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(moved.reference.join("state.json")).unwrap()) + .unwrap(); + assert_eq!( + state["agents"]["main"]["homedir"], + json!(moved.reference.join("agents/main").to_str().unwrap()) + ); +} + +#[test] +fn delete_removes_the_session_and_tombstones_the_index() { + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + let saved = store.save(&saveable("session_abc", "/repo")).unwrap(); + + store.delete(&saved.reference).unwrap(); + assert!(!saved.reference.exists()); + // Kimi's index is append-only; a removal is a `deleted` record, not an + // edit, so a live Kimi reading the index still converges on "gone". + let index = std::fs::read_to_string(home.path().join("session_index.jsonl")).unwrap(); + let last: serde_json::Value = serde_json::from_str(index.lines().last().unwrap()).unwrap(); + assert_eq!(last["sessionId"], json!("session_abc")); + assert_eq!(last["deleted"], json!(true)); + assert_eq!(store.discover().unwrap().len(), 0); +} + +#[test] +fn save_fills_identity_a_converted_session_lacks() { + // A session converted from another harness has no Kimi state.json, so the + // fields Kimi and `from_text` read the session back from must be supplied. + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + let mut transcript = saveable("session_abc", "/repo"); + transcript.body.state = json!({}); + + let saved = store.save(&transcript).unwrap(); + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(saved.reference.join("state.json")).unwrap()) + .unwrap(); + assert_eq!(state["sessionId"], json!("session_abc")); + assert_eq!(state["workDir"], json!("/repo")); + assert_eq!(state["createdAt"], json!(1_767_323_045_000_i64)); + // And the id survives without the directory, which is what the wasm + // parser and `from_text` depend on. + let text = kimi::Kimi::to_text(&store.load(&saved.reference).unwrap()).unwrap(); + assert_eq!(kimi::Kimi::from_text(&text).unwrap().meta.id, "session_abc"); +} + +/// Kimi accepts sessions written from outside — it reads whatever the index +/// points at — so `continue --with kimi` writes a real session rather than +/// refusing. +#[test] +fn continuing_into_kimi_writes_a_session() { + let home = tempfile::tempdir().unwrap(); + let sessions = home.path().join("sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + + let common = kimi::Kimi::to_common(&saveable("session_abc", "/repo")).unwrap(); + let written = txcript::local::write(txcript::HarnessId::Kimi, &common, Some(&sessions)) + .expect("kimi accepts written sessions"); + assert_eq!(written.id, "session_abc"); + + // Reach the session through discovery rather than the printed location, + // which is a Debug rendering shared by every harness. + let store = kimi::KimiStore::new(&sessions); + let found = store.discover().unwrap(); + assert_eq!(found.len(), 1); + let dir = &found[0].reference; + assert!(dir.join("state.json").is_file()); + assert!(dir.join("agents/main/wire.jsonl").is_file()); + assert!(home.path().join("session_index.jsonl").is_file()); +} + +#[test] +fn saved_state_carries_what_kimi_reads_a_session_by() { + // Kimi renders a session's time from `updatedAt` — without it the CLI + // lists the session at the epoch — and locates each agent's log through + // `agents..homedir`, which only the store knows the absolute path + // for. Both must be written even when the source had neither. + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + let mut transcript = saveable("session_abc", "/repo"); + transcript.body.state = json!({}); + + let saved = store.save(&transcript).unwrap(); + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(saved.reference.join("state.json")).unwrap()) + .unwrap(); + + assert_eq!(state["updatedAt"], json!(1_767_323_045_000_i64)); + assert_eq!( + state["agents"]["main"]["homedir"], + json!(saved.reference.join("agents/main").to_str().unwrap()) + ); +} + +#[test] +fn save_preserves_state_a_native_session_already_had() { + // A real Kimi state.json owns these fields; a save must not overwrite the + // session's own bookkeeping with txcript's idea of it. + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + let mut transcript = saveable("session_abc", "/repo"); + transcript.body.state["updatedAt"] = json!(1_799_999_999_000_i64); + transcript.body.state["isCustomTitle"] = json!(true); + + let saved = store.save(&transcript).unwrap(); + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(saved.reference.join("state.json")).unwrap()) + .unwrap(); + assert_eq!(state["updatedAt"], json!(1_799_999_999_000_i64)); + assert_eq!(state["isCustomTitle"], json!(true)); +} + +#[test] +fn save_refuses_an_id_that_would_escape_the_store() { + let home = kimi_home(); + let store = kimi::KimiStore::new(home.path().join("sessions")); + let error = store + .save(&saveable("../../escape", "/repo")) + .expect_err("a traversing id must be rejected"); + assert!( + error.to_string().contains("not usable as a file name"), + "expected an id-shape rejection, got: {error}" + ); + assert!(!home.path().parent().unwrap().join("escape").exists()); +} + fn write_session(dir: &std::path::Path, body: &kimi::KimiSession) { std::fs::create_dir_all(dir.join("agents/main")).unwrap(); std::fs::write( diff --git a/tests/integration/path_safety.rs b/tests/integration/path_safety.rs index 64b82bc..a3a9726 100644 --- a/tests/integration/path_safety.rs +++ b/tests/integration/path_safety.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use chrono::{TimeZone, Utc}; use txcript::common::{Block, Message, Meta, Role}; use txcript::harness::{ - amp, antigravity, campfire, claude_code, codex, cowork, cursor, fx, grok, grok_bot, pi, + amp, antigravity, campfire, claude_code, codex, cowork, cursor, fx, grok, grok_bot, kimi, pi, }; use txcript::{Codec, Common, Store, Transcript}; @@ -84,6 +84,7 @@ fn hostile_ids_cannot_escape_any_file_backed_store() { assert_save_confined(&cursor::CursorStore::new(root.to_path_buf()), root); assert_save_confined(&grok::GrokStore::new(root.to_path_buf()), root); assert_save_confined(&grok_bot::GrokBotStore::new(root.to_path_buf()), root); + assert_save_confined(&kimi::KimiStore::new(root.to_path_buf()), root); assert_save_confined(&::AmpStore::new(root.to_path_buf()), root); assert_save_confined( &antigravity::AntigravityStore::new(root.to_path_buf()), diff --git a/tests/integration/properties.rs b/tests/integration/properties.rs index 176c2da..711a254 100644 --- a/tests/integration/properties.rs +++ b/tests/integration/properties.rs @@ -19,7 +19,7 @@ use serde_json::json; use txcript::common::{Block, Message, Meta, Role, Tool, ToolOutput}; use txcript::harness::{ amp, antigravity, campfire, claude_code, codex, cowork, cursor, cursor_desktop, fx, grok, - grok_bot, hermes, opencode, pi, simple, + grok_bot, hermes, kimi, opencode, pi, simple, }; use txcript::{Codec, Common, Transcript}; @@ -274,6 +274,7 @@ proptest! { assert_fixpoint::("cursor_desktop", &common)?; assert_fixpoint::("grok", &common)?; assert_fixpoint::("grok_bot", &common)?; + assert_fixpoint::("kimi", &common)?; assert_fixpoint::("fx", &common)?; assert_fixpoint::("hermes", &common)?; assert_fixpoint::("amp", &common)?; diff --git a/tests/integration/store_delete.rs b/tests/integration/store_delete.rs index 7dd918d..f23fc42 100644 --- a/tests/integration/store_delete.rs +++ b/tests/integration/store_delete.rs @@ -4,7 +4,7 @@ use chrono::{TimeZone, Utc}; use txcript::common::{Block, Message, Meta, Role}; -use txcript::harness::{amp, antigravity, campfire, claude_code, codex, grok, grok_bot, pi}; +use txcript::harness::{amp, antigravity, campfire, claude_code, codex, grok, grok_bot, kimi, pi}; use txcript::{Codec, Common, Store, Transcript}; #[cfg(feature = "opencode")] @@ -185,6 +185,20 @@ fn grok_bot_delete_removes_the_session_directory() { assert!(leftovers.is_empty(), "no files left behind: {leftovers:?}"); } +#[test] +fn kimi_delete_removes_the_session_directory() { + // Kimi's index lives one level above `sessions/`, so the store is rooted + // the way Kimi itself lays a data directory out. + let home = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}")); + let sessions = home.path().join("sessions"); + std::fs::create_dir_all(&sessions).unwrap_or_else(|e| panic!("mkdir: {e}")); + let store = kimi::KimiStore::new(sessions.clone()); + roundtrip(&store); + // The workspace directory may remain, but no session files do. + let leftover: Vec<_> = walk_files(&sessions); + assert!(leftover.is_empty(), "no files left behind: {leftover:?}"); +} + #[cfg(feature = "opencode")] #[test] fn cursor_delete_removes_the_whole_session_dir() { From d4fba528275d5cdd602155aea7fc6f112d0fef16 Mon Sep 17 00:00:00 2001 From: zzyking Date: Mon, 7 Sep 2026 20:51:34 +0800 Subject: [PATCH 5/6] test(kimi): compare agent paths segment by segment Windows keeps a forward slash inside a joined path component verbatim, so `join("agents/main")` did not match the `agents\main` the store writes. The store was already building the path correctly; only the assertions were platform-dependent. --- tests/integration/kimi.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/integration/kimi.rs b/tests/integration/kimi.rs index c4d0007..aeba9a8 100644 --- a/tests/integration/kimi.rs +++ b/tests/integration/kimi.rs @@ -318,9 +318,10 @@ fn save_points_the_agent_homedir_at_where_the_session_landed() { let state: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(moved.reference.join("state.json")).unwrap()) .unwrap(); + let agent_dir = moved.reference.join("agents").join("main"); assert_eq!( state["agents"]["main"]["homedir"], - json!(moved.reference.join("agents/main").to_str().unwrap()) + json!(agent_dir.to_str().unwrap()) ); } @@ -405,9 +406,10 @@ fn saved_state_carries_what_kimi_reads_a_session_by() { .unwrap(); assert_eq!(state["updatedAt"], json!(1_767_323_045_000_i64)); + let agent_dir = saved.reference.join("agents").join("main"); assert_eq!( state["agents"]["main"]["homedir"], - json!(saved.reference.join("agents/main").to_str().unwrap()) + json!(agent_dir.to_str().unwrap()) ); } From ea3f2c84cdebd31e0ac16a728c5b23c82b6e95c1 Mon Sep 17 00:00:00 2001 From: zzyking Date: Thu, 15 Oct 2026 14:38:05 +0800 Subject: [PATCH 6/6] fix(kimi): confine delete to a session under the sessions root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `delete` called `remove_dir_all` on whatever path it was handed. A caller passing a directory outside the store, a workspace directory, or a symlink planted inside the root would have it removed recursively. It now requires a `state.json`, resolves the path with `canonicalize`, and refuses anything that isn't exactly `//` — the same guard the grok store applies. Tests cover a session-shaped directory outside the root, a workspace directory, a directory with no `state.json`, and a symlink escaping the root. --- docs/formats/kimi.md | 4 ++- src/harness/kimi.rs | 29 +++++++++++++++-- tests/integration/path_safety.rs | 56 ++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/docs/formats/kimi.md b/docs/formats/kimi.md index 58bd970..cb10e84 100644 --- a/docs/formats/kimi.md +++ b/docs/formats/kimi.md @@ -99,7 +99,9 @@ line, sitting one level above `sessions/`: **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. +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 `//`. Kimi validates each record on read: `sessionDir` must be absolute, must sit inside the sessions directory, and its last path segment must equal diff --git a/src/harness/kimi.rs b/src/harness/kimi.rs index b88f3af..33d7ffa 100644 --- a/src/harness/kimi.rs +++ b/src/harness/kimi.rs @@ -26,7 +26,7 @@ use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::common::{Block, Lineage, Message, Meta, Relation, Role, StopReason, Tool, ToolOutput}; -use crate::error::Result; +use crate::error::{Error, Result}; use crate::harness::jsonl; use crate::transcript::{Codec, Common, Discovered, Harness, Saved, Store, TextCodec, Transcript}; @@ -222,8 +222,31 @@ impl Store for KimiStore { } fn delete(&self, reference: &PathBuf) -> Result<()> { - let id = session_id_from_path(reference); - fs::remove_dir_all(reference)?; + if !reference.join("state.json").is_file() { + return Err(Error::Malformed { + harness: Kimi::NAME, + detail: format!("not a kimi session directory: {}", reference.display()), + }); + } + // Resolve symlinks before checking containment, and require the exact + // `/` depth so neither the root nor a workspace + // directory can be removed wholesale. + let canon = reference.canonicalize()?; + let root = self.sessions_dir.canonicalize()?; + let contained = canon + .strip_prefix(&root) + .is_ok_and(|rest| rest.components().count() == 2); + if !contained { + return Err(Error::Malformed { + harness: Kimi::NAME, + detail: format!( + "refusing to delete outside the sessions root: {}", + reference.display() + ), + }); + } + let id = session_id_from_path(&canon); + fs::remove_dir_all(canon)?; // The index is append-only, so a removal is a tombstone record rather // than a rewrite — matching how Kimi itself retires a session. self.append_index(&json!({"sessionId": id, "deleted": true})) diff --git a/tests/integration/path_safety.rs b/tests/integration/path_safety.rs index a3a9726..92c90a2 100644 --- a/tests/integration/path_safety.rs +++ b/tests/integration/path_safety.rs @@ -175,6 +175,62 @@ fn grok_delete_refuses_a_symlink_into_the_sessions_root() { ); } +#[test] +fn kimi_delete_refuses_paths_outside_the_sessions_root() { + let home = tempfile::tempdir().unwrap(); + let sessions = home.path().join("sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + let victim = tempfile::tempdir().unwrap(); + // Give the victim a session's shape: the guard must hold even when the + // directory looks like a real session. + std::fs::write(victim.path().join("state.json"), b"{}").unwrap(); + + let store = kimi::KimiStore::new(sessions.clone()); + assert!( + store.delete(&victim.path().to_path_buf()).is_err(), + "a session-shaped directory outside the root must be refused" + ); + assert!(victim.path().is_dir(), "the foreign directory must survive"); + + // A workspace directory holds sessions but isn't one itself. + let workspace = sessions.join("wd_proj_0123456789ab"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write(workspace.join("state.json"), b"{}").unwrap(); + assert!(store.delete(&workspace).is_err()); + assert!(workspace.is_dir()); + + // A directory with no state.json isn't a session at all. + let bare = workspace.join("session_bare"); + std::fs::create_dir_all(&bare).unwrap(); + assert!(store.delete(&bare).is_err()); + assert!(bare.is_dir()); +} + +#[cfg(unix)] +#[test] +fn kimi_delete_refuses_a_symlink_into_the_sessions_root() { + let home = tempfile::tempdir().unwrap(); + let sessions = home.path().join("sessions"); + let victim = tempfile::tempdir().unwrap(); + std::fs::write(victim.path().join("state.json"), b"{}").unwrap(); + + // Plant a link at the exact depth delete expects a session at. + let workspace = sessions.join("wd_proj_0123456789ab"); + std::fs::create_dir_all(&workspace).unwrap(); + let link = workspace.join("session_linked"); + std::os::unix::fs::symlink(victim.path(), &link).unwrap(); + + let store = kimi::KimiStore::new(sessions); + assert!( + store.delete(&link).is_err(), + "a symlink resolving outside the root must be refused" + ); + assert!( + victim.path().join("state.json").is_file(), + "the symlink target must survive" + ); +} + #[test] fn antigravity_delete_refuses_paths_outside_the_conversations_root() { let root = tempfile::tempdir().unwrap();