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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ CLI tool to read, display, export, and migrate conversation sessions across AI c
- Codex: `~/.codex/sessions/<year>/<month>/<day>/rollout-<date>-<uuid>.jsonl`
- Cursor (store.db): `~/.cursor/chats/<md5-hash>/<session-id>/store.db`
- Cursor (transcripts): `~/.cursor/projects/<encoded-workspace>/agent-transcripts/<session-id>.jsonl`
- Kimi Code: `~/.kimi-code/sessions/wd_<basename>_<sha256(root)[:12]>/session_<id>/agents/main/wire.jsonl` (+ `state.json`; global `session_index.jsonl` / `workspaces.json`)

## Testing

Expand All @@ -50,3 +51,4 @@ CLI tool to read, display, export, and migrate conversation sessions across AI c
- Cursor `--resume` does not render past messages in TUI, but context is preserved
- Codex records assistant responses in both `event_msg(agent_message)` and `response_item`. Both must be written for resume to restore responses
- Dedup uses `originId + originSource + originDigest` (SHA-256 of conversation history). Re-migration is allowed when the source session has been updated
- Kimi Code stores user turns as `turn.prompt` + `context.append_message` with `origin.kind=user`; assistant turns exist ONLY as `context.append_loop_event` (`content.part` type `text`), grouped per turn. `role=user` append_messages with other `origin.kind` (injection/background_task/skill_activation) are NOT user turns. The TUI renders user prompts from `context.append_message` (not `turn.prompt`) and hides only `origin.kind=injection` (plus 3 goal-related `system_trigger` names); any other origin renders as a visible user prompt. So injected source noise (claude `<task-notification>`/`[Subagent]`, command blocks, system reminders — classified via `MessageFilter.isNoise`) must be written as `origin.kind=injection` append_messages with NO `turn.prompt`, and excluded from `state.json` title/lastPrompt. Resume: `kimi --session <id>`. A minimal wire (`metadata` + the user/assistant events above) is sufficient for resume — `config.update`(systemPrompt) and `tools.set_active_tools` can be omitted; kimi injects its own on resume (verified). The `ctxmv_migration` dedup marker lives in `state.json`'s `custom` field (never a wire line), and dedup scans `session_index.jsonl` → each `state.json`.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ let package = Package(
.package(url: "https://github.com/onevcat/Rainbow.git", from: "4.0.1"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"),
.package(url: "https://github.com/apple/swift-async-algorithms.git", from: "1.1.3"),
.package(url: "https://github.com/Ryu0118/AgentSessions.git", from: "0.4.1"),
.package(url: "https://github.com/Ryu0118/AgentSessions.git", from: "0.5.0"),
],
targets: [
.executableTarget(
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
- Claude Code
- Codex
- Cursor (CLI agent via `cursor-agent`, not the GUI app)
- Kimi Code (`kimi` CLI)

## Install

Expand Down Expand Up @@ -75,6 +76,9 @@ ctxmv <session-id> --to claude-code

# Any → Cursor
ctxmv <session-id> --to cursor

# Any → Kimi Code
ctxmv <session-id> --to kimi-code
```

After migration, the tool prints the resume command:
Expand Down
4 changes: 2 additions & 2 deletions Sources/CTXMVCLI/MigrateCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ struct MigrateCommand: AsyncParsableCommand {
@Argument(help: "Session ID to migrate")
var sessionID: String

@Option(name: .customLong("to"), help: "Target agent: claude-code, codex, or cursor")
@Option(name: .customLong("to"), help: "Target agent: claude-code, codex, cursor, or kimi-code")
var target: AgentSource

@Option(name: .customLong("from"), help: "Source agent: claude-code, codex, or cursor")
@Option(name: .customLong("from"), help: "Source agent: claude-code, codex, cursor, or kimi-code")
var source: AgentSource?

@OptionGroup var options: GlobalOptions
Expand Down
153 changes: 153 additions & 0 deletions Sources/CTXMVKit/Migrators/KimiCodeMigrator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import Foundation

/// Writes unified conversations into kimi-code's session store so `kimi --session <id>` resumes them.
struct KimiCodeMigrator: SessionMigrator {
let target: AgentSource = .kimiCode

private enum Constants {
static let kimiDir = ".kimi-code"
static let sessionsDir = "sessions"
static let indexFile = "session_index.jsonl"
static let workspacesFile = "workspaces.json"
static let sessionPrefix = "session_"
static let wireFile = "wire.jsonl"
static let stateFile = "state.json"
}

private let fileSystem: any FileSystemProtocol
private let builder: KimiCodeWireBuilder
private let workingDirectoryProvider: @Sendable () -> String

init(
fileSystem: any FileSystemProtocol = DefaultFileSystem(),
builder: KimiCodeWireBuilder = KimiCodeWireBuilder(),
workingDirectoryProvider: @escaping @Sendable () -> String = { FileManager.default.currentDirectoryPath }
) {
self.fileSystem = fileSystem
self.builder = builder
self.workingDirectoryProvider = workingDirectoryProvider
}

func migrate(_ conversation: UnifiedConversation) throws -> MigrationResult {
guard !conversation.messages.isEmpty else { throw MigrationError.sessionEmpty }

let root = conversation.projectPath ?? workingDirectoryProvider()
let origin = MigrationOrigin(
originId: conversation.id,
originSource: conversation.source,
originMessageCount: conversation.messages.count,
originDigest: MigrationDeduplicator.originDigest(for: conversation)
)
if let existing = findExistingMigration(origin: origin) {
throw MigrationError.alreadyMigrated(existingPath: existing)
}

let sessionId = Constants.sessionPrefix + UUID().uuidString.lowercased()
let workspaceId = KimiCodeWorkspace.workspaceId(forRoot: root)
let sessionDir = sessionsBase()
.appendingPathComponent(workspaceId, isDirectory: true)
.appendingPathComponent(sessionId, isDirectory: true)
let mainDir = sessionDir.appendingPathComponent(KimiCodeWireBuilder.mainAgentPath, isDirectory: true)

// Compute the upsert first: a corrupt registry fails closed and must abort
// before any session file exists.
let updatedWorkspaces = try makeWorkspacesUpsert(workspaceId: workspaceId, root: root)

let doc = builder.makeDocument(
conversation: conversation,
sessionId: sessionId,
sessionDirPath: sessionDir.path,
workDir: root
)

try fileSystem.createDirectory(at: mainDir, withIntermediateDirectories: true, attributes: nil)
try write(doc.stateJSON, to: sessionDir.appendingPathComponent(Constants.stateFile))
try write(doc.wireJSONL, to: mainDir.appendingPathComponent(Constants.wireFile))
// The index append is the commit point: dedup and kimi's listing are index-driven,
// so an earlier failure leaves only an invisible unindexed orphan.
try writeData(updatedWorkspaces, to: kimiBase().appendingPathComponent(Constants.workspacesFile))
try appendIndexLine(sessionId: sessionId, sessionDir: sessionDir, root: root)

logger.info("💾 Wrote kimi-code session messages=\(conversation.messages.count) path=\(sessionDir.path)")
return .written(path: sessionDir.path, sessionID: sessionId)
}

private func kimiBase() -> URL {
fileSystem.homeDirectoryForCurrentUser.appendingPathComponent(Constants.kimiDir)
}

private func sessionsBase() -> URL {
kimiBase().appendingPathComponent(Constants.sessionsDir)
}

private func write(_ contents: String, to file: URL) throws {
guard let data = contents.data(using: .utf8) else {
throw MigrationError.writeFailed("Failed to encode \(file.lastPathComponent) as UTF-8")
}
try writeData(data, to: file)
}

private func writeData(_ data: Data, to file: URL) throws {
guard fileSystem.createFile(atPath: file.path, contents: data, attributes: nil) else {
throw MigrationError.writeFailed("Failed to write \(file.lastPathComponent)")
}
}

private func makeWorkspacesUpsert(workspaceId: String, root: String) throws -> Data {
let workspacesFile = kimiBase().appendingPathComponent(Constants.workspacesFile)
let timestamp = MigratorUtils.isoFormatter.string(from: Date())
return try KimiCodeWorkspace.upsertWorkspaces(
existing: fileSystem.contents(atPath: workspacesFile.path),
workspaceId: workspaceId,
root: root,
name: URL(fileURLWithPath: root).lastPathComponent,
timestamp: timestamp
)
}

private func appendIndexLine(sessionId: String, sessionDir: URL, root: String) throws {
let indexFile = kimiBase().appendingPathComponent(Constants.indexFile)
guard let line = KimiCodeWorkspace.indexLine(
sessionId: sessionId, sessionDir: sessionDir.path, workDir: root
) else {
throw MigrationError.writeFailed("Failed to encode session index entry")
}
var indexText = fileSystem.contents(atPath: indexFile.path).flatMap { String(data: $0, encoding: .utf8) } ?? ""
if !indexText.isEmpty, !indexText.hasSuffix("\n") { indexText += "\n" }
indexText += line + "\n"
try write(indexText, to: indexFile)
}

/// Index-driven (not directory-walking) so it resolves known paths and behaves identically
/// against the real FS and the test mock.
private func findExistingMigration(origin: MigrationOrigin) -> String? {
let indexFile = kimiBase().appendingPathComponent(Constants.indexFile)
guard let data = fileSystem.contents(atPath: indexFile.path),
let text = String(data: data, encoding: .utf8) else { return nil }

for line in text.split(separator: "\n") {
guard let entryData = line.data(using: .utf8),
let entry = try? JSONDecoder().decode(KimiCodeWorkspace.IndexEntry.self, from: entryData)
else { continue }
let stateFile = URL(fileURLWithPath: entry.sessionDir).appendingPathComponent(Constants.stateFile)
guard let stateData = fileSystem.contents(atPath: stateFile.path),
let meta = decodeMeta(from: stateData) else { continue }
if MigrationDeduplicator.matches(meta, origin) {
return entry.sessionDir
}
}
return nil
}

private func decodeMeta(from data: Data) -> MigrationMeta? {
struct Envelope: Decodable {
struct Custom: Decodable {
let ctxmvMigration: MigrationMeta?
enum CodingKeys: String, CodingKey { case ctxmvMigration = "ctxmv_migration" }
}

let custom: Custom?
}
return (try? JSONDecoder().decode(Envelope.self, from: data))?.custom?.ctxmvMigration
}
}
Loading
Loading