@vibe-cafe/vibe-usage 0.5.2 → 0.6.1
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 +16 -4
- package/package.json +1 -1
- package/src/api.js +6 -14
- package/src/parsers/claude-code.js +71 -21
- package/src/parsers/codex.js +19 -7
- package/src/parsers/gemini-cli.js +17 -11
- package/src/parsers/index.js +70 -0
- package/src/parsers/kimi-code.js +18 -3
- package/src/parsers/openclaw.js +16 -7
- package/src/parsers/opencode.js +48 -34
- package/src/parsers/qwen-code.js +19 -9
- package/src/sync.js +57 -12
package/README.md
CHANGED
|
@@ -29,10 +29,10 @@ npx vibe-usage status # Show config & detected tools
|
|
|
29
29
|
|
|
30
30
|
| Tool | Data Location |
|
|
31
31
|
|------|---------------|
|
|
32
|
-
| Claude Code | `~/.claude/projects/` |
|
|
32
|
+
| Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only) |
|
|
33
33
|
| Codex CLI | `~/.codex/sessions/` |
|
|
34
34
|
| Gemini CLI | `~/.gemini/tmp/` |
|
|
35
|
-
| OpenCode | `~/.local/share/opencode/opencode.db` (SQLite) |
|
|
35
|
+
| OpenCode | `~/.local/share/opencode/opencode.db` (SQLite, `json_extract` query) |
|
|
36
36
|
| OpenClaw | `~/.openclaw/agents/` |
|
|
37
37
|
| Qwen Code | `~/.qwen/tmp/` |
|
|
38
38
|
| Kimi Code | `~/.kimi/sessions/` |
|
|
@@ -41,13 +41,25 @@ npx vibe-usage status # Show config & detected tools
|
|
|
41
41
|
|
|
42
42
|
- Parses local session logs from each AI coding tool
|
|
43
43
|
- Aggregates token usage into 30-minute buckets
|
|
44
|
-
-
|
|
44
|
+
- Extracts session metadata from all 7 parsers: active time (sum of turn durations), total duration, message counts
|
|
45
|
+
- Uploads buckets + sessions to your vibecafe.ai dashboard
|
|
45
46
|
- Stateless: computes full totals from local logs each sync (idempotent, no state files)
|
|
46
47
|
- For continuous syncing, use `npx vibe-usage daemon` or the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app)
|
|
47
48
|
|
|
49
|
+
## Development
|
|
50
|
+
|
|
51
|
+
Test against a local vibe-cafe dev server without publishing:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
VIBE_USAGE_DEV=1 VIBE_USAGE_API_URL=http://localhost:3000 npx vibe-usage init
|
|
55
|
+
VIBE_USAGE_DEV=1 npx vibe-usage sync
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`VIBE_USAGE_DEV=1` uses a separate config file (`~/.vibe-usage/config.dev.json`).
|
|
59
|
+
|
|
48
60
|
## Config
|
|
49
61
|
|
|
50
|
-
Config stored at `~/.vibe-usage/config.json
|
|
62
|
+
Config stored at `~/.vibe-usage/config.json` (dev: `config.dev.json`). Contains your API key and server URL.
|
|
51
63
|
|
|
52
64
|
## Daemon Mode
|
|
53
65
|
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -5,21 +5,11 @@ import { URL } from 'node:url';
|
|
|
5
5
|
const MAX_RETRIES = 3;
|
|
6
6
|
const INITIAL_DELAY = 1000;
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
* POST buckets to the vibecafe ingest API.
|
|
10
|
-
* Uses native http/https — zero dependencies.
|
|
11
|
-
* Retries up to 3 times with exponential backoff on transient failures.
|
|
12
|
-
* @param {string} apiUrl - Base URL (e.g. "https://vibecafe.ai")
|
|
13
|
-
* @param {string} apiKey - Bearer token (vbu_xxx)
|
|
14
|
-
* @param {Array} buckets - Array of usage bucket objects
|
|
15
|
-
* @param {{onProgress?: (sent: number, total: number) => void}} [opts]
|
|
16
|
-
* @returns {Promise<{ingested: number}>}
|
|
17
|
-
*/
|
|
18
|
-
export async function ingest(apiUrl, apiKey, buckets, opts) {
|
|
8
|
+
export async function ingest(apiUrl, apiKey, buckets, opts, sessions) {
|
|
19
9
|
let lastError;
|
|
20
10
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
21
11
|
try {
|
|
22
|
-
return await _send(apiUrl, apiKey, buckets, opts?.onProgress);
|
|
12
|
+
return await _send(apiUrl, apiKey, buckets, opts?.onProgress, sessions);
|
|
23
13
|
} catch (err) {
|
|
24
14
|
lastError = err;
|
|
25
15
|
// Don't retry auth errors or client errors
|
|
@@ -35,10 +25,12 @@ export async function ingest(apiUrl, apiKey, buckets, opts) {
|
|
|
35
25
|
throw lastError;
|
|
36
26
|
}
|
|
37
27
|
|
|
38
|
-
function _send(apiUrl, apiKey, buckets, onProgress) {
|
|
28
|
+
function _send(apiUrl, apiKey, buckets, onProgress, sessions) {
|
|
39
29
|
return new Promise((resolve, reject) => {
|
|
40
30
|
const url = new URL('/api/usage/ingest', apiUrl);
|
|
41
|
-
const
|
|
31
|
+
const payload = { buckets };
|
|
32
|
+
if (sessions && sessions.length > 0) payload.sessions = sessions;
|
|
33
|
+
const body = Buffer.from(JSON.stringify(payload));
|
|
42
34
|
const totalBytes = body.length;
|
|
43
35
|
const mod = url.protocol === 'https:' ? https : http;
|
|
44
36
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join, basename, sep } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { aggregateToBuckets } from './index.js';
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Stateless Claude Code parser.
|
|
@@ -11,7 +11,8 @@ import { aggregateToBuckets } from './index.js';
|
|
|
11
11
|
* ON CONFLICT ... DO UPDATE SET idempotent.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
const
|
|
14
|
+
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
15
|
+
const CLAUDE_TRANSCRIPTS_DIR = join(homedir(), '.claude', 'transcripts');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Recursively find all .jsonl files under a directory.
|
|
@@ -44,29 +45,29 @@ function findJsonlFiles(dir) {
|
|
|
44
45
|
* We extract the last path segment as the project name.
|
|
45
46
|
*/
|
|
46
47
|
function extractProject(filePath) {
|
|
47
|
-
|
|
48
|
-
const projectsPrefix = CLAUDE_DIR + sep;
|
|
48
|
+
const projectsPrefix = CLAUDE_PROJECTS_DIR + sep;
|
|
49
49
|
if (!filePath.startsWith(projectsPrefix)) return 'unknown';
|
|
50
50
|
const relative = filePath.slice(projectsPrefix.length);
|
|
51
|
-
// First segment is the encoded project path
|
|
52
51
|
const firstSeg = relative.split(sep)[0];
|
|
53
52
|
if (!firstSeg) return 'unknown';
|
|
54
|
-
// The encoded path uses dashes: -Users-kalasoo-Projects-myproject
|
|
55
|
-
// Take the last segment after splitting by dash
|
|
56
53
|
const parts = firstSeg.split('-').filter(Boolean);
|
|
57
54
|
return parts.length > 0 ? parts[parts.length - 1] : 'unknown';
|
|
58
55
|
}
|
|
59
56
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const files = findJsonlFiles(CLAUDE_DIR);
|
|
64
|
-
if (files.length === 0) return [];
|
|
57
|
+
function extractSessionId(filePath) {
|
|
58
|
+
return basename(filePath, '.jsonl');
|
|
59
|
+
}
|
|
65
60
|
|
|
61
|
+
export async function parse() {
|
|
66
62
|
const entries = [];
|
|
63
|
+
const sessionEvents = [];
|
|
67
64
|
const seenUuids = new Set();
|
|
65
|
+
const seenSessionIds = new Set();
|
|
66
|
+
|
|
67
|
+
// --- projects/ directory: extract BOTH token buckets AND session events ---
|
|
68
|
+
const projectFiles = findJsonlFiles(CLAUDE_PROJECTS_DIR);
|
|
68
69
|
|
|
69
|
-
for (const filePath of
|
|
70
|
+
for (const filePath of projectFiles) {
|
|
70
71
|
let content;
|
|
71
72
|
try {
|
|
72
73
|
content = readFileSync(filePath, 'utf-8');
|
|
@@ -75,13 +76,29 @@ export async function parse() {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
const project = extractProject(filePath);
|
|
79
|
+
const sessionId = extractSessionId(filePath);
|
|
80
|
+
seenSessionIds.add(sessionId);
|
|
78
81
|
|
|
79
82
|
for (const line of content.split('\n')) {
|
|
80
83
|
if (!line.trim()) continue;
|
|
81
84
|
try {
|
|
82
85
|
const obj = JSON.parse(line);
|
|
83
86
|
|
|
84
|
-
|
|
87
|
+
const timestamp = obj.timestamp;
|
|
88
|
+
if (!timestamp) continue;
|
|
89
|
+
const ts = new Date(timestamp);
|
|
90
|
+
if (isNaN(ts.getTime())) continue;
|
|
91
|
+
|
|
92
|
+
if (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'tool_use' || obj.type === 'tool_result') {
|
|
93
|
+
sessionEvents.push({
|
|
94
|
+
sessionId,
|
|
95
|
+
source: 'claude-code',
|
|
96
|
+
project,
|
|
97
|
+
timestamp: ts,
|
|
98
|
+
role: obj.type === 'user' ? 'user' : 'assistant',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
85
102
|
if (obj.type !== 'assistant') continue;
|
|
86
103
|
const msg = obj.message;
|
|
87
104
|
if (!msg || !msg.usage) continue;
|
|
@@ -89,18 +106,12 @@ export async function parse() {
|
|
|
89
106
|
const usage = msg.usage;
|
|
90
107
|
if (usage.input_tokens == null && usage.output_tokens == null) continue;
|
|
91
108
|
|
|
92
|
-
// Deduplicate by UUID across all files
|
|
93
109
|
const uuid = obj.uuid;
|
|
94
110
|
if (uuid) {
|
|
95
111
|
if (seenUuids.has(uuid)) continue;
|
|
96
112
|
seenUuids.add(uuid);
|
|
97
113
|
}
|
|
98
114
|
|
|
99
|
-
const timestamp = obj.timestamp;
|
|
100
|
-
if (!timestamp) continue;
|
|
101
|
-
const ts = new Date(timestamp);
|
|
102
|
-
if (isNaN(ts.getTime())) continue;
|
|
103
|
-
|
|
104
115
|
entries.push({
|
|
105
116
|
source: 'claude-code',
|
|
106
117
|
model: msg.model || 'unknown',
|
|
@@ -117,5 +128,44 @@ export async function parse() {
|
|
|
117
128
|
}
|
|
118
129
|
}
|
|
119
130
|
|
|
120
|
-
|
|
131
|
+
// --- transcripts/ directory: extract session events ONLY (no token data) ---
|
|
132
|
+
const transcriptFiles = findJsonlFiles(CLAUDE_TRANSCRIPTS_DIR);
|
|
133
|
+
|
|
134
|
+
for (const filePath of transcriptFiles) {
|
|
135
|
+
const sessionId = extractSessionId(filePath);
|
|
136
|
+
if (seenSessionIds.has(sessionId)) continue;
|
|
137
|
+
|
|
138
|
+
let content;
|
|
139
|
+
try {
|
|
140
|
+
content = readFileSync(filePath, 'utf-8');
|
|
141
|
+
} catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const line of content.split('\n')) {
|
|
146
|
+
if (!line.trim()) continue;
|
|
147
|
+
try {
|
|
148
|
+
const obj = JSON.parse(line);
|
|
149
|
+
|
|
150
|
+
const timestamp = obj.timestamp;
|
|
151
|
+
if (!timestamp) continue;
|
|
152
|
+
const ts = new Date(timestamp);
|
|
153
|
+
if (isNaN(ts.getTime())) continue;
|
|
154
|
+
|
|
155
|
+
if (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'tool_use' || obj.type === 'tool_result') {
|
|
156
|
+
sessionEvents.push({
|
|
157
|
+
sessionId,
|
|
158
|
+
source: 'claude-code',
|
|
159
|
+
project: 'unknown',
|
|
160
|
+
timestamp: ts,
|
|
161
|
+
role: obj.type === 'user' ? 'user' : 'assistant',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
121
171
|
}
|
package/src/parsers/codex.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { aggregateToBuckets } from './index.js';
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
5
5
|
|
|
6
6
|
const SESSIONS_DIR = join(homedir(), '.codex', 'sessions');
|
|
7
7
|
|
|
@@ -28,11 +28,12 @@ function findJsonlFiles(dir) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export async function parse() {
|
|
31
|
-
if (!existsSync(SESSIONS_DIR)) return [];
|
|
31
|
+
if (!existsSync(SESSIONS_DIR)) return { buckets: [], sessions: [] };
|
|
32
32
|
|
|
33
33
|
const entries = [];
|
|
34
|
+
const sessionEvents = [];
|
|
34
35
|
const files = findJsonlFiles(SESSIONS_DIR);
|
|
35
|
-
if (files.length === 0) return [];
|
|
36
|
+
if (files.length === 0) return { buckets: [], sessions: [] };
|
|
36
37
|
for (const filePath of files) {
|
|
37
38
|
|
|
38
39
|
let content;
|
|
@@ -64,16 +65,27 @@ export async function parse() {
|
|
|
64
65
|
} catch { break; }
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
// Track model from turn_context events (fallback when token_count lacks model)
|
|
68
68
|
let turnContextModel = 'unknown';
|
|
69
|
-
// Track previous cumulative totals per model to compute deltas when only total_token_usage is available
|
|
70
69
|
const prevTotal = new Map();
|
|
71
70
|
for (const line of content.split('\n')) {
|
|
72
71
|
if (!line.trim()) continue;
|
|
73
72
|
try {
|
|
74
73
|
const obj = JSON.parse(line);
|
|
75
74
|
|
|
76
|
-
|
|
75
|
+
if (obj.timestamp) {
|
|
76
|
+
const evTs = new Date(obj.timestamp);
|
|
77
|
+
if (!isNaN(evTs.getTime())) {
|
|
78
|
+
const isUserTurn = obj.type === 'turn_context' || obj.type === 'session_meta';
|
|
79
|
+
sessionEvents.push({
|
|
80
|
+
sessionId: filePath,
|
|
81
|
+
source: 'codex',
|
|
82
|
+
project: sessionProject,
|
|
83
|
+
timestamp: evTs,
|
|
84
|
+
role: isUserTurn ? 'user' : 'assistant',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
if (obj.type === 'turn_context' && obj.payload?.model) {
|
|
78
90
|
turnContextModel = obj.payload.model;
|
|
79
91
|
continue;
|
|
@@ -135,5 +147,5 @@ export async function parse() {
|
|
|
135
147
|
}
|
|
136
148
|
}
|
|
137
149
|
|
|
138
|
-
return aggregateToBuckets(entries);
|
|
150
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
139
151
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { aggregateToBuckets } from './index.js';
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
5
5
|
|
|
6
6
|
const TMP_DIR = join(homedir(), '.gemini', 'tmp');
|
|
7
7
|
|
|
@@ -32,9 +32,10 @@ function findSessionFiles(baseDir) {
|
|
|
32
32
|
|
|
33
33
|
export async function parse() {
|
|
34
34
|
const sessionFiles = findSessionFiles(TMP_DIR);
|
|
35
|
-
if (sessionFiles.length === 0) return [];
|
|
35
|
+
if (sessionFiles.length === 0) return { buckets: [], sessions: [] };
|
|
36
36
|
|
|
37
37
|
const entries = [];
|
|
38
|
+
const sessionEvents = [];
|
|
38
39
|
|
|
39
40
|
for (const filePath of sessionFiles) {
|
|
40
41
|
|
|
@@ -47,19 +48,25 @@ export async function parse() {
|
|
|
47
48
|
|
|
48
49
|
const messages = data.messages || data.history || [];
|
|
49
50
|
for (const msg of messages) {
|
|
50
|
-
// New format: tokens on type=gemini messages (ChatRecordingService)
|
|
51
|
-
// Old format: usage/usageMetadata on any message
|
|
52
|
-
const tokens = msg.tokens;
|
|
53
|
-
const usage = msg.usage || msg.usageMetadata || msg.token_count;
|
|
54
|
-
if (!tokens && !usage) continue;
|
|
55
|
-
|
|
56
51
|
const timestamp = msg.timestamp || msg.createTime || data.createTime;
|
|
57
52
|
if (!timestamp) continue;
|
|
58
53
|
const ts = new Date(timestamp);
|
|
59
54
|
if (isNaN(ts.getTime())) continue;
|
|
60
55
|
|
|
56
|
+
const role = (msg.role === 'user') ? 'user' : 'assistant';
|
|
57
|
+
sessionEvents.push({
|
|
58
|
+
sessionId: filePath,
|
|
59
|
+
source: 'gemini-cli',
|
|
60
|
+
project: 'unknown',
|
|
61
|
+
timestamp: ts,
|
|
62
|
+
role,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const tokens = msg.tokens;
|
|
66
|
+
const usage = msg.usage || msg.usageMetadata || msg.token_count;
|
|
67
|
+
if (!tokens && !usage) continue;
|
|
68
|
+
|
|
61
69
|
if (tokens) {
|
|
62
|
-
// Gemini API: input INCLUDES cached, output INCLUDES thoughts. Normalize to non-overlapping.
|
|
63
70
|
const cached = tokens.cached || 0;
|
|
64
71
|
const thoughts = tokens.thoughts || 0;
|
|
65
72
|
entries.push({
|
|
@@ -73,7 +80,6 @@ export async function parse() {
|
|
|
73
80
|
reasoningOutputTokens: thoughts,
|
|
74
81
|
});
|
|
75
82
|
} else {
|
|
76
|
-
// Gemini API: promptTokenCount INCLUDES cachedContentTokenCount. Normalize to non-overlapping.
|
|
77
83
|
const cached = usage.cachedContentTokenCount || 0;
|
|
78
84
|
const thoughts = usage.thoughtsTokenCount || 0;
|
|
79
85
|
entries.push({
|
|
@@ -90,5 +96,5 @@ export async function parse() {
|
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
|
|
93
|
-
return aggregateToBuckets(entries);
|
|
99
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
94
100
|
}
|
package/src/parsers/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { parse as parseClaudeCode } from './claude-code.js';
|
|
2
3
|
import { parse as parseCodex } from './codex.js';
|
|
3
4
|
import { parse as parseGeminiCli } from './gemini-cli.js';
|
|
@@ -54,3 +55,72 @@ export function aggregateToBuckets(entries) {
|
|
|
54
55
|
|
|
55
56
|
return Array.from(map.values());
|
|
56
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract session metadata from timing events.
|
|
61
|
+
* Each event: { sessionId, source, project, timestamp: Date, role: 'user'|'assistant' }
|
|
62
|
+
*
|
|
63
|
+
* Turn = user prompt → last agent message before next user prompt.
|
|
64
|
+
* activeSeconds = sum(turn durations). durationSeconds = wall clock.
|
|
65
|
+
*/
|
|
66
|
+
export function extractSessions(events) {
|
|
67
|
+
const groups = new Map();
|
|
68
|
+
for (const e of events) {
|
|
69
|
+
if (!groups.has(e.sessionId)) groups.set(e.sessionId, []);
|
|
70
|
+
groups.get(e.sessionId).push(e);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const sessions = [];
|
|
74
|
+
for (const [sessionId, sessionEvents] of groups) {
|
|
75
|
+
sessionEvents.sort((a, b) => a.timestamp - b.timestamp);
|
|
76
|
+
|
|
77
|
+
const first = sessionEvents[0];
|
|
78
|
+
const last = sessionEvents[sessionEvents.length - 1];
|
|
79
|
+
const durationSeconds = Math.round((last.timestamp - first.timestamp) / 1000);
|
|
80
|
+
|
|
81
|
+
let activeSeconds = 0;
|
|
82
|
+
let turnStart = null;
|
|
83
|
+
let turnEnd = null;
|
|
84
|
+
|
|
85
|
+
for (const event of sessionEvents) {
|
|
86
|
+
if (event.role === 'user') {
|
|
87
|
+
if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
|
|
88
|
+
activeSeconds += Math.round((turnEnd - turnStart) / 1000);
|
|
89
|
+
}
|
|
90
|
+
turnStart = event.timestamp;
|
|
91
|
+
turnEnd = event.timestamp;
|
|
92
|
+
} else if (turnStart !== null) {
|
|
93
|
+
turnEnd = event.timestamp;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
|
|
97
|
+
activeSeconds += Math.round((turnEnd - turnStart) / 1000);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const userPromptHours = new Array(24).fill(0);
|
|
101
|
+
let userMessageCount = 0;
|
|
102
|
+
for (const event of sessionEvents) {
|
|
103
|
+
if (event.role === 'user') {
|
|
104
|
+
userMessageCount++;
|
|
105
|
+
userPromptHours[event.timestamp.getUTCHours()]++;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const sessionHash = createHash('sha256').update(sessionId).digest('hex').slice(0, 16);
|
|
110
|
+
|
|
111
|
+
sessions.push({
|
|
112
|
+
source: first.source,
|
|
113
|
+
project: first.project || 'unknown',
|
|
114
|
+
sessionHash,
|
|
115
|
+
firstMessageAt: first.timestamp.toISOString(),
|
|
116
|
+
lastMessageAt: last.timestamp.toISOString(),
|
|
117
|
+
durationSeconds,
|
|
118
|
+
activeSeconds,
|
|
119
|
+
messageCount: sessionEvents.length,
|
|
120
|
+
userMessageCount,
|
|
121
|
+
userPromptHours,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return sessions;
|
|
126
|
+
}
|
package/src/parsers/kimi-code.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join, sep } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { aggregateToBuckets } from './index.js';
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Kimi Code CLI parser.
|
|
@@ -62,10 +62,11 @@ function loadProjectMap() {
|
|
|
62
62
|
|
|
63
63
|
export async function parse() {
|
|
64
64
|
const wireFiles = findWireFiles(KIMI_SESSIONS_DIR);
|
|
65
|
-
if (wireFiles.length === 0) return [];
|
|
65
|
+
if (wireFiles.length === 0) return { buckets: [], sessions: [] };
|
|
66
66
|
|
|
67
67
|
const projectMap = loadProjectMap();
|
|
68
68
|
const entries = [];
|
|
69
|
+
const sessionEvents = [];
|
|
69
70
|
const seenMessageIds = new Set();
|
|
70
71
|
|
|
71
72
|
for (const { filePath, workDirHash } of wireFiles) {
|
|
@@ -91,6 +92,20 @@ export async function parse() {
|
|
|
91
92
|
if (payload.timestamp) lastTimestamp = payload.timestamp;
|
|
92
93
|
if (payload.model) currentModel = payload.model;
|
|
93
94
|
|
|
95
|
+
if (lastTimestamp) {
|
|
96
|
+
const evTs = new Date(lastTimestamp);
|
|
97
|
+
if (!isNaN(evTs.getTime())) {
|
|
98
|
+
const isUser = type === 'UserMessage' || type === 'user_message' || type === 'Input';
|
|
99
|
+
sessionEvents.push({
|
|
100
|
+
sessionId: filePath,
|
|
101
|
+
source: 'kimi-code',
|
|
102
|
+
project,
|
|
103
|
+
timestamp: evTs,
|
|
104
|
+
role: isUser ? 'user' : 'assistant',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
94
109
|
if (type !== 'StatusUpdate') continue;
|
|
95
110
|
|
|
96
111
|
const tokenUsage = payload.token_usage;
|
|
@@ -121,5 +136,5 @@ export async function parse() {
|
|
|
121
136
|
}
|
|
122
137
|
}
|
|
123
138
|
|
|
124
|
-
return aggregateToBuckets(entries);
|
|
139
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
125
140
|
}
|
package/src/parsers/openclaw.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { aggregateToBuckets } from './index.js';
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
5
5
|
|
|
6
6
|
// OpenClaw stores data at ~/.openclaw/agents/<agentId>/sessions/*.jsonl
|
|
7
7
|
// Legacy paths: ~/.clawdbot, ~/.moltbot, ~/.moldbot
|
|
@@ -22,6 +22,7 @@ function getTokens(usage, ...keys) {
|
|
|
22
22
|
|
|
23
23
|
export async function parse() {
|
|
24
24
|
const entries = [];
|
|
25
|
+
const sessionEvents = [];
|
|
25
26
|
|
|
26
27
|
for (const root of POSSIBLE_ROOTS) {
|
|
27
28
|
const agentsDir = join(root, 'agents');
|
|
@@ -62,19 +63,27 @@ export async function parse() {
|
|
|
62
63
|
try {
|
|
63
64
|
const obj = JSON.parse(line);
|
|
64
65
|
|
|
65
|
-
// Only process message entries with assistant role
|
|
66
66
|
if (obj.type !== 'message') continue;
|
|
67
67
|
const msg = obj.message;
|
|
68
|
-
if (!msg
|
|
69
|
-
|
|
70
|
-
const usage = msg.usage;
|
|
71
|
-
if (!usage) continue;
|
|
68
|
+
if (!msg) continue;
|
|
72
69
|
|
|
73
70
|
const timestamp = obj.timestamp || msg.timestamp;
|
|
74
71
|
if (!timestamp) continue;
|
|
75
72
|
const ts = new Date(typeof timestamp === 'number' ? timestamp : timestamp);
|
|
76
73
|
if (isNaN(ts.getTime())) continue;
|
|
77
74
|
|
|
75
|
+
sessionEvents.push({
|
|
76
|
+
sessionId: filePath,
|
|
77
|
+
source: 'openclaw',
|
|
78
|
+
project,
|
|
79
|
+
timestamp: ts,
|
|
80
|
+
role: msg.role === 'user' ? 'user' : 'assistant',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (msg.role !== 'assistant') continue;
|
|
84
|
+
const usage = msg.usage;
|
|
85
|
+
if (!usage) continue;
|
|
86
|
+
|
|
78
87
|
entries.push({
|
|
79
88
|
source: 'openclaw',
|
|
80
89
|
model: msg.model || obj.model || 'unknown',
|
|
@@ -93,5 +102,5 @@ export async function parse() {
|
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
|
|
96
|
-
return aggregateToBuckets(entries);
|
|
105
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
97
106
|
}
|
package/src/parsers/opencode.js
CHANGED
|
@@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
3
3
|
import { join, basename } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
import { aggregateToBuckets } from './index.js';
|
|
5
|
+
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
6
6
|
|
|
7
7
|
const DATA_DIR = join(homedir(), '.local', 'share', 'opencode');
|
|
8
8
|
const DB_PATH = join(DATA_DIR, 'opencode.db');
|
|
@@ -24,12 +24,14 @@ export async function parse() {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
function parseFromSqlite() {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
const query = `SELECT
|
|
28
|
+
session_id as sessionID,
|
|
29
|
+
json_extract(data, '$.role') as role,
|
|
30
|
+
json_extract(data, '$.time.created') as created,
|
|
31
|
+
json_extract(data, '$.modelID') as modelID,
|
|
32
|
+
json_extract(data, '$.tokens') as tokens,
|
|
33
|
+
json_extract(data, '$.path.root') as rootPath
|
|
34
|
+
FROM message`;
|
|
33
35
|
|
|
34
36
|
let output;
|
|
35
37
|
try {
|
|
@@ -46,7 +48,7 @@ function parseFromSqlite() {
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
output = output.trim();
|
|
49
|
-
if (!output || output === '[]') return [];
|
|
51
|
+
if (!output || output === '[]') return { buckets: [], sessions: [] };
|
|
50
52
|
|
|
51
53
|
let rows;
|
|
52
54
|
try {
|
|
@@ -56,29 +58,34 @@ function parseFromSqlite() {
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
const entries = [];
|
|
61
|
+
const sessionEvents = [];
|
|
59
62
|
for (const row of rows) {
|
|
60
|
-
|
|
63
|
+
const timestamp = new Date(row.created);
|
|
64
|
+
if (isNaN(timestamp.getTime())) continue;
|
|
65
|
+
|
|
66
|
+
const project = row.rootPath ? basename(row.rootPath) : 'unknown';
|
|
67
|
+
const sessionId = row.sessionID || 'unknown';
|
|
68
|
+
|
|
69
|
+
sessionEvents.push({
|
|
70
|
+
sessionId,
|
|
71
|
+
source: 'opencode',
|
|
72
|
+
project,
|
|
73
|
+
timestamp,
|
|
74
|
+
role: row.role === 'user' ? 'user' : 'assistant',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (!row.modelID) continue;
|
|
78
|
+
let tokens;
|
|
61
79
|
try {
|
|
62
|
-
|
|
80
|
+
tokens = typeof row.tokens === 'string' ? JSON.parse(row.tokens) : row.tokens;
|
|
63
81
|
} catch {
|
|
64
82
|
continue;
|
|
65
83
|
}
|
|
66
|
-
|
|
67
|
-
if (!data.modelID) continue;
|
|
68
|
-
|
|
69
|
-
const tokens = data.tokens;
|
|
70
|
-
if (!tokens) continue;
|
|
71
|
-
if (!tokens.input && !tokens.output) continue;
|
|
72
|
-
|
|
73
|
-
const timestamp = new Date(data.time?.created);
|
|
74
|
-
if (isNaN(timestamp.getTime())) continue;
|
|
75
|
-
|
|
76
|
-
const rootPath = data.path?.root;
|
|
77
|
-
const project = rootPath ? basename(rootPath) : 'unknown';
|
|
84
|
+
if (!tokens || (!tokens.input && !tokens.output)) continue;
|
|
78
85
|
|
|
79
86
|
entries.push({
|
|
80
87
|
source: 'opencode',
|
|
81
|
-
model:
|
|
88
|
+
model: row.modelID || 'unknown',
|
|
82
89
|
project,
|
|
83
90
|
timestamp,
|
|
84
91
|
inputTokens: tokens.input || 0,
|
|
@@ -88,20 +95,20 @@ function parseFromSqlite() {
|
|
|
88
95
|
});
|
|
89
96
|
}
|
|
90
97
|
|
|
91
|
-
return aggregateToBuckets(entries);
|
|
98
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
92
99
|
}
|
|
93
100
|
|
|
94
|
-
/** Legacy parser: reads JSON files from storage/message directories. */
|
|
95
101
|
function parseFromJson() {
|
|
96
|
-
if (!existsSync(MESSAGES_DIR)) return [];
|
|
102
|
+
if (!existsSync(MESSAGES_DIR)) return { buckets: [], sessions: [] };
|
|
97
103
|
|
|
98
104
|
const entries = [];
|
|
105
|
+
const sessionEvents = [];
|
|
99
106
|
let sessionDirs;
|
|
100
107
|
try {
|
|
101
108
|
sessionDirs = readdirSync(MESSAGES_DIR, { withFileTypes: true })
|
|
102
109
|
.filter(d => d.isDirectory() && d.name.startsWith('ses_'));
|
|
103
110
|
} catch {
|
|
104
|
-
return [];
|
|
111
|
+
return { buckets: [], sessions: [] };
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
for (const sessionDir of sessionDirs) {
|
|
@@ -123,18 +130,25 @@ function parseFromJson() {
|
|
|
123
130
|
continue;
|
|
124
131
|
}
|
|
125
132
|
|
|
126
|
-
if (!data.modelID) continue;
|
|
127
|
-
|
|
128
|
-
const tokens = data.tokens;
|
|
129
|
-
if (!tokens) continue;
|
|
130
|
-
if (!tokens.input && !tokens.output) continue;
|
|
131
|
-
|
|
132
133
|
const timestamp = new Date(data.time?.created);
|
|
133
134
|
if (isNaN(timestamp.getTime())) continue;
|
|
134
135
|
|
|
135
136
|
const rootPath = data.path?.root;
|
|
136
137
|
const project = rootPath ? basename(rootPath) : 'unknown';
|
|
137
138
|
|
|
139
|
+
sessionEvents.push({
|
|
140
|
+
sessionId: sessionDir.name,
|
|
141
|
+
source: 'opencode',
|
|
142
|
+
project,
|
|
143
|
+
timestamp,
|
|
144
|
+
role: data.role === 'user' ? 'user' : 'assistant',
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (!data.modelID) continue;
|
|
148
|
+
const tokens = data.tokens;
|
|
149
|
+
if (!tokens) continue;
|
|
150
|
+
if (!tokens.input && !tokens.output) continue;
|
|
151
|
+
|
|
138
152
|
entries.push({
|
|
139
153
|
source: 'opencode',
|
|
140
154
|
model: data.modelID || 'unknown',
|
|
@@ -148,5 +162,5 @@ function parseFromJson() {
|
|
|
148
162
|
}
|
|
149
163
|
}
|
|
150
164
|
|
|
151
|
-
return aggregateToBuckets(entries);
|
|
165
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
152
166
|
}
|
package/src/parsers/qwen-code.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join, basename, sep } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { aggregateToBuckets } from './index.js';
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Qwen Code parser (Gemini CLI fork).
|
|
@@ -54,9 +54,10 @@ function extractProject(cwd, filePath) {
|
|
|
54
54
|
|
|
55
55
|
export async function parse() {
|
|
56
56
|
const sessionFiles = findSessionFiles(QWEN_TMP_DIR);
|
|
57
|
-
if (sessionFiles.length === 0) return [];
|
|
57
|
+
if (sessionFiles.length === 0) return { buckets: [], sessions: [] };
|
|
58
58
|
|
|
59
59
|
const entries = [];
|
|
60
|
+
const sessionEvents = [];
|
|
60
61
|
const seenUuids = new Set();
|
|
61
62
|
|
|
62
63
|
for (const filePath of sessionFiles) {
|
|
@@ -72,6 +73,21 @@ export async function parse() {
|
|
|
72
73
|
try {
|
|
73
74
|
const obj = JSON.parse(line);
|
|
74
75
|
|
|
76
|
+
const timestamp = obj.timestamp;
|
|
77
|
+
if (!timestamp) continue;
|
|
78
|
+
const ts = new Date(timestamp);
|
|
79
|
+
if (isNaN(ts.getTime())) continue;
|
|
80
|
+
|
|
81
|
+
if (obj.type === 'user' || obj.type === 'assistant') {
|
|
82
|
+
sessionEvents.push({
|
|
83
|
+
sessionId: filePath,
|
|
84
|
+
source: 'qwen-code',
|
|
85
|
+
project: extractProject(obj.cwd, filePath),
|
|
86
|
+
timestamp: ts,
|
|
87
|
+
role: obj.type === 'user' ? 'user' : 'assistant',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
75
91
|
if (obj.type !== 'assistant') continue;
|
|
76
92
|
const usage = obj.usageMetadata;
|
|
77
93
|
if (!usage) continue;
|
|
@@ -83,12 +99,6 @@ export async function parse() {
|
|
|
83
99
|
seenUuids.add(uuid);
|
|
84
100
|
}
|
|
85
101
|
|
|
86
|
-
const timestamp = obj.timestamp;
|
|
87
|
-
if (!timestamp) continue;
|
|
88
|
-
const ts = new Date(timestamp);
|
|
89
|
-
if (isNaN(ts.getTime())) continue;
|
|
90
|
-
|
|
91
|
-
// promptTokenCount INCLUDES cachedContentTokenCount — normalize to non-overlapping
|
|
92
102
|
const cached = usage.cachedContentTokenCount || 0;
|
|
93
103
|
const thoughts = usage.thoughtsTokenCount || 0;
|
|
94
104
|
|
|
@@ -108,5 +118,5 @@ export async function parse() {
|
|
|
108
118
|
}
|
|
109
119
|
}
|
|
110
120
|
|
|
111
|
-
return aggregateToBuckets(entries);
|
|
121
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
112
122
|
}
|
package/src/sync.js
CHANGED
|
@@ -4,6 +4,7 @@ import { ingest, fetchSettings } from './api.js';
|
|
|
4
4
|
import { parsers } from './parsers/index.js';
|
|
5
5
|
|
|
6
6
|
const BATCH_SIZE = 100;
|
|
7
|
+
const SESSION_BATCH_SIZE = 500;
|
|
7
8
|
|
|
8
9
|
function formatBytes(bytes) {
|
|
9
10
|
if (bytes < 1024) return `${bytes}B`;
|
|
@@ -26,28 +27,45 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
const allBuckets = [];
|
|
30
|
+
const allSessions = [];
|
|
31
|
+
const parserResults = [];
|
|
29
32
|
|
|
30
33
|
for (const [source, parse] of Object.entries(parsers)) {
|
|
31
34
|
try {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
const result = await parse();
|
|
36
|
+
const buckets = Array.isArray(result) ? result : result.buckets;
|
|
37
|
+
const sessions = Array.isArray(result) ? [] : (result.sessions || []);
|
|
38
|
+
if (buckets.length > 0) allBuckets.push(...buckets);
|
|
39
|
+
if (sessions.length > 0) allSessions.push(...sessions);
|
|
40
|
+
if (buckets.length > 0 || sessions.length > 0) {
|
|
41
|
+
parserResults.push({ source, buckets: buckets.length, sessions: sessions.length });
|
|
35
42
|
}
|
|
36
43
|
} catch (err) {
|
|
37
44
|
process.stderr.write(`warn: ${source} parser failed: ${err.message}\n`);
|
|
38
45
|
}
|
|
39
46
|
}
|
|
40
47
|
|
|
41
|
-
if (allBuckets.length === 0) {
|
|
48
|
+
if (allBuckets.length === 0 && allSessions.length === 0) {
|
|
42
49
|
if (!quiet) console.log('No new usage data found.');
|
|
43
50
|
return 0;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
|
|
53
|
+
if (!quiet && parserResults.length > 0) {
|
|
54
|
+
for (const p of parserResults) {
|
|
55
|
+
const parts = [];
|
|
56
|
+
if (p.buckets > 0) parts.push(`${p.buckets} buckets`);
|
|
57
|
+
if (p.sessions > 0) parts.push(`${p.sessions} sessions`);
|
|
58
|
+
console.log(` ${p.source}: ${parts.join(', ')}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
const host = osHostname().replace(/\.local$/, '');
|
|
48
63
|
for (const b of allBuckets) {
|
|
49
64
|
b.hostname = host;
|
|
50
65
|
}
|
|
66
|
+
for (const s of allSessions) {
|
|
67
|
+
s.hostname = host;
|
|
68
|
+
}
|
|
51
69
|
|
|
52
70
|
// Privacy: check if user allows project name upload
|
|
53
71
|
const apiUrl = config.apiUrl || 'https://vibecafe.ai';
|
|
@@ -61,17 +79,27 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
61
79
|
for (const b of allBuckets) {
|
|
62
80
|
b.project = 'unknown';
|
|
63
81
|
}
|
|
82
|
+
for (const s of allSessions) {
|
|
83
|
+
s.project = 'unknown';
|
|
84
|
+
}
|
|
64
85
|
}
|
|
65
86
|
|
|
66
87
|
let totalIngested = 0;
|
|
67
|
-
|
|
88
|
+
let totalSessionsSynced = 0;
|
|
89
|
+
const bucketBatches = Math.ceil(allBuckets.length / BATCH_SIZE);
|
|
90
|
+
const sessionBatches = Math.ceil(allSessions.length / SESSION_BATCH_SIZE);
|
|
91
|
+
const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
|
|
68
92
|
|
|
69
|
-
|
|
93
|
+
const parts = [];
|
|
94
|
+
if (allBuckets.length > 0) parts.push(`${allBuckets.length} buckets`);
|
|
95
|
+
if (allSessions.length > 0) parts.push(`${allSessions.length} sessions`);
|
|
96
|
+
console.log(`Uploading ${parts.join(' + ')} (${totalBatches} batch${totalBatches > 1 ? 'es' : ''})...`);
|
|
70
97
|
|
|
71
98
|
try {
|
|
72
|
-
for (let
|
|
73
|
-
const batch = allBuckets.slice(
|
|
74
|
-
const
|
|
99
|
+
for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
|
|
100
|
+
const batch = allBuckets.slice(batchIdx * BATCH_SIZE, (batchIdx + 1) * BATCH_SIZE);
|
|
101
|
+
const batchSessions = allSessions.slice(batchIdx * SESSION_BATCH_SIZE, (batchIdx + 1) * SESSION_BATCH_SIZE);
|
|
102
|
+
const batchNum = batchIdx + 1;
|
|
75
103
|
const prefix = totalBatches > 1 ? ` [${batchNum}/${totalBatches}] ` : ' ';
|
|
76
104
|
|
|
77
105
|
const result = await ingest(apiUrl, config.apiKey, batch, {
|
|
@@ -79,14 +107,31 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
79
107
|
const pct = Math.round((sent / total) * 100);
|
|
80
108
|
process.stdout.write(`\r${prefix}${formatBytes(sent)}/${formatBytes(total)} (${pct}%)\x1b[K`);
|
|
81
109
|
},
|
|
82
|
-
});
|
|
110
|
+
}, batchSessions.length > 0 ? batchSessions : undefined);
|
|
83
111
|
totalIngested += result.ingested ?? batch.length;
|
|
112
|
+
totalSessionsSynced += result.sessions ?? 0;
|
|
84
113
|
}
|
|
85
114
|
|
|
86
115
|
if (totalBatches > 1 || allBuckets.length > 0) {
|
|
87
116
|
process.stdout.write('\n');
|
|
88
117
|
}
|
|
89
|
-
|
|
118
|
+
const syncParts = [`${totalIngested} buckets`];
|
|
119
|
+
if (totalSessionsSynced > 0) syncParts.push(`${totalSessionsSynced} sessions`);
|
|
120
|
+
console.log(`Synced ${syncParts.join(' + ')}.`);
|
|
121
|
+
|
|
122
|
+
if (!quiet && totalSessionsSynced > 0) {
|
|
123
|
+
const totalActive = allSessions.reduce((s, x) => s + x.activeSeconds, 0);
|
|
124
|
+
const totalDuration = allSessions.reduce((s, x) => s + x.durationSeconds, 0);
|
|
125
|
+
const totalMsgs = allSessions.reduce((s, x) => s + x.messageCount, 0);
|
|
126
|
+
const fmtTime = (secs) => {
|
|
127
|
+
if (secs < 60) return `${secs}s`;
|
|
128
|
+
const h = Math.floor(secs / 3600);
|
|
129
|
+
const m = Math.floor((secs % 3600) / 60);
|
|
130
|
+
return h > 0 ? (m > 0 ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
|
|
131
|
+
};
|
|
132
|
+
console.log(` active: ${fmtTime(totalActive)} / total: ${fmtTime(totalDuration)}, ${totalMsgs} messages`);
|
|
133
|
+
}
|
|
134
|
+
|
|
90
135
|
return totalIngested;
|
|
91
136
|
} catch (err) {
|
|
92
137
|
if (err.message === 'UNAUTHORIZED') {
|