@its-clawdia/ocusage 1.0.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 ADDED
@@ -0,0 +1,52 @@
1
+ # ocusage
2
+
3
+ OpenClaw Usage — cost analysis CLI for OpenClaw session logs.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ # Run without installing
9
+ npx ocusage@latest
10
+ bunx ocusage@latest
11
+
12
+ # Or install globally
13
+ npm install -g ocusage
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```
19
+ ocusage [command] [options]
20
+
21
+ Commands:
22
+ daily (default) Aggregate costs by date
23
+ weekly Aggregate costs by ISO week
24
+ monthly Aggregate costs by month
25
+ session Per-session breakdown
26
+
27
+ Options:
28
+ --since YYYYMMDD Filter sessions on or after this date
29
+ --until YYYYMMDD Filter sessions on or before this date
30
+ --json Output as JSON
31
+ --breakdown Show per-model breakdown
32
+ --timezone TZ Timezone for date grouping (default: local)
33
+ --path DIR Custom session directory
34
+ --help, -h Show help
35
+ ```
36
+
37
+ ## Examples
38
+
39
+ ```bash
40
+ ocusage # Daily report
41
+ ocusage daily # Daily report
42
+ ocusage monthly # Monthly report
43
+ ocusage weekly # Weekly report
44
+ ocusage session # Per-session breakdown
45
+ ocusage --since 20260301 # Filter from March 2026
46
+ ocusage --json # JSON output
47
+ ocusage --breakdown # Per-model cost breakdown
48
+ ```
49
+
50
+ ## Data Source
51
+
52
+ Reads from `~/.openclaw/agents/main/sessions/*.jsonl` by default.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@its-clawdia/ocusage",
3
+ "version": "1.0.1",
4
+ "description": "CLI tool for analyzing OpenClaw token usage and costs from local session logs",
5
+ "type": "module",
6
+ "bin": {
7
+ "ocusage": "./src/cli.js"
8
+ },
9
+ "files": [
10
+ "src/"
11
+ ],
12
+ "scripts": {
13
+ "start": "node src/cli.js"
14
+ },
15
+ "keywords": [
16
+ "openclaw",
17
+ "usage",
18
+ "cost",
19
+ "analysis",
20
+ "tokens",
21
+ "llm",
22
+ "ai",
23
+ "cli"
24
+ ],
25
+ "author": "its-clawdia",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/its-clawdia/ocusage.git"
30
+ },
31
+ "homepage": "https://github.com/its-clawdia/ocusage#readme",
32
+ "engines": {
33
+ "node": ">=18"
34
+ }
35
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * aggregate.js — Group session data by time period and/or model
3
+ */
4
+
5
+ import { getLocalDate, getISOWeek, getMonth } from './parser.js';
6
+
7
+ /**
8
+ * Create an empty bucket for accumulating usage
9
+ */
10
+ function emptyBucket(key) {
11
+ return {
12
+ key,
13
+ input: 0,
14
+ output: 0,
15
+ cacheRead: 0,
16
+ cacheWrite: 0,
17
+ totalTokens: 0,
18
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
19
+ sessions: 0,
20
+ models: new Set()
21
+ };
22
+ }
23
+
24
+ function addUsage(bucket, usage, model) {
25
+ bucket.input += usage.input;
26
+ bucket.output += usage.output;
27
+ bucket.cacheRead += usage.cacheRead;
28
+ bucket.cacheWrite += usage.cacheWrite;
29
+ bucket.totalTokens += usage.totalTokens;
30
+ bucket.cost.input += usage.cost.input;
31
+ bucket.cost.output += usage.cost.output;
32
+ bucket.cost.cacheRead += usage.cost.cacheRead;
33
+ bucket.cost.cacheWrite += usage.cost.cacheWrite;
34
+ bucket.cost.total += usage.cost.total;
35
+ if (model) bucket.models.add(model);
36
+ }
37
+
38
+ /**
39
+ * Aggregate sessions by a key function
40
+ * Returns sorted array of buckets
41
+ */
42
+ export async function aggregateByPeriod(sessionIter, keyFn, { breakdown = false, timezone } = {}) {
43
+ const buckets = new Map(); // key -> bucket
44
+ const modelBuckets = new Map(); // `${key}::${model}` -> bucket
45
+
46
+ for await (const session of sessionIter) {
47
+ if (!session.timestamp) continue;
48
+ const date = getLocalDate(session.timestamp, timezone);
49
+ const key = keyFn(date);
50
+
51
+ // Ensure bucket exists
52
+ if (!buckets.has(key)) buckets.set(key, emptyBucket(key));
53
+ const bucket = buckets.get(key);
54
+ bucket.sessions++;
55
+
56
+ // Accumulate per-message usage
57
+ for (const msg of session.messages) {
58
+ addUsage(bucket, msg.usage, msg.model);
59
+
60
+ if (breakdown) {
61
+ const model = msg.model || 'unknown';
62
+ const mkey = `${key}::${model}`;
63
+ if (!modelBuckets.has(mkey)) modelBuckets.set(mkey, emptyBucket(model));
64
+ addUsage(modelBuckets.get(mkey), msg.usage, model);
65
+ }
66
+ }
67
+ }
68
+
69
+ // Finalize: convert Sets to arrays
70
+ const result = [...buckets.entries()]
71
+ .sort(([a], [b]) => a.localeCompare(b))
72
+ .map(([key, bucket]) => {
73
+ const r = { ...bucket, models: [...bucket.models] };
74
+ if (breakdown) {
75
+ r.breakdown = [...modelBuckets.entries()]
76
+ .filter(([k]) => k.startsWith(key + '::'))
77
+ .map(([k, b]) => ({ ...b, models: [...b.models] }))
78
+ .sort((a, b) => b.cost.total - a.cost.total);
79
+ }
80
+ return r;
81
+ });
82
+
83
+ return result;
84
+ }
85
+
86
+ /**
87
+ * Aggregate sessions per-session
88
+ */
89
+ export async function aggregateBySessions(sessionIter, { breakdown = false, timezone } = {}) {
90
+ const sessions = [];
91
+
92
+ for await (const session of sessionIter) {
93
+ const date = session.timestamp ? getLocalDate(session.timestamp, timezone) : 'unknown';
94
+ const s = {
95
+ key: session.id.slice(0, 8),
96
+ id: session.id,
97
+ date,
98
+ models: session.models,
99
+ ...session.totals,
100
+ cost: { ...session.totals.cost }
101
+ };
102
+
103
+ if (breakdown) {
104
+ // Group by model within session
105
+ const modelMap = new Map();
106
+ for (const msg of session.messages) {
107
+ const model = msg.model || 'unknown';
108
+ if (!modelMap.has(model)) modelMap.set(model, emptyBucket(model));
109
+ addUsage(modelMap.get(model), msg.usage, model);
110
+ }
111
+ s.breakdown = [...modelMap.values()]
112
+ .map(b => ({ ...b, models: [...b.models] }))
113
+ .sort((a, b) => b.cost.total - a.cost.total);
114
+ }
115
+
116
+ sessions.push(s);
117
+ }
118
+
119
+ return sessions.sort((a, b) => a.date.localeCompare(b.date));
120
+ }
121
+
122
+ export { getISOWeek, getMonth };
package/src/cli.js ADDED
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cli.js — ocusage CLI entry point
4
+ */
5
+
6
+ import { resolveSessionDir, parseAllSessions, getISOWeek, getMonth } from './parser.js';
7
+ import { aggregateByPeriod, aggregateBySessions } from './aggregate.js';
8
+ import { printPeriodReport, printSessionReport } from './format.js';
9
+
10
+ // ─── Argument Parsing ───────────────────────────────────────────────────────
11
+
12
+ function parseArgs(argv) {
13
+ const args = argv.slice(2);
14
+ const opts = {
15
+ command: 'daily',
16
+ since: null,
17
+ until: null,
18
+ json: false,
19
+ breakdown: false,
20
+ timezone: null,
21
+ path: null,
22
+ help: false,
23
+ };
24
+
25
+ let i = 0;
26
+ while (i < args.length) {
27
+ const arg = args[i];
28
+ switch (arg) {
29
+ case 'daily':
30
+ case 'weekly':
31
+ case 'monthly':
32
+ case 'session':
33
+ opts.command = arg;
34
+ break;
35
+ case '--since':
36
+ opts.since = args[++i];
37
+ break;
38
+ case '--until':
39
+ opts.until = args[++i];
40
+ break;
41
+ case '--json':
42
+ opts.json = true;
43
+ break;
44
+ case '--breakdown':
45
+ opts.breakdown = true;
46
+ break;
47
+ case '--timezone':
48
+ opts.timezone = args[++i];
49
+ break;
50
+ case '--path':
51
+ opts.path = args[++i];
52
+ break;
53
+ case '--help':
54
+ case '-h':
55
+ opts.help = true;
56
+ break;
57
+ default:
58
+ if (!arg.startsWith('-')) {
59
+ // Assume it's a subcommand we don't know
60
+ console.error(`Unknown command: ${arg}`);
61
+ }
62
+ }
63
+ i++;
64
+ }
65
+
66
+ // Parse YYYYMMDD dates into YYYY-MM-DD
67
+ if (opts.since) {
68
+ opts.since = opts.since.replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3');
69
+ }
70
+ if (opts.until) {
71
+ opts.until = opts.until.replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3');
72
+ }
73
+
74
+ return opts;
75
+ }
76
+
77
+ // ─── Help ────────────────────────────────────────────────────────────────────
78
+
79
+ function printHelp() {
80
+ console.log(`
81
+ ocusage — OpenClaw session cost analysis
82
+
83
+ Usage:
84
+ ocusage [command] [options]
85
+
86
+ Commands:
87
+ daily (default) Aggregate costs by date
88
+ weekly Aggregate costs by ISO week
89
+ monthly Aggregate costs by month
90
+ session Per-session breakdown
91
+
92
+ Options:
93
+ --since YYYYMMDD Filter sessions on or after this date
94
+ --until YYYYMMDD Filter sessions on or before this date
95
+ --json Output as JSON
96
+ --breakdown Show per-model cost breakdown
97
+ --timezone TZ Timezone for date grouping (e.g. America/Los_Angeles)
98
+ --path DIR Custom session directory
99
+ --help, -h Show this help
100
+
101
+ Examples:
102
+ ocusage Daily cost report
103
+ ocusage monthly Monthly summary
104
+ ocusage session --breakdown Sessions with per-model breakdown
105
+ ocusage --since 20260301 Filter from March 1, 2026
106
+ ocusage --json | jq . JSON output piped to jq
107
+ `);
108
+ }
109
+
110
+ // ─── Main ─────────────────────────────────────────────────────────────────────
111
+
112
+ async function main() {
113
+ const opts = parseArgs(process.argv);
114
+
115
+ if (opts.help) {
116
+ printHelp();
117
+ process.exit(0);
118
+ }
119
+
120
+ const sessionDir = resolveSessionDir(opts.path);
121
+
122
+ const streamOpts = {
123
+ since: opts.since,
124
+ until: opts.until,
125
+ timezone: opts.timezone,
126
+ };
127
+
128
+ try {
129
+ let data;
130
+
131
+ if (opts.command === 'session') {
132
+ const sessionStream = parseAllSessions(sessionDir, streamOpts);
133
+ data = await aggregateBySessions(sessionStream, {
134
+ breakdown: opts.breakdown,
135
+ timezone: opts.timezone,
136
+ });
137
+ } else {
138
+ // Period-based aggregation
139
+ const keyFn = {
140
+ daily: (date) => date,
141
+ weekly: (date) => getISOWeek(date),
142
+ monthly: (date) => date.slice(0, 7),
143
+ }[opts.command] || ((date) => date);
144
+
145
+ const sessionStream = parseAllSessions(sessionDir, streamOpts);
146
+ data = await aggregateByPeriod(sessionStream, keyFn, {
147
+ breakdown: opts.breakdown,
148
+ timezone: opts.timezone,
149
+ });
150
+ }
151
+
152
+ if (opts.json) {
153
+ // Serialize (Sets are already converted to arrays in aggregation)
154
+ console.log(JSON.stringify(data, null, 2));
155
+ return;
156
+ }
157
+
158
+ if (opts.command === 'session') {
159
+ printSessionReport(data, { breakdown: opts.breakdown });
160
+ } else {
161
+ printPeriodReport(data, { mode: opts.command, breakdown: opts.breakdown });
162
+ }
163
+
164
+ } catch (err) {
165
+ console.error(`\nError: ${err.message}`);
166
+ process.exit(1);
167
+ }
168
+ }
169
+
170
+ main();
package/src/format.js ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * format.js — Terminal table formatting with ANSI colors
3
+ */
4
+
5
+ // ANSI color codes
6
+ const C = {
7
+ reset: '\x1b[0m',
8
+ bold: '\x1b[1m',
9
+ dim: '\x1b[2m',
10
+ cyan: '\x1b[36m',
11
+ green: '\x1b[32m',
12
+ yellow: '\x1b[33m',
13
+ blue: '\x1b[34m',
14
+ magenta: '\x1b[35m',
15
+ white: '\x1b[37m',
16
+ gray: '\x1b[90m',
17
+ bgBlue: '\x1b[44m',
18
+ bgDark: '\x1b[48;5;235m',
19
+ };
20
+
21
+ function c(color, text) {
22
+ if (!process.stdout.isTTY) return text;
23
+ return C[color] + text + C.reset;
24
+ }
25
+
26
+ function bold(text) { return c('bold', text); }
27
+ function dim(text) { return c('dim', text); }
28
+ function gray(text) { return c('gray', text); }
29
+ function cyan(text) { return c('cyan', text); }
30
+ function green(text) { return c('green', text); }
31
+ function yellow(text) { return c('yellow', text); }
32
+ function blue(text) { return c('blue', text); }
33
+ function magenta(text) { return c('magenta', text); }
34
+
35
+ /**
36
+ * Format a number with commas
37
+ */
38
+ function fmtNum(n) {
39
+ if (!n || n === 0) return gray('0');
40
+ return n.toLocaleString();
41
+ }
42
+
43
+ /**
44
+ * Format cost as $X.XXXX
45
+ */
46
+ function fmtCost(n) {
47
+ if (!n || n === 0) return gray('$0.0000');
48
+ if (n < 0.001) return green(`$${n.toFixed(6)}`);
49
+ if (n < 1) return green(`$${n.toFixed(4)}`);
50
+ return yellow(`$${n.toFixed(4)}`);
51
+ }
52
+
53
+ /**
54
+ * Format token count compactly (e.g. 14.9k, 1.2M)
55
+ */
56
+ function fmtTokens(n) {
57
+ if (!n || n === 0) return gray('0');
58
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
59
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
60
+ return String(n);
61
+ }
62
+
63
+ /**
64
+ * Pad string to width (accounting for ANSI escape codes)
65
+ */
66
+ function visLen(str) {
67
+ return str.replace(/\x1b\[[0-9;]*m/g, '').length;
68
+ }
69
+
70
+ function padEnd(str, width) {
71
+ const vl = visLen(str);
72
+ return str + ' '.repeat(Math.max(0, width - vl));
73
+ }
74
+
75
+ function padStart(str, width) {
76
+ const vl = visLen(str);
77
+ return ' '.repeat(Math.max(0, width - vl)) + str;
78
+ }
79
+
80
+ /**
81
+ * Render a table
82
+ * @param {string[]} headers
83
+ * @param {string[][]} rows
84
+ * @param {'left'|'right'[]} aligns
85
+ */
86
+ function renderTable(headers, rows, aligns) {
87
+ const allRows = [headers, ...rows];
88
+ const cols = headers.length;
89
+ const widths = Array(cols).fill(0);
90
+
91
+ for (const row of allRows) {
92
+ for (let i = 0; i < cols; i++) {
93
+ widths[i] = Math.max(widths[i], visLen(row[i] || ''));
94
+ }
95
+ }
96
+
97
+ const sep = gray('┼' + widths.map(w => '─'.repeat(w + 2)).join('┼') + '┼');
98
+ const topBorder = gray('┌' + widths.map(w => '─'.repeat(w + 2)).join('┬') + '┐');
99
+ const midBorder = gray('├' + widths.map(w => '─'.repeat(w + 2)).join('┼') + '┤');
100
+ const botBorder = gray('└' + widths.map(w => '─'.repeat(w + 2)).join('┴') + '┘');
101
+
102
+ function renderRow(row, isHeader = false) {
103
+ const cells = row.map((cell, i) => {
104
+ const align = aligns?.[i] || 'left';
105
+ const padded = align === 'right'
106
+ ? padStart(cell || '', widths[i])
107
+ : padEnd(cell || '', widths[i]);
108
+ return ` ${padded} `;
109
+ });
110
+ const line = gray('│') + cells.join(gray('│')) + gray('│');
111
+ return line;
112
+ }
113
+
114
+ const lines = [];
115
+ lines.push(topBorder);
116
+ lines.push(renderRow(headers.map(h => bold(cyan(h))), true));
117
+ lines.push(midBorder);
118
+
119
+ for (let i = 0; i < rows.length; i++) {
120
+ lines.push(renderRow(rows[i]));
121
+ }
122
+ lines.push(botBorder);
123
+
124
+ return lines.join('\n');
125
+ }
126
+
127
+ /**
128
+ * Format the "Totals" summary footer
129
+ */
130
+ function formatTotals(rows) {
131
+ let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, cost = 0;
132
+ for (const row of rows) {
133
+ input += row._input || 0;
134
+ output += row._output || 0;
135
+ cacheRead += row._cacheRead || 0;
136
+ cacheWrite += row._cacheWrite || 0;
137
+ cost += row._cost || 0;
138
+ }
139
+ return { input, output, cacheRead, cacheWrite, cost };
140
+ }
141
+
142
+ /**
143
+ * Print daily/weekly/monthly report
144
+ */
145
+ export function printPeriodReport(data, { mode = 'daily', breakdown = false } = {}) {
146
+ if (data.length === 0) {
147
+ console.log(yellow('No data found.'));
148
+ return;
149
+ }
150
+
151
+ const title = { daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly' }[mode] || 'Report';
152
+ console.log(bold(cyan(`\n OpenClaw Usage — ${title} Report\n`)));
153
+
154
+ const headers = ['Period', 'Sessions', 'Input', 'Output', 'Cache↑', 'Cache↓', 'Total Tokens', 'Cost'];
155
+ const aligns = ['left', 'right', 'right', 'right', 'right', 'right', 'right', 'right'];
156
+ const rawRows = [];
157
+
158
+ let totalSessions = 0, totalInput = 0, totalOutput = 0;
159
+ let totalCacheR = 0, totalCacheW = 0, totalTokens = 0, totalCost = 0;
160
+
161
+ for (const d of data) {
162
+ rawRows.push([
163
+ bold(d.key),
164
+ fmtNum(d.sessions),
165
+ fmtTokens(d.input),
166
+ fmtTokens(d.output),
167
+ fmtTokens(d.cacheRead),
168
+ fmtTokens(d.cacheWrite),
169
+ fmtTokens(d.totalTokens),
170
+ fmtCost(d.cost.total),
171
+ ]);
172
+ totalSessions += d.sessions;
173
+ totalInput += d.input;
174
+ totalOutput += d.output;
175
+ totalCacheR += d.cacheRead;
176
+ totalCacheW += d.cacheWrite;
177
+ totalTokens += d.totalTokens;
178
+ totalCost += d.cost.total;
179
+ }
180
+
181
+ console.log(renderTable(headers, rawRows, aligns));
182
+
183
+ // Totals row
184
+ const totalsRow = [
185
+ bold(yellow('TOTAL')),
186
+ fmtNum(totalSessions),
187
+ fmtTokens(totalInput),
188
+ fmtTokens(totalOutput),
189
+ fmtTokens(totalCacheR),
190
+ fmtTokens(totalCacheW),
191
+ fmtTokens(totalTokens),
192
+ fmtCost(totalCost),
193
+ ];
194
+ console.log('\n' + bold(' Totals: ') +
195
+ `${fmtNum(totalSessions)} sessions · ` +
196
+ `${fmtTokens(totalTokens)} tokens · ` +
197
+ `${bold(fmtCost(totalCost))} total cost`);
198
+
199
+ if (breakdown) {
200
+ for (const d of data) {
201
+ if (!d.breakdown?.length) continue;
202
+ console.log(`\n ${bold(cyan(d.key))} — model breakdown:`);
203
+ const bHeaders = ['Model', 'Input', 'Output', 'Cache↑', 'Cache↓', 'Cost'];
204
+ const bAligns = ['left', 'right', 'right', 'right', 'right', 'right'];
205
+ const bRows = d.breakdown.map(b => [
206
+ magenta(b.key),
207
+ fmtTokens(b.input),
208
+ fmtTokens(b.output),
209
+ fmtTokens(b.cacheRead),
210
+ fmtTokens(b.cacheWrite),
211
+ fmtCost(b.cost.total),
212
+ ]);
213
+ console.log(renderTable(bHeaders, bRows, bAligns));
214
+ }
215
+ }
216
+
217
+ console.log('');
218
+ }
219
+
220
+ /**
221
+ * Print session report
222
+ */
223
+ export function printSessionReport(data, { breakdown = false } = {}) {
224
+ if (data.length === 0) {
225
+ console.log(yellow('No sessions found.'));
226
+ return;
227
+ }
228
+
229
+ console.log(bold(cyan('\n OpenClaw Usage — Session Report\n')));
230
+
231
+ const headers = ['Session ID', 'Date', 'Models', 'Input', 'Output', 'Cache↑', 'Cache↓', 'Cost'];
232
+ const aligns = ['left', 'left', 'left', 'right', 'right', 'right', 'right', 'right'];
233
+
234
+ let totalCost = 0, totalTokens = 0;
235
+
236
+ const rows = data.map(s => {
237
+ totalCost += s.cost.total;
238
+ totalTokens += s.totalTokens;
239
+ return [
240
+ gray(s.key),
241
+ s.date,
242
+ magenta(s.models.slice(0, 2).map(m => m.replace('claude-', '')).join(', ') || 'unknown'),
243
+ fmtTokens(s.input),
244
+ fmtTokens(s.output),
245
+ fmtTokens(s.cacheRead),
246
+ fmtTokens(s.cacheWrite),
247
+ fmtCost(s.cost.total),
248
+ ];
249
+ });
250
+
251
+ console.log(renderTable(headers, rows, aligns));
252
+ console.log('\n' + bold(' Totals: ') +
253
+ `${data.length} sessions · ` +
254
+ `${fmtTokens(totalTokens)} tokens · ` +
255
+ `${bold(fmtCost(totalCost))} total cost`);
256
+
257
+ if (breakdown) {
258
+ for (const s of data) {
259
+ if (!s.breakdown?.length) continue;
260
+ console.log(`\n Session ${gray(s.key)} (${s.date}) — model breakdown:`);
261
+ const bHeaders = ['Model', 'Input', 'Output', 'Cache↑', 'Cache↓', 'Cost'];
262
+ const bAligns = ['left', 'right', 'right', 'right', 'right', 'right'];
263
+ const bRows = s.breakdown.map(b => [
264
+ magenta(b.key),
265
+ fmtTokens(b.input),
266
+ fmtTokens(b.output),
267
+ fmtTokens(b.cacheRead),
268
+ fmtTokens(b.cacheWrite),
269
+ fmtCost(b.cost.total),
270
+ ]);
271
+ console.log(renderTable(bHeaders, bRows, bAligns));
272
+ }
273
+ }
274
+
275
+ console.log('');
276
+ }
package/src/parser.js ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * parser.js — Stream JSONL session files, extract usage data
3
+ */
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import readline from 'readline';
8
+ import os from 'os';
9
+
10
+ const DEFAULT_SESSION_DIR = path.join(os.homedir(), '.openclaw', 'agents', 'main', 'sessions');
11
+
12
+ /**
13
+ * Resolve session directory path
14
+ */
15
+ export function resolveSessionDir(customPath) {
16
+ return customPath ? path.resolve(customPath) : DEFAULT_SESSION_DIR;
17
+ }
18
+
19
+ /**
20
+ * List all .jsonl session files in the directory (exclude .reset. files)
21
+ */
22
+ export function listSessionFiles(dir) {
23
+ try {
24
+ return fs.readdirSync(dir)
25
+ .filter(f => f.endsWith('.jsonl') && !f.includes('.reset.'))
26
+ .map(f => path.join(dir, f));
27
+ } catch (err) {
28
+ throw new Error(`Cannot read session directory: ${dir}\n${err.message}`);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Parse a single JSONL session file, yielding session data
34
+ * Returns: { id, timestamp, date, models, messages: [{timestamp, model, usage}] }
35
+ */
36
+ export async function parseSessionFile(filePath) {
37
+ const session = {
38
+ id: path.basename(filePath, '.jsonl'),
39
+ filePath,
40
+ timestamp: null,
41
+ date: null,
42
+ models: new Set(),
43
+ currentModel: null,
44
+ messages: [],
45
+ totals: {
46
+ input: 0,
47
+ output: 0,
48
+ cacheRead: 0,
49
+ cacheWrite: 0,
50
+ totalTokens: 0,
51
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
52
+ }
53
+ };
54
+
55
+ const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
56
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
57
+
58
+ for await (const line of rl) {
59
+ if (!line.trim()) continue;
60
+ let record;
61
+ try {
62
+ record = JSON.parse(line);
63
+ } catch {
64
+ continue; // skip malformed lines
65
+ }
66
+
67
+ switch (record.type) {
68
+ case 'session':
69
+ session.id = record.id || session.id;
70
+ session.timestamp = record.timestamp;
71
+ session.date = record.timestamp ? record.timestamp.slice(0, 10) : null;
72
+ break;
73
+
74
+ case 'model_change':
75
+ session.currentModel = record.modelId;
76
+ if (record.modelId) session.models.add(record.modelId);
77
+ break;
78
+
79
+ case 'message':
80
+ if (record.message?.role === 'assistant' && record.message?.usage) {
81
+ const usage = record.message.usage;
82
+ const model = record.message?.model || session.currentModel || 'unknown';
83
+ if (model) session.models.add(model);
84
+
85
+ const msg = {
86
+ timestamp: record.timestamp,
87
+ model,
88
+ usage: {
89
+ input: usage.input || 0,
90
+ output: usage.output || 0,
91
+ cacheRead: usage.cacheRead || 0,
92
+ cacheWrite: usage.cacheWrite || 0,
93
+ totalTokens: usage.totalTokens || 0,
94
+ cost: {
95
+ input: usage.cost?.input || 0,
96
+ output: usage.cost?.output || 0,
97
+ cacheRead: usage.cost?.cacheRead || 0,
98
+ cacheWrite: usage.cost?.cacheWrite || 0,
99
+ total: usage.cost?.total || 0
100
+ }
101
+ }
102
+ };
103
+
104
+ session.messages.push(msg);
105
+
106
+ // Accumulate totals
107
+ session.totals.input += msg.usage.input;
108
+ session.totals.output += msg.usage.output;
109
+ session.totals.cacheRead += msg.usage.cacheRead;
110
+ session.totals.cacheWrite += msg.usage.cacheWrite;
111
+ session.totals.totalTokens += msg.usage.totalTokens;
112
+ session.totals.cost.input += msg.usage.cost.input;
113
+ session.totals.cost.output += msg.usage.cost.output;
114
+ session.totals.cost.cacheRead += msg.usage.cost.cacheRead;
115
+ session.totals.cost.cacheWrite += msg.usage.cost.cacheWrite;
116
+ session.totals.cost.total += msg.usage.cost.total;
117
+ }
118
+ break;
119
+ }
120
+ }
121
+
122
+ session.models = [...session.models];
123
+ return session;
124
+ }
125
+
126
+ /**
127
+ * Parse all session files in parallel (with concurrency limit)
128
+ * Yields results as they complete
129
+ */
130
+ export async function* parseAllSessions(sessionDir, { since, until, timezone } = {}) {
131
+ const files = listSessionFiles(sessionDir);
132
+
133
+ // Process in batches of 10 for parallelism
134
+ const BATCH = 10;
135
+ for (let i = 0; i < files.length; i += BATCH) {
136
+ const batch = files.slice(i, i + BATCH);
137
+ const results = await Promise.all(batch.map(parseSessionFile));
138
+ for (const session of results) {
139
+ if (!session.timestamp) continue;
140
+
141
+ // Date filtering
142
+ const sessionDate = getLocalDate(session.timestamp, timezone);
143
+ if (since && sessionDate < since) continue;
144
+ if (until && sessionDate > until) continue;
145
+
146
+ yield session;
147
+ }
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Get YYYY-MM-DD for a timestamp in a given timezone
153
+ */
154
+ export function getLocalDate(timestamp, timezone) {
155
+ const date = new Date(timestamp);
156
+ if (!timezone) {
157
+ // Local system time
158
+ const y = date.getFullYear();
159
+ const m = String(date.getMonth() + 1).padStart(2, '0');
160
+ const d = String(date.getDate()).padStart(2, '0');
161
+ return `${y}-${m}-${d}`;
162
+ }
163
+ return date.toLocaleDateString('en-CA', { timeZone: timezone }); // en-CA gives YYYY-MM-DD
164
+ }
165
+
166
+ /**
167
+ * Get ISO week string "YYYY-Www" for a date string "YYYY-MM-DD"
168
+ */
169
+ export function getISOWeek(dateStr) {
170
+ const date = new Date(dateStr + 'T12:00:00Z');
171
+ const dayOfWeek = date.getUTCDay() || 7; // Monday=1, Sunday=7
172
+ date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
173
+ const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
174
+ const weekNo = Math.ceil(((date - yearStart) / 86400000 + 1) / 7);
175
+ return `${date.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
176
+ }
177
+
178
+ /**
179
+ * Get "YYYY-MM" from "YYYY-MM-DD"
180
+ */
181
+ export function getMonth(dateStr) {
182
+ return dateStr ? dateStr.slice(0, 7) : 'unknown';
183
+ }