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
|
@@ -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 };
|