@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 +52 -0
- package/package.json +35 -0
- package/src/aggregate.js +122 -0
- package/src/cli.js +170 -0
- package/src/format.js +276 -0
- package/src/parser.js +183 -0
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
|
+
}
|
package/src/aggregate.js
ADDED
|
@@ -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
|
+
}
|