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
@@ -0,0 +1,60 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ /**
6
+ * Claude Desktop app version, surfaced as Cowork's Agent Profile `agent_version`.
7
+ *
8
+ * Cowork has no per-session version signal — its `local_<sessionId>.json` session files
9
+ * (see cowork_session_whitelist.ts) carry no version field, confirmed against a live install.
10
+ * Cowork runs inside the Claude Desktop app rather than as a standalone CLI, so the app's own
11
+ * bundle version is the only real "version" available for it: `CFBundleShortVersionString` from
12
+ * Claude.app's Info.plist, read via `plutil` (a stock macOS binary — no new dependency, same
13
+ * shell-out pattern as the sqlite3-based collectors).
14
+ *
15
+ * Gated on the Cowork enablement marker (~/Library/Application Support/Claude/
16
+ * cowork-enabled-cli-ops.json) so a machine with Claude Desktop installed but Cowork never
17
+ * enabled doesn't get an agent=cowork version row. macOS only — Claude Desktop/Cowork ships on
18
+ * macOS today.
19
+ */
20
+ const FILE_TYPE = 'cowork_desktop_version';
21
+ const BUNDLE_VERSION_KEY = 'CFBundleShortVersionString';
22
+ const CLAUDE_APP_INFO_PLIST = '/Applications/Claude.app/Contents/Info.plist';
23
+ function coworkEnabledMarkerPath(home) {
24
+ return join(home, 'Library', 'Application Support', 'Claude', 'cowork-enabled-cli-ops.json');
25
+ }
26
+ /** `plutil -extract CFBundleShortVersionString raw <path>`, or '' when plutil/the plist is unavailable. */
27
+ function readBundleVersion(plistPath) {
28
+ try {
29
+ return execFileSync('/usr/bin/plutil', ['-extract', BUNDLE_VERSION_KEY, 'raw', plistPath], {
30
+ encoding: 'utf8',
31
+ stdio: ['ignore', 'pipe', 'ignore'],
32
+ }).trim();
33
+ }
34
+ catch {
35
+ return '';
36
+ }
37
+ }
38
+ /**
39
+ * Emit the Claude Desktop app version as a single-element aggregate, or [] when this isn't
40
+ * macOS, Cowork isn't enabled on this machine, or Claude.app/plutil is unavailable.
41
+ */
42
+ function collectCoworkDesktopVersion(home = homedir(), plistPath = CLAUDE_APP_INFO_PLIST) {
43
+ if (process.platform !== 'darwin')
44
+ return [];
45
+ if (!existsSync(coworkEnabledMarkerPath(home)))
46
+ return [];
47
+ if (!existsSync(plistPath))
48
+ return [];
49
+ const version = readBundleVersion(plistPath);
50
+ if (!version)
51
+ return [];
52
+ return [
53
+ {
54
+ file_type: FILE_TYPE,
55
+ file_path: plistPath,
56
+ raw_content: { version },
57
+ },
58
+ ];
59
+ }
60
+ export { collectCoworkDesktopVersion, FILE_TYPE as COWORK_DESKTOP_VERSION_FILE_TYPE };
@@ -0,0 +1,188 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
6
+ /**
7
+ * Session / version / identity aggregator for Cursor.
8
+ *
9
+ * Cursor stores everything in a VSCode-style SQLite state DB at
10
+ * ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb (macOS).
11
+ * Two tables carry the data we need:
12
+ * ItemTable — key/value store: Cursor version, cursorAuth/* identity rows
13
+ * cursorDiskKV — key/value store: composerData:<conversationId> entries,
14
+ * each with a JSON blob carrying createdAt, modelConfig.modelName
15
+ *
16
+ * We read identity (email, plan, version) from ItemTable and aggregate session counts +
17
+ * model names from composerData entries in cursorDiskKV, bounded to the last 30 days.
18
+ * Token counts are no longer written by Cursor 3.8+ so totals are always 0.
19
+ * No message content is read — only model names and timestamps.
20
+ */
21
+ const TOKEN_USAGE_RECENCY_DAYS = 30;
22
+ const TOKEN_USAGE_FILE_TYPE = 'cursor_token_usage';
23
+ const ROW_SEP = '\x1e';
24
+ const FIELD_SEP = '\x1f';
25
+ /** Platform-aware path to Cursor's global state.vscdb. */
26
+ function vscdbPath(home) {
27
+ if (process.platform === 'darwin') {
28
+ return join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb');
29
+ }
30
+ if (process.platform === 'linux') {
31
+ return join(home, '.config', 'Cursor', 'User', 'globalStorage', 'state.vscdb');
32
+ }
33
+ // Windows: AppData/Roaming/Cursor/...
34
+ const appdata = process.env['APPDATA'] || join(home, 'AppData', 'Roaming');
35
+ return join(appdata, 'Cursor', 'User', 'globalStorage', 'state.vscdb');
36
+ }
37
+ function querySqlite(sqlite3, dbPath, sql) {
38
+ try {
39
+ return execFileSync(sqlite3, ['-readonly', '-noheader', dbPath], {
40
+ input: `.timeout 5000\n${sql}\n`,
41
+ encoding: 'utf8',
42
+ stdio: ['pipe', 'pipe', 'pipe'],
43
+ });
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ function num(value) {
50
+ if (typeof value === 'number' && Number.isFinite(value))
51
+ return value;
52
+ if (typeof value === 'string') {
53
+ const n = parseInt(value, 10);
54
+ return Number.isFinite(n) ? n : 0;
55
+ }
56
+ return 0;
57
+ }
58
+ /**
59
+ * Aggregate Cursor token usage / sessions / version / identity from state.vscdb.
60
+ * Returns a single-element array (one per-machine aggregate) or [] when no usable DB exists.
61
+ */
62
+ function collectCursorTokenUsage(home = homedir()) {
63
+ const dbPath = vscdbPath(home);
64
+ if (!existsSync(dbPath))
65
+ return [];
66
+ const cutoffMs = Date.now() - TOKEN_USAGE_RECENCY_DAYS * 24 * 60 * 60 * 1000;
67
+ const sqlite3 = resolveSqlite3Binary();
68
+ if (!sqlite3)
69
+ return [];
70
+ // Identity: version, email, plan, subscription status, sign-up type, display name.
71
+ const identityRaw = querySqlite(sqlite3, dbPath, `SELECT key || char(31) || COALESCE(value, '') || char(30)
72
+ FROM ItemTable
73
+ WHERE key IN (
74
+ 'cursor.startupMetrics.lastVersion',
75
+ 'cursorAuth/cachedEmail',
76
+ 'cursorAuth/stripeMembershipType',
77
+ 'cursorAuth/stripeSubscriptionStatus',
78
+ 'cursorAuth/cachedSignUpType',
79
+ 'cursorAuth/cachedScopedProfile'
80
+ );`);
81
+ let version = '';
82
+ let email = '';
83
+ let plan = '';
84
+ let subscriptionStatus = '';
85
+ let signUpType = '';
86
+ let displayName = '';
87
+ if (identityRaw) {
88
+ for (const row of identityRaw.split(ROW_SEP)) {
89
+ const trimmed = row.trim();
90
+ if (!trimmed)
91
+ continue;
92
+ const sep = trimmed.indexOf(FIELD_SEP);
93
+ if (sep === -1)
94
+ continue;
95
+ const key = trimmed.slice(0, sep);
96
+ const value = trimmed.slice(sep + 1);
97
+ switch (key) {
98
+ case 'cursor.startupMetrics.lastVersion':
99
+ version = value;
100
+ break;
101
+ case 'cursorAuth/cachedEmail':
102
+ email = value;
103
+ break;
104
+ case 'cursorAuth/stripeMembershipType':
105
+ plan = value;
106
+ break;
107
+ case 'cursorAuth/stripeSubscriptionStatus':
108
+ subscriptionStatus = value;
109
+ break;
110
+ case 'cursorAuth/cachedSignUpType':
111
+ signUpType = value;
112
+ break;
113
+ case 'cursorAuth/cachedScopedProfile':
114
+ try {
115
+ const profile = JSON.parse(value);
116
+ if (typeof profile['displayName'] === 'string')
117
+ displayName = profile['displayName'];
118
+ }
119
+ catch {
120
+ /* non-JSON or missing — skip */
121
+ }
122
+ break;
123
+ }
124
+ }
125
+ }
126
+ // Usage: aggregate session counts and model names from composerData entries in the last 30
127
+ // days. composerData:<conversationId> entries carry a Unix ms timestamp in $.createdAt and
128
+ // the model in $.modelConfig.modelName. Token counts are not written by Cursor 3.8+.
129
+ // cutoffMs is a safe integer — interpolating directly avoids an extra round-trip.
130
+ // key range (not LIKE 'composerData:%') so the query hits the primary-key index on `key`
131
+ // instead of a full-table scan. SQLite's LIKE optimizer only rewrites a prefix match to an
132
+ // index range scan when the comparison is case-sensitive: either PRAGMA case_sensitive_like
133
+ // is ON (with `key`'s default BINARY collation), or the column itself uses NOCASE collation.
134
+ // Neither holds here, so LIKE 'composerData:%' would fall back to a full scan of cursorDiskKV.
135
+ // ';' is the char after ':' in ASCII, so [composerData: , composerData;) is exactly the
136
+ // composerData:* prefix range. Note: this also makes the match case-sensitive (BINARY
137
+ // collation), narrower than the old case-insensitive LIKE — safe because Cursor always
138
+ // writes this key prefix as the exact literal 'composerData:', never a different casing.
139
+ const usageRaw = querySqlite(sqlite3, dbPath, `SELECT
140
+ COALESCE(json_extract(value, '$.modelConfig.modelName'), 'default') || char(31) ||
141
+ COUNT(*) || char(30)
142
+ FROM cursorDiskKV
143
+ WHERE key >= 'composerData:' AND key < 'composerData;'
144
+ AND CAST(json_extract(value, '$.createdAt') AS INTEGER) >= ${cutoffMs}
145
+ GROUP BY json_extract(value, '$.modelConfig.modelName');`);
146
+ const models = new Set();
147
+ let sessionCount = 0;
148
+ if (usageRaw) {
149
+ for (const row of usageRaw.split(ROW_SEP)) {
150
+ const trimmed = row.trim();
151
+ if (!trimmed)
152
+ continue;
153
+ const sep = trimmed.indexOf(FIELD_SEP);
154
+ if (sep === -1)
155
+ continue;
156
+ const model = trimmed.slice(0, sep).trim();
157
+ const cnt = num(trimmed.slice(sep + 1));
158
+ sessionCount += cnt;
159
+ if (model)
160
+ models.add(model);
161
+ }
162
+ }
163
+ if (sessionCount === 0 && !email && !version)
164
+ return [];
165
+ return [
166
+ {
167
+ file_type: TOKEN_USAGE_FILE_TYPE,
168
+ file_path: dbPath,
169
+ raw_content: {
170
+ version,
171
+ session_count: sessionCount,
172
+ models: [...models].sort(),
173
+ model_token_split: {},
174
+ window_days: TOKEN_USAGE_RECENCY_DAYS,
175
+ totals: {
176
+ input_tokens: 0,
177
+ output_tokens: 0,
178
+ },
179
+ email,
180
+ display_name: displayName,
181
+ plan,
182
+ subscription_status: subscriptionStatus,
183
+ auth_method: signUpType ? 'cursor_account' : '',
184
+ },
185
+ },
186
+ ];
187
+ }
188
+ export { collectCursorTokenUsage, TOKEN_USAGE_RECENCY_DAYS, TOKEN_USAGE_FILE_TYPE, vscdbPath };
@@ -0,0 +1,219 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
6
+ /**
7
+ * Session / token usage / model / version aggregator for Hermes Agent (Nous Research).
8
+ *
9
+ * Hermes keeps all state under ~/.hermes/. Three sources feed this aggregate, each read
10
+ * narrowly to avoid ever touching credential material:
11
+ * - state.db (SQLite) `sessions` table — real per-session model + token columns
12
+ * (input_tokens, output_tokens, reasoning_tokens, cache_read_tokens). started_at is a
13
+ * Unix epoch *seconds* REAL column, bounded to the last 30 days.
14
+ * - config.yaml — only the top-level `model` key (string, or a block with a `default`
15
+ * sub-key) is extracted; config.yaml can hold literal provider API keys under
16
+ * `providers:`, so this is a scoped line-scan, never a full YAML parse.
17
+ * - auth.json — only the top-level `active_provider` key (e.g. "nous") is read; the
18
+ * per-provider blocks under `providers:` hold OAuth/API-key secrets and are never
19
+ * touched. "nous" is Nous Portal OAuth; anything else is a traditional API-key
20
+ * provider (see hermes_cli/auth.py's ProviderConfig registry).
21
+ *
22
+ * There is no on-disk record of email/org/plan — those only exist inside the live OAuth
23
+ * access_token's JWT claims (the actual bearer credential, not a side identity token like
24
+ * Codex's id_token), so this collector does not decode it. Version comes from the bundled
25
+ * source checkout's hermes_cli/__init__.py (best-effort; empty string if unavailable).
26
+ */
27
+ const TOKEN_USAGE_RECENCY_DAYS = 30;
28
+ const TOKEN_USAGE_FILE_TYPE = 'hermes_token_usage';
29
+ const ROW_SEP = '\x1e';
30
+ const FIELD_SEP = '\x1f';
31
+ const PROVIDER_NOUS = 'nous';
32
+ const AUTH_NOUS_OAUTH = 'nous_oauth';
33
+ const AUTH_API_KEY = 'api_key';
34
+ function emptyTotals() {
35
+ return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
36
+ }
37
+ function num(value) {
38
+ if (typeof value === 'number' && Number.isFinite(value))
39
+ return value;
40
+ if (typeof value === 'string') {
41
+ const n = parseFloat(value);
42
+ return Number.isFinite(n) ? n : 0;
43
+ }
44
+ return 0;
45
+ }
46
+ function querySqlite(sqlite3, dbPath, sql) {
47
+ try {
48
+ return execFileSync(sqlite3, ['-readonly', '-noheader', dbPath], {
49
+ input: `.timeout 5000\n${sql}\n`,
50
+ encoding: 'utf8',
51
+ stdio: ['pipe', 'pipe', 'pipe'],
52
+ });
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /**
59
+ * Extract a plain YAML scalar value, stripping quotes and a trailing inline comment.
60
+ * A quoted value's content ends at the matching close-quote (anything after, including a
61
+ * comment, is discarded). An unquoted value's comment starts at a `#` preceded by whitespace
62
+ * or at the start of the string (a bare `#` glued to non-space text is part of the value, per
63
+ * the YAML spec's plain-scalar comment rule).
64
+ */
65
+ function stripYamlScalar(value) {
66
+ const trimmed = value.trim();
67
+ if (trimmed.startsWith('"')) {
68
+ const end = trimmed.indexOf('"', 1);
69
+ return end !== -1 ? trimmed.slice(1, end) : trimmed.slice(1);
70
+ }
71
+ if (trimmed.startsWith("'")) {
72
+ const end = trimmed.indexOf("'", 1);
73
+ return end !== -1 ? trimmed.slice(1, end) : trimmed.slice(1);
74
+ }
75
+ const commentIdx = trimmed.search(/(?:^|\s)#/);
76
+ return (commentIdx === -1 ? trimmed : trimmed.slice(0, commentIdx)).trim();
77
+ }
78
+ /**
79
+ * Scoped read of config.yaml's top-level `model` key only. Handles both the plain-string
80
+ * form (`model: "name"`) and the block form (`model:\n default: "name"\n provider: ...`).
81
+ * Never parses or returns any other key — config.yaml's `providers:` block can carry
82
+ * literal API keys, so this deliberately avoids a full YAML parse.
83
+ */
84
+ function readConfiguredModel(home) {
85
+ let text;
86
+ try {
87
+ text = readFileSync(join(home, '.hermes', 'config.yaml'), 'utf8');
88
+ }
89
+ catch {
90
+ return '';
91
+ }
92
+ const lines = text.split('\n');
93
+ for (let i = 0; i < lines.length; i++) {
94
+ const m = lines[i].match(/^model:\s*(.*)$/);
95
+ if (!m)
96
+ continue;
97
+ const inline = stripYamlScalar(m[1]);
98
+ if (inline)
99
+ return inline;
100
+ // Block form: scan indented lines immediately below for `default:`.
101
+ for (let j = i + 1; j < lines.length; j++) {
102
+ const next = lines[j];
103
+ if (next.trim() === '')
104
+ continue;
105
+ if (/^\S/.test(next))
106
+ break; // dedented — end of the `model:` block
107
+ const dm = next.match(/^\s+default:\s*(.*)$/);
108
+ if (dm)
109
+ return stripYamlScalar(dm[1]);
110
+ }
111
+ break; // found the top-level `model:` key; stop (avoid nested `model:` keys elsewhere)
112
+ }
113
+ return '';
114
+ }
115
+ /** Scoped read of auth.json's top-level `active_provider` key only — never the `providers` block. */
116
+ function readActiveProvider(home) {
117
+ try {
118
+ const raw = JSON.parse(readFileSync(join(home, '.hermes', 'auth.json'), 'utf8'));
119
+ return typeof raw.active_provider === 'string' ? raw.active_provider : '';
120
+ }
121
+ catch {
122
+ return '';
123
+ }
124
+ }
125
+ /** Best-effort CLI version from the bundled source checkout; empty string if unavailable. */
126
+ function readHermesVersion(home) {
127
+ try {
128
+ const text = readFileSync(join(home, '.hermes', 'hermes-agent', 'hermes_cli', '__init__.py'), 'utf8');
129
+ const m = text.match(/^__version__\s*=\s*["']([^"']+)["']/m);
130
+ return m ? m[1] : '';
131
+ }
132
+ catch {
133
+ return '';
134
+ }
135
+ }
136
+ /**
137
+ * Aggregate Hermes session count / token usage / models / version / active provider.
138
+ * Returns a single-element array (one per-machine aggregate) or [] when no usable DB exists.
139
+ */
140
+ function collectHermesTokenUsage(home = homedir()) {
141
+ const dbPath = join(home, '.hermes', 'state.db');
142
+ if (!existsSync(dbPath))
143
+ return [];
144
+ const sqlite3 = resolveSqlite3Binary();
145
+ if (!sqlite3)
146
+ return [];
147
+ // started_at is Unix epoch *seconds* (REAL), unlike the epoch-ms convention elsewhere.
148
+ const cutoffSeconds = Math.floor(Date.now() / 1000) - TOKEN_USAGE_RECENCY_DAYS * 24 * 60 * 60;
149
+ const usageRaw = querySqlite(sqlite3, dbPath, `SELECT
150
+ COALESCE(model, 'default') || char(31) ||
151
+ COUNT(*) || char(31) ||
152
+ COALESCE(SUM(input_tokens), 0) || char(31) ||
153
+ COALESCE(SUM(output_tokens), 0) || char(31) ||
154
+ COALESCE(SUM(reasoning_tokens), 0) || char(31) ||
155
+ COALESCE(SUM(cache_read_tokens), 0) || char(30)
156
+ FROM sessions
157
+ WHERE started_at >= ${cutoffSeconds}
158
+ GROUP BY model;`);
159
+ const models = new Set();
160
+ const totals = emptyTotals();
161
+ const modelTokenSplit = {};
162
+ let sessionCount = 0;
163
+ if (usageRaw) {
164
+ for (const row of usageRaw.split(ROW_SEP)) {
165
+ const trimmed = row.trim();
166
+ if (!trimmed)
167
+ continue;
168
+ const fields = trimmed.split(FIELD_SEP);
169
+ if (fields.length < 6)
170
+ continue;
171
+ const [model, count, input, output, reasoning, cacheRead] = fields;
172
+ const cnt = num(count);
173
+ sessionCount += cnt;
174
+ if (model)
175
+ models.add(model);
176
+ const modelInput = num(input);
177
+ // reasoning_tokens folds into output_tokens — TokenTotals has no separate reasoning
178
+ // field (matches Codex's reasoning_output_tokens fold-in), so a reasoning/non-reasoning
179
+ // split isn't distinguishable downstream.
180
+ const modelOutput = num(output) + num(reasoning);
181
+ const modelCacheRead = num(cacheRead);
182
+ totals.input_tokens += modelInput;
183
+ totals.output_tokens += modelOutput;
184
+ totals.cache_read_input_tokens += modelCacheRead;
185
+ const modelSum = modelInput + modelOutput + modelCacheRead;
186
+ if (model && modelSum > 0) {
187
+ modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + modelSum;
188
+ }
189
+ }
190
+ }
191
+ const configuredModel = readConfiguredModel(home);
192
+ const activeProvider = readActiveProvider(home);
193
+ const version = readHermesVersion(home);
194
+ const authMethod = activeProvider
195
+ ? activeProvider === PROVIDER_NOUS
196
+ ? AUTH_NOUS_OAUTH
197
+ : AUTH_API_KEY
198
+ : '';
199
+ if (sessionCount === 0 && !configuredModel && !activeProvider)
200
+ return [];
201
+ return [
202
+ {
203
+ file_type: TOKEN_USAGE_FILE_TYPE,
204
+ file_path: dbPath,
205
+ raw_content: {
206
+ version,
207
+ session_count: sessionCount,
208
+ models: [...models].sort(),
209
+ model_token_split: modelTokenSplit,
210
+ configured_model: configuredModel,
211
+ window_days: TOKEN_USAGE_RECENCY_DAYS,
212
+ totals,
213
+ active_provider: activeProvider,
214
+ auth_method: authMethod,
215
+ },
216
+ },
217
+ ];
218
+ }
219
+ export { collectHermesTokenUsage, TOKEN_USAGE_RECENCY_DAYS, TOKEN_USAGE_FILE_TYPE };
@@ -0,0 +1,190 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ /**
5
+ * Session / token usage / model / version aggregator for OpenClaw
6
+ * (github.com/openclaw/openclaw — a multi-channel AI gateway, unrelated to Hermes/Nous Research
7
+ * despite Hermes shipping a `hermes claw migrate` path to import from it).
8
+ *
9
+ * OpenClaw keeps a session store per agent at
10
+ * ~/.openclaw/agents/<agentId>/sessions/sessions.json — a sessionKey -> SessionEntry map
11
+ * (see docs/reference/session-management-compaction.md in the openclaw package: "Session
12
+ * store schema"). SessionEntry carries both routing/identity fields (displayName, channel,
13
+ * groupChannel, groupId, lastAccountId, lastChannel, lastThreadId, lastTo, deliveryContext,
14
+ * origin — who the session's conversation partner was) and numeric/model fields (inputTokens,
15
+ * outputTokens, cacheRead, cacheWrite, totalTokens, model, modelProvider). This collector reads
16
+ * ONLY the numeric/model fields — the existing openclaw_sessions file type registration already
17
+ * treats this file's content as sensitive (collect_style="metadata", path+mtime only), so the
18
+ * routing/identity fields are never touched or returned here.
19
+ *
20
+ * Version comes from a scoped read of openclaw.json's top-level `meta.lastTouchedVersion` key
21
+ * only — never any other key (openclaw.json's other sections can carry messaging/provider
22
+ * settings).
23
+ *
24
+ * Auth method comes from a scoped read of openclaw.json's `auth.profiles.*` entries — each
25
+ * profile is `{provider, mode}` (e.g. `{"provider": "anthropic", "mode": "api_key"}`); we read
26
+ * only those two keys, never any credential material (profiles carry no token/key fields to
27
+ * begin with). Exposed per-provider so the extractor can match it against the provider actually
28
+ * observed in session usage.
29
+ *
30
+ * Only checks ~/.openclaw (the canonical root); the moltbot/clawdbot compatible-fork roots
31
+ * tracked by the presence/log file types are not covered here.
32
+ */
33
+ const TOKEN_USAGE_RECENCY_DAYS = 30;
34
+ const TOKEN_USAGE_FILE_TYPE = 'openclaw_token_usage';
35
+ function emptyTotals() {
36
+ return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
37
+ }
38
+ function num(value) {
39
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
40
+ }
41
+ function str(value) {
42
+ return typeof value === 'string' ? value.trim() : '';
43
+ }
44
+ /** Every ~/.openclaw/agents/<agentId>/sessions/sessions.json that exists. */
45
+ function discoverSessionsStores(openclawDir) {
46
+ const agentsDir = join(openclawDir, 'agents');
47
+ let entries;
48
+ try {
49
+ entries = readdirSync(agentsDir, { withFileTypes: true });
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ const out = [];
55
+ for (const entry of entries) {
56
+ if (!entry.isDirectory())
57
+ continue;
58
+ const storePath = join(agentsDir, entry.name, 'sessions', 'sessions.json');
59
+ if (existsSync(storePath))
60
+ out.push(storePath);
61
+ }
62
+ return out;
63
+ }
64
+ /**
65
+ * Scoped read of one sessions.json store: only numeric/model fields per SessionEntry, bounded
66
+ * to the recency window. See the module docstring for the fields this deliberately never reads.
67
+ */
68
+ function readSessionsStore(storePath, cutoffMs, acc) {
69
+ let raw;
70
+ try {
71
+ raw = JSON.parse(readFileSync(storePath, 'utf8'));
72
+ }
73
+ catch {
74
+ return;
75
+ }
76
+ if (!raw || typeof raw !== 'object')
77
+ return;
78
+ for (const value of Object.values(raw)) {
79
+ if (!value || typeof value !== 'object')
80
+ continue;
81
+ const entry = value;
82
+ // updatedAt is Unix epoch *milliseconds*, matching cutoffMs below (unlike Hermes's
83
+ // started_at, which is epoch seconds). Confirmed against a real sessions.json: a raw
84
+ // value of 1772443946772 resolves to 2026-03-02T09:32:26.772Z, the correct magnitude
85
+ // for epoch-ms — a seconds value at that date would be ~10 digits, not 13.
86
+ if (num(entry.updatedAt) < cutoffMs)
87
+ continue;
88
+ const sessionId = str(entry.sessionId);
89
+ if (sessionId)
90
+ acc.sessionIds.add(sessionId);
91
+ const model = str(entry.model);
92
+ const provider = str(entry.modelProvider);
93
+ if (model)
94
+ acc.models.add(model);
95
+ if (provider)
96
+ acc.providers.add(provider);
97
+ const input = num(entry.inputTokens);
98
+ const output = num(entry.outputTokens);
99
+ const cacheRead = num(entry.cacheRead);
100
+ acc.totals.input_tokens += input;
101
+ acc.totals.output_tokens += output;
102
+ acc.totals.cache_read_input_tokens += cacheRead;
103
+ const sum = input + output + cacheRead;
104
+ if (model && sum > 0) {
105
+ acc.modelTokenSplit[model] = (acc.modelTokenSplit[model] ?? 0) + sum;
106
+ }
107
+ }
108
+ }
109
+ /** Scoped read of openclaw.json's top-level `meta.lastTouchedVersion` key only. */
110
+ function readVersion(openclawDir) {
111
+ try {
112
+ const raw = JSON.parse(readFileSync(join(openclawDir, 'openclaw.json'), 'utf8'));
113
+ const meta = raw.meta;
114
+ return meta && typeof meta === 'object'
115
+ ? str(meta.lastTouchedVersion)
116
+ : '';
117
+ }
118
+ catch {
119
+ return '';
120
+ }
121
+ }
122
+ /**
123
+ * Scoped read of openclaw.json's `auth.profiles` — provider + mode only, never credentials
124
+ * (the profiles carry no token/key fields). Returns a provider -> mode map (e.g.
125
+ * `{"anthropic": "api_key"}`); a provider with multiple profiles keeps the last one read.
126
+ */
127
+ function readAuthModesByProvider(openclawDir) {
128
+ try {
129
+ const raw = JSON.parse(readFileSync(join(openclawDir, 'openclaw.json'), 'utf8'));
130
+ const auth = raw.auth;
131
+ const profiles = auth && typeof auth === 'object' ? auth.profiles : undefined;
132
+ const out = {};
133
+ if (profiles && typeof profiles === 'object') {
134
+ for (const value of Object.values(profiles)) {
135
+ if (!value || typeof value !== 'object')
136
+ continue;
137
+ const entry = value;
138
+ const provider = str(entry.provider);
139
+ const mode = str(entry.mode);
140
+ if (provider && mode)
141
+ out[provider] = mode;
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ catch {
147
+ return {};
148
+ }
149
+ }
150
+ /**
151
+ * Aggregate OpenClaw session count / token usage / models / providers / version across every
152
+ * agent dir's sessions.json store. Returns a single-element array (one per-machine aggregate)
153
+ * or [] when there is no recent session data.
154
+ */
155
+ function collectOpenclawTokenUsage(home = homedir()) {
156
+ const openclawDir = join(home, '.openclaw');
157
+ const stores = discoverSessionsStores(openclawDir);
158
+ if (stores.length === 0)
159
+ return [];
160
+ const cutoffMs = Date.now() - TOKEN_USAGE_RECENCY_DAYS * 24 * 60 * 60 * 1000;
161
+ const acc = {
162
+ sessionIds: new Set(),
163
+ models: new Set(),
164
+ providers: new Set(),
165
+ totals: emptyTotals(),
166
+ modelTokenSplit: {},
167
+ };
168
+ for (const store of stores) {
169
+ readSessionsStore(store, cutoffMs, acc);
170
+ }
171
+ if (acc.sessionIds.size === 0)
172
+ return [];
173
+ return [
174
+ {
175
+ file_type: TOKEN_USAGE_FILE_TYPE,
176
+ file_path: openclawDir,
177
+ raw_content: {
178
+ version: readVersion(openclawDir),
179
+ session_count: acc.sessionIds.size,
180
+ models: [...acc.models].sort(),
181
+ providers: [...acc.providers].sort(),
182
+ model_token_split: acc.modelTokenSplit,
183
+ window_days: TOKEN_USAGE_RECENCY_DAYS,
184
+ totals: acc.totals,
185
+ auth_modes_by_provider: readAuthModesByProvider(openclawDir),
186
+ },
187
+ },
188
+ ];
189
+ }
190
+ export { collectOpenclawTokenUsage, TOKEN_USAGE_RECENCY_DAYS, TOKEN_USAGE_FILE_TYPE };