462 lines
11 KiB
Go
462 lines
11 KiB
Go
package kimi
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/chenhg5/cc-connect/core"
|
|
)
|
|
|
|
func init() {
|
|
core.RegisterAgent("kimi", New)
|
|
}
|
|
|
|
// Agent drives Kimi Code CLI using --print --output-format stream-json.
|
|
//
|
|
// Modes:
|
|
// - "default": standard mode (note: --print implicitly enables --yolo)
|
|
// - "yolo": auto-approve all tool calls
|
|
// - "plan": read-only plan mode
|
|
// - "quiet": alias for --quiet (print + text + final-message-only)
|
|
type Agent struct {
|
|
workDir string
|
|
model string
|
|
mode string
|
|
cmd string // CLI binary name, default "kimi"
|
|
timeout time.Duration
|
|
providers []core.ProviderConfig
|
|
activeIdx int // -1 = no provider set
|
|
sessionEnv []string
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
func New(opts map[string]any) (core.Agent, error) {
|
|
workDir, _ := opts["work_dir"].(string)
|
|
if workDir == "" {
|
|
workDir = "."
|
|
}
|
|
model, _ := opts["model"].(string)
|
|
mode, _ := opts["mode"].(string)
|
|
mode = normalizeMode(mode)
|
|
cmd, _ := opts["cmd"].(string)
|
|
if cmd == "" {
|
|
cmd = "kimi"
|
|
}
|
|
|
|
var timeoutMins int64
|
|
switch v := opts["timeout_mins"].(type) {
|
|
case int64:
|
|
timeoutMins = v
|
|
case int:
|
|
timeoutMins = int64(v)
|
|
case float64:
|
|
timeoutMins = int64(v)
|
|
default:
|
|
if v != nil {
|
|
slog.Debug("kimi: timeout_mins has unexpected type", "type", fmt.Sprintf("%T", v))
|
|
}
|
|
}
|
|
var timeout time.Duration
|
|
if timeoutMins > 0 {
|
|
timeout = time.Duration(timeoutMins) * time.Minute
|
|
}
|
|
|
|
if _, err := exec.LookPath(cmd); err != nil {
|
|
return nil, fmt.Errorf("kimi: %q CLI not found in PATH, install with: pip install kimi-cli", cmd)
|
|
}
|
|
|
|
return &Agent{
|
|
workDir: workDir,
|
|
model: model,
|
|
mode: mode,
|
|
cmd: cmd,
|
|
timeout: timeout,
|
|
activeIdx: -1,
|
|
}, nil
|
|
}
|
|
|
|
func normalizeMode(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "yolo", "force", "bypass", "auto":
|
|
return "yolo"
|
|
case "plan":
|
|
return "plan"
|
|
case "quiet":
|
|
return "quiet"
|
|
default:
|
|
return "default"
|
|
}
|
|
}
|
|
|
|
func (a *Agent) Name() string { return "kimi" }
|
|
func (a *Agent) CLIBinaryName() string { return "kimi" }
|
|
func (a *Agent) CLIDisplayName() string { return "Kimi" }
|
|
|
|
func (a *Agent) SetWorkDir(dir string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.workDir = dir
|
|
slog.Info("kimi: work_dir changed", "work_dir", dir)
|
|
}
|
|
|
|
func (a *Agent) GetWorkDir() string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return a.workDir
|
|
}
|
|
|
|
func (a *Agent) SetModel(model string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.model = model
|
|
slog.Info("kimi: model changed", "model", model)
|
|
}
|
|
|
|
func (a *Agent) GetModel() string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return core.GetProviderModel(a.providers, a.activeIdx, a.model)
|
|
}
|
|
|
|
func (a *Agent) configuredModels() []core.ModelOption {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
return core.GetProviderModels(a.providers, a.activeIdx)
|
|
}
|
|
|
|
func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption {
|
|
if models := a.configuredModels(); len(models) > 0 {
|
|
return models
|
|
}
|
|
return []core.ModelOption{
|
|
{Name: "kimi-k2-0711-preview", Desc: "Kimi K2 (most capable)"},
|
|
{Name: "kimi-k2-0711", Desc: "Kimi K2"},
|
|
{Name: "kimi-k2-5-preview", Desc: "Kimi K2.5 (balanced)"},
|
|
{Name: "kimi-k2-5", Desc: "Kimi K2.5"},
|
|
}
|
|
}
|
|
|
|
func (a *Agent) SetSessionEnv(env []string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.sessionEnv = env
|
|
}
|
|
|
|
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
|
|
a.mu.Lock()
|
|
model := a.model
|
|
mode := a.mode
|
|
cmd := a.cmd
|
|
workDir := a.workDir
|
|
timeout := a.timeout
|
|
extraEnv := a.providerEnvLocked()
|
|
extraEnv = append(extraEnv, a.sessionEnv...)
|
|
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
|
|
if m := a.providers[a.activeIdx].Model; m != "" {
|
|
model = m
|
|
}
|
|
}
|
|
a.mu.Unlock()
|
|
|
|
return newKimiSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv, timeout)
|
|
}
|
|
|
|
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
|
return listKimiSessions(a.workDir)
|
|
}
|
|
|
|
func (a *Agent) DeleteSession(_ context.Context, sessionID string) error {
|
|
path := findKimiSessionDir(sessionID)
|
|
if path == "" {
|
|
return fmt.Errorf("session not found: %s", sessionID)
|
|
}
|
|
return os.RemoveAll(path)
|
|
}
|
|
|
|
func (a *Agent) Stop() error { return nil }
|
|
|
|
// ── ModeSwitcher ────────────────────────────────────────────────
|
|
|
|
func (a *Agent) SetMode(mode string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.mode = normalizeMode(mode)
|
|
slog.Info("kimi: mode changed", "mode", a.mode)
|
|
}
|
|
|
|
func (a *Agent) GetMode() string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
return a.mode
|
|
}
|
|
|
|
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
|
|
return []core.PermissionModeInfo{
|
|
{Key: "default", Name: "Default", NameZh: "默认", Desc: "Standard mode (print output)", DescZh: "标准模式(打印输出)"},
|
|
{Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"},
|
|
{Key: "plan", Name: "Plan", NameZh: "规划模式", Desc: "Read-only plan mode, no execution", DescZh: "只读规划模式,不做修改"},
|
|
{Key: "quiet", Name: "Quiet", NameZh: "静默", Desc: "Quiet mode (final message only)", DescZh: "静默模式(仅最终消息)"},
|
|
}
|
|
}
|
|
|
|
// ── SkillProvider implementation ──────────────────────────────
|
|
|
|
func (a *Agent) SkillDirs() []string {
|
|
absDir, err := filepath.Abs(a.workDir)
|
|
if err != nil {
|
|
absDir = a.workDir
|
|
}
|
|
dirs := []string{filepath.Join(absDir, ".kimi", "skills")}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
dirs = append(dirs, filepath.Join(home, ".kimi", "skills"))
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
// ── ContextCompressor implementation ──────────────────────────
|
|
|
|
func (a *Agent) CompressCommand() string { return "" }
|
|
|
|
// ── MemoryFileProvider implementation ─────────────────────────
|
|
|
|
func (a *Agent) ProjectMemoryFile() string {
|
|
absDir, err := filepath.Abs(a.workDir)
|
|
if err != nil {
|
|
absDir = a.workDir
|
|
}
|
|
return filepath.Join(absDir, "AGENTS.md")
|
|
}
|
|
|
|
func (a *Agent) GlobalMemoryFile() string {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return filepath.Join(homeDir, ".kimi", "AGENTS.md")
|
|
}
|
|
|
|
// ── ProviderSwitcher ────────────────────────────────────────────
|
|
|
|
func (a *Agent) SetProviders(providers []core.ProviderConfig) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.providers = providers
|
|
}
|
|
|
|
func (a *Agent) SetActiveProvider(name string) bool {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if name == "" {
|
|
a.activeIdx = -1
|
|
slog.Info("kimi: provider cleared")
|
|
return true
|
|
}
|
|
for i, p := range a.providers {
|
|
if p.Name == name {
|
|
a.activeIdx = i
|
|
slog.Info("kimi: provider switched", "provider", name)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *Agent) GetActiveProvider() *core.ProviderConfig {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
|
|
return nil
|
|
}
|
|
p := a.providers[a.activeIdx]
|
|
return &p
|
|
}
|
|
|
|
func (a *Agent) ListProviders() []core.ProviderConfig {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
result := make([]core.ProviderConfig, len(a.providers))
|
|
copy(result, a.providers)
|
|
return result
|
|
}
|
|
|
|
func (a *Agent) providerEnvLocked() []string {
|
|
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
|
|
return nil
|
|
}
|
|
p := a.providers[a.activeIdx]
|
|
var env []string
|
|
if p.APIKey != "" {
|
|
env = append(env, "KIMI_API_KEY="+p.APIKey)
|
|
}
|
|
for k, v := range p.Env {
|
|
env = append(env, k+"="+v)
|
|
}
|
|
return env
|
|
}
|
|
|
|
// ── Session listing ─────────────────────────────────────────────
|
|
|
|
func kimiSessionsBaseDir() string {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return filepath.Join(homeDir, ".kimi", "sessions")
|
|
}
|
|
|
|
func listKimiSessions(workDir string) ([]core.AgentSessionInfo, error) {
|
|
absWorkDir, err := filepath.Abs(workDir)
|
|
if err != nil {
|
|
absWorkDir = workDir
|
|
}
|
|
|
|
sessionsBase := kimiSessionsBaseDir()
|
|
if sessionsBase == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
entries, err := os.ReadDir(sessionsBase)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("kimi: read sessions dir: %w", err)
|
|
}
|
|
|
|
var sessions []core.AgentSessionInfo
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
projectDir := filepath.Join(sessionsBase, entry.Name())
|
|
sessionEntries, err := os.ReadDir(projectDir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, se := range sessionEntries {
|
|
if !se.IsDir() {
|
|
continue
|
|
}
|
|
sessionDir := filepath.Join(projectDir, se.Name())
|
|
info := parseKimiSessionDir(sessionDir, absWorkDir)
|
|
if info != nil {
|
|
sessions = append(sessions, *info)
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.Slice(sessions, func(i, j int) bool {
|
|
return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt)
|
|
})
|
|
|
|
return sessions, nil
|
|
}
|
|
|
|
func parseKimiSessionDir(sessionDir, filterWorkDir string) *core.AgentSessionInfo {
|
|
statePath := filepath.Join(sessionDir, "state.json")
|
|
stateData, err := os.ReadFile(statePath)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var state struct {
|
|
CustomTitle string `json:"custom_title"`
|
|
Archived bool `json:"archived"`
|
|
}
|
|
if json.Unmarshal(stateData, &state) != nil {
|
|
return nil
|
|
}
|
|
if state.Archived {
|
|
return nil
|
|
}
|
|
|
|
sessionID := filepath.Base(sessionDir)
|
|
|
|
info, err := os.Stat(sessionDir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
msgCount := 0
|
|
summary := ""
|
|
contextPath := filepath.Join(sessionDir, "context.jsonl")
|
|
if f, err := os.Open(contextPath); err == nil {
|
|
defer f.Close()
|
|
scanner := bufio.NewScanner(f)
|
|
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
|
for scanner.Scan() {
|
|
var entry struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
if json.Unmarshal(scanner.Bytes(), &entry) != nil {
|
|
continue
|
|
}
|
|
if entry.Role == "user" || entry.Role == "assistant" {
|
|
msgCount++
|
|
if entry.Role == "user" && entry.Content != "" && summary == "" {
|
|
summary = strings.TrimSpace(entry.Content)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_ = filterWorkDir // Kimi does not store cwd in session metadata; list all sessions.
|
|
|
|
if summary == "" {
|
|
summary = state.CustomTitle
|
|
}
|
|
if utf8.RuneCountInString(summary) > 60 {
|
|
summary = string([]rune(summary)[:60]) + "..."
|
|
}
|
|
|
|
return &core.AgentSessionInfo{
|
|
ID: sessionID,
|
|
Summary: summary,
|
|
MessageCount: msgCount,
|
|
ModifiedAt: info.ModTime(),
|
|
}
|
|
}
|
|
|
|
func findKimiSessionDir(sessionID string) string {
|
|
sessionsBase := kimiSessionsBaseDir()
|
|
if sessionsBase == "" {
|
|
return ""
|
|
}
|
|
|
|
entries, err := os.ReadDir(sessionsBase)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
projectDir := filepath.Join(sessionsBase, entry.Name())
|
|
sessionEntries, err := os.ReadDir(projectDir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, se := range sessionEntries {
|
|
if !se.IsDir() {
|
|
continue
|
|
}
|
|
if se.Name() == sessionID {
|
|
return filepath.Join(projectDir, se.Name())
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|