@vibe-cafe/vibe-usage 0.7.11 → 0.7.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/parsers/cline.js +116 -0
- package/src/parsers/index.js +6 -0
- package/src/parsers/kiro.js +238 -0
- package/src/parsers/roo-code.js +133 -0
- package/src/sync.js +14 -0
- package/src/tools.js +65 -1
package/README.md
CHANGED
|
@@ -59,6 +59,9 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
59
59
|
| Amp | `~/.local/share/amp/threads/` |
|
|
60
60
|
| Droid | `~/.factory/sessions/` |
|
|
61
61
|
| Hermes | `~/.hermes/state.db` + `~/.hermes/profiles/<name>/state.db` (SQLite, multi-profile) |
|
|
62
|
+
| Kiro | `~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent/dev_data/devdata.sqlite` (SQLite, JSONL fallback; model name resolved from `.chat` timeline) |
|
|
63
|
+
| Cline | `<host>/User/globalStorage/saoudrizwan.claude-dev/{state/taskHistory.json,tasks/<id>/ui_messages.json}` (walks all VSCode-fork hosts: Code, Cursor, Windsurf, VSCodium, Trae, ...) |
|
|
64
|
+
| Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
|
|
62
65
|
|
|
63
66
|
## How It Works
|
|
64
67
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
5
|
+
|
|
6
|
+
const EXTENSION_ID = 'saoudrizwan.claude-dev';
|
|
7
|
+
|
|
8
|
+
// VSCode-fork application names that may host extensions.
|
|
9
|
+
const HOSTS = ['Code', 'Cursor', 'Windsurf', 'VSCodium', 'Code - Insiders', 'Trae', 'Trae CN'];
|
|
10
|
+
|
|
11
|
+
function getHostRoots() {
|
|
12
|
+
const out = [];
|
|
13
|
+
if (process.platform === 'darwin') {
|
|
14
|
+
const base = join(homedir(), 'Library', 'Application Support');
|
|
15
|
+
for (const h of HOSTS) out.push(join(base, h));
|
|
16
|
+
} else if (process.platform === 'win32') {
|
|
17
|
+
const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
|
|
18
|
+
for (const h of HOSTS) out.push(join(appData, h));
|
|
19
|
+
} else {
|
|
20
|
+
const xdg = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
|
|
21
|
+
for (const h of HOSTS) out.push(join(xdg, h));
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function findClineExtensionDirs() {
|
|
27
|
+
const dirs = [];
|
|
28
|
+
for (const root of getHostRoots()) {
|
|
29
|
+
const ext = join(root, 'User', 'globalStorage', EXTENSION_ID);
|
|
30
|
+
try {
|
|
31
|
+
if (statSync(ext).isDirectory()) dirs.push(ext);
|
|
32
|
+
} catch {
|
|
33
|
+
// not installed in this host; skip
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return dirs;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readJsonSafe(path) {
|
|
40
|
+
try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function projectFromPath(absPath) {
|
|
44
|
+
if (!absPath || typeof absPath !== 'string') return 'unknown';
|
|
45
|
+
const trimmed = absPath.replace(/[\\/]+$/, '');
|
|
46
|
+
const name = basename(trimmed);
|
|
47
|
+
return name || 'unknown';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function parse() {
|
|
51
|
+
const extDirs = findClineExtensionDirs();
|
|
52
|
+
if (extDirs.length === 0) return { buckets: [], sessions: [] };
|
|
53
|
+
|
|
54
|
+
const entries = [];
|
|
55
|
+
const events = [];
|
|
56
|
+
|
|
57
|
+
for (const extDir of extDirs) {
|
|
58
|
+
const history = readJsonSafe(join(extDir, 'state', 'taskHistory.json'));
|
|
59
|
+
if (!Array.isArray(history)) continue;
|
|
60
|
+
|
|
61
|
+
for (const item of history) {
|
|
62
|
+
try {
|
|
63
|
+
if (!item || typeof item !== 'object' || !item.id) continue;
|
|
64
|
+
const taskId = String(item.id);
|
|
65
|
+
const project = projectFromPath(item.cwdOnTaskInitialization || item.shadowGitConfigWorkTree);
|
|
66
|
+
const fallbackModel = (item.modelId && String(item.modelId).trim()) || 'cline-unknown';
|
|
67
|
+
|
|
68
|
+
const messages = readJsonSafe(join(extDir, 'tasks', taskId, 'ui_messages.json'));
|
|
69
|
+
if (!Array.isArray(messages)) continue;
|
|
70
|
+
|
|
71
|
+
for (const msg of messages) {
|
|
72
|
+
if (!msg || typeof msg !== 'object') continue;
|
|
73
|
+
const ts = Number(msg.ts);
|
|
74
|
+
if (!Number.isFinite(ts)) continue;
|
|
75
|
+
const timestamp = new Date(ts);
|
|
76
|
+
|
|
77
|
+
if (msg.type === 'say' && msg.say === 'api_req_started') {
|
|
78
|
+
let info = null;
|
|
79
|
+
try { info = JSON.parse(msg.text); } catch { /* skip */ }
|
|
80
|
+
if (!info) continue;
|
|
81
|
+
|
|
82
|
+
const inputTokens = Math.max(0, Number(info.tokensIn) || 0);
|
|
83
|
+
const outputTokens = Math.max(0, Number(info.tokensOut) || 0);
|
|
84
|
+
const cacheWrites = Math.max(0, Number(info.cacheWrites) || 0);
|
|
85
|
+
const cacheReads = Math.max(0, Number(info.cacheReads) || 0);
|
|
86
|
+
if (inputTokens + outputTokens + cacheWrites + cacheReads === 0) continue;
|
|
87
|
+
|
|
88
|
+
// Newer Cline embeds the model id directly on the api_req_started payload.
|
|
89
|
+
const model = (info.model && String(info.model).trim()) || fallbackModel;
|
|
90
|
+
|
|
91
|
+
// Bucket schema (matches Cursor's CSV semantics):
|
|
92
|
+
// inputTokens = non-cache input + cache-write tokens (both billed as input)
|
|
93
|
+
// cachedInputTokens = cache-read tokens (10% input rate)
|
|
94
|
+
entries.push({
|
|
95
|
+
source: 'cline',
|
|
96
|
+
model,
|
|
97
|
+
project,
|
|
98
|
+
timestamp,
|
|
99
|
+
inputTokens: inputTokens + cacheWrites,
|
|
100
|
+
outputTokens,
|
|
101
|
+
cachedInputTokens: cacheReads,
|
|
102
|
+
reasoningOutputTokens: 0,
|
|
103
|
+
});
|
|
104
|
+
events.push({ sessionId: taskId, source: 'cline', project, timestamp, role: 'assistant' });
|
|
105
|
+
} else if (msg.type === 'ask' || (msg.type === 'say' && msg.say === 'user_feedback')) {
|
|
106
|
+
events.push({ sessionId: taskId, source: 'cline', project, timestamp, role: 'user' });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// Skip this task; keep going for the rest of the history.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(events) };
|
|
116
|
+
}
|
package/src/parsers/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { parse as parseClaudeCode } from './claude-code.js';
|
|
3
|
+
import { parse as parseCline } from './cline.js';
|
|
3
4
|
import { parse as parseCodex } from './codex.js';
|
|
4
5
|
import { parse as parseCopilotCli } from './copilot-cli.js';
|
|
5
6
|
import { parse as parseCursor } from './cursor.js';
|
|
7
|
+
import { parse as parseRooCode } from './roo-code.js';
|
|
6
8
|
import { parse as parseGeminiCli } from './gemini-cli.js';
|
|
7
9
|
import { parse as parseOpencode } from './opencode.js';
|
|
8
10
|
import { parse as parseOpenclaw } from './openclaw.js';
|
|
@@ -12,13 +14,16 @@ import { parse as parseAmp } from './amp.js';
|
|
|
12
14
|
import { parse as parseDroid } from './droid.js';
|
|
13
15
|
import { parse as parseAntigravity } from './antigravity.js';
|
|
14
16
|
import { parse as parseHermes } from './hermes.js';
|
|
17
|
+
import { parse as parseKiro } from './kiro.js';
|
|
15
18
|
import { parse as parsePiCodingAgent } from './pi-coding-agent.js';
|
|
16
19
|
|
|
17
20
|
export const parsers = {
|
|
18
21
|
'claude-code': parseClaudeCode,
|
|
22
|
+
'cline': parseCline,
|
|
19
23
|
'codex': parseCodex,
|
|
20
24
|
'copilot-cli': parseCopilotCli,
|
|
21
25
|
'cursor': parseCursor,
|
|
26
|
+
'roo-code': parseRooCode,
|
|
22
27
|
'gemini-cli': parseGeminiCli,
|
|
23
28
|
'opencode': parseOpencode,
|
|
24
29
|
'openclaw': parseOpenclaw,
|
|
@@ -28,6 +33,7 @@ export const parsers = {
|
|
|
28
33
|
'droid': parseDroid,
|
|
29
34
|
'antigravity': parseAntigravity,
|
|
30
35
|
'hermes': parseHermes,
|
|
36
|
+
'kiro': parseKiro,
|
|
31
37
|
'pi-coding-agent': parsePiCodingAgent,
|
|
32
38
|
};
|
|
33
39
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { join, resolve } from 'node:path';
|
|
12
|
+
import { homedir, tmpdir } from 'node:os';
|
|
13
|
+
import { aggregateToBuckets } from './index.js';
|
|
14
|
+
|
|
15
|
+
const KIROAGENT_RELATIVE = join('User', 'globalStorage', 'kiro.kiroagent');
|
|
16
|
+
|
|
17
|
+
function getDefaultBasePath() {
|
|
18
|
+
if (process.platform === 'darwin') {
|
|
19
|
+
return join(homedir(), 'Library', 'Application Support', 'Kiro', KIROAGENT_RELATIVE);
|
|
20
|
+
}
|
|
21
|
+
if (process.platform === 'win32') {
|
|
22
|
+
const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
|
|
23
|
+
return join(appData, 'Kiro', KIROAGENT_RELATIVE);
|
|
24
|
+
}
|
|
25
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
|
|
26
|
+
return join(xdgConfigHome, 'Kiro', KIROAGENT_RELATIVE);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getKiroBasePath() {
|
|
30
|
+
const explicit = process.env.KIRO_BASE_PATH?.trim();
|
|
31
|
+
if (explicit) {
|
|
32
|
+
const r = resolve(explicit);
|
|
33
|
+
return existsSync(r) ? r : null;
|
|
34
|
+
}
|
|
35
|
+
const def = getDefaultBasePath();
|
|
36
|
+
return existsSync(def) ? def : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isLockError(err) {
|
|
40
|
+
return err && typeof err.message === 'string' && /database is locked/i.test(err.message);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function queryDb(dbPath, sql) {
|
|
44
|
+
const out = execFileSync('sqlite3', ['-json', dbPath, sql], {
|
|
45
|
+
encoding: 'utf-8',
|
|
46
|
+
maxBuffer: 100 * 1024 * 1024,
|
|
47
|
+
timeout: 30000,
|
|
48
|
+
});
|
|
49
|
+
const trimmed = out.trim();
|
|
50
|
+
if (!trimmed || trimmed === '[]') return [];
|
|
51
|
+
return JSON.parse(trimmed);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const TOKENS_SQL =
|
|
55
|
+
'SELECT id, model, tokens_prompt, tokens_generated, timestamp ' +
|
|
56
|
+
'FROM tokens_generated ' +
|
|
57
|
+
'WHERE tokens_prompt > 0 OR tokens_generated > 0 ' +
|
|
58
|
+
'ORDER BY id ASC';
|
|
59
|
+
|
|
60
|
+
function readDb(dbPath) {
|
|
61
|
+
try {
|
|
62
|
+
return queryDb(dbPath, TOKENS_SQL);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (!isLockError(err)) throw err;
|
|
65
|
+
// Kiro app holds a write lock; snapshot WAL set to a temp dir and retry.
|
|
66
|
+
const snapshotDir = mkdtempSync(join(tmpdir(), 'vibe-usage-kiro-'));
|
|
67
|
+
const queryPath = join(snapshotDir, 'devdata.sqlite');
|
|
68
|
+
copyFileSync(dbPath, queryPath);
|
|
69
|
+
for (const suffix of ['-shm', '-wal']) {
|
|
70
|
+
const companion = `${dbPath}${suffix}`;
|
|
71
|
+
if (existsSync(companion)) copyFileSync(companion, `${queryPath}${suffix}`);
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
return queryDb(queryPath, TOKENS_SQL);
|
|
75
|
+
} finally {
|
|
76
|
+
rmSync(snapshotDir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// JSONL fallback: tokens_generated.jsonl. Each line:
|
|
82
|
+
// {"model":"agent","provider":"kiro","promptTokens":N,"generatedTokens":N}
|
|
83
|
+
// No per-row timestamp — bucket all rows under the file mtime.
|
|
84
|
+
function readJsonl(jsonlPath) {
|
|
85
|
+
let raw;
|
|
86
|
+
try { raw = readFileSync(jsonlPath, 'utf-8'); } catch { return []; }
|
|
87
|
+
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
|
88
|
+
if (lines.length === 0) return [];
|
|
89
|
+
let mtime;
|
|
90
|
+
try { mtime = statSync(jsonlPath).mtime; } catch { mtime = new Date(); }
|
|
91
|
+
const ts = mtime.toISOString().replace('T', ' ').replace('Z', '').slice(0, 19);
|
|
92
|
+
const rows = [];
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
try {
|
|
95
|
+
const obj = JSON.parse(lines[i]);
|
|
96
|
+
rows.push({
|
|
97
|
+
id: i + 1,
|
|
98
|
+
model: obj.model || 'agent',
|
|
99
|
+
tokens_prompt: obj.promptTokens || 0,
|
|
100
|
+
tokens_generated: obj.generatedTokens || 0,
|
|
101
|
+
timestamp: ts,
|
|
102
|
+
});
|
|
103
|
+
} catch {
|
|
104
|
+
// skip malformed lines
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return rows;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Walk workspace dirs under kiro.kiroagent/ and collect every .chat file's
|
|
111
|
+
// modelId + start/end window. Used to attribute each tokens_generated row to
|
|
112
|
+
// a real model (the SQLite `model` column is usually the literal "agent").
|
|
113
|
+
function buildModelTimeline(base) {
|
|
114
|
+
const timeline = [];
|
|
115
|
+
let entries;
|
|
116
|
+
try { entries = readdirSync(base, { withFileTypes: true }); } catch { return timeline; }
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (!entry.isDirectory() || entry.name === 'dev_data') continue;
|
|
119
|
+
const dirPath = join(base, entry.name);
|
|
120
|
+
let files;
|
|
121
|
+
try {
|
|
122
|
+
files = readdirSync(dirPath).filter(f => f.endsWith('.chat'));
|
|
123
|
+
} catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
for (const file of files) {
|
|
127
|
+
try {
|
|
128
|
+
const data = JSON.parse(readFileSync(join(dirPath, file), 'utf-8'));
|
|
129
|
+
const meta = data?.metadata;
|
|
130
|
+
if (!meta?.modelId || !meta?.startTime) continue;
|
|
131
|
+
const startMs = Number(meta.startTime);
|
|
132
|
+
const endMs = Number(meta.endTime || meta.startTime);
|
|
133
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue;
|
|
134
|
+
timeline.push({ startMs, endMs, model: String(meta.modelId) });
|
|
135
|
+
} catch {
|
|
136
|
+
// skip unreadable / malformed chat files
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
timeline.sort((a, b) => a.startMs - b.startMs);
|
|
141
|
+
return timeline;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolveModel(timeline, ts) {
|
|
145
|
+
if (!timeline.length || !ts) return null;
|
|
146
|
+
const t = ts.getTime();
|
|
147
|
+
if (!Number.isFinite(t)) return null;
|
|
148
|
+
let best = null;
|
|
149
|
+
let bestDist = Infinity;
|
|
150
|
+
for (const e of timeline) {
|
|
151
|
+
if (t >= e.startMs && t <= e.endMs) return e.model;
|
|
152
|
+
const d = Math.min(Math.abs(t - e.startMs), Math.abs(t - e.endMs));
|
|
153
|
+
if (d < bestDist) { bestDist = d; best = e.model; }
|
|
154
|
+
}
|
|
155
|
+
// 10-minute tolerance — beyond that, treat as no match.
|
|
156
|
+
return bestDist < 10 * 60 * 1000 ? best : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// "CLAUDE_SONNET_4_20250514_V1_0" -> "claude-sonnet-4"
|
|
160
|
+
function normalizeModelName(raw) {
|
|
161
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
162
|
+
const trimmed = raw.trim();
|
|
163
|
+
if (!trimmed) return null;
|
|
164
|
+
if (trimmed === trimmed.toLowerCase() && trimmed.includes('-')) return trimmed;
|
|
165
|
+
const cleaned = trimmed
|
|
166
|
+
.replace(/_\d{8}_V\d+_\d+$/i, '')
|
|
167
|
+
.replace(/_V\d+$/i, '')
|
|
168
|
+
.toLowerCase()
|
|
169
|
+
.replace(/_/g, '-');
|
|
170
|
+
return cleaned || null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseDbTimestamp(value) {
|
|
174
|
+
if (!value) return null;
|
|
175
|
+
// SQLite CURRENT_TIMESTAMP: "2026-01-09 15:25:30" (UTC, naive — append Z).
|
|
176
|
+
const d = new Date(String(value).trim().replace(' ', 'T') + 'Z');
|
|
177
|
+
return isNaN(d.getTime()) ? null : d;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function parse() {
|
|
181
|
+
const base = getKiroBasePath();
|
|
182
|
+
if (!base) return { buckets: [], sessions: [] };
|
|
183
|
+
|
|
184
|
+
const dbPath = join(base, 'dev_data', 'devdata.sqlite');
|
|
185
|
+
const jsonlPath = join(base, 'dev_data', 'tokens_generated.jsonl');
|
|
186
|
+
|
|
187
|
+
let rows;
|
|
188
|
+
try {
|
|
189
|
+
if (existsSync(dbPath)) {
|
|
190
|
+
rows = readDb(dbPath);
|
|
191
|
+
} else if (existsSync(jsonlPath)) {
|
|
192
|
+
rows = readJsonl(jsonlPath);
|
|
193
|
+
} else {
|
|
194
|
+
return { buckets: [], sessions: [] };
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
if (err && typeof err.message === 'string' && err.message.includes('ENOENT')) {
|
|
198
|
+
throw new Error('sqlite3 CLI not found. Install sqlite3 to sync Kiro data.');
|
|
199
|
+
}
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!rows.length) return { buckets: [], sessions: [] };
|
|
204
|
+
|
|
205
|
+
const timeline = buildModelTimeline(base);
|
|
206
|
+
const entries = [];
|
|
207
|
+
for (const row of rows) {
|
|
208
|
+
const inputTokens = Math.max(0, Number(row.tokens_prompt) || 0);
|
|
209
|
+
const outputTokens = Math.max(0, Number(row.tokens_generated) || 0);
|
|
210
|
+
if (inputTokens === 0 && outputTokens === 0) continue;
|
|
211
|
+
const timestamp = parseDbTimestamp(row.timestamp);
|
|
212
|
+
if (!timestamp) continue;
|
|
213
|
+
|
|
214
|
+
// Prefer the .chat timeline; fall back to the row's literal model (skip
|
|
215
|
+
// the placeholder "agent"); then "kiro-agent".
|
|
216
|
+
let model = normalizeModelName(resolveModel(timeline, timestamp));
|
|
217
|
+
if (!model) {
|
|
218
|
+
const literal = (row.model || '').trim();
|
|
219
|
+
if (literal && literal.toLowerCase() !== 'agent') {
|
|
220
|
+
model = normalizeModelName(literal);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (!model) model = 'kiro-agent';
|
|
224
|
+
|
|
225
|
+
entries.push({
|
|
226
|
+
source: 'kiro',
|
|
227
|
+
model,
|
|
228
|
+
project: 'unknown',
|
|
229
|
+
timestamp,
|
|
230
|
+
inputTokens,
|
|
231
|
+
outputTokens,
|
|
232
|
+
cachedInputTokens: 0,
|
|
233
|
+
reasoningOutputTokens: 0,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { buckets: aggregateToBuckets(entries), sessions: [] };
|
|
238
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
5
|
+
|
|
6
|
+
const EXTENSION_ID = 'rooveterinaryinc.roo-cline';
|
|
7
|
+
|
|
8
|
+
const HOSTS = ['Code', 'Cursor', 'Windsurf', 'VSCodium', 'Code - Insiders', 'Trae', 'Trae CN'];
|
|
9
|
+
|
|
10
|
+
function getHostRoots() {
|
|
11
|
+
const out = [];
|
|
12
|
+
if (process.platform === 'darwin') {
|
|
13
|
+
const base = join(homedir(), 'Library', 'Application Support');
|
|
14
|
+
for (const h of HOSTS) out.push(join(base, h));
|
|
15
|
+
} else if (process.platform === 'win32') {
|
|
16
|
+
const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
|
|
17
|
+
for (const h of HOSTS) out.push(join(appData, h));
|
|
18
|
+
} else {
|
|
19
|
+
const xdg = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
|
|
20
|
+
for (const h of HOSTS) out.push(join(xdg, h));
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function findRooCodeExtensionDirs() {
|
|
26
|
+
const dirs = [];
|
|
27
|
+
for (const root of getHostRoots()) {
|
|
28
|
+
const ext = join(root, 'User', 'globalStorage', EXTENSION_ID);
|
|
29
|
+
try {
|
|
30
|
+
if (statSync(ext).isDirectory()) dirs.push(ext);
|
|
31
|
+
} catch {
|
|
32
|
+
// not installed in this host; skip
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return dirs;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readJsonSafe(path) {
|
|
39
|
+
try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function projectFromPath(absPath) {
|
|
43
|
+
if (!absPath || typeof absPath !== 'string') return 'unknown';
|
|
44
|
+
const trimmed = absPath.replace(/[\\/]+$/, '');
|
|
45
|
+
const name = basename(trimmed);
|
|
46
|
+
return name || 'unknown';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Read all HistoryItems from `_index.json` if present, else fall back to
|
|
50
|
+
// scanning per-task `history_item.json` files (Roo migrated to per-task
|
|
51
|
+
// files in 2025; the index is a cache).
|
|
52
|
+
function readHistoryItems(extDir) {
|
|
53
|
+
const tasksDir = join(extDir, 'tasks');
|
|
54
|
+
const indexPath = join(tasksDir, '_index.json');
|
|
55
|
+
const index = readJsonSafe(indexPath);
|
|
56
|
+
if (index && Array.isArray(index.entries)) return index.entries;
|
|
57
|
+
|
|
58
|
+
const items = [];
|
|
59
|
+
let names;
|
|
60
|
+
try { names = readdirSync(tasksDir, { withFileTypes: true }); } catch { return items; }
|
|
61
|
+
for (const entry of names) {
|
|
62
|
+
if (!entry.isDirectory() || entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
|
|
63
|
+
const item = readJsonSafe(join(tasksDir, entry.name, 'history_item.json'));
|
|
64
|
+
if (item && typeof item === 'object') items.push(item);
|
|
65
|
+
}
|
|
66
|
+
return items;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function parse() {
|
|
70
|
+
const extDirs = findRooCodeExtensionDirs();
|
|
71
|
+
if (extDirs.length === 0) return { buckets: [], sessions: [] };
|
|
72
|
+
|
|
73
|
+
const entries = [];
|
|
74
|
+
const events = [];
|
|
75
|
+
|
|
76
|
+
for (const extDir of extDirs) {
|
|
77
|
+
const items = readHistoryItems(extDir);
|
|
78
|
+
if (!items.length) continue;
|
|
79
|
+
|
|
80
|
+
for (const item of items) {
|
|
81
|
+
try {
|
|
82
|
+
if (!item || typeof item !== 'object' || !item.id) continue;
|
|
83
|
+
const taskId = String(item.id);
|
|
84
|
+
const project = projectFromPath(item.workspace);
|
|
85
|
+
// Roo doesn't store modelId; the profile name (apiConfigName) is the
|
|
86
|
+
// best fallback — users typically name profiles after the model.
|
|
87
|
+
const fallbackModel = (item.apiConfigName && String(item.apiConfigName).trim()) || 'roo-unknown';
|
|
88
|
+
|
|
89
|
+
const messages = readJsonSafe(join(extDir, 'tasks', taskId, 'ui_messages.json'));
|
|
90
|
+
if (!Array.isArray(messages)) continue;
|
|
91
|
+
|
|
92
|
+
for (const msg of messages) {
|
|
93
|
+
if (!msg || typeof msg !== 'object') continue;
|
|
94
|
+
const ts = Number(msg.ts);
|
|
95
|
+
if (!Number.isFinite(ts)) continue;
|
|
96
|
+
const timestamp = new Date(ts);
|
|
97
|
+
|
|
98
|
+
if (msg.type === 'say' && msg.say === 'api_req_started') {
|
|
99
|
+
let info = null;
|
|
100
|
+
try { info = JSON.parse(msg.text); } catch { /* skip */ }
|
|
101
|
+
if (!info) continue;
|
|
102
|
+
|
|
103
|
+
const inputTokens = Math.max(0, Number(info.tokensIn) || 0);
|
|
104
|
+
const outputTokens = Math.max(0, Number(info.tokensOut) || 0);
|
|
105
|
+
const cacheWrites = Math.max(0, Number(info.cacheWrites) || 0);
|
|
106
|
+
const cacheReads = Math.max(0, Number(info.cacheReads) || 0);
|
|
107
|
+
if (inputTokens + outputTokens + cacheWrites + cacheReads === 0) continue;
|
|
108
|
+
|
|
109
|
+
const model = (info.model && String(info.model).trim()) || fallbackModel;
|
|
110
|
+
|
|
111
|
+
entries.push({
|
|
112
|
+
source: 'roo-code',
|
|
113
|
+
model,
|
|
114
|
+
project,
|
|
115
|
+
timestamp,
|
|
116
|
+
inputTokens: inputTokens + cacheWrites,
|
|
117
|
+
outputTokens,
|
|
118
|
+
cachedInputTokens: cacheReads,
|
|
119
|
+
reasoningOutputTokens: 0,
|
|
120
|
+
});
|
|
121
|
+
events.push({ sessionId: taskId, source: 'roo-code', project, timestamp, role: 'assistant' });
|
|
122
|
+
} else if (msg.type === 'ask' || (msg.type === 'say' && msg.say === 'user_feedback')) {
|
|
123
|
+
events.push({ sessionId: taskId, source: 'roo-code', project, timestamp, role: 'user' });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Skip this task; keep going for the rest of the history.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(events) };
|
|
133
|
+
}
|
package/src/sync.js
CHANGED
|
@@ -89,6 +89,8 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
89
89
|
|
|
90
90
|
let totalIngested = 0;
|
|
91
91
|
let totalSessionsSynced = 0;
|
|
92
|
+
let totalDroppedBuckets = 0;
|
|
93
|
+
const droppedSources = new Set();
|
|
92
94
|
const bucketBatches = Math.ceil(allBuckets.length / BATCH_SIZE);
|
|
93
95
|
const sessionBatches = Math.ceil(allSessions.length / SESSION_BATCH_SIZE);
|
|
94
96
|
const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
|
|
@@ -108,6 +110,10 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
108
110
|
}, batchSessions.length > 0 ? batchSessions : undefined);
|
|
109
111
|
totalIngested += result.ingested ?? batch.length;
|
|
110
112
|
totalSessionsSynced += result.sessions ?? 0;
|
|
113
|
+
if (result.dropped) {
|
|
114
|
+
totalDroppedBuckets += Number(result.dropped.buckets) || 0;
|
|
115
|
+
for (const s of result.dropped.unknownSources || []) droppedSources.add(s);
|
|
116
|
+
}
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
if (totalBatches > 1 || allBuckets.length > 0) {
|
|
@@ -117,6 +123,14 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
117
123
|
if (totalSessionsSynced > 0) syncParts.push(`${totalSessionsSynced} sessions`);
|
|
118
124
|
console.log(success(`已同步 ${syncParts.join(' · ')}`));
|
|
119
125
|
|
|
126
|
+
if (totalDroppedBuckets > 0) {
|
|
127
|
+
// Server doesn't (yet) recognize these source IDs — usually means the
|
|
128
|
+
// CLI is newer than the deployed vibe-cafe. Surface so the user knows
|
|
129
|
+
// the data wasn't lost on their end, just not stored upstream.
|
|
130
|
+
const sourcesList = Array.from(droppedSources).sort().join(', ');
|
|
131
|
+
console.log(dim(` ${totalDroppedBuckets} buckets dropped (服务端未收录的 source: ${sourcesList})`));
|
|
132
|
+
}
|
|
133
|
+
|
|
120
134
|
if (!quiet && totalSessionsSynced > 0) {
|
|
121
135
|
const totalActive = allSessions.reduce((s, x) => s + x.activeSeconds, 0);
|
|
122
136
|
const totalDuration = allSessions.reduce((s, x) => s + x.durationSeconds, 0);
|
package/src/tools.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
|
|
@@ -15,6 +15,53 @@ function getCursorStateDbPath() {
|
|
|
15
15
|
return join(xdgConfigHome, 'Cursor', rel);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function getKiroAgentPath() {
|
|
19
|
+
const rel = join('User', 'globalStorage', 'kiro.kiroagent');
|
|
20
|
+
if (process.platform === 'darwin') {
|
|
21
|
+
return join(homedir(), 'Library', 'Application Support', 'Kiro', rel);
|
|
22
|
+
}
|
|
23
|
+
if (process.platform === 'win32') {
|
|
24
|
+
const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
|
|
25
|
+
return join(appData, 'Kiro', rel);
|
|
26
|
+
}
|
|
27
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
|
|
28
|
+
return join(xdgConfigHome, 'Kiro', rel);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// VSCode-fork host directories where extensions like Cline / Roo Code live.
|
|
32
|
+
const VSCODE_HOSTS = ['Code', 'Cursor', 'Windsurf', 'VSCodium', 'Code - Insiders', 'Trae', 'Trae CN'];
|
|
33
|
+
|
|
34
|
+
function getVscodeHostRoots() {
|
|
35
|
+
const out = [];
|
|
36
|
+
if (process.platform === 'darwin') {
|
|
37
|
+
const base = join(homedir(), 'Library', 'Application Support');
|
|
38
|
+
for (const h of VSCODE_HOSTS) out.push(join(base, h));
|
|
39
|
+
} else if (process.platform === 'win32') {
|
|
40
|
+
const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
|
|
41
|
+
for (const h of VSCODE_HOSTS) out.push(join(appData, h));
|
|
42
|
+
} else {
|
|
43
|
+
const xdg = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
|
|
44
|
+
for (const h of VSCODE_HOSTS) out.push(join(xdg, h));
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function findExtensionDirs(extensionId) {
|
|
50
|
+
const dirs = [];
|
|
51
|
+
for (const root of getVscodeHostRoots()) {
|
|
52
|
+
const ext = join(root, 'User', 'globalStorage', extensionId);
|
|
53
|
+
try {
|
|
54
|
+
if (statSync(ext).isDirectory()) dirs.push(ext);
|
|
55
|
+
} catch {
|
|
56
|
+
// not present in this host
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return dirs;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const findClineDataDirs = () => findExtensionDirs('saoudrizwan.claude-dev');
|
|
63
|
+
const findRooCodeDataDirs = () => findExtensionDirs('rooveterinaryinc.roo-cline');
|
|
64
|
+
|
|
18
65
|
/** Find all OpenClaw data roots: ~/.openclaw and ~/.openclaw-<profile> */
|
|
19
66
|
function findOpenclawDataDirs() {
|
|
20
67
|
const home = homedir();
|
|
@@ -44,6 +91,12 @@ export const TOOLS = [
|
|
|
44
91
|
id: 'claude-code',
|
|
45
92
|
dataDir: join(homedir(), '.claude', 'projects'),
|
|
46
93
|
},
|
|
94
|
+
{
|
|
95
|
+
name: 'Cline',
|
|
96
|
+
id: 'cline',
|
|
97
|
+
dataDir: join(homedir(), 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev'),
|
|
98
|
+
detectDataDirs: findClineDataDirs,
|
|
99
|
+
},
|
|
47
100
|
{
|
|
48
101
|
name: 'Codex CLI',
|
|
49
102
|
id: 'codex',
|
|
@@ -105,6 +158,17 @@ export const TOOLS = [
|
|
|
105
158
|
id: 'hermes',
|
|
106
159
|
dataDir: join(homedir(), '.hermes', 'state.db'),
|
|
107
160
|
},
|
|
161
|
+
{
|
|
162
|
+
name: 'Kiro',
|
|
163
|
+
id: 'kiro',
|
|
164
|
+
dataDir: getKiroAgentPath(),
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'Roo Code',
|
|
168
|
+
id: 'roo-code',
|
|
169
|
+
dataDir: join(homedir(), 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline'),
|
|
170
|
+
detectDataDirs: findRooCodeDataDirs,
|
|
171
|
+
},
|
|
108
172
|
];
|
|
109
173
|
|
|
110
174
|
export function detectInstalledTools() {
|