From bf5919bee06b42375d42a3ab70079e6213c2677d Mon Sep 17 00:00:00 2001 From: berk-developer Date: Mon, 9 Mar 2026 01:56:53 +0300 Subject: [PATCH 1/2] add kimi cli adapter --- CONTRIBUTING.md | 18 + README.md | 5 +- editors/index.js | 3 +- editors/kimi.js | 441 ++++++++++++++++++ index.js | 1 + package.json | 3 +- relay-client.js | 1 + share-image.js | 2 + test/editors/kimi.test.js | 117 +++++ test/fixtures/kimi/base/config.toml | 1 + test/fixtures/kimi/base/kimi.json | 9 + .../session-main/context.jsonl | 5 + .../session-main/context_sub_1.jsonl | 7 + .../session-main/wire.jsonl | 7 + test/fixtures/kimi/mismatch/config.toml | 1 + test/fixtures/kimi/mismatch/kimi.json | 9 + .../session-mismatch/context.jsonl | 5 + .../session-mismatch/wire.jsonl | 4 + test/fixtures/kimi/no-config/kimi.json | 9 + .../session-no-config/context.jsonl | 3 + .../session-no-config/context_1.jsonl | 3 + test/fixtures/kimi/no-mapping/config.toml | 1 + test/fixtures/kimi/no-mapping/kimi.json | 9 + .../session-no-mapping/context.jsonl | 3 + .../session-no-mapping/wire.jsonl | 4 + ui/src/lib/constants.js | 2 + 26 files changed, 669 insertions(+), 4 deletions(-) create mode 100644 editors/kimi.js create mode 100644 test/editors/kimi.test.js create mode 100644 test/fixtures/kimi/base/config.toml create mode 100644 test/fixtures/kimi/base/kimi.json create mode 100644 test/fixtures/kimi/base/sessions/cc34b7d06eb1f7001ade540e399bae11/session-main/context.jsonl create mode 100644 test/fixtures/kimi/base/sessions/cc34b7d06eb1f7001ade540e399bae11/session-main/context_sub_1.jsonl create mode 100644 test/fixtures/kimi/base/sessions/cc34b7d06eb1f7001ade540e399bae11/session-main/wire.jsonl create mode 100644 test/fixtures/kimi/mismatch/config.toml create mode 100644 test/fixtures/kimi/mismatch/kimi.json create mode 100644 test/fixtures/kimi/mismatch/sessions/cc34b7d06eb1f7001ade540e399bae11/session-mismatch/context.jsonl create mode 100644 test/fixtures/kimi/mismatch/sessions/cc34b7d06eb1f7001ade540e399bae11/session-mismatch/wire.jsonl create mode 100644 test/fixtures/kimi/no-config/kimi.json create mode 100644 test/fixtures/kimi/no-config/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-config/context.jsonl create mode 100644 test/fixtures/kimi/no-config/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-config/context_1.jsonl create mode 100644 test/fixtures/kimi/no-mapping/config.toml create mode 100644 test/fixtures/kimi/no-mapping/kimi.json create mode 100644 test/fixtures/kimi/no-mapping/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-mapping/context.jsonl create mode 100644 test/fixtures/kimi/no-mapping/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-mapping/wire.jsonl diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cf9ec08..c864892 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -147,6 +147,24 @@ Adapter behavior: - Token usage prefers `last_token_usage`; when only `total_token_usage` exists, the adapter diffs against the previous cumulative totals - Models are carried forward from the latest `turn_context`; if none is available, the session still ingests but leaves `_model` unset +### Kimi CLI + +Reads from `${KIMI_SHARE_DIR:-~/.kimi}`: +- `kimi.json` — maps working directories to session hashes via `work_dirs[].path` +- `config.toml` — optional `default_model` fallback for assistant messages +- `sessions///context*.jsonl` — authoritative transcript chunks +- `sessions///wire.jsonl` — timestamps and per-assistant token usage + +Adapter behavior: +- Session folders are discovered by enumerating `sessions/*/*` +- Project folders are resolved by MD5 hashing each `kimi.json` work-dir path and matching it to the session hash +- Transcript chunks include `context_sub_N.jsonl`, `context_N.jsonl`, and `context.jsonl`, ordered oldest-to-newest with archived chunks first +- `_checkpoint` and `_usage` transcript records are skipped as visible messages +- Assistant `tool_calls` become visible `[tool-call: ...]` transcript lines and populate `_toolCalls` analytics +- Tool messages are condensed from text blocks in tool results and linked back to the originating tool name when possible +- Token usage comes from `wire.jsonl` `StatusUpdate` events and is only attached when the number of status updates matches the number of assistant turns +- Historical model attribution is approximate: when no session-level model is stored, assistant messages fall back to `config.toml` `default_model` + ### VS Code / VS Code Insiders Reads from `~/Library/Application Support/{Code,Code - Insiders}/User/`: diff --git a/README.md b/README.md index 3f3526d..9679ce0 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@

Your Cursor, Windsurf, Claude Code sessions — analyzed, unified, tracked.
- One command to turn scattered AI conversations from 14 editors into a unified analytics dashboard.
Sessions, costs, models, tools — finally in one place. 100% local.
+ One command to turn scattered AI conversations from 15 editors into a unified analytics dashboard.
Sessions, costs, models, tools — finally in one place. 100% local.

npm - editors + editors license node

@@ -94,6 +94,7 @@ npx agentlytics --collect | **OpenCode** | ✅ | ✅ | ✅ | ✅ | | **Codex** | ✅ | ✅ | ✅ | ✅ | | **Gemini CLI** | ✅ | ✅ | ✅ | ✅ | +| **Kimi CLI** | ✅ | ✅ | ⚠️ | ✅ | | **Copilot CLI** | ✅ | ✅ | ✅ | ✅ | | **Cursor Agent** | ✅ | ❌ | ❌ | ❌ | | **Command Code** | ✅ | ✅ | ❌ | ❌ | diff --git a/editors/index.js b/editors/index.js index 9f6d28c..1332daf 100644 --- a/editors/index.js +++ b/editors/index.js @@ -6,11 +6,12 @@ const zed = require('./zed'); const opencode = require('./opencode'); const codex = require('./codex'); const gemini = require('./gemini'); +const kimi = require('./kimi'); const copilot = require('./copilot'); const cursorAgent = require('./cursor-agent'); const commandcode = require('./commandcode'); -const editors = [cursor, windsurf, claude, vscode, zed, opencode, codex, gemini, copilot, cursorAgent, commandcode]; +const editors = [cursor, windsurf, claude, vscode, zed, opencode, codex, gemini, kimi, copilot, cursorAgent, commandcode]; /** * Get all chats from all editor adapters, sorted by most recent first. diff --git a/editors/kimi.js b/editors/kimi.js new file mode 100644 index 0000000..e19b3ec --- /dev/null +++ b/editors/kimi.js @@ -0,0 +1,441 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const name = 'kimi-cli'; +const DEFAULT_KIMI_DIR = path.join(os.homedir(), '.kimi'); +const SESSIONS_SUBDIR = 'sessions'; +const KIMI_JSON = 'kimi.json'; +const CONFIG_TOML = 'config.toml'; +const MAX_TOOL_TEXT = 2000; + +function getChats() { + const kimiDir = getKimiDir(); + const sessionsDir = path.join(kimiDir, SESSIONS_SUBDIR); + if (!fs.existsSync(sessionsDir)) return []; + + const folderMap = loadFolderMap(kimiDir); + const defaultModel = loadDefaultModel(kimiDir); + const chats = []; + + for (const session of walkSessions(sessionsDir)) { + const parsed = parseSession(session.sessionDir, defaultModel); + if (!parsed || parsed.messages.length === 0) continue; + + chats.push({ + source: name, + composerId: session.sessionId, + name: parsed.title, + createdAt: parsed.createdAt, + lastUpdatedAt: parsed.lastUpdatedAt, + mode: 'kimi', + folder: folderMap.get(session.hash) || null, + encrypted: false, + bubbleCount: parsed.messages.length, + _sessionDir: session.sessionDir, + _defaultModel: defaultModel, + }); + } + + return chats; +} + +function getMessages(chat) { + const sessionDir = chat && chat._sessionDir; + if (!sessionDir || !fs.existsSync(sessionDir)) return []; + const parsed = parseSession(sessionDir, chat._defaultModel || null); + return parsed ? parsed.messages : []; +} + +function getKimiDir() { + const fromEnv = process.env.KIMI_SHARE_DIR && process.env.KIMI_SHARE_DIR.trim(); + return fromEnv ? path.resolve(fromEnv) : DEFAULT_KIMI_DIR; +} + +function walkSessions(sessionsDir) { + const sessions = []; + let hashDirs; + try { + hashDirs = fs.readdirSync(sessionsDir, { withFileTypes: true }); + } catch { + return sessions; + } + + for (const hashEntry of hashDirs) { + if (!hashEntry.isDirectory()) continue; + const hash = hashEntry.name; + const hashDir = path.join(sessionsDir, hash); + let sessionDirs; + try { + sessionDirs = fs.readdirSync(hashDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const sessionEntry of sessionDirs) { + if (!sessionEntry.isDirectory()) continue; + sessions.push({ + hash, + sessionId: sessionEntry.name, + sessionDir: path.join(hashDir, sessionEntry.name), + }); + } + } + + return sessions; +} + +function loadFolderMap(kimiDir) { + const map = new Map(); + const jsonPath = path.join(kimiDir, KIMI_JSON); + let data; + try { + data = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + } catch { + return map; + } + + const workDirs = Array.isArray(data.work_dirs) ? data.work_dirs : []; + for (const workDir of workDirs) { + if (!workDir || typeof workDir.path !== 'string' || !workDir.path.trim()) continue; + const rawPath = workDir.path.trim(); + map.set(hashPath(rawPath), rawPath); + try { + const resolved = path.resolve(rawPath); + map.set(hashPath(resolved), rawPath); + } catch { + // Ignore invalid paths and keep the raw mapping. + } + } + + return map; +} + +function loadDefaultModel(kimiDir) { + const configPath = path.join(kimiDir, CONFIG_TOML); + let raw; + try { + raw = fs.readFileSync(configPath, 'utf-8'); + } catch { + return null; + } + + const match = raw.match(/^default_model\s*=\s*"([^"]+)"/m); + return match ? match[1].trim() : null; +} + +function hashPath(folderPath) { + return crypto.createHash('md5').update(folderPath).digest('hex'); +} + +function parseSession(sessionDir, defaultModel) { + const contextFiles = getContextFiles(sessionDir); + if (contextFiles.length === 0) return null; + + const contextEntries = []; + for (const filePath of contextFiles) { + contextEntries.push(...readJsonl(filePath)); + } + + const wire = parseWireFile(path.join(sessionDir, 'wire.jsonl')); + const messages = []; + const assistantIndexes = []; + const toolNamesById = new Map(); + + for (const entry of contextEntries) { + if (!entry || entry.role === '_checkpoint' || entry.role === '_usage') continue; + + if (entry.role === 'user') { + const text = extractText(entry.content); + if (text) messages.push({ role: 'user', content: text }); + continue; + } + + if (entry.role === 'assistant') { + const parsed = parseAssistant(entry, defaultModel); + if (!parsed) continue; + assistantIndexes.push(messages.length); + if (Array.isArray(entry.tool_calls)) { + for (const call of entry.tool_calls) { + const callId = call && call.id; + const toolName = call && (call.function && call.function.name || call.name); + if (callId && toolName) toolNamesById.set(callId, toolName); + } + } + messages.push(parsed); + continue; + } + + if (entry.role === 'tool') { + const toolName = entry.tool_call_id ? toolNamesById.get(entry.tool_call_id) : null; + const text = extractToolText(entry.content, toolName); + if (text) messages.push({ role: 'tool', content: text }); + continue; + } + + if (entry.role === 'system') { + const text = extractText(entry.content); + if (text) messages.push({ role: 'system', content: text }); + } + } + + if (assistantIndexes.length > 0 && wire.statuses.length === assistantIndexes.length) { + for (let i = 0; i < assistantIndexes.length; i++) { + const msg = messages[assistantIndexes[i]]; + const usage = wire.statuses[i]; + if (!msg || !usage) continue; + if (usage.inputTokens > 0) msg._inputTokens = usage.inputTokens; + if (usage.outputTokens > 0) msg._outputTokens = usage.outputTokens; + if (usage.cacheRead > 0) msg._cacheRead = usage.cacheRead; + if (usage.cacheWrite > 0) msg._cacheWrite = usage.cacheWrite; + } + } + + const title = getTitle(messages); + const fallbackTimes = getFallbackTimes(contextFiles, wire.filePath ? [wire.filePath] : []); + + return { + title, + messages, + createdAt: wire.firstTimestamp || fallbackTimes.createdAt, + lastUpdatedAt: wire.lastTimestamp || fallbackTimes.lastUpdatedAt, + }; +} + +function getContextFiles(sessionDir) { + let fileNames; + try { + fileNames = fs.readdirSync(sessionDir); + } catch { + return []; + } + + return fileNames + .filter((name) => /^context(?:_(?:sub_)?\d+)?\.jsonl$/.test(name)) + .sort(compareContextFiles) + .map((name) => path.join(sessionDir, name)); +} + +function compareContextFiles(a, b) { + const aLive = a === 'context.jsonl'; + const bLive = b === 'context.jsonl'; + if (aLive !== bLive) return aLive ? 1 : -1; + + const aNum = getContextSuffix(a); + const bNum = getContextSuffix(b); + if (aNum !== bNum) return aNum - bNum; + + return a.localeCompare(b, undefined, { numeric: true }); +} + +function getContextSuffix(fileName) { + const match = fileName.match(/^context(?:_sub)?_(\d+)\.jsonl$/); + return match ? parseInt(match[1], 10) : Number.MAX_SAFE_INTEGER; +} + +function readJsonl(filePath) { + let raw; + try { + raw = fs.readFileSync(filePath, 'utf-8'); + } catch { + return []; + } + + return raw + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter(Boolean); +} + +function parseAssistant(entry, defaultModel) { + const parts = []; + + if (typeof entry.content === 'string') { + const text = cleanText(entry.content); + if (text) parts.push(text); + } else if (Array.isArray(entry.content)) { + for (const block of entry.content) { + if (!block) continue; + if (block.type === 'think' && block.think && !block.encrypted) { + parts.push(`[thinking] ${cleanText(block.think)}`); + } else if (block.type === 'text' && block.text) { + parts.push(cleanText(block.text)); + } + } + } + + const toolCalls = []; + if (Array.isArray(entry.tool_calls)) { + for (const call of entry.tool_calls) { + const normalized = normalizeToolCall(call); + if (!normalized) continue; + const argKeys = Object.keys(normalized.args || {}).join(', '); + parts.push(`[tool-call: ${normalized.name}(${argKeys})]`); + toolCalls.push(normalized); + } + } + + const content = parts.filter(Boolean).join('\n').trim(); + if (!content && toolCalls.length === 0) return null; + + const message = { + role: 'assistant', + content, + }; + + const model = entry.model || defaultModel || null; + if (model) message._model = model; + if (toolCalls.length > 0) message._toolCalls = toolCalls; + return message; +} + +function normalizeToolCall(call) { + if (!call) return null; + const name = call.function && call.function.name || call.name || 'unknown'; + const rawArgs = call.function && call.function.arguments !== undefined + ? call.function.arguments + : call.arguments !== undefined + ? call.arguments + : call.input; + + let args = {}; + if (typeof rawArgs === 'string') { + try { + args = JSON.parse(rawArgs); + } catch { + args = {}; + } + } else if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) { + args = rawArgs; + } + + return { name, args }; +} + +function extractToolText(content, toolName) { + const parts = []; + if (typeof content === 'string') { + const text = cleanText(stripSystemTags(content)); + if (text) parts.push(text); + } else if (Array.isArray(content)) { + for (const block of content) { + if (!block || block.type !== 'text' || !block.text) continue; + const text = cleanText(stripSystemTags(block.text)); + if (text) parts.push(text); + } + } + + const joined = parts.join('\n').trim(); + if (!joined) return null; + + const condensed = joined.length > MAX_TOOL_TEXT + ? `${joined.substring(0, MAX_TOOL_TEXT)}...` + : joined; + + return toolName ? `[${toolName}] ${condensed}` : condensed; +} + +function extractText(content) { + if (!content) return ''; + if (typeof content === 'string') return cleanText(content); + if (Array.isArray(content)) { + return content + .map((block) => { + if (!block) return ''; + if (block.type === 'text' && block.text) return cleanText(block.text); + if (block.type === 'think' && block.think && !block.encrypted) return cleanText(block.think); + return ''; + }) + .filter(Boolean) + .join('\n') + .trim(); + } + if (content.text) return cleanText(content.text); + return ''; +} + +function cleanText(text) { + return String(text || '').replace(/\r\n/g, '\n').trim(); +} + +function stripSystemTags(text) { + return String(text || '') + .replace(/^/, '') + .replace(/<\/system>$/, '') + .trim(); +} + +function getTitle(messages) { + const firstUser = messages.find((msg) => msg.role === 'user' && msg.content && msg.content.trim()); + if (!firstUser) return null; + return firstUser.content.replace(/\s+/g, ' ').trim().substring(0, 120) || null; +} + +function parseWireFile(filePath) { + if (!fs.existsSync(filePath)) { + return { filePath: null, firstTimestamp: null, lastTimestamp: null, statuses: [] }; + } + + const entries = readJsonl(filePath); + let firstTimestamp = null; + let lastTimestamp = null; + const statuses = []; + + for (const entry of entries) { + const timestamp = toTimestampMs(entry.timestamp); + if (timestamp) { + if (firstTimestamp === null) firstTimestamp = timestamp; + lastTimestamp = timestamp; + } + + const type = entry.message && entry.message.type || entry.type; + if (type !== 'StatusUpdate') continue; + + const usage = entry.message && entry.message.payload && entry.message.payload.token_usage || {}; + statuses.push({ + inputTokens: usage.input_other || 0, + outputTokens: usage.output || 0, + cacheRead: usage.input_cache_read || 0, + cacheWrite: usage.input_cache_creation || 0, + }); + } + + return { filePath, firstTimestamp, lastTimestamp, statuses }; +} + +function toTimestampMs(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + return value > 1e12 ? Math.round(value) : Math.round(value * 1000); +} + +function getFallbackTimes(contextFiles, extraFiles = []) { + const filePaths = [...contextFiles, ...extraFiles].filter((filePath) => filePath && fs.existsSync(filePath)); + let createdAt = null; + let lastUpdatedAt = null; + + for (const filePath of filePaths) { + let stat; + try { + stat = fs.statSync(filePath); + } catch { + continue; + } + + const birthtime = Number.isFinite(stat.birthtimeMs) && stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs; + const mtime = stat.mtimeMs || birthtime; + if (birthtime && (createdAt === null || birthtime < createdAt)) createdAt = birthtime; + if (mtime && (lastUpdatedAt === null || mtime > lastUpdatedAt)) lastUpdatedAt = mtime; + } + + return { createdAt, lastUpdatedAt }; +} + +module.exports = { name, getChats, getMessages }; diff --git a/index.js b/index.js index 75d0ba8..7acfe00 100755 --- a/index.js +++ b/index.js @@ -228,6 +228,7 @@ const EDITOR_DISPLAY = [ ['opencode', 'OpenCode'], ['codex', 'Codex'], ['gemini-cli', 'Gemini CLI'], + ['kimi-cli', 'Kimi CLI'], ['copilot-cli', 'Copilot CLI'], ['cursor-agent', 'Cursor Agent'], ['commandcode', 'Command Code'], diff --git a/package.json b/package.json index 686cd8c..3f96970 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "scripts": { "start": "node index.js", "build": "cd ui && npm install && npm run build", - "dev": "cd ui && npm run dev" + "dev": "cd ui && npm run dev", + "test": "node --test" }, "keywords": [ "cursor", diff --git a/relay-client.js b/relay-client.js index ac89d81..d1d755a 100644 --- a/relay-client.js +++ b/relay-client.js @@ -20,6 +20,7 @@ const EDITOR_LABELS = { 'opencode': 'OpenCode', 'codex': 'Codex CLI', 'gemini-cli': 'Gemini CLI', + 'kimi-cli': 'Kimi CLI', 'copilot-cli': 'Copilot CLI', 'cursor-agent': 'Cursor (Background Agent)', 'commandcode': 'CommandCode', diff --git a/share-image.js b/share-image.js index d1bd885..229db88 100644 --- a/share-image.js +++ b/share-image.js @@ -31,6 +31,7 @@ const EDITOR_COLORS = { 'opencode': '#ec4899', 'codex': '#0f766e', 'gemini-cli': '#4285f4', + 'kimi-cli': '#84cc16', 'copilot-cli': '#8957e5', 'cursor-agent': '#f59e0b', 'commandcode': '#e11d48', @@ -49,6 +50,7 @@ const EDITOR_LABELS = { 'opencode': 'OpenCode', 'codex': 'Codex', 'gemini-cli': 'Gemini CLI', + 'kimi-cli': 'Kimi CLI', 'copilot-cli': 'Copilot CLI', 'cursor-agent': 'Cursor Agent', 'commandcode': 'Cmd Code', diff --git a/test/editors/kimi.test.js b/test/editors/kimi.test.js new file mode 100644 index 0000000..2e7f097 --- /dev/null +++ b/test/editors/kimi.test.js @@ -0,0 +1,117 @@ +const assert = require('assert/strict'); +const path = require('path'); +const test = require('node:test'); + +const kimi = require('../../editors/kimi'); + +const FIXTURES_DIR = path.join(__dirname, '..', 'fixtures', 'kimi'); + +function withShareDir(fixtureName, fn) { + const previous = process.env.KIMI_SHARE_DIR; + process.env.KIMI_SHARE_DIR = path.join(FIXTURES_DIR, fixtureName); + try { + return fn(); + } finally { + if (previous === undefined) delete process.env.KIMI_SHARE_DIR; + else process.env.KIMI_SHARE_DIR = previous; + } +} + +test('reads Kimi sessions from KIMI_SHARE_DIR and resolves hashed folders', () => { + withShareDir('base', () => { + const chats = kimi.getChats(); + assert.equal(chats.length, 1); + + const chat = chats[0]; + assert.equal(chat.source, 'kimi-cli'); + assert.equal(chat.composerId, 'session-main'); + assert.equal(chat.mode, 'kimi'); + assert.equal(chat.folder, '/Users/test/project-a'); + assert.equal(chat.name, 'Inspect the repository status and summarize the next step'); + assert.equal(chat.createdAt, 1700000000125); + assert.equal(chat.lastUpdatedAt, 1700000020750); + assert.equal(chat.bubbleCount, 5); + }); +}); + +test('merges archived and live context files, preserving transcript order', () => { + withShareDir('base', () => { + const chat = kimi.getChats()[0]; + const messages = kimi.getMessages(chat); + + assert.deepEqual( + messages.map((message) => message.role), + ['user', 'assistant', 'tool', 'user', 'assistant'] + ); + assert.equal(messages[0].content, 'Inspect the repository status and summarize the next step'); + assert.match(messages[1].content, /\[thinking\] I should inspect the repo first\./); + assert.match(messages[1].content, /\[tool-call: ReadFile\(path\)\]/); + assert.match(messages[2].content, /^\[ReadFile\] 2 lines read from file starting from line 1\./); + assert.equal(messages[3].content, 'Now tell me the deployment risk'); + assert.equal(messages[4].content, 'The main risk is missing environment validation.'); + }); +}); + +test('extracts Kimi tool calls and sequential StatusUpdate token usage', () => { + withShareDir('base', () => { + const chat = kimi.getChats()[0]; + const messages = kimi.getMessages(chat); + const assistants = messages.filter((message) => message.role === 'assistant'); + + assert.deepEqual(assistants[0]._toolCalls, [ + { name: 'ReadFile', args: { path: 'package.json' } }, + ]); + assert.equal(assistants[0]._model, 'kimi-code/kimi-for-coding'); + assert.equal(assistants[0]._inputTokens, 1200); + assert.equal(assistants[0]._outputTokens, 300); + assert.equal(assistants[0]._cacheRead, 80); + assert.equal(assistants[0]._cacheWrite, 10); + + assert.equal(assistants[1]._model, 'kimi-code/kimi-for-coding'); + assert.equal(assistants[1]._inputTokens, 900); + assert.equal(assistants[1]._outputTokens, 250); + assert.equal(assistants[1]._cacheRead, 40); + assert.equal(assistants[1]._cacheWrite, undefined); + }); +}); + +test('drops token attribution when StatusUpdate counts do not match assistant turns', () => { + withShareDir('mismatch', () => { + const chat = kimi.getChats()[0]; + const assistants = kimi.getMessages(chat).filter((message) => message.role === 'assistant'); + + assert.equal(assistants.length, 2); + for (const assistant of assistants) { + assert.equal(assistant._inputTokens, undefined); + assert.equal(assistant._outputTokens, undefined); + assert.equal(assistant._cacheRead, undefined); + assert.equal(assistant._cacheWrite, undefined); + assert.equal(assistant._model, 'kimi-code/kimi-for-coding'); + } + }); +}); + +test('falls back safely when wire.jsonl is missing and config.toml is absent', () => { + withShareDir('no-config', () => { + const chat = kimi.getChats()[0]; + const messages = kimi.getMessages(chat); + + assert.equal(chat.folder, '/Users/test/project-a'); + assert.equal(chat.createdAt > 0, true); + assert.equal(chat.lastUpdatedAt > 0, true); + assert.equal(messages[1]._model, undefined); + assert.equal(messages[3]._model, undefined); + assert.deepEqual( + messages.map((message) => message.role), + ['user', 'assistant', 'user', 'assistant'] + ); + }); +}); + +test('keeps folder null when kimi.json does not map the session hash', () => { + withShareDir('no-mapping', () => { + const chats = kimi.getChats(); + assert.equal(chats.length, 1); + assert.equal(chats[0].folder, null); + }); +}); diff --git a/test/fixtures/kimi/base/config.toml b/test/fixtures/kimi/base/config.toml new file mode 100644 index 0000000..8fdb092 --- /dev/null +++ b/test/fixtures/kimi/base/config.toml @@ -0,0 +1 @@ +default_model = "kimi-code/kimi-for-coding" diff --git a/test/fixtures/kimi/base/kimi.json b/test/fixtures/kimi/base/kimi.json new file mode 100644 index 0000000..ce87211 --- /dev/null +++ b/test/fixtures/kimi/base/kimi.json @@ -0,0 +1,9 @@ +{ + "work_dirs": [ + { + "path": "/Users/test/project-a", + "kaos": "local", + "last_session_id": "session-main" + } + ] +} diff --git a/test/fixtures/kimi/base/sessions/cc34b7d06eb1f7001ade540e399bae11/session-main/context.jsonl b/test/fixtures/kimi/base/sessions/cc34b7d06eb1f7001ade540e399bae11/session-main/context.jsonl new file mode 100644 index 0000000..9f032fb --- /dev/null +++ b/test/fixtures/kimi/base/sessions/cc34b7d06eb1f7001ade540e399bae11/session-main/context.jsonl @@ -0,0 +1,5 @@ +{"role":"_checkpoint","id":2} +{"role":"user","content":"Now tell me the deployment risk"} +{"role":"_usage","token_count":700} +{"role":"assistant","content":[{"type":"text","text":"The main risk is missing environment validation."}]} +{"role":"_usage","token_count":760} diff --git a/test/fixtures/kimi/base/sessions/cc34b7d06eb1f7001ade540e399bae11/session-main/context_sub_1.jsonl b/test/fixtures/kimi/base/sessions/cc34b7d06eb1f7001ade540e399bae11/session-main/context_sub_1.jsonl new file mode 100644 index 0000000..277292f --- /dev/null +++ b/test/fixtures/kimi/base/sessions/cc34b7d06eb1f7001ade540e399bae11/session-main/context_sub_1.jsonl @@ -0,0 +1,7 @@ +{"role":"_checkpoint","id":0} +{"role":"user","content":"Inspect the repository status and summarize the next step"} +{"role":"_checkpoint","id":1} +{"role":"_usage","token_count":99} +{"role":"assistant","content":[{"type":"think","think":"I should inspect the repo first.","encrypted":null},{"type":"text","text":"I will read package.json before summarizing."}],"tool_calls":[{"type":"function","id":"tool_read_package","function":{"name":"ReadFile","arguments":"{\"path\":\"package.json\"}"}}]} +{"role":"_usage","token_count":150} +{"role":"tool","tool_call_id":"tool_read_package","content":[{"type":"text","text":"2 lines read from file starting from line 1."},{"type":"text","text":"1\t{\n2\t \"name\": \"demo\"\n"}]} diff --git a/test/fixtures/kimi/base/sessions/cc34b7d06eb1f7001ade540e399bae11/session-main/wire.jsonl b/test/fixtures/kimi/base/sessions/cc34b7d06eb1f7001ade540e399bae11/session-main/wire.jsonl new file mode 100644 index 0000000..0854973 --- /dev/null +++ b/test/fixtures/kimi/base/sessions/cc34b7d06eb1f7001ade540e399bae11/session-main/wire.jsonl @@ -0,0 +1,7 @@ +{"type":"metadata","protocol_version":"1.1"} +{"timestamp":1700000000.125,"message":{"type":"TurnBegin","payload":{"user_input":[{"type":"text","text":"Inspect the repository status and summarize the next step"}]}}} +{"timestamp":1700000001.0,"message":{"type":"StepBegin","payload":{"n":1}}} +{"timestamp":1700000002.0,"message":{"type":"StatusUpdate","payload":{"context_usage":0.1,"token_usage":{"input_other":1200,"output":300,"input_cache_read":80,"input_cache_creation":10},"message_id":"msg-1"}}} +{"timestamp":1700000010.0,"message":{"type":"TurnBegin","payload":{"user_input":[{"type":"text","text":"Now tell me the deployment risk"}]}}} +{"timestamp":1700000012.5,"message":{"type":"StatusUpdate","payload":{"context_usage":0.2,"token_usage":{"input_other":900,"output":250,"input_cache_read":40,"input_cache_creation":0},"message_id":"msg-2"}}} +{"timestamp":1700000020.75,"message":{"type":"TurnEnd","payload":{}}} diff --git a/test/fixtures/kimi/mismatch/config.toml b/test/fixtures/kimi/mismatch/config.toml new file mode 100644 index 0000000..8fdb092 --- /dev/null +++ b/test/fixtures/kimi/mismatch/config.toml @@ -0,0 +1 @@ +default_model = "kimi-code/kimi-for-coding" diff --git a/test/fixtures/kimi/mismatch/kimi.json b/test/fixtures/kimi/mismatch/kimi.json new file mode 100644 index 0000000..d3cd944 --- /dev/null +++ b/test/fixtures/kimi/mismatch/kimi.json @@ -0,0 +1,9 @@ +{ + "work_dirs": [ + { + "path": "/Users/test/project-a", + "kaos": "local", + "last_session_id": "session-mismatch" + } + ] +} diff --git a/test/fixtures/kimi/mismatch/sessions/cc34b7d06eb1f7001ade540e399bae11/session-mismatch/context.jsonl b/test/fixtures/kimi/mismatch/sessions/cc34b7d06eb1f7001ade540e399bae11/session-mismatch/context.jsonl new file mode 100644 index 0000000..90a9518 --- /dev/null +++ b/test/fixtures/kimi/mismatch/sessions/cc34b7d06eb1f7001ade540e399bae11/session-mismatch/context.jsonl @@ -0,0 +1,5 @@ +{"role":"_checkpoint","id":0} +{"role":"user","content":"First prompt"} +{"role":"assistant","content":[{"type":"text","text":"First answer"}]} +{"role":"user","content":"Second prompt"} +{"role":"assistant","content":[{"type":"text","text":"Second answer"}]} diff --git a/test/fixtures/kimi/mismatch/sessions/cc34b7d06eb1f7001ade540e399bae11/session-mismatch/wire.jsonl b/test/fixtures/kimi/mismatch/sessions/cc34b7d06eb1f7001ade540e399bae11/session-mismatch/wire.jsonl new file mode 100644 index 0000000..27a62fe --- /dev/null +++ b/test/fixtures/kimi/mismatch/sessions/cc34b7d06eb1f7001ade540e399bae11/session-mismatch/wire.jsonl @@ -0,0 +1,4 @@ +{"type":"metadata","protocol_version":"1.1"} +{"timestamp":1700100000.0,"message":{"type":"TurnBegin","payload":{"user_input":[{"type":"text","text":"First prompt"}]}}} +{"timestamp":1700100001.0,"message":{"type":"StatusUpdate","payload":{"context_usage":0.1,"token_usage":{"input_other":100,"output":20,"input_cache_read":5,"input_cache_creation":1},"message_id":"msg-mismatch-1"}}} +{"timestamp":1700100002.0,"message":{"type":"TurnEnd","payload":{}}} diff --git a/test/fixtures/kimi/no-config/kimi.json b/test/fixtures/kimi/no-config/kimi.json new file mode 100644 index 0000000..dab3d25 --- /dev/null +++ b/test/fixtures/kimi/no-config/kimi.json @@ -0,0 +1,9 @@ +{ + "work_dirs": [ + { + "path": "/Users/test/project-a", + "kaos": "local", + "last_session_id": "session-no-config" + } + ] +} diff --git a/test/fixtures/kimi/no-config/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-config/context.jsonl b/test/fixtures/kimi/no-config/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-config/context.jsonl new file mode 100644 index 0000000..e7ad8a8 --- /dev/null +++ b/test/fixtures/kimi/no-config/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-config/context.jsonl @@ -0,0 +1,3 @@ +{"role":"_checkpoint","id":1} +{"role":"user","content":"Live prompt"} +{"role":"assistant","content":[{"type":"text","text":"Live answer"}]} diff --git a/test/fixtures/kimi/no-config/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-config/context_1.jsonl b/test/fixtures/kimi/no-config/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-config/context_1.jsonl new file mode 100644 index 0000000..ccba8cf --- /dev/null +++ b/test/fixtures/kimi/no-config/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-config/context_1.jsonl @@ -0,0 +1,3 @@ +{"role":"_checkpoint","id":0} +{"role":"user","content":"Archived prompt"} +{"role":"assistant","content":[{"type":"text","text":"Archived answer"}]} diff --git a/test/fixtures/kimi/no-mapping/config.toml b/test/fixtures/kimi/no-mapping/config.toml new file mode 100644 index 0000000..8fdb092 --- /dev/null +++ b/test/fixtures/kimi/no-mapping/config.toml @@ -0,0 +1 @@ +default_model = "kimi-code/kimi-for-coding" diff --git a/test/fixtures/kimi/no-mapping/kimi.json b/test/fixtures/kimi/no-mapping/kimi.json new file mode 100644 index 0000000..025d82f --- /dev/null +++ b/test/fixtures/kimi/no-mapping/kimi.json @@ -0,0 +1,9 @@ +{ + "work_dirs": [ + { + "path": "/Users/test/another-project", + "kaos": "local", + "last_session_id": "session-no-mapping" + } + ] +} diff --git a/test/fixtures/kimi/no-mapping/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-mapping/context.jsonl b/test/fixtures/kimi/no-mapping/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-mapping/context.jsonl new file mode 100644 index 0000000..545da06 --- /dev/null +++ b/test/fixtures/kimi/no-mapping/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-mapping/context.jsonl @@ -0,0 +1,3 @@ +{"role":"_checkpoint","id":0} +{"role":"user","content":"Prompt without mapping"} +{"role":"assistant","content":[{"type":"text","text":"Answer without mapping"}]} diff --git a/test/fixtures/kimi/no-mapping/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-mapping/wire.jsonl b/test/fixtures/kimi/no-mapping/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-mapping/wire.jsonl new file mode 100644 index 0000000..d0d90dc --- /dev/null +++ b/test/fixtures/kimi/no-mapping/sessions/cc34b7d06eb1f7001ade540e399bae11/session-no-mapping/wire.jsonl @@ -0,0 +1,4 @@ +{"type":"metadata","protocol_version":"1.1"} +{"timestamp":1700200000.0,"message":{"type":"TurnBegin","payload":{"user_input":[{"type":"text","text":"Prompt without mapping"}]}}} +{"timestamp":1700200001.0,"message":{"type":"StatusUpdate","payload":{"context_usage":0.1,"token_usage":{"input_other":200,"output":40,"input_cache_read":8,"input_cache_creation":0},"message_id":"msg-no-mapping-1"}}} +{"timestamp":1700200002.0,"message":{"type":"TurnEnd","payload":{}}} diff --git a/ui/src/lib/constants.js b/ui/src/lib/constants.js index 59d451f..eec2933 100644 --- a/ui/src/lib/constants.js +++ b/ui/src/lib/constants.js @@ -11,6 +11,7 @@ export const EDITOR_COLORS = { 'opencode': '#ec4899', 'codex': '#0f766e', 'gemini-cli': '#4285f4', + 'kimi-cli': '#84cc16', 'copilot-cli': '#8957e5', 'cursor-agent': '#f59e0b', 'commandcode': '#e11d48', @@ -29,6 +30,7 @@ export const EDITOR_LABELS = { 'opencode': 'OpenCode', 'codex': 'Codex', 'gemini-cli': 'Gemini CLI', + 'kimi-cli': 'Kimi CLI', 'copilot-cli': 'Copilot CLI', 'cursor-agent': 'Cursor Agent', 'commandcode': 'Command Code', From c6d3f43ed3b5dffabdff8183b9bf61a3105e6406 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 09:14:57 +0000 Subject: [PATCH 2/2] refactor: centralize EDITOR_LABELS and EDITOR_COLORS in editors/index.js Move duplicated label and color definitions from share-image.js and relay-client.js into editors/index.js as the single source of truth. This resolves the merge conflict in PR #12 and addresses the owner's request to export labels from the editor file. https://claude.ai/code/session_01UyQSBoGQwx5ih6sL6zEF5t --- editors/index.js | 40 +++++++++++++++++++++++++++++++++++++++- relay-client.js | 20 +------------------- share-image.js | 40 ++-------------------------------------- 3 files changed, 42 insertions(+), 58 deletions(-) diff --git a/editors/index.js b/editors/index.js index 1332daf..ed8ce6d 100644 --- a/editors/index.js +++ b/editors/index.js @@ -11,6 +11,44 @@ const copilot = require('./copilot'); const cursorAgent = require('./cursor-agent'); const commandcode = require('./commandcode'); +const EDITOR_COLORS = { + 'cursor': '#f59e0b', + 'windsurf': '#06b6d4', + 'windsurf-next': '#22d3ee', + 'antigravity': '#a78bfa', + 'claude-code': '#f97316', + 'claude': '#f97316', + 'vscode': '#3b82f6', + 'vscode-insiders': '#60a5fa', + 'zed': '#10b981', + 'opencode': '#ec4899', + 'codex': '#0f766e', + 'gemini-cli': '#4285f4', + 'kimi-cli': '#84cc16', + 'copilot-cli': '#8957e5', + 'cursor-agent': '#f59e0b', + 'commandcode': '#e11d48', +}; + +const EDITOR_LABELS = { + 'cursor': 'Cursor', + 'windsurf': 'Windsurf', + 'windsurf-next': 'Windsurf Next', + 'antigravity': 'Antigravity', + 'claude-code': 'Claude Code', + 'claude': 'Claude Code', + 'vscode': 'VS Code', + 'vscode-insiders': 'VS Code Insiders', + 'zed': 'Zed', + 'opencode': 'OpenCode', + 'codex': 'Codex', + 'gemini-cli': 'Gemini CLI', + 'kimi-cli': 'Kimi CLI', + 'copilot-cli': 'Copilot CLI', + 'cursor-agent': 'Cursor Agent', + 'commandcode': 'Command Code', +}; + const editors = [cursor, windsurf, claude, vscode, zed, opencode, codex, gemini, kimi, copilot, cursorAgent, commandcode]; /** @@ -53,4 +91,4 @@ function resetCaches() { } } -module.exports = { getAllChats, getMessages, editors, resetCaches }; +module.exports = { getAllChats, getMessages, editors, resetCaches, EDITOR_LABELS, EDITOR_COLORS }; diff --git a/relay-client.js b/relay-client.js index d1d755a..9d046b9 100644 --- a/relay-client.js +++ b/relay-client.js @@ -4,28 +4,10 @@ const readline = require('readline'); const crypto = require('crypto'); const cache = require('./cache'); +const { EDITOR_LABELS } = require('./editors'); const SYNC_INTERVAL_MS = 30000; // 30 seconds -const EDITOR_LABELS = { - 'cursor': 'Cursor', - 'windsurf': 'Windsurf', - 'windsurf-next': 'Windsurf Next', - 'antigravity': 'Antigravity', - 'claude-code': 'Claude Code', - 'claude': 'Claude Code', - 'vscode': 'VS Code', - 'vscode-insiders': 'VS Code Insiders', - 'zed': 'Zed', - 'opencode': 'OpenCode', - 'codex': 'Codex CLI', - 'gemini-cli': 'Gemini CLI', - 'kimi-cli': 'Kimi CLI', - 'copilot-cli': 'Copilot CLI', - 'cursor-agent': 'Cursor (Background Agent)', - 'commandcode': 'CommandCode', -}; - /** * Interactive project picker using readline (no external deps beyond Node built-ins). * Returns an array of selected folder paths. diff --git a/share-image.js b/share-image.js index 229db88..159e172 100644 --- a/share-image.js +++ b/share-image.js @@ -3,6 +3,8 @@ * Accepts an `opts` object to toggle sections on/off. */ +const { EDITOR_LABELS, EDITOR_COLORS } = require('./editors'); + function fmt(n) { if (n == null) return '0'; if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; @@ -18,44 +20,6 @@ function fmtCost(n) { return '$' + n.toFixed(2); } -const EDITOR_COLORS = { - 'cursor': '#f59e0b', - 'windsurf': '#06b6d4', - 'windsurf-next': '#22d3ee', - 'antigravity': '#a78bfa', - 'claude-code': '#f97316', - 'claude': '#f97316', - 'vscode': '#3b82f6', - 'vscode-insiders': '#60a5fa', - 'zed': '#10b981', - 'opencode': '#ec4899', - 'codex': '#0f766e', - 'gemini-cli': '#4285f4', - 'kimi-cli': '#84cc16', - 'copilot-cli': '#8957e5', - 'cursor-agent': '#f59e0b', - 'commandcode': '#e11d48', -}; - -const EDITOR_LABELS = { - 'cursor': 'Cursor', - 'windsurf': 'Windsurf', - 'windsurf-next': 'WS Next', - 'antigravity': 'Antigravity', - 'claude-code': 'Claude Code', - 'claude': 'Claude Code', - 'vscode': 'VS Code', - 'vscode-insiders': 'VS Code Ins.', - 'zed': 'Zed', - 'opencode': 'OpenCode', - 'codex': 'Codex', - 'gemini-cli': 'Gemini CLI', - 'kimi-cli': 'Kimi CLI', - 'copilot-cli': 'Copilot CLI', - 'cursor-agent': 'Cursor Agent', - 'commandcode': 'Cmd Code', -}; - /** * @param {object} overview — from getCachedOverview() * @param {object} stats — from getCachedDashboardStats()