Skip to content

Commit 26dec1c

Browse files
feat(parser): migrate kimi provider
Kimi uses two wire.jsonl layouts whose raw IDs include colon-delimited path components, so it cannot rely entirely on the generic JSONL raw-ID lookup. Moving it behind a concrete provider keeps discovery and source classification on the shared JSONL helper while preserving Kimi-specific layout validation and lookup semantics.\n\nThe provider keeps legacy support for both the .kimi project/session layout and the .kimi-code workdir/session/agents layout, including symlinked directories, invalid component filtering, project hints, deleted-path classification, and parser output normalization. test(parser): cover kimi new-layout provider parse The roborev design review questioned whether the provider-backed Kimi migration proved the newer .kimi-code layout could round-trip through lookup and parsing. The existing parser and lookup code already handled that raw ID shape, but the provider tests only parsed the legacy layout.\n\nThis adds provider-level coverage for the .kimi-code workdir/session/agents layout so the branch itself documents the persisted session ID, project hint, source path, machine, hash propagation, and message output expected from that source shape. test(parser): opt kimi into provider shadow Kimi now has a concrete facade provider on this branch, so its migration mode should enter shadow comparison instead of remaining legacy-only and additive. Lower provider opt-ins stay inherited and later branches own their provider modes. Validation: go test -tags "fts5" ./internal/parser -run TestProviderMigrationModes -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; git diff --check test(sync): compare kimi shadow parity Kimi is shadow-compared on this branch, so add source-level migration coverage that compares provider observation with ParseKimiSession. The test covers both the legacy project/session wire.jsonl layout and the newer .kimi-code agents layout, keeping the fragile path-derived ID and project behavior visible during review. Validation: go test -tags "fts5" ./internal/parser ./internal/sync -run 'TestObserveProviderSourceMatchesKimiLegacyParser|TestKimiProvider|TestParseKimi|TestSyncPathsAndSingleSession_KimiNewLayout|TestClassifyOnePath_Kimi' -count=1; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; go fmt ./...; go vet ./...; git diff --check; ./custom-gcl run --config .golangci.nilaway.yml ./internal/parser/... ./internal/sync/...; make nilaway refactor(parser): fold kimi into provider Move Kimi parse and raw-ID source lookup onto the concrete provider and remove package-level discover/find/parse entrypoints. Route Kimi sync classification and processing through provider changed-path handling so the branch migrates the provider instead of preserving legacy dispatch.
1 parent e47ff7d commit 26dec1c

9 files changed

Lines changed: 485 additions & 301 deletions

File tree

internal/parser/kimi.go

Lines changed: 3 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -5,150 +5,12 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8-
"sort"
98
"strings"
109
"time"
1110

1211
"github.com/tidwall/gjson"
1312
)
1413

15-
// DiscoverKimiSessions finds all wire.jsonl files under the Kimi
16-
// sessions directory. It supports two layouts:
17-
//
18-
// Legacy (".kimi/sessions"):
19-
//
20-
// <sessionsDir>/<project-hash>/<session-uuid>/wire.jsonl
21-
//
22-
// New (".kimi-code/sessions"):
23-
//
24-
// <sessionsDir>/<workdir>_<hash>/session_<uuid>/agents/<agent>/wire.jsonl
25-
func DiscoverKimiSessions(sessionsDir string) []DiscoveredFile {
26-
if sessionsDir == "" {
27-
return nil
28-
}
29-
30-
projDirs, err := os.ReadDir(sessionsDir)
31-
if err != nil {
32-
return nil
33-
}
34-
35-
var files []DiscoveredFile
36-
for _, projEntry := range projDirs {
37-
if !isDirOrSymlink(projEntry, sessionsDir) {
38-
continue
39-
}
40-
41-
projDir := filepath.Join(sessionsDir, projEntry.Name())
42-
sessionDirs, err := os.ReadDir(projDir)
43-
if err != nil {
44-
continue
45-
}
46-
47-
for _, sessEntry := range sessionDirs {
48-
if !isDirOrSymlink(sessEntry, projDir) {
49-
continue
50-
}
51-
52-
sessDir := filepath.Join(projDir, sessEntry.Name())
53-
54-
// Legacy layout.
55-
wirePath := filepath.Join(sessDir, "wire.jsonl")
56-
if _, err := os.Stat(wirePath); err == nil {
57-
// The project and session names become ':'-delimited
58-
// session-ID components; skip sessions whose names
59-
// cannot round-trip through FindKimiSourceFile.
60-
if kimiIDComponentsValid(
61-
projEntry.Name(), sessEntry.Name(),
62-
) {
63-
files = append(files, DiscoveredFile{
64-
Path: wirePath,
65-
Project: DecodeKimiProjectDir(projEntry.Name()),
66-
Agent: AgentKimi,
67-
})
68-
}
69-
continue
70-
}
71-
72-
// New .kimi-code layout.
73-
agentsDir := filepath.Join(sessDir, "agents")
74-
agentEntries, err := os.ReadDir(agentsDir)
75-
if err != nil {
76-
continue
77-
}
78-
for _, agentEntry := range agentEntries {
79-
if !isDirOrSymlink(agentEntry, agentsDir) {
80-
continue
81-
}
82-
wirePath = filepath.Join(
83-
agentsDir, agentEntry.Name(), "wire.jsonl",
84-
)
85-
if _, err := os.Stat(wirePath); err == nil &&
86-
kimiIDComponentsValid(
87-
projEntry.Name(),
88-
sessEntry.Name(),
89-
agentEntry.Name(),
90-
) {
91-
files = append(files, DiscoveredFile{
92-
Path: wirePath,
93-
Project: DecodeKimiProjectDir(projEntry.Name()),
94-
Agent: AgentKimi,
95-
})
96-
}
97-
}
98-
}
99-
}
100-
101-
sort.Slice(files, func(i, j int) bool {
102-
return files[i].Path < files[j].Path
103-
})
104-
return files
105-
}
106-
107-
// FindKimiSourceFile locates a Kimi session file by its raw
108-
// session ID (without the "kimi:" prefix). Supported raw ID formats:
109-
//
110-
// Legacy:
111-
//
112-
// <project-hash>:<session-uuid>
113-
// → <sessionsDir>/<project-hash>/<session-uuid>/wire.jsonl
114-
//
115-
// New (.kimi-code):
116-
//
117-
// <workdir>_<hash>:<agent>:<session-uuid>
118-
// → <sessionsDir>/<workdir>_<hash>/<session-uuid>/agents/<agent>/wire.jsonl
119-
func FindKimiSourceFile(sessionsDir, rawID string) string {
120-
if sessionsDir == "" {
121-
return ""
122-
}
123-
124-
parts := strings.Split(rawID, ":")
125-
for _, p := range parts {
126-
if !IsValidSessionID(p) {
127-
return ""
128-
}
129-
}
130-
131-
switch len(parts) {
132-
case 2:
133-
// Legacy layout.
134-
candidate := filepath.Join(
135-
sessionsDir, parts[0], parts[1], "wire.jsonl",
136-
)
137-
if _, err := os.Stat(candidate); err == nil {
138-
return candidate
139-
}
140-
case 3:
141-
// New .kimi-code layout.
142-
candidate := filepath.Join(
143-
sessionsDir, parts[0], parts[2], "agents", parts[1], "wire.jsonl",
144-
)
145-
if _, err := os.Stat(candidate); err == nil {
146-
return candidate
147-
}
148-
}
149-
return ""
150-
}
151-
15214
// kimiSessionIDFromPath extracts the raw Kimi session ID from its
15315
// wire.jsonl path. Legacy paths yield "<project>:<session>"; .kimi-code
15416
// paths yield "<workdir>:<agent>:<session>".
@@ -208,7 +70,7 @@ func isKimiHash(s string) bool {
20870
}
20971

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

226-
// ParseKimiSession parses a Kimi wire.jsonl file. Legacy Kimi CLI
88+
// parseSession parses a Kimi wire.jsonl file. Legacy Kimi CLI
22789
// sessions store nested message.type records (TurnBegin, ContentPart,
22890
// ToolCall, ToolResult, StatusUpdate, TurnEnd). Kimi Code sessions store
22991
// top-level records (turn.prompt, context.append_loop_event, usage.record).
230-
func ParseKimiSession(
92+
func parseKimiSession(
23193
path, project, machine string,
23294
) (*ParsedSession, []ParsedMessage, error) {
23395
info, err := os.Stat(path)

internal/parser/kimi_provider.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package parser
2+
3+
import (
4+
"context"
5+
"path/filepath"
6+
"strings"
7+
)
8+
9+
// Kimi stores each session as a wire.jsonl transcript under a per-workspace
10+
// directory, with subagent transcripts nested under an "agents" subdirectory.
11+
// It is a directory-of-files provider: discovery, watching, change
12+
// classification, and fingerprinting come from JSONLSourceSet. The ParseFile
13+
// option makes that source set a full SourceSet so it rides the generic
14+
// factory; RawSessionIDSourceFiles reconstructs the wire.jsonl path from a
15+
// colon-joined raw ID, which the standard filename-stem lookup cannot match.
16+
func newKimiProviderFactory(def AgentDef) ProviderFactory {
17+
return newSourceSetFactory(
18+
def,
19+
kimiProviderCapabilities(),
20+
func(cfg ProviderConfig) SourceSet { return newKimiSourceSet(cfg.Roots) },
21+
)
22+
}
23+
24+
func newKimiSourceSet(roots []string) JSONLSourceSet {
25+
return newJSONLSourceSet(AgentKimi, roots,
26+
withRecursive(),
27+
withSymlinkFollowing(),
28+
withIncludePath(isKimiSourcePath),
29+
withProjectHint(kimiProjectHintFromPath),
30+
withSessionIDFromPath(func(root, path string) string {
31+
if !isKimiSourcePath(root, path) {
32+
return ""
33+
}
34+
return kimiSessionIDFromPath(path)
35+
}),
36+
withRawSessionIDSourceFiles(kimiRawSessionIDSourceFiles),
37+
withParseFile(kimiParseFile),
38+
)
39+
}
40+
41+
func kimiParseFile(
42+
_ context.Context, path string, req ParseRequest,
43+
) ([]ParseResult, []string, error) {
44+
sess, msgs, err := parseKimiSession(path, req.Source.ProjectHint, req.Machine)
45+
if err != nil {
46+
return nil, nil, err
47+
}
48+
if sess == nil {
49+
return nil, nil, nil
50+
}
51+
if req.Fingerprint.Hash != "" {
52+
sess.File.Hash = req.Fingerprint.Hash
53+
}
54+
return []ParseResult{{Session: *sess, Messages: msgs}}, nil, nil
55+
}
56+
57+
// kimiRawSessionIDSourceFiles reconstructs wire.jsonl candidate paths from a
58+
// colon-joined raw ID. A two-part ID maps to <root>/<workspace>/<session>/
59+
// wire.jsonl; a three-part ID adds the agents/ subagent layout
60+
// <root>/<workspace>/<session>/agents/<agent>/wire.jsonl.
61+
func kimiRawSessionIDSourceFiles(roots []string, rawID string) []string {
62+
parts := strings.Split(rawID, ":")
63+
if !kimiIDComponentsValid(parts...) {
64+
return nil
65+
}
66+
var candidates []string
67+
for _, root := range roots {
68+
if root == "" {
69+
continue
70+
}
71+
switch len(parts) {
72+
case 2:
73+
candidates = append(
74+
candidates,
75+
filepath.Join(root, parts[0], parts[1], "wire.jsonl"),
76+
)
77+
case 3:
78+
candidates = append(candidates, filepath.Join(
79+
root, parts[0], parts[2], "agents", parts[1], "wire.jsonl",
80+
))
81+
}
82+
}
83+
return candidates
84+
}
85+
86+
func isKimiSourcePath(root, path string) bool {
87+
parts, ok := kimiSourceRelParts(root, path)
88+
if !ok || len(parts) == 0 || parts[len(parts)-1] != "wire.jsonl" {
89+
return false
90+
}
91+
switch len(parts) {
92+
case 3:
93+
return kimiIDComponentsValid(parts[0], parts[1])
94+
case 5:
95+
return parts[2] == "agents" &&
96+
kimiIDComponentsValid(parts[0], parts[1], parts[3])
97+
default:
98+
return false
99+
}
100+
}
101+
102+
func kimiProjectHintFromPath(root, path string) string {
103+
parts, ok := kimiSourceRelParts(root, path)
104+
if !ok || len(parts) == 0 {
105+
return ""
106+
}
107+
return DecodeKimiProjectDir(parts[0])
108+
}
109+
110+
func kimiSourceRelParts(root, path string) ([]string, bool) {
111+
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
112+
if err != nil {
113+
return nil, false
114+
}
115+
parts := strings.Split(rel, string(filepath.Separator))
116+
for _, part := range parts {
117+
if part == "" || part == "." || part == ".." {
118+
return nil, false
119+
}
120+
}
121+
return parts, true
122+
}
123+
124+
func kimiProviderCapabilities() Capabilities {
125+
return Capabilities{
126+
Source: jsonlFileProviderSourceCapabilities(),
127+
Content: ContentCapabilities{
128+
FirstMessage: CapabilitySupported,
129+
Thinking: CapabilitySupported,
130+
ToolCalls: CapabilitySupported,
131+
ToolResults: CapabilitySupported,
132+
},
133+
}
134+
}

0 commit comments

Comments
 (0)