diff --git a/CLAUDE.md b/CLAUDE.md index dcc28ec..2354946 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ CLI tool to read, display, export, and migrate conversation sessions across AI c - Codex: `~/.codex/sessions////rollout--.jsonl` - Cursor (store.db): `~/.cursor/chats///store.db` - Cursor (transcripts): `~/.cursor/projects//agent-transcripts/.jsonl` +- Kimi Code: `~/.kimi-code/sessions/wd__/session_/agents/main/wire.jsonl` (+ `state.json`; global `session_index.jsonl` / `workspaces.json`) ## Testing @@ -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 ``/`[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 `. 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`. diff --git a/Package.swift b/Package.swift index c718b8a..15ede6a 100644 --- a/Package.swift +++ b/Package.swift @@ -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( diff --git a/README.md b/README.md index 916173d..5df6351 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - Claude Code - Codex - Cursor (CLI agent via `cursor-agent`, not the GUI app) +- Kimi Code (`kimi` CLI) ## Install @@ -75,6 +76,9 @@ ctxmv --to claude-code # Any → Cursor ctxmv --to cursor + +# Any → Kimi Code +ctxmv --to kimi-code ``` After migration, the tool prints the resume command: diff --git a/Sources/CTXMVCLI/MigrateCommand.swift b/Sources/CTXMVCLI/MigrateCommand.swift index 9db39c3..ec9c1b1 100644 --- a/Sources/CTXMVCLI/MigrateCommand.swift +++ b/Sources/CTXMVCLI/MigrateCommand.swift @@ -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 diff --git a/Sources/CTXMVKit/Migrators/KimiCodeMigrator.swift b/Sources/CTXMVKit/Migrators/KimiCodeMigrator.swift new file mode 100644 index 0000000..84ec752 --- /dev/null +++ b/Sources/CTXMVKit/Migrators/KimiCodeMigrator.swift @@ -0,0 +1,153 @@ +import Foundation + +/// Writes unified conversations into kimi-code's session store so `kimi --session ` 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 + } +} diff --git a/Sources/CTXMVKit/Migrators/KimiCodeWireBuilder.swift b/Sources/CTXMVKit/Migrators/KimiCodeWireBuilder.swift new file mode 100644 index 0000000..174ba9a --- /dev/null +++ b/Sources/CTXMVKit/Migrators/KimiCodeWireBuilder.swift @@ -0,0 +1,292 @@ +import Foundation + +struct KimiSessionDocument { + let stateJSON: String + let wireJSONL: String +} + +private enum WireType: String { + case metadata + case turnPrompt = "turn.prompt" + case appendMessage = "context.append_message" + case appendLoopEvent = "context.append_loop_event" +} + +private enum LoopType: String { + case stepBegin = "step.begin" + case contentPart = "content.part" +} + +private enum PartType: String { case text } + +private enum WireFormat { + static let protocolVersion = "1.4" + static let mainAgent = "main" +} + +/// Builds kimi-code `state.json` + `agents/main/wire.jsonl` from a unified conversation. +/// Pure transformation — no I/O — so it can be unit-tested without a file system. +struct KimiCodeWireBuilder { + /// `agents/main`, single-sourced: the migrator creates this dir, this builder writes it into `state.json`. + static let mainAgentPath = "agents/" + WireFormat.mainAgent + + private enum Constants { + static let titleMaxLength = 80 + static let defaultTitle = "Migrated session" + } + + private enum OriginKind: String { case user, injection } + + func makeDocument( + conversation: UnifiedConversation, + sessionId: String, + sessionDirPath: String, + workDir: String + ) -> KimiSessionDocument { + let createdMs = MigratorUtils.epochMillis(from: conversation.createdAt) + let origin = MigrationOrigin( + originId: conversation.id, + originSource: conversation.source, + originMessageCount: conversation.messages.count, + originDigest: MigrationDeduplicator.originDigest(for: conversation) + ) + let meta = MigrationDeduplicator.makeMeta(origin: origin) + + let wireJSONL = buildWire(conversation: conversation, createdMs: createdMs) + let stateJSON = buildState( + conversation: conversation, + sessionDirPath: sessionDirPath, + workDir: workDir, + meta: meta + ) + return KimiSessionDocument(stateJSON: stateJSON, wireJSONL: wireJSONL) + } + + private func buildWire(conversation: UnifiedConversation, createdMs: Int) -> String { + var lines: [String] = [] + lines.appendIfEncodable(MetadataEvent(createdAt: createdMs)) + + var turnCounter = -1 + // Native kimi increments `step` within a turn; reset on each new user turn. + var stepCounter = 0 + for message in conversation.messages { + let epochMs = MigratorUtils.epochMillis(from: message.timestamp ?? conversation.createdAt) + let body = message.decodedContent(for: conversation.source) + switch message.role { + case .user where MessageFilter.isNoise(body): + // kimi's TUI hides `injection` origins but keeps them in model context; + // written as a user turn, injected noise renders as a visible user prompt. + lines.appendIfEncodable(makeAppendMessage(body: body, originKind: .injection, epochMs: epochMs)) + case .user: + turnCounter += 1 + stepCounter = 0 + lines.appendIfEncodable(TurnPromptEvent( + input: [TextPart(text: body)], + origin: Origin(kind: OriginKind.user.rawValue), + time: epochMs + )) + lines.appendIfEncodable(makeAppendMessage(body: body, originKind: .user, epochMs: epochMs)) + case .assistant: + stepCounter += 1 + lines.append(contentsOf: makeAssistantEvents( + body: body, + turnId: String(max(turnCounter, 0)), + step: stepCounter, + epochMs: epochMs + )) + case .system, .tool: + continue + } + } + return lines.joined(separator: "\n") + "\n" + } + + private func makeAppendMessage(body: String, originKind: OriginKind, epochMs: Int) -> AppendMessageEvent { + AppendMessageEvent( + message: AppendedMessage( + role: MessageRole.user.rawValue, + content: [TextPart(text: body)], + toolCalls: [], + origin: Origin(kind: originKind.rawValue) + ), + time: epochMs + ) + } + + /// One assistant reply = `step.begin` + `content.part` sharing a `stepUuid`, per native wires. + /// Consecutive assistant messages share a turn; kimi's reader merges them on re-read + /// (kimi→kimi re-migration changes the digest). + private func makeAssistantEvents(body: String, turnId: String, step: Int, epochMs: Int) -> [String] { + let stepUuid = UUID().uuidString.lowercased() + return [ + LoopEvent( + event: .stepBegin(StepBegin(uuid: stepUuid, turnId: turnId, step: step)), + time: epochMs + ), + LoopEvent( + event: .contentPart(ContentPart( + uuid: UUID().uuidString.lowercased(), + turnId: turnId, + step: step, + stepUuid: stepUuid, + part: Part(text: body) + )), + time: epochMs + ), + ].compactMap(MigratorUtils.encodeLine) + } + + private func buildState( + conversation: UnifiedConversation, + sessionDirPath: String, + workDir: String, + meta: MigrationMeta + ) -> String { + let created = MigratorUtils.isoFormatter.string(from: conversation.createdAt) + // Empty bodies and injected noise (same classification as the wire) must not become the title. + let prompts = conversation.messages + .filter { $0.role == .user } + .map { $0.decodedContent(for: conversation.source) } + .filter { !$0.isEmpty && !MessageFilter.isNoise($0) } + let homedir = sessionDirPath + "/" + Self.mainAgentPath + + let state = StateFile( + createdAt: created, + updatedAt: created, + title: (prompts.first?.truncated(to: Constants.titleMaxLength)) ?? Constants.defaultTitle, + isCustomTitle: false, + lastPrompt: prompts.last ?? "", + agents: [WireFormat.mainAgent: AgentEntry(homedir: homedir)], + custom: CustomMeta(ctxmvMigration: meta), + workDir: workDir + ) + return MigratorUtils.encodeLine(state) ?? "{}" + } +} + +private extension [String] { + mutating func appendIfEncodable(_ value: some Encodable) { + if let line = MigratorUtils.encodeLine(value) { append(line) } + } +} + +private struct MetadataEvent: Encodable { + let type: String = WireType.metadata.rawValue + let protocolVersion: String = WireFormat.protocolVersion + let createdAt: Int + + enum CodingKeys: String, CodingKey { + case type + case protocolVersion = "protocol_version" + case createdAt = "created_at" + } +} + +private struct TextPart: Encodable { + let type: String = PartType.text.rawValue + let text: String +} + +private struct Origin: Encodable { + let kind: String +} + +private struct TurnPromptEvent: Encodable { + let type: String = WireType.turnPrompt.rawValue + let input: [TextPart] + let origin: Origin + let time: Int +} + +private struct AppendedMessage: Encodable { + let role: String + let content: [TextPart] + let toolCalls: [String] + let origin: Origin +} + +private struct AppendMessageEvent: Encodable { + let type: String = WireType.appendMessage.rawValue + let message: AppendedMessage + let time: Int +} + +private struct StepBegin: Encodable { + let type: String = LoopType.stepBegin.rawValue + let uuid: String + let turnId: String + let step: Int +} + +private struct Part: Encodable { + let type: String = PartType.text.rawValue + let text: String +} + +private struct ContentPart: Encodable { + let type: String = LoopType.contentPart.rawValue + let uuid: String + let turnId: String + let step: Int + let stepUuid: String + let part: Part +} + +/// Wraps a `step.begin` or `content.part` under the `event` key; custom encoding flattens the payload enum. +private struct LoopEvent: Encodable { + enum Payload { + case stepBegin(StepBegin) + case contentPart(ContentPart) + } + + let type: String = WireType.appendLoopEvent.rawValue + let event: Payload + let time: Int + + enum CodingKeys: String, CodingKey { case type, event, time } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encode(time, forKey: .time) + switch event { + case let .stepBegin(value): try container.encode(value, forKey: .event) + case let .contentPart(value): try container.encode(value, forKey: .event) + } + } +} + +private struct AgentEntry: Encodable { + let homedir: String + let type: String = WireFormat.mainAgent + let parentAgentId: String? = nil + + enum CodingKeys: String, CodingKey { case homedir, type, parentAgentId } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(homedir, forKey: .homedir) + try container.encode(type, forKey: .type) + // Native kimi writes an explicit null here; synthesized encoding would omit the key. + try container.encode(parentAgentId, forKey: .parentAgentId) + } +} + +private struct CustomMeta: Encodable { + let ctxmvMigration: MigrationMeta + + enum CodingKeys: String, CodingKey { + case ctxmvMigration = "ctxmv_migration" + } +} + +private struct StateFile: Encodable { + let createdAt: String + let updatedAt: String + let title: String + let isCustomTitle: Bool + let lastPrompt: String + let agents: [String: AgentEntry] + let custom: CustomMeta + let workDir: String +} diff --git a/Sources/CTXMVKit/Migrators/KimiCodeWorkspace.swift b/Sources/CTXMVKit/Migrators/KimiCodeWorkspace.swift new file mode 100644 index 0000000..0b57856 --- /dev/null +++ b/Sources/CTXMVKit/Migrators/KimiCodeWorkspace.swift @@ -0,0 +1,71 @@ +#if canImport(CryptoKit) + import CryptoKit +#else + import Crypto +#endif +import Foundation + +/// Helpers for kimi-code's workspace directory naming. +enum KimiCodeWorkspace { + private enum Constants { + static let workspacePrefix = "wd_" + static let hashPrefixLength = 12 + } + + /// kimi-code names each workspace `wd__`. + static func workspaceId(forRoot root: String) -> String { + let basename = URL(fileURLWithPath: root).lastPathComponent + let digest = SHA256.hash(data: Data(root.utf8)) + let hex = MigratorUtils.hexString(Data(digest)) + return "\(Constants.workspacePrefix)\(basename)_\(hex.prefix(Constants.hashPrefixLength))" + } +} + +extension KimiCodeWorkspace { + /// Shared by `indexLine` (encode) and the migrator's dedup scan (decode) so the two can't drift. + struct IndexEntry: Codable { + let sessionId: String + let sessionDir: String + let workDir: String + } + + static func indexLine(sessionId: String, sessionDir: String, workDir: String) -> String? { + MigratorUtils.encodeLine(IndexEntry(sessionId: sessionId, sessionDir: sessionDir, workDir: workDir)) + } + + /// Upserts the `wd_…` entry into `workspaces.json`, preserving `version`/existing workspaces/ + /// `deleted_workspace_ids`. Throws `MigrationError.writeFailed` on unparseable input (fail closed). + static func upsertWorkspaces( + existing: Data?, + workspaceId: String, + root: String, + name: String, + timestamp: String + ) throws -> Data { + var object: [String: Any] + if let existing { + guard let parsed = (try? JSONSerialization.jsonObject(with: existing)) as? [String: Any] else { + throw MigrationError.writeFailed("workspaces.json is not valid JSON; refusing to overwrite") + } + object = parsed + } else { + object = ["version": 1, "workspaces": [String: Any](), "deleted_workspace_ids": [String]()] + } + + var workspaces = object["workspaces"] as? [String: Any] ?? [:] + if var entry = workspaces[workspaceId] as? [String: Any] { + entry["last_opened_at"] = timestamp + workspaces[workspaceId] = entry + } else { + workspaces[workspaceId] = [ + "root": root, + "name": name, + "created_at": timestamp, + "last_opened_at": timestamp, + ] + } + object["workspaces"] = workspaces + + return try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + } +} diff --git a/Sources/CTXMVKit/Migrators/MigrationDeduplicator.swift b/Sources/CTXMVKit/Migrators/MigrationDeduplicator.swift index 17aff90..b0edb6e 100644 --- a/Sources/CTXMVKit/Migrators/MigrationDeduplicator.swift +++ b/Sources/CTXMVKit/Migrators/MigrationDeduplicator.swift @@ -54,6 +54,16 @@ private struct ClaudeProgressMetaLine: Codable { enum MigrationDeduplicator { private static let decoder = JSONDecoder() + /// Prefer the full-history digest; for legacy markers with no digest, fall back to message-count equality. + static func matches(_ meta: MigrationMeta, _ origin: MigrationOrigin) -> Bool { + guard meta.originId == origin.originId, + meta.originSource == origin.originSource.rawValue else { return false } + if let metaDigest = meta.originDigest { + return metaDigest == origin.originDigest + } + return meta.originMessageCount == origin.originMessageCount + } + static func makeMeta(origin: MigrationOrigin) -> MigrationMeta { MigrationMeta( type: MigrationMeta.migrationType, @@ -111,18 +121,7 @@ enum MigrationDeduplicator { fileSystem: fileSystem, allowBareMetaLine: allowBareMetaLine ) else { continue } - guard meta.originId == origin.originId, meta.originSource == origin.originSource.rawValue else { continue } - - // Strict dedup: prefer digest (full history signature); fall back to message - // count equality for legacy files that have no stored digest. - if meta.originDigest == origin.originDigest { - return file.path - } - if meta.originDigest != nil { - continue - } - - if meta.originMessageCount == origin.originMessageCount { + if matches(meta, origin) { return file.path } } diff --git a/Sources/CTXMVKit/Migrators/MigratorUtils.swift b/Sources/CTXMVKit/Migrators/MigratorUtils.swift index 18117b7..8db9923 100644 --- a/Sources/CTXMVKit/Migrators/MigratorUtils.swift +++ b/Sources/CTXMVKit/Migrators/MigratorUtils.swift @@ -18,6 +18,12 @@ enum MigratorUtils { data.map { String(format: "%02x", $0) }.joined() } + private static let millisPerSecond = 1000.0 + + static func epochMillis(from date: Date) -> Int { + Int((date.timeIntervalSince1970 * millisPerSecond).rounded()) + } + static func encodeLine(_ value: some Encodable) -> String? { guard let data = try? jsonEncoder.encode(value), let encodedLine = String(data: data, encoding: .utf8) else { return nil } diff --git a/Sources/CTXMVKit/Runners/ListRunner.swift b/Sources/CTXMVKit/Runners/ListRunner.swift index a86f583..f6807b3 100644 --- a/Sources/CTXMVKit/Runners/ListRunner.swift +++ b/Sources/CTXMVKit/Runners/ListRunner.swift @@ -152,6 +152,7 @@ package struct ListRunner { case .claudeCode: .cyan case .codex: .green case .cursor: .magenta + case .kimiCode: .blue } } } diff --git a/Sources/CTXMVKit/Runners/MigrateRunner.swift b/Sources/CTXMVKit/Runners/MigrateRunner.swift index b142466..9e6f4f0 100644 --- a/Sources/CTXMVKit/Runners/MigrateRunner.swift +++ b/Sources/CTXMVKit/Runners/MigrateRunner.swift @@ -88,9 +88,19 @@ package struct MigrateRunner { case .claudeCode: ClaudeCodeMigrator(logicalCwd: ProcessInfo.processInfo.environment["PWD"]) case .codex: CodexMigrator() case .cursor: CursorMigrator() + // Same `PWD` rationale as above: kimi's workspace id hashes the root path. + case .kimiCode: KimiCodeMigrator( + fileSystem: fileSystem, + workingDirectoryProvider: Self.logicalWorkingDirectory + ) } } + /// The shell's logical cwd (symlinks preserved), falling back to the physical one. + private static func logicalWorkingDirectory() -> String { + ProcessInfo.processInfo.environment["PWD"] ?? FileManager.default.currentDirectoryPath + } + /// Prints the exact resume command, reusing the existing session path when migration was skipped as a duplicate. private func printResumeHint(path: String, sessionID: String, projectPath: String?, alreadyMigrated: Bool) { let resumeCommand = resumeCommand(forSessionID: sessionID) @@ -102,7 +112,7 @@ package struct MigrateRunner { writtenJSONLPath: path, fileSystem: fileSystem ) - case .codex, .cursor: + case .codex, .cursor, .kimiCode: resolvedProjectPath } let cwdLine = cwdForHint.map { " cd \($0)\n" } ?? "" @@ -141,6 +151,7 @@ package struct MigrateRunner { case .claudeCode: "claude --resume \(sessionID)" case .codex: "codex resume \(sessionID)" case .cursor: "cursor-agent --resume \(sessionID)" + case .kimiCode: "kimi --session \(sessionID)" } } @@ -158,6 +169,9 @@ package struct MigrateRunner { return fileName == "store" ? URL(filePath: path).deletingLastPathComponent().lastPathComponent : fileName + case .kimiCode: + // kimi returns the session directory; its last component is the resumable id. + return fileName } } } diff --git a/Tests/CTXMVKitTests/KimiCodeMigrateRunnerTests.swift b/Tests/CTXMVKitTests/KimiCodeMigrateRunnerTests.swift new file mode 100644 index 0000000..c7f0d8a --- /dev/null +++ b/Tests/CTXMVKitTests/KimiCodeMigrateRunnerTests.swift @@ -0,0 +1,34 @@ +@testable import CTXMVKit +import Foundation +import Testing + +struct KimiCodeMigrateRunnerTests { + private struct StubReader: SessionReader { + let source: AgentSource + let conversation: UnifiedConversation + func listSessions() async throws -> [SessionSummary] { + [] + } + + func loadSession(id: String, storagePath: String?, limit: Int?) async throws -> UnifiedConversation? { + id == conversation.id ? conversation : nil + } + } + + @Test("MigrateRunner --to kimi-code selects the kimi migrator and writes a session") + func runnerMigratesToKimi() async throws { + let mockFS = MockFileManager() + mockFS.homeDirectoryForCurrentUser = URL(fileURLWithPath: "/Users/tester") + let convo = TestFixtures.makeConversation(id: "run-1", source: .claudeCode, projectPath: "/proj") + let runner = MigrateRunner( + sessionID: "run-1", + target: .kimiCode, + source: .claudeCode, + readers: [StubReader(source: .claudeCode, conversation: convo)], + fileSystem: mockFS + ) + try await runner.run() + + #expect(mockFS.files.keys.contains { $0.contains("/.kimi-code/sessions/") && $0.hasSuffix("wire.jsonl") }) + } +} diff --git a/Tests/CTXMVKitTests/KimiCodeMigratorTests.swift b/Tests/CTXMVKitTests/KimiCodeMigratorTests.swift new file mode 100644 index 0000000..c05fdd1 --- /dev/null +++ b/Tests/CTXMVKitTests/KimiCodeMigratorTests.swift @@ -0,0 +1,154 @@ +@testable import CTXMVKit +import Foundation +import Testing + +struct KimiCodeMigratorTests { + private func makeMigrator(_ mockFS: MockFileManager) -> KimiCodeMigrator { + KimiCodeMigrator(fileSystem: mockFS) + } + + @Test("migrate writes state.json, wire.jsonl, and appends the session index") + func migrateWritesFiles() throws { + let mockFS = MockFileManager() + mockFS.homeDirectoryForCurrentUser = URL(fileURLWithPath: "/Users/tester") + let convo = TestFixtures.makeConversation(id: "src-1", source: .claudeCode, projectPath: "/proj") + + let result = try makeMigrator(mockFS).migrate(convo) + guard case let .written(path, sessionID) = result else { + Issue.record("expected .written"); return + } + #expect(sessionID.hasPrefix("session_")) + #expect(path.hasSuffix(sessionID)) + + #expect(mockFS.files[path + "/state.json"] != nil) + #expect(mockFS.files[path + "/agents/main/wire.jsonl"] != nil) + + let indexPath = "/Users/tester/.kimi-code/session_index.jsonl" + let index = try #require(mockFS.files[indexPath]).flatMap { String(data: $0, encoding: .utf8) } + #expect(index?.contains(sessionID) == true) + + let wsPath = "/Users/tester/.kimi-code/workspaces.json" + let wsData = try #require(mockFS.files[wsPath]) + let wsObject = try #require((try? JSONSerialization.jsonObject(with: wsData)) as? [String: Any]) + let workspaces = try #require(wsObject["workspaces"] as? [String: Any]) + #expect(workspaces.keys.contains { $0.hasPrefix("wd_proj_") }) + } + + @Test("re-migrating the same conversation is blocked as already migrated") + func migrateIsIdempotent() throws { + let mockFS = MockFileManager() + mockFS.homeDirectoryForCurrentUser = URL(fileURLWithPath: "/Users/tester") + let convo = TestFixtures.makeConversation(id: "src-dup", source: .codex, projectPath: "/proj") + + let first = try makeMigrator(mockFS).migrate(convo) + guard case let .written(firstPath, _) = first else { Issue.record("expected .written"); return } + + #expect(throws: MigrationError.self) { + try makeMigrator(mockFS).migrate(convo) + } + do { + _ = try makeMigrator(mockFS).migrate(convo) + } catch let MigrationError.alreadyMigrated(existingPath) { + #expect(existingPath == firstPath) + } + } + + @Test("re-migrating an updated source (different content/digest) is allowed and writes a second session") + func migrateAllowsUpdatedSource() throws { + let mockFS = MockFileManager() + mockFS.homeDirectoryForCurrentUser = URL(fileURLWithPath: "/Users/tester") + let migrator = makeMigrator(mockFS) + + let original = TestFixtures.makeConversation( + id: "src-updated", + source: .codex, + projectPath: "/proj", + messages: [ + UnifiedMessage(role: .user, content: "Question one", timestamp: TestFixtures.sampleDate), + UnifiedMessage(role: .assistant, content: "Answer one", timestamp: TestFixtures.sampleDate), + ] + ) + let updated = TestFixtures.makeConversation( + id: "src-updated", + source: .codex, + projectPath: "/proj", + messages: [ + UnifiedMessage(role: .user, content: "Question one", timestamp: TestFixtures.sampleDate), + UnifiedMessage(role: .assistant, content: "Answer one", timestamp: TestFixtures.sampleDate), + UnifiedMessage(role: .user, content: "Follow-up question", timestamp: TestFixtures.sampleDate), + UnifiedMessage(role: .assistant, content: "Follow-up answer", timestamp: TestFixtures.sampleDate), + ] + ) + + guard case let .written(firstPath, firstID) = try migrator.migrate(original) else { + Issue.record("expected .written for the original conversation"); return + } + guard case let .written(secondPath, secondID) = try migrator.migrate(updated) else { + Issue.record("expected .written for the updated conversation"); return + } + + #expect(firstPath != secondPath) + #expect(firstID != secondID) + #expect(mockFS.files[firstPath + "/state.json"] != nil) + #expect(mockFS.files[secondPath + "/state.json"] != nil) + } + + @Test("empty conversation is rejected") + func migrateRejectsEmpty() { + let mockFS = MockFileManager() + mockFS.homeDirectoryForCurrentUser = URL(fileURLWithPath: "/Users/tester") + let convo = TestFixtures.makeConversation(id: "empty", messages: []) + #expect(throws: MigrationError.self) { try makeMigrator(mockFS).migrate(convo) } + } + + @Test("corrupt workspaces.json fails closed before any file is written") + func migrateFailsClosedOnCorruptWorkspaces() { + let mockFS = MockFileManager() + mockFS.homeDirectoryForCurrentUser = URL(fileURLWithPath: "/Users/tester") + mockFS.files["/Users/tester/.kimi-code/workspaces.json"] = Data("{not json".utf8) + let convo = TestFixtures.makeConversation(id: "corrupt", source: .codex, projectPath: "/proj") + + #expect(throws: MigrationError.self) { try makeMigrator(mockFS).migrate(convo) } + // Nothing may be created when the registry can't be parsed safely. + #expect(mockFS.files.count == 1) + } + + @Test("round-trip: written session reads back through KimiCodeSessionReader") + func roundTrip() async throws { + let mockFS = MockFileManager() + mockFS.homeDirectoryForCurrentUser = URL(fileURLWithPath: "/Users/tester") + let convo = TestFixtures.makeConversation( + id: "rt", + source: .claudeCode, + projectPath: "/proj", + messages: [ + UnifiedMessage(role: .user, content: "Question one", timestamp: TestFixtures.sampleDate), + UnifiedMessage(role: .assistant, content: "Answer one", timestamp: TestFixtures.sampleDate), + ] + ) + guard case let .written(path, sessionID) = try makeMigrator(mockFS).migrate(convo) else { + Issue.record("expected .written"); return + } + + let reader = KimiCodeSessionReader(fileSystem: mockFS) + let restored = try #require(try await reader.loadSession(id: sessionID, storagePath: path)) + #expect(restored.messages.count == 2) + #expect(restored.messages[0].role == .user) + #expect(restored.messages[0].content == "Question one") + #expect(restored.messages[1].role == .assistant) + #expect(restored.messages[1].content == "Answer one") + } + + @Test("two migrations append two lines to session_index.jsonl") + func twoMigrationsAppendIndexLines() throws { + let mockFS = MockFileManager() + mockFS.homeDirectoryForCurrentUser = URL(fileURLWithPath: "/Users/tester") + let migrator = KimiCodeMigrator(fileSystem: mockFS) + _ = try migrator.migrate(TestFixtures.makeConversation(id: "conv-a", source: .claudeCode, projectPath: "/pa")) + _ = try migrator.migrate(TestFixtures.makeConversation(id: "conv-b", source: .codex, projectPath: "/pb")) + let indexText = mockFS.files["/Users/tester/.kimi-code/session_index.jsonl"] + .flatMap { String(data: $0, encoding: .utf8) } ?? "" + let lines = indexText.split(separator: "\n").filter { !$0.isEmpty } + #expect(lines.count == 2) + } +} diff --git a/Tests/CTXMVKitTests/KimiCodeWireBuilderTests.swift b/Tests/CTXMVKitTests/KimiCodeWireBuilderTests.swift new file mode 100644 index 0000000..4e3e8a1 --- /dev/null +++ b/Tests/CTXMVKitTests/KimiCodeWireBuilderTests.swift @@ -0,0 +1,208 @@ +@testable import CTXMVKit +import Foundation +import Testing + +struct KimiCodeWireBuilderTests { + private func decodeLines(_ jsonl: String) -> [[String: Any]] { + jsonl.split(separator: "\n").compactMap { + (try? JSONSerialization.jsonObject(with: Data($0.utf8))) as? [String: Any] + } + } + + @Test("wire.jsonl carries metadata, a user turn.prompt+append_message, and a turnId-linked assistant turn") + func buildsWire() throws { + let convo = TestFixtures.makeConversation( + id: "kimi-build", + source: .claudeCode, + projectPath: "/mock/project", + messages: [ + UnifiedMessage(role: .user, content: "Hi", timestamp: TestFixtures.sampleDate), + UnifiedMessage(role: .assistant, content: "Hello!", timestamp: TestFixtures.sampleDate), + ] + ) + let builder = KimiCodeWireBuilder() + let doc = builder.makeDocument( + conversation: convo, + sessionId: "session_abc", + sessionDirPath: "/home/.kimi-code/sessions/wd_project_deadbeef0000/session_abc", + workDir: "/mock/project" + ) + let lines = decodeLines(doc.wireJSONL) + + #expect(lines.first?["type"] as? String == "metadata") + #expect(lines.first?["protocol_version"] as? String == "1.4") + + let prompt = try #require(lines.first { $0["type"] as? String == "turn.prompt" }) + let promptOrigin = prompt["origin"] as? [String: Any] + #expect(promptOrigin?["kind"] as? String == "user") + + let appendMsg = try #require(lines.first { $0["type"] as? String == "context.append_message" }) + let message = appendMsg["message"] as? [String: Any] + #expect(message?["role"] as? String == "user") + #expect((message?["origin"] as? [String: Any])?["kind"] as? String == "user") + + let loopEvents = lines.filter { $0["type"] as? String == "context.append_loop_event" } + #expect(loopEvents.count == 2) // step.begin + content.part(text) + let stepBegin = try #require(loopEvents.first) + let stepEvent = stepBegin["event"] as? [String: Any] + #expect(stepEvent?["type"] as? String == "step.begin") + #expect(stepEvent?["turnId"] as? String == "0") + let partEvent = (loopEvents.last?["event"]) as? [String: Any] + #expect(partEvent?["turnId"] as? String == "0") + #expect(partEvent?["stepUuid"] as? String == stepEvent?["uuid"] as? String) + let part = partEvent?["part"] as? [String: Any] + #expect(part?["type"] as? String == "text") + #expect(part?["text"] as? String == "Hello!") + } + + @Test("step increments per assistant message within a turn, sharing the same turnId") + func stepIncrementsWithinTurn() { + let convo = TestFixtures.makeConversation( + id: "kimi-step", + messages: [ + UnifiedMessage(role: .user, content: "Hi", timestamp: TestFixtures.sampleDate), + UnifiedMessage(role: .assistant, content: "First", timestamp: TestFixtures.sampleDate), + UnifiedMessage(role: .assistant, content: "Second", timestamp: TestFixtures.sampleDate), + ] + ) + let doc = KimiCodeWireBuilder().makeDocument( + conversation: convo, + sessionId: "session_step", + sessionDirPath: "/d", + workDir: "/w" + ) + let lines = decodeLines(doc.wireJSONL) + let contentParts = lines + .filter { $0["type"] as? String == "context.append_loop_event" } + .compactMap { $0["event"] as? [String: Any] } + .filter { $0["type"] as? String == "content.part" } + + #expect(contentParts.count == 2) + let turnIds = Set(contentParts.compactMap { $0["turnId"] as? String }) + #expect(turnIds == ["0"]) // both assistant messages belong to the same (only) user turn + let steps = contentParts.compactMap { $0["step"] as? Int } + #expect(steps == [1, 2]) + } + + @Test("state.json embeds the migration meta and a main agent") + func buildsState() throws { + let convo = TestFixtures.makeConversation(id: "kimi-state", source: .codex) + let builder = KimiCodeWireBuilder() + let doc = builder.makeDocument( + conversation: convo, + sessionId: "session_xyz", + sessionDirPath: "/home/.kimi-code/sessions/wd_project_deadbeef0000/session_xyz", + workDir: "/test/project" + ) + let state = try #require( + (try? JSONSerialization.jsonObject(with: Data(doc.stateJSON.utf8))) as? [String: Any] + ) + #expect(state["workDir"] as? String == "/test/project") + let custom = state["custom"] as? [String: Any] + let meta = custom?["ctxmv_migration"] as? [String: Any] + #expect(meta?["type"] as? String == MigrationMeta.migrationType) + #expect(meta?["originId"] as? String == "kimi-state") + #expect(meta?["originSource"] as? String == "codex") + let agents = state["agents"] as? [String: Any] + let mainAgent = agents?["main"] as? [String: Any] + #expect(mainAgent?["type"] as? String == "main") + // Native kimi writes an explicit null; the key must be present. + #expect(mainAgent?["parentAgentId"] is NSNull) + } + + @Test("system and tool messages are skipped") + func skipsSystemAndTool() { + let convo = TestFixtures.makeConversation( + id: "kimi-skip", + messages: [ + UnifiedMessage(role: .system, content: "sys", timestamp: nil), + UnifiedMessage(role: .user, content: "Hi", timestamp: nil), + UnifiedMessage(role: .tool, content: "tool", timestamp: nil), + UnifiedMessage(role: .assistant, content: "Yo", timestamp: nil), + ] + ) + let doc = KimiCodeWireBuilder().makeDocument( + conversation: convo, + sessionId: "session_s", + sessionDirPath: "/d", + workDir: "/w" + ) + let lines = decodeLines(doc.wireJSONL) + #expect(lines.count { $0["type"] as? String == "turn.prompt" } == 1) + #expect(!doc.wireJSONL.contains("\"sys\"")) + #expect(!doc.wireJSONL.contains("\"tool\"")) + } + + @Test("injected noise is a context-only append_message, never a turn.prompt") + func noiseBecomesInjection() throws { + let convo = TestFixtures.makeConversation( + id: "kimi-noise", + source: .claudeCode, + messages: [ + UnifiedMessage(role: .user, content: "Real question", timestamp: TestFixtures.sampleDate), + UnifiedMessage( + role: .user, + content: "Review found 2 issues", + timestamp: TestFixtures.sampleDate + ), + UnifiedMessage(role: .assistant, content: "Answer", timestamp: TestFixtures.sampleDate), + ] + ) + let doc = KimiCodeWireBuilder().makeDocument( + conversation: convo, + sessionId: "session_noise", + sessionDirPath: "/d", + workDir: "/w" + ) + let lines = decodeLines(doc.wireJSONL) + + // Only the genuine prompt opens a turn. + #expect(lines.count { $0["type"] as? String == "turn.prompt" } == 1) + let appendMessages = lines + .filter { $0["type"] as? String == "context.append_message" } + .compactMap { $0["message"] as? [String: Any] } + #expect(appendMessages.count == 2) + let injected = try #require(appendMessages.first { + (($0["content"] as? [[String: Any]])?.first?["text"] as? String)?.contains("[Subagent]") == true + }) + #expect((injected["origin"] as? [String: Any])?["kind"] as? String == "injection") + + // Noise must not open a turn: the assistant reply stays on the real prompt's turn. + let stepBegin = lines + .compactMap { $0["event"] as? [String: Any] } + .first { $0["type"] as? String == "step.begin" } + #expect(stepBegin?["turnId"] as? String == "0") + } + + @Test("title and lastPrompt skip injected noise") + func stateSkipsNoise() throws { + let convo = TestFixtures.makeConversation( + id: "kimi-title-noise", + source: .claudeCode, + messages: [ + UnifiedMessage( + role: .user, + content: "noise", + timestamp: TestFixtures.sampleDate + ), + UnifiedMessage(role: .user, content: "Genuine prompt", timestamp: TestFixtures.sampleDate), + UnifiedMessage( + role: .user, + content: "/review", + timestamp: TestFixtures.sampleDate + ), + ] + ) + let doc = KimiCodeWireBuilder().makeDocument( + conversation: convo, + sessionId: "session_t", + sessionDirPath: "/d", + workDir: "/w" + ) + let state = try #require( + (try? JSONSerialization.jsonObject(with: Data(doc.stateJSON.utf8))) as? [String: Any] + ) + #expect(state["title"] as? String == "Genuine prompt") + #expect(state["lastPrompt"] as? String == "Genuine prompt") + } +} diff --git a/Tests/CTXMVKitTests/KimiCodeWorkspaceTests.swift b/Tests/CTXMVKitTests/KimiCodeWorkspaceTests.swift new file mode 100644 index 0000000..93b85b1 --- /dev/null +++ b/Tests/CTXMVKitTests/KimiCodeWorkspaceTests.swift @@ -0,0 +1,52 @@ +@testable import CTXMVKit +import Foundation +import Testing + +struct KimiCodeWorkspaceTests { + @Test("workspaceId matches kimi-code's wd__ scheme") + func workspaceIdMatchesReal() { + // Verified against a real ~/.kimi-code/workspaces.json install. + #expect(KimiCodeWorkspace.workspaceId(forRoot: "/Users/jayden") == "wd_jayden_154602f87fd0") + #expect( + KimiCodeWorkspace.workspaceId(forRoot: "/Users/jayden/public_html/optimogo-expo") + == "wd_optimogo-expo_f5d65b8a66aa" + ) + } + + @Test("upsertWorkspaces preserves version, existing workspaces, and deleted_workspace_ids") + func upsertPreservesExisting() throws { + // swiftlint:disable line_length + let existingJSON = """ + {"version":1,"workspaces":{"wd_other_aaaaaaaaaaaa":{"root":"/other","name":"other","created_at":"t0","last_opened_at":"t0"}},"deleted_workspace_ids":["wd_gone_bbbbbbbbbbbb"]} + """ + // swiftlint:enable line_length + let updated = try KimiCodeWorkspace.upsertWorkspaces( + existing: Data(existingJSON.utf8), + workspaceId: "wd_new_cccccccccccc", + root: "/new", + name: "new", + timestamp: "t1" + ) + let object = try #require((try? JSONSerialization.jsonObject(with: updated)) as? [String: Any]) + #expect(object["version"] as? Int == 1) + #expect((object["deleted_workspace_ids"] as? [String]) == ["wd_gone_bbbbbbbbbbbb"]) + let workspaces = try #require(object["workspaces"] as? [String: Any]) + #expect(workspaces["wd_other_aaaaaaaaaaaa"] != nil) + let newEntry = try #require(workspaces["wd_new_cccccccccccc"] as? [String: Any]) + #expect(newEntry["root"] as? String == "/new") + #expect(newEntry["last_opened_at"] as? String == "t1") + } + + @Test("upsertWorkspaces fails closed on unparseable existing JSON") + func upsertFailsClosedOnCorrupt() { + #expect(throws: MigrationError.self) { + _ = try KimiCodeWorkspace.upsertWorkspaces( + existing: Data("{not json".utf8), + workspaceId: "wd_x_000000000000", + root: "/x", + name: "x", + timestamp: "t1" + ) + } + } +} diff --git a/Tests/CTXMVKitTests/MigrationDeduplicatorTests.swift b/Tests/CTXMVKitTests/MigrationDeduplicatorTests.swift index 2019242..ebe348a 100644 --- a/Tests/CTXMVKitTests/MigrationDeduplicatorTests.swift +++ b/Tests/CTXMVKitTests/MigrationDeduplicatorTests.swift @@ -201,4 +201,29 @@ struct MigrationDeduplicatorTests { #expect(existing == nil) } + + @Test("matches: digest equality wins; nil digest falls back to message count") + func matchesPredicate() { + func meta(id: String, digest: String?) -> MigrationMeta { + MigrationMeta( + type: MigrationMeta.migrationType, + originId: id, + originSource: "claude-code", + originMessageCount: 3, + originDigest: digest, + targetFormatVersion: nil + ) + } + let origin = MigrationOrigin( + originId: "s1", + originSource: .claudeCode, + originMessageCount: 3, + originDigest: "abc" + ) + + #expect(MigrationDeduplicator.matches(meta(id: "s1", digest: "abc"), origin)) // digest match + #expect(!MigrationDeduplicator.matches(meta(id: "s1", digest: "zzz"), origin)) // digest mismatch → no + #expect(MigrationDeduplicator.matches(meta(id: "s1", digest: nil), origin)) // nil digest → count fallback + #expect(!MigrationDeduplicator.matches(meta(id: "other", digest: "abc"), origin)) // id mismatch → no + } }