log-llm-config 1.5.2 → 1.5.9
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 +1 -1
- package/dist/bootstrap_constants.js +3 -3
- package/dist/dialog_prefs_cli.js +1 -1
- package/dist/log_config_files/auth/auth_flow.js +2 -2
- package/dist/log_config_files/auth/auth_key_store.js +2 -2
- package/dist/log_config_files/collection/claude_token_usage_collector.js +156 -0
- package/dist/log_config_files/collection/codex_token_usage_collector.js +245 -0
- package/dist/log_config_files/collection/config_collector.js +9 -0
- package/dist/log_config_files/collection/copilot_token_usage_collector.js +154 -0
- package/dist/log_config_files/collection/cowork_desktop_version_collector.js +60 -0
- package/dist/log_config_files/collection/cursor_token_usage_collector.js +188 -0
- package/dist/log_config_files/collection/hermes_token_usage_collector.js +219 -0
- package/dist/log_config_files/collection/openclaw_token_usage_collector.js +190 -0
- package/dist/log_config_files/collection/opencode_token_usage_collector.js +182 -0
- package/dist/log_config_files/collection/pi_token_usage_collector.js +203 -0
- package/dist/log_config_files/paths/path_constants_helpers.js +1 -1
- package/dist/log_config_files/runtime/client_event_reporter.js +4 -4
- package/dist/log_config_files/runtime/compliance_session_log.js +2 -2
- package/dist/log_config_files/runtime/hook_logger.js +3 -3
- package/dist/log_config_files/runtime/log_metadata.js +2 -2
- package/dist/log_config_files/runtime/main_runner.js +75 -3
- package/dist/log_config_files/runtime/management_storage.js +8 -8
- package/dist/log_config_files/runtime/remediation_sync.js +3 -3
- package/dist/log_config_files/sender/batch_sender.js +1 -1
- package/dist/log_sensitive_paths_audit.js +3 -3
- package/dist/log_uuid/auth_key_store.js +2 -2
- package/dist/log_uuid/startup_sender.js +1 -1
- package/dist/tofu.js +1 -1
- package/dist/tofu_environment.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ CLI helpers for Optimus Security startup workflows.
|
|
|
11
11
|
|
|
12
12
|
#### `log_uuid`
|
|
13
13
|
|
|
14
|
-
Collects the machine hardware UUID (or uses the `OPTIMUS_HARDWARE_UUID` env variable), prints it, and POSTs a startup payload to `OPTIMUS_ENDPOINT/startup/`. If the server responds with a key, it stores it at
|
|
14
|
+
Collects the machine hardware UUID (or uses the `OPTIMUS_HARDWARE_UUID` env variable), prints it, and POSTs a startup payload to `OPTIMUS_ENDPOINT/startup/`. If the server responds with a key, it stores it at `~/.optimuslabs/management/auth_key.txt`.
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
OPTIMUS_ENDPOINT=http://localhost:8080/endpoint_security/ \
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* Bootstrap-level path constants — known before the API response is available.
|
|
3
3
|
* Used by auth, hook logging, and audit output. Do not add agent-specific paths here.
|
|
4
4
|
*/
|
|
5
|
-
export const
|
|
5
|
+
export const OPTIMUSLABS_MANAGEMENT_REL = '.optimuslabs/management';
|
|
6
6
|
/**
|
|
7
7
|
* All hook-written log files live under this subdirectory, grouped by type
|
|
8
8
|
* (see LOG_GROUP_* below). Keeping logs in one subtree leaves the management
|
|
9
9
|
* root limited to secrets/identity/state (auth_key, organization-uuid, contracts).
|
|
10
10
|
*/
|
|
11
|
-
export const
|
|
12
|
-
/** Log group subdirectories under {@link
|
|
11
|
+
export const OPTIMUSLABS_LOGS_REL = `${OPTIMUSLABS_MANAGEMENT_REL}/logs`;
|
|
12
|
+
/** Log group subdirectories under {@link OPTIMUSLABS_LOGS_REL}. */
|
|
13
13
|
export const LOG_GROUP_HOOK = 'hook';
|
|
14
14
|
export const LOG_GROUP_COMPLIANCE = 'compliance';
|
|
15
15
|
export const LOG_GROUP_AUDIT = 'audit';
|
package/dist/dialog_prefs_cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* CLI for macOS non-restart autofix dialogs and dialog preferences in
|
|
3
|
+
* CLI for macOS non-restart autofix dialogs and dialog preferences in ~/.optimuslabs/management.
|
|
4
4
|
* Invoked from optimus-compliance-check.sh hooks.
|
|
5
5
|
*/
|
|
6
6
|
import { execFileSync } from 'node:child_process';
|
|
@@ -2,9 +2,9 @@ import path from 'node:path';
|
|
|
2
2
|
import { postStartupPayload } from '../../endpoint_client/index.js';
|
|
3
3
|
import { ensureAuthentication as ensureTofuAuthentication } from '../../tofu.js';
|
|
4
4
|
import { loadEndpointBase } from '../sender/endpoint_config.js';
|
|
5
|
-
import {
|
|
5
|
+
import { OPTIMUSLABS_MANAGEMENT_REL } from '../../bootstrap_constants.js';
|
|
6
6
|
import { hookRunLog } from '../runtime/hook_logger.js';
|
|
7
|
-
const AUTH_KEY_RELATIVE_PATH = path.join(
|
|
7
|
+
const AUTH_KEY_RELATIVE_PATH = path.join(OPTIMUSLABS_MANAGEMENT_REL, 'auth', 'auth_key.txt');
|
|
8
8
|
/** Ensure authentication — verify stored key or request new one via handshake. */
|
|
9
9
|
async function ensureAuthentication(hardwareUuid, endpointBase) {
|
|
10
10
|
const key = await ensureTofuAuthentication({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { getAuthKeyPath as getSharedAuthKeyPath, readStoredAuthKey as readSharedStoredAuthKey, } from '../../tofu.js';
|
|
3
|
-
import {
|
|
4
|
-
const AUTH_KEY_RELATIVE_PATH = path.join(
|
|
3
|
+
import { OPTIMUSLABS_MANAGEMENT_REL } from '../../bootstrap_constants.js';
|
|
4
|
+
const AUTH_KEY_RELATIVE_PATH = path.join(OPTIMUSLABS_MANAGEMENT_REL, 'auth', 'auth_key.txt');
|
|
5
5
|
const AUTH_KEY_OPTIONS = { authRelativePath: AUTH_KEY_RELATIVE_PATH };
|
|
6
6
|
/** Return absolute path to auth_key.txt, or null if home dir is unavailable. */
|
|
7
7
|
function getAuthKeyPath() {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
/**
|
|
5
|
+
* Token-usage / session / CLI-version aggregator for Claude Code.
|
|
6
|
+
*
|
|
7
|
+
* Claude Code writes one JSONL transcript per session under ~/.claude/projects/**. Each
|
|
8
|
+
* assistant line carries a `message.usage` block ({input,output,cache_*}_tokens), and every
|
|
9
|
+
* line carries the CLI `version` and `sessionId`. We aggregate the *numbers* (and version /
|
|
10
|
+
* model ids) on the endpoint and emit only those — never transcript content — so the backend
|
|
11
|
+
* gets token usage, session count, and a reliable CLI version without ingesting prompts.
|
|
12
|
+
*
|
|
13
|
+
* Bounded by mtime recency so a large transcript history (can be hundreds of MB) does not get
|
|
14
|
+
* fully re-parsed on every hook run; this yields "recent activity" usage and the current version.
|
|
15
|
+
*/
|
|
16
|
+
const TOKEN_USAGE_RECENCY_DAYS = 30;
|
|
17
|
+
const TOKEN_USAGE_FILE_TYPE = 'claude_token_usage';
|
|
18
|
+
function emptyTotals() {
|
|
19
|
+
return {
|
|
20
|
+
input_tokens: 0,
|
|
21
|
+
output_tokens: 0,
|
|
22
|
+
cache_creation_input_tokens: 0,
|
|
23
|
+
cache_read_input_tokens: 0,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function addUsage(totals, usage) {
|
|
27
|
+
for (const key of Object.keys(totals)) {
|
|
28
|
+
const value = usage[key];
|
|
29
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
30
|
+
totals[key] += value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Sum the token fields of one usage block (used for per-model attribution). */
|
|
34
|
+
function usageSum(usage) {
|
|
35
|
+
let sum = 0;
|
|
36
|
+
for (const key of ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']) {
|
|
37
|
+
const value = usage[key];
|
|
38
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
39
|
+
sum += value;
|
|
40
|
+
}
|
|
41
|
+
return sum;
|
|
42
|
+
}
|
|
43
|
+
/** Recursively collect *.jsonl files under a directory whose mtime is within the recency window. */
|
|
44
|
+
function recentJsonlFiles(dir, cutoffMs) {
|
|
45
|
+
const out = [];
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
const full = join(dir, entry.name);
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
out.push(...recentJsonlFiles(full, cutoffMs));
|
|
57
|
+
}
|
|
58
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
59
|
+
try {
|
|
60
|
+
if (statSync(full).mtimeMs >= cutoffMs)
|
|
61
|
+
out.push(full);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* unreadable file — skip */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Aggregate token usage / session count / version across recent Claude session transcripts.
|
|
72
|
+
* Returns a single-element array (one per-machine aggregate) or [] when there is no recent data.
|
|
73
|
+
*/
|
|
74
|
+
function collectClaudeTokenUsage(home = homedir()) {
|
|
75
|
+
const projectsDir = join(home, '.claude', 'projects');
|
|
76
|
+
if (!existsSync(projectsDir))
|
|
77
|
+
return [];
|
|
78
|
+
const cutoffMs = Date.now() - TOKEN_USAGE_RECENCY_DAYS * 24 * 60 * 60 * 1000;
|
|
79
|
+
const files = recentJsonlFiles(projectsDir, cutoffMs);
|
|
80
|
+
if (files.length === 0)
|
|
81
|
+
return [];
|
|
82
|
+
const totals = emptyTotals();
|
|
83
|
+
const sessions = new Set();
|
|
84
|
+
const models = new Set();
|
|
85
|
+
const modelTokenSplit = {};
|
|
86
|
+
let version = '';
|
|
87
|
+
let versionAt = '';
|
|
88
|
+
let sawData = false;
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
let content;
|
|
91
|
+
try {
|
|
92
|
+
content = readFileSync(file, 'utf8');
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
for (const line of content.split('\n')) {
|
|
98
|
+
const trimmed = line.trim();
|
|
99
|
+
if (!trimmed)
|
|
100
|
+
continue;
|
|
101
|
+
let entry;
|
|
102
|
+
try {
|
|
103
|
+
entry = JSON.parse(trimmed);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
sawData = true;
|
|
109
|
+
const sessionId = entry.sessionId;
|
|
110
|
+
if (typeof sessionId === 'string' && sessionId)
|
|
111
|
+
sessions.add(sessionId);
|
|
112
|
+
// Keep the version from the most recent line (timestamps are ISO-8601, sort lexically).
|
|
113
|
+
const lineVersion = entry.version;
|
|
114
|
+
const lineAt = typeof entry.timestamp === 'string' ? entry.timestamp : '';
|
|
115
|
+
if (typeof lineVersion === 'string' && lineVersion && lineAt >= versionAt) {
|
|
116
|
+
version = lineVersion;
|
|
117
|
+
versionAt = lineAt;
|
|
118
|
+
}
|
|
119
|
+
const message = entry.message;
|
|
120
|
+
if (message && typeof message === 'object') {
|
|
121
|
+
const msg = message;
|
|
122
|
+
// Skip Claude Code's internal placeholder model ids (e.g. "<synthetic>").
|
|
123
|
+
const model = typeof msg.model === 'string' && msg.model && !msg.model.startsWith('<') ? msg.model : '';
|
|
124
|
+
if (model)
|
|
125
|
+
models.add(model);
|
|
126
|
+
if (msg.usage && typeof msg.usage === 'object') {
|
|
127
|
+
const usage = msg.usage;
|
|
128
|
+
addUsage(totals, usage);
|
|
129
|
+
if (model) {
|
|
130
|
+
const sum = usageSum(usage);
|
|
131
|
+
if (sum > 0)
|
|
132
|
+
modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + sum;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!sawData)
|
|
139
|
+
return [];
|
|
140
|
+
return [
|
|
141
|
+
{
|
|
142
|
+
file_type: TOKEN_USAGE_FILE_TYPE,
|
|
143
|
+
// Stable synthetic path → one aggregate row per machine.
|
|
144
|
+
file_path: projectsDir,
|
|
145
|
+
raw_content: {
|
|
146
|
+
version,
|
|
147
|
+
session_count: sessions.size,
|
|
148
|
+
models: [...models].sort(),
|
|
149
|
+
model_token_split: modelTokenSplit,
|
|
150
|
+
window_days: TOKEN_USAGE_RECENCY_DAYS,
|
|
151
|
+
totals,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
export { collectClaudeTokenUsage, TOKEN_USAGE_RECENCY_DAYS, TOKEN_USAGE_FILE_TYPE };
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
/**
|
|
5
|
+
* Token-usage / session / version / identity aggregator for OpenAI Codex CLI.
|
|
6
|
+
*
|
|
7
|
+
* Codex writes one JSONL rollout per session under ~/.codex/sessions/<y>/<m>/<d>/rollout-*.jsonl
|
|
8
|
+
* (and ~/.codex/archived_sessions/). A `session_meta` line carries `cli_version` + `model_provider`;
|
|
9
|
+
* `token_count` lines carry a *cumulative* `total_token_usage` ({input,cached_input,output,
|
|
10
|
+
* reasoning_output}_tokens) — so the session total is its LAST token_count, summed across sessions.
|
|
11
|
+
*
|
|
12
|
+
* Identity lives in ~/.codex/auth.json as a JWT (`auth_mode` + an `id_token` whose claims hold
|
|
13
|
+
* email / name / chatgpt_plan_type / account). We decode the id_token's claims locally and emit
|
|
14
|
+
* ONLY those (email, name, plan, account) — never the token, access_token, or API key. Configured
|
|
15
|
+
* model is read from ~/.codex/config.toml. Numbers + identity claims only — no transcript content.
|
|
16
|
+
*
|
|
17
|
+
* Bounded by rollout-file mtime recency so a long session history isn't fully re-parsed each run.
|
|
18
|
+
*/
|
|
19
|
+
const TOKEN_USAGE_RECENCY_DAYS = 30;
|
|
20
|
+
const TOKEN_USAGE_FILE_TYPE = 'codex_token_usage';
|
|
21
|
+
function emptyTotals() {
|
|
22
|
+
return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
|
|
23
|
+
}
|
|
24
|
+
function num(value) {
|
|
25
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
26
|
+
}
|
|
27
|
+
function asObject(value) {
|
|
28
|
+
return value && typeof value === 'object' ? value : {};
|
|
29
|
+
}
|
|
30
|
+
/** Recursively collect rollout-*.jsonl files under a dir whose mtime is within the window. */
|
|
31
|
+
function recentRolloutFiles(dir, cutoffMs) {
|
|
32
|
+
const out = [];
|
|
33
|
+
let entries;
|
|
34
|
+
try {
|
|
35
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
const full = join(dir, entry.name);
|
|
42
|
+
if (entry.isDirectory()) {
|
|
43
|
+
out.push(...recentRolloutFiles(full, cutoffMs));
|
|
44
|
+
}
|
|
45
|
+
else if (entry.isFile() &&
|
|
46
|
+
entry.name.startsWith('rollout-') &&
|
|
47
|
+
entry.name.endsWith('.jsonl')) {
|
|
48
|
+
try {
|
|
49
|
+
if (statSync(full).mtimeMs >= cutoffMs)
|
|
50
|
+
out.push(full);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* unreadable — skip */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/** Decode a JWT's payload claims (no verification — we only read non-secret identity claims). */
|
|
60
|
+
function decodeJwtClaims(jwt) {
|
|
61
|
+
const parts = jwt.split('.');
|
|
62
|
+
if (parts.length !== 3)
|
|
63
|
+
return {};
|
|
64
|
+
try {
|
|
65
|
+
let b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
66
|
+
b64 += '='.repeat((4 - (b64.length % 4)) % 4);
|
|
67
|
+
return JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Identity from ~/.codex/auth.json: auth_mode + id_token claims (email/name/plan/account). */
|
|
74
|
+
function readCodexIdentity(home) {
|
|
75
|
+
const out = {};
|
|
76
|
+
let auth;
|
|
77
|
+
try {
|
|
78
|
+
auth = JSON.parse(readFileSync(join(home, '.codex', 'auth.json'), 'utf8'));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
if (typeof auth.auth_mode === 'string' && auth.auth_mode)
|
|
84
|
+
out.auth_mode = auth.auth_mode;
|
|
85
|
+
const idToken = asObject(auth.tokens).id_token;
|
|
86
|
+
if (typeof idToken !== 'string' || !idToken)
|
|
87
|
+
return out;
|
|
88
|
+
const claims = decodeJwtClaims(idToken);
|
|
89
|
+
if (typeof claims.email === 'string')
|
|
90
|
+
out.email = claims.email;
|
|
91
|
+
if (typeof claims.name === 'string')
|
|
92
|
+
out.name = claims.name;
|
|
93
|
+
const openaiAuth = asObject(claims['https://api.openai.com/auth']);
|
|
94
|
+
if (typeof openaiAuth.chatgpt_plan_type === 'string')
|
|
95
|
+
out.plan = openaiAuth.chatgpt_plan_type;
|
|
96
|
+
if (typeof openaiAuth.chatgpt_account_id === 'string') {
|
|
97
|
+
out.account_id = openaiAuth.chatgpt_account_id;
|
|
98
|
+
}
|
|
99
|
+
const orgs = openaiAuth.organizations;
|
|
100
|
+
if (Array.isArray(orgs) && orgs.length > 0) {
|
|
101
|
+
const org = asObject(orgs[0]);
|
|
102
|
+
if (typeof org.id === 'string')
|
|
103
|
+
out.account_id = out.account_id || org.id;
|
|
104
|
+
if (typeof org.title === 'string')
|
|
105
|
+
out.org_name = org.title;
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
/** Configured model from ~/.codex/config.toml (TOML — a simple `model = "..."` line). */
|
|
110
|
+
function readConfiguredModel(home) {
|
|
111
|
+
try {
|
|
112
|
+
const toml = readFileSync(join(home, '.codex', 'config.toml'), 'utf8');
|
|
113
|
+
const m = toml.match(/^\s*model\s*=\s*["']([^"']+)["']/m);
|
|
114
|
+
return m ? m[1] : '';
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return '';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Aggregate Codex token usage / sessions / version across recent rollout transcripts + identity.
|
|
122
|
+
* Returns a single-element array (one per-machine aggregate) or [] when there is no recent data.
|
|
123
|
+
*/
|
|
124
|
+
function collectCodexTokenUsage(home = homedir()) {
|
|
125
|
+
const codexDir = join(home, '.codex');
|
|
126
|
+
const sessionRoots = [join(codexDir, 'sessions'), join(codexDir, 'archived_sessions')];
|
|
127
|
+
if (!sessionRoots.some((d) => existsSync(d)) && !existsSync(join(codexDir, 'auth.json'))) {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const cutoffMs = Date.now() - TOKEN_USAGE_RECENCY_DAYS * 24 * 60 * 60 * 1000;
|
|
131
|
+
const files = sessionRoots.flatMap((d) => (existsSync(d) ? recentRolloutFiles(d, cutoffMs) : []));
|
|
132
|
+
const totals = emptyTotals();
|
|
133
|
+
const models = new Set();
|
|
134
|
+
const providers = new Set();
|
|
135
|
+
const modelTokenSplit = {};
|
|
136
|
+
const sessions = new Set();
|
|
137
|
+
// Per-session cumulative usage, keyed by session id (else file path). total_token_usage is
|
|
138
|
+
// cumulative within a session, so the same session appearing in multiple rollout files
|
|
139
|
+
// (sessions/ + archived_sessions/) must be deduped — keep the largest snapshot rather than
|
|
140
|
+
// summing the files, which would double-count.
|
|
141
|
+
const sessionTotals = new Map();
|
|
142
|
+
let version = '';
|
|
143
|
+
let versionAt = '';
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
let content;
|
|
146
|
+
try {
|
|
147
|
+
content = readFileSync(file, 'utf8');
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
let sessionModel = '';
|
|
153
|
+
let sessionTotal = null; // last (cumulative) token usage
|
|
154
|
+
let sessionId = '';
|
|
155
|
+
for (const line of content.split('\n')) {
|
|
156
|
+
const trimmed = line.trim();
|
|
157
|
+
if (!trimmed)
|
|
158
|
+
continue;
|
|
159
|
+
let entry;
|
|
160
|
+
try {
|
|
161
|
+
entry = JSON.parse(trimmed);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const payload = asObject(entry.payload);
|
|
167
|
+
const ts = typeof entry.timestamp === 'string' ? entry.timestamp : '';
|
|
168
|
+
if (entry.type === 'session_meta') {
|
|
169
|
+
if (typeof payload.session_id === 'string')
|
|
170
|
+
sessionId = payload.session_id;
|
|
171
|
+
if (typeof payload.cli_version === 'string' && payload.cli_version && ts >= versionAt) {
|
|
172
|
+
version = payload.cli_version;
|
|
173
|
+
versionAt = ts;
|
|
174
|
+
}
|
|
175
|
+
if (typeof payload.model_provider === 'string' && payload.model_provider) {
|
|
176
|
+
providers.add(payload.model_provider);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Model id can appear on turn/response lines as payload.model.
|
|
180
|
+
if (typeof payload.model === 'string' && payload.model) {
|
|
181
|
+
models.add(payload.model);
|
|
182
|
+
if (!sessionModel)
|
|
183
|
+
sessionModel = payload.model;
|
|
184
|
+
}
|
|
185
|
+
if (payload.type === 'token_count') {
|
|
186
|
+
const usage = asObject(asObject(payload.info).total_token_usage);
|
|
187
|
+
if (Object.keys(usage).length) {
|
|
188
|
+
// Cumulative within the session — keep the latest seen.
|
|
189
|
+
sessionTotal = {
|
|
190
|
+
input: num(usage.input_tokens),
|
|
191
|
+
cached: num(usage.cached_input_tokens),
|
|
192
|
+
output: num(usage.output_tokens) + num(usage.reasoning_output_tokens),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const sessionKey = sessionId || file;
|
|
198
|
+
sessions.add(sessionKey);
|
|
199
|
+
if (sessionTotal) {
|
|
200
|
+
const sum = sessionTotal.input + sessionTotal.cached + sessionTotal.output;
|
|
201
|
+
const prev = sessionTotals.get(sessionKey);
|
|
202
|
+
// Keep the largest cumulative snapshot for a session so a duplicate file doesn't double-count.
|
|
203
|
+
if (!prev || sum > prev.sum) {
|
|
204
|
+
sessionTotals.set(sessionKey, {
|
|
205
|
+
model: sessionModel,
|
|
206
|
+
input: sessionTotal.input,
|
|
207
|
+
cached: sessionTotal.cached,
|
|
208
|
+
output: sessionTotal.output,
|
|
209
|
+
sum,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const st of sessionTotals.values()) {
|
|
215
|
+
totals.input_tokens += st.input;
|
|
216
|
+
totals.cache_read_input_tokens += st.cached;
|
|
217
|
+
totals.output_tokens += st.output;
|
|
218
|
+
if (st.model && st.sum > 0) {
|
|
219
|
+
modelTokenSplit[st.model] = (modelTokenSplit[st.model] ?? 0) + st.sum;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const identity = readCodexIdentity(home);
|
|
223
|
+
const configuredModel = readConfiguredModel(home);
|
|
224
|
+
const sawUsage = files.length > 0;
|
|
225
|
+
if (!sawUsage && !identity.email)
|
|
226
|
+
return [];
|
|
227
|
+
return [
|
|
228
|
+
{
|
|
229
|
+
file_type: TOKEN_USAGE_FILE_TYPE,
|
|
230
|
+
file_path: codexDir, // stable synthetic path → one aggregate row per machine
|
|
231
|
+
raw_content: {
|
|
232
|
+
version,
|
|
233
|
+
session_count: sessions.size,
|
|
234
|
+
models: [...models].sort(),
|
|
235
|
+
providers: [...providers].sort(),
|
|
236
|
+
model_token_split: modelTokenSplit,
|
|
237
|
+
configured_model: configuredModel,
|
|
238
|
+
window_days: TOKEN_USAGE_RECENCY_DAYS,
|
|
239
|
+
totals,
|
|
240
|
+
...identity, // auth_mode, email, name, plan, account_id, org_name (claims only)
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
];
|
|
244
|
+
}
|
|
245
|
+
export { collectCodexTokenUsage, TOKEN_USAGE_RECENCY_DAYS, TOKEN_USAGE_FILE_TYPE };
|
|
@@ -227,6 +227,15 @@ export { collectConfigFilesFromPatterns };
|
|
|
227
227
|
export { collectMcpToolFiles } from './mcp_tool_collector.js';
|
|
228
228
|
export { collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles } from './plugin_collector.js';
|
|
229
229
|
export { collectMcpFromClaudeJsonProjects } from './claude_known_projects_collector.js';
|
|
230
|
+
export { collectClaudeTokenUsage } from './claude_token_usage_collector.js';
|
|
231
|
+
export { collectCopilotTokenUsage } from './copilot_token_usage_collector.js';
|
|
232
|
+
export { collectOpencodeTokenUsage } from './opencode_token_usage_collector.js';
|
|
233
|
+
export { collectCodexTokenUsage } from './codex_token_usage_collector.js';
|
|
234
|
+
export { collectCursorTokenUsage } from './cursor_token_usage_collector.js';
|
|
235
|
+
export { collectHermesTokenUsage } from './hermes_token_usage_collector.js';
|
|
236
|
+
export { collectOpenclawTokenUsage } from './openclaw_token_usage_collector.js';
|
|
237
|
+
export { collectPiTokenUsage } from './pi_token_usage_collector.js';
|
|
238
|
+
export { collectCoworkDesktopVersion } from './cowork_desktop_version_collector.js';
|
|
230
239
|
export { collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, mergeClaudeDesktopExtensionEnableSettings, } from './claude_desktop_extensions_collector.js';
|
|
231
240
|
export { collectCursorProjectWorkspaceMcpConfigs } from './cursor_project_mcp_collector.js';
|
|
232
241
|
export { determineFileTypeFromPath } from './file_type_rules.js';
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
/**
|
|
5
|
+
* Token-usage / session / CLI-version aggregator for GitHub Copilot CLI.
|
|
6
|
+
*
|
|
7
|
+
* Copilot CLI writes one transcript per session under ~/.copilot/session-state/<id>/events.jsonl.
|
|
8
|
+
* Each transcript's `session.start` carries `copilotVersion` + `selectedModel`; its
|
|
9
|
+
* `session.shutdown` carries `tokenDetails` ({input,output,cache_read}.tokenCount),
|
|
10
|
+
* `totalPremiumRequests`, and per-model `modelMetrics`. We aggregate the *numbers* (and version /
|
|
11
|
+
* model ids) on the endpoint and emit only those — never transcript content.
|
|
12
|
+
*
|
|
13
|
+
* Bounded by mtime recency so a large session history is not fully re-parsed on every hook run.
|
|
14
|
+
*/
|
|
15
|
+
const TOKEN_USAGE_RECENCY_DAYS = 30;
|
|
16
|
+
const TOKEN_USAGE_FILE_TYPE = 'copilot_token_usage';
|
|
17
|
+
function emptyTotals() {
|
|
18
|
+
return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
|
|
19
|
+
}
|
|
20
|
+
function num(value) {
|
|
21
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
22
|
+
}
|
|
23
|
+
function asObject(value) {
|
|
24
|
+
return value && typeof value === 'object' ? value : {};
|
|
25
|
+
}
|
|
26
|
+
/** session.shutdown.tokenDetails → {input,output,cache_read}.tokenCount */
|
|
27
|
+
function addTokenDetails(totals, tokenDetails) {
|
|
28
|
+
totals.input_tokens += num(asObject(tokenDetails.input).tokenCount);
|
|
29
|
+
totals.output_tokens += num(asObject(tokenDetails.output).tokenCount);
|
|
30
|
+
totals.cache_read_input_tokens += num(asObject(tokenDetails.cache_read).tokenCount);
|
|
31
|
+
}
|
|
32
|
+
/** Recursively collect events.jsonl files whose mtime is within the recency window. */
|
|
33
|
+
function recentEventFiles(dir, cutoffMs) {
|
|
34
|
+
const out = [];
|
|
35
|
+
let entries;
|
|
36
|
+
try {
|
|
37
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const full = join(dir, entry.name);
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
out.push(...recentEventFiles(full, cutoffMs));
|
|
46
|
+
}
|
|
47
|
+
else if (entry.isFile() && entry.name === 'events.jsonl') {
|
|
48
|
+
try {
|
|
49
|
+
if (statSync(full).mtimeMs >= cutoffMs)
|
|
50
|
+
out.push(full);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* unreadable — skip */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Aggregate Copilot token usage / session count / version across recent session transcripts.
|
|
61
|
+
* Returns a single-element array (one per-machine aggregate) or [] when there is no recent data.
|
|
62
|
+
*/
|
|
63
|
+
function collectCopilotTokenUsage(home = homedir()) {
|
|
64
|
+
const sessionsDir = join(home, '.copilot', 'session-state');
|
|
65
|
+
if (!existsSync(sessionsDir))
|
|
66
|
+
return [];
|
|
67
|
+
const cutoffMs = Date.now() - TOKEN_USAGE_RECENCY_DAYS * 24 * 60 * 60 * 1000;
|
|
68
|
+
const files = recentEventFiles(sessionsDir, cutoffMs);
|
|
69
|
+
if (files.length === 0)
|
|
70
|
+
return [];
|
|
71
|
+
const totals = emptyTotals();
|
|
72
|
+
const sessions = new Set();
|
|
73
|
+
const models = new Set();
|
|
74
|
+
const modelTokenSplit = {};
|
|
75
|
+
let premiumRequests = 0;
|
|
76
|
+
let version = '';
|
|
77
|
+
let versionAt = -1;
|
|
78
|
+
let sawData = false;
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
let content;
|
|
81
|
+
try {
|
|
82
|
+
content = readFileSync(file, 'utf8');
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
for (const line of content.split('\n')) {
|
|
88
|
+
const trimmed = line.trim();
|
|
89
|
+
if (!trimmed)
|
|
90
|
+
continue;
|
|
91
|
+
let entry;
|
|
92
|
+
try {
|
|
93
|
+
entry = JSON.parse(trimmed);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
sawData = true;
|
|
99
|
+
const type = entry.type;
|
|
100
|
+
const data = asObject(entry.data);
|
|
101
|
+
const sessionId = data.sessionId;
|
|
102
|
+
if (typeof sessionId === 'string' && sessionId)
|
|
103
|
+
sessions.add(sessionId);
|
|
104
|
+
if (type === 'session.start') {
|
|
105
|
+
// Keep the copilotVersion from the most recently started session.
|
|
106
|
+
const startTime = num(data.startTime);
|
|
107
|
+
const v = data.copilotVersion;
|
|
108
|
+
if (typeof v === 'string' && v && startTime >= versionAt) {
|
|
109
|
+
version = v;
|
|
110
|
+
versionAt = startTime;
|
|
111
|
+
}
|
|
112
|
+
if (typeof data.selectedModel === 'string' && data.selectedModel) {
|
|
113
|
+
models.add(data.selectedModel);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else if (type === 'assistant.message') {
|
|
117
|
+
if (typeof data.model === 'string' && data.model)
|
|
118
|
+
models.add(data.model);
|
|
119
|
+
}
|
|
120
|
+
else if (type === 'session.shutdown') {
|
|
121
|
+
premiumRequests += num(data.totalPremiumRequests);
|
|
122
|
+
addTokenDetails(totals, asObject(data.tokenDetails));
|
|
123
|
+
const modelMetrics = asObject(data.modelMetrics);
|
|
124
|
+
for (const [model, metrics] of Object.entries(modelMetrics)) {
|
|
125
|
+
models.add(model);
|
|
126
|
+
const usage = asObject(asObject(metrics).usage);
|
|
127
|
+
const sum = num(usage.inputTokens) + num(usage.outputTokens) + num(usage.cacheReadTokens);
|
|
128
|
+
if (sum > 0)
|
|
129
|
+
modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + sum;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!sawData)
|
|
135
|
+
return [];
|
|
136
|
+
// Sessions without a sessionId in data still count via their transcript file.
|
|
137
|
+
const sessionCount = Math.max(sessions.size, files.length);
|
|
138
|
+
return [
|
|
139
|
+
{
|
|
140
|
+
file_type: TOKEN_USAGE_FILE_TYPE,
|
|
141
|
+
file_path: sessionsDir, // stable synthetic path → one aggregate row per machine
|
|
142
|
+
raw_content: {
|
|
143
|
+
version,
|
|
144
|
+
session_count: sessionCount,
|
|
145
|
+
models: [...models].sort(),
|
|
146
|
+
premium_requests: premiumRequests,
|
|
147
|
+
model_token_split: modelTokenSplit,
|
|
148
|
+
window_days: TOKEN_USAGE_RECENCY_DAYS,
|
|
149
|
+
totals,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
export { collectCopilotTokenUsage, TOKEN_USAGE_RECENCY_DAYS, TOKEN_USAGE_FILE_TYPE };
|