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.
Files changed (30) hide show
  1. package/README.md +1 -1
  2. package/dist/bootstrap_constants.js +3 -3
  3. package/dist/dialog_prefs_cli.js +1 -1
  4. package/dist/log_config_files/auth/auth_flow.js +2 -2
  5. package/dist/log_config_files/auth/auth_key_store.js +2 -2
  6. package/dist/log_config_files/collection/claude_token_usage_collector.js +156 -0
  7. package/dist/log_config_files/collection/codex_token_usage_collector.js +245 -0
  8. package/dist/log_config_files/collection/config_collector.js +9 -0
  9. package/dist/log_config_files/collection/copilot_token_usage_collector.js +154 -0
  10. package/dist/log_config_files/collection/cowork_desktop_version_collector.js +60 -0
  11. package/dist/log_config_files/collection/cursor_token_usage_collector.js +188 -0
  12. package/dist/log_config_files/collection/hermes_token_usage_collector.js +219 -0
  13. package/dist/log_config_files/collection/openclaw_token_usage_collector.js +190 -0
  14. package/dist/log_config_files/collection/opencode_token_usage_collector.js +182 -0
  15. package/dist/log_config_files/collection/pi_token_usage_collector.js +203 -0
  16. package/dist/log_config_files/paths/path_constants_helpers.js +1 -1
  17. package/dist/log_config_files/runtime/client_event_reporter.js +4 -4
  18. package/dist/log_config_files/runtime/compliance_session_log.js +2 -2
  19. package/dist/log_config_files/runtime/hook_logger.js +3 -3
  20. package/dist/log_config_files/runtime/log_metadata.js +2 -2
  21. package/dist/log_config_files/runtime/main_runner.js +75 -3
  22. package/dist/log_config_files/runtime/management_storage.js +8 -8
  23. package/dist/log_config_files/runtime/remediation_sync.js +3 -3
  24. package/dist/log_config_files/sender/batch_sender.js +1 -1
  25. package/dist/log_sensitive_paths_audit.js +3 -3
  26. package/dist/log_uuid/auth_key_store.js +2 -2
  27. package/dist/log_uuid/startup_sender.js +1 -1
  28. package/dist/tofu.js +1 -1
  29. package/dist/tofu_environment.js +1 -1
  30. 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 `~/opt-ai-sec/management/auth_key.txt`.
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 OPT_AI_SEC_MANAGEMENT_REL = 'opt-ai-sec/management';
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 OPT_AI_SEC_LOGS_REL = `${OPT_AI_SEC_MANAGEMENT_REL}/logs`;
12
- /** Log group subdirectories under {@link OPT_AI_SEC_LOGS_REL}. */
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';
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * CLI for macOS non-restart autofix dialogs and dialog preferences in ~/opt-ai-sec/management.
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 { OPT_AI_SEC_MANAGEMENT_REL } from '../../bootstrap_constants.js';
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(OPT_AI_SEC_MANAGEMENT_REL, 'auth', 'auth_key.txt');
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 { OPT_AI_SEC_MANAGEMENT_REL } from '../../bootstrap_constants.js';
4
- const AUTH_KEY_RELATIVE_PATH = path.join(OPT_AI_SEC_MANAGEMENT_REL, 'auth', 'auth_key.txt');
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 };