Skip to content
Closed
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
144 changes: 3 additions & 141 deletions internal/parser/kimi.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,150 +5,12 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"

"github.com/tidwall/gjson"
)

// DiscoverKimiSessions finds all wire.jsonl files under the Kimi
// sessions directory. It supports two layouts:
//
// Legacy (".kimi/sessions"):
//
// <sessionsDir>/<project-hash>/<session-uuid>/wire.jsonl
//
// New (".kimi-code/sessions"):
//
// <sessionsDir>/<workdir>_<hash>/session_<uuid>/agents/<agent>/wire.jsonl
func DiscoverKimiSessions(sessionsDir string) []DiscoveredFile {
if sessionsDir == "" {
return nil
}

projDirs, err := os.ReadDir(sessionsDir)
if err != nil {
return nil
}

var files []DiscoveredFile
for _, projEntry := range projDirs {
if !isDirOrSymlink(projEntry, sessionsDir) {
continue
}

projDir := filepath.Join(sessionsDir, projEntry.Name())
sessionDirs, err := os.ReadDir(projDir)
if err != nil {
continue
}

for _, sessEntry := range sessionDirs {
if !isDirOrSymlink(sessEntry, projDir) {
continue
}

sessDir := filepath.Join(projDir, sessEntry.Name())

// Legacy layout.
wirePath := filepath.Join(sessDir, "wire.jsonl")
if _, err := os.Stat(wirePath); err == nil {
// The project and session names become ':'-delimited
// session-ID components; skip sessions whose names
// cannot round-trip through FindKimiSourceFile.
if kimiIDComponentsValid(
projEntry.Name(), sessEntry.Name(),
) {
files = append(files, DiscoveredFile{
Path: wirePath,
Project: DecodeKimiProjectDir(projEntry.Name()),
Agent: AgentKimi,
})
}
continue
}

// New .kimi-code layout.
agentsDir := filepath.Join(sessDir, "agents")
agentEntries, err := os.ReadDir(agentsDir)
if err != nil {
continue
}
for _, agentEntry := range agentEntries {
if !isDirOrSymlink(agentEntry, agentsDir) {
continue
}
wirePath = filepath.Join(
agentsDir, agentEntry.Name(), "wire.jsonl",
)
if _, err := os.Stat(wirePath); err == nil &&
kimiIDComponentsValid(
projEntry.Name(),
sessEntry.Name(),
agentEntry.Name(),
) {
files = append(files, DiscoveredFile{
Path: wirePath,
Project: DecodeKimiProjectDir(projEntry.Name()),
Agent: AgentKimi,
})
}
}
}
}

sort.Slice(files, func(i, j int) bool {
return files[i].Path < files[j].Path
})
return files
}

// FindKimiSourceFile locates a Kimi session file by its raw
// session ID (without the "kimi:" prefix). Supported raw ID formats:
//
// Legacy:
//
// <project-hash>:<session-uuid>
// → <sessionsDir>/<project-hash>/<session-uuid>/wire.jsonl
//
// New (.kimi-code):
//
// <workdir>_<hash>:<agent>:<session-uuid>
// → <sessionsDir>/<workdir>_<hash>/<session-uuid>/agents/<agent>/wire.jsonl
func FindKimiSourceFile(sessionsDir, rawID string) string {
if sessionsDir == "" {
return ""
}

parts := strings.Split(rawID, ":")
for _, p := range parts {
if !IsValidSessionID(p) {
return ""
}
}

switch len(parts) {
case 2:
// Legacy layout.
candidate := filepath.Join(
sessionsDir, parts[0], parts[1], "wire.jsonl",
)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
case 3:
// New .kimi-code layout.
candidate := filepath.Join(
sessionsDir, parts[0], parts[2], "agents", parts[1], "wire.jsonl",
)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return ""
}

// kimiSessionIDFromPath extracts the raw Kimi session ID from its
// wire.jsonl path. Legacy paths yield "<project>:<session>"; .kimi-code
// paths yield "<workdir>:<agent>:<session>".
Expand Down Expand Up @@ -208,7 +70,7 @@ func isKimiHash(s string) bool {
}

// kimiIDComponentsValid reports whether the given path-derived
// components can form a session ID that FindKimiSourceFile can
// components can form a session ID that provider raw-ID lookup can
// round-trip back to the source file. Each component must itself be a
// valid session ID (alphanumeric, '-', '_'); a ':' or any other
// character outside that set would break the ':'-delimited ID split
Expand All @@ -223,11 +85,11 @@ func kimiIDComponentsValid(components ...string) bool {
return true
}

// ParseKimiSession parses a Kimi wire.jsonl file. Legacy Kimi CLI
// parseSession parses a Kimi wire.jsonl file. Legacy Kimi CLI
// sessions store nested message.type records (TurnBegin, ContentPart,
// ToolCall, ToolResult, StatusUpdate, TurnEnd). Kimi Code sessions store
// top-level records (turn.prompt, context.append_loop_event, usage.record).
func ParseKimiSession(
func parseKimiSession(
path, project, machine string,
) (*ParsedSession, []ParsedMessage, error) {
info, err := os.Stat(path)
Expand Down
134 changes: 134 additions & 0 deletions internal/parser/kimi_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package parser

import (
"context"
"path/filepath"
"strings"
)

// Kimi stores each session as a wire.jsonl transcript under a per-workspace
// directory, with subagent transcripts nested under an "agents" subdirectory.
// It is a directory-of-files provider: discovery, watching, change
// classification, and fingerprinting come from JSONLSourceSet. The ParseFile
// option makes that source set a full SourceSet so it rides the generic
// factory; RawSessionIDSourceFiles reconstructs the wire.jsonl path from a
// colon-joined raw ID, which the standard filename-stem lookup cannot match.
func newKimiProviderFactory(def AgentDef) ProviderFactory {
return newSourceSetFactory(
def,
kimiProviderCapabilities(),
func(cfg ProviderConfig) SourceSet { return newKimiSourceSet(cfg.Roots) },
)
}

func newKimiSourceSet(roots []string) JSONLSourceSet {
return newJSONLSourceSet(AgentKimi, roots,
withRecursive(),
withSymlinkFollowing(),
withIncludePath(isKimiSourcePath),
withProjectHint(kimiProjectHintFromPath),
withSessionIDFromPath(func(root, path string) string {
if !isKimiSourcePath(root, path) {
return ""
}
return kimiSessionIDFromPath(path)
}),
withRawSessionIDSourceFiles(kimiRawSessionIDSourceFiles),
withParseFile(kimiParseFile),
)
}

func kimiParseFile(
_ context.Context, path string, req ParseRequest,
) ([]ParseResult, []string, error) {
sess, msgs, err := parseKimiSession(path, req.Source.ProjectHint, req.Machine)
if err != nil {
return nil, nil, err
}
if sess == nil {
return nil, nil, nil
}
if req.Fingerprint.Hash != "" {
sess.File.Hash = req.Fingerprint.Hash
}
return []ParseResult{{Session: *sess, Messages: msgs}}, nil, nil
}

// kimiRawSessionIDSourceFiles reconstructs wire.jsonl candidate paths from a
// colon-joined raw ID. A two-part ID maps to <root>/<workspace>/<session>/
// wire.jsonl; a three-part ID adds the agents/ subagent layout
// <root>/<workspace>/<session>/agents/<agent>/wire.jsonl.
func kimiRawSessionIDSourceFiles(roots []string, rawID string) []string {
parts := strings.Split(rawID, ":")
if !kimiIDComponentsValid(parts...) {
return nil
}
var candidates []string
for _, root := range roots {
if root == "" {
continue
}
switch len(parts) {
case 2:
candidates = append(
candidates,
filepath.Join(root, parts[0], parts[1], "wire.jsonl"),
)
case 3:
candidates = append(candidates, filepath.Join(
root, parts[0], parts[2], "agents", parts[1], "wire.jsonl",
))
}
}
return candidates
}

func isKimiSourcePath(root, path string) bool {
parts, ok := kimiSourceRelParts(root, path)
if !ok || len(parts) == 0 || parts[len(parts)-1] != "wire.jsonl" {
return false
}
switch len(parts) {
case 3:
return kimiIDComponentsValid(parts[0], parts[1])
case 5:
return parts[2] == "agents" &&
kimiIDComponentsValid(parts[0], parts[1], parts[3])
default:
return false
}
}

func kimiProjectHintFromPath(root, path string) string {
parts, ok := kimiSourceRelParts(root, path)
if !ok || len(parts) == 0 {
return ""
}
return DecodeKimiProjectDir(parts[0])
}

func kimiSourceRelParts(root, path string) ([]string, bool) {
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
if err != nil {
return nil, false
}
parts := strings.Split(rel, string(filepath.Separator))
for _, part := range parts {
if part == "" || part == "." || part == ".." {
return nil, false
}
}
return parts, true
}

func kimiProviderCapabilities() Capabilities {
return Capabilities{
Source: jsonlFileProviderSourceCapabilities(),
Content: ContentCapabilities{
FirstMessage: CapabilitySupported,
Thinking: CapabilitySupported,
ToolCalls: CapabilitySupported,
ToolResults: CapabilitySupported,
},
}
}
Loading