log-llm-config 1.5.0 → 1.5.8

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.
@@ -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 };
@@ -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,50 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readJSONFile } from '../readers/file_readers.js';
3
+ import { PORTABLE_CURSOR_USER_SETTINGS, cursorUserSettingsAbsolutePaths, } from '../runtime/remediation_config_path.js';
4
+ export function isPortableCursorUserSettingsUploadPath(filePath) {
5
+ const p = filePath.replace(/\\/g, '/').toLowerCase();
6
+ return (p === 'cursor/user/settings.json' ||
7
+ p.endsWith('/cursor/user/settings.json') ||
8
+ p.includes('application support/cursor/user/settings.json'));
9
+ }
10
+ function settingsPayloadHasGlobalIgnoreListKey(raw) {
11
+ if (!raw || typeof raw !== 'object')
12
+ return false;
13
+ const nested = raw.cursor
14
+ ?.general?.globalCursorIgnoreList;
15
+ if (Array.isArray(nested))
16
+ return true;
17
+ const flat = raw['cursor.general.globalCursorIgnoreList'];
18
+ return Array.isArray(flat);
19
+ }
20
+ /**
21
+ * Sensitive Directories scans need full User/settings.json (globalCursorIgnoreList).
22
+ * Pattern collection can omit the key or send an empty object; merge disk when the batch
23
+ * lacks an explicit globalCursorIgnoreList array (including []).
24
+ */
25
+ export function ensureCursorUserSettingsSnapshotInBatch(configFiles) {
26
+ const hasIgnoreKeyInBatch = configFiles.some((c) => c.file_type === 'vscode_settings' &&
27
+ isPortableCursorUserSettingsUploadPath(c.file_path) &&
28
+ settingsPayloadHasGlobalIgnoreListKey(c.raw_content));
29
+ if (hasIgnoreKeyInBatch)
30
+ return;
31
+ for (const abs of cursorUserSettingsAbsolutePaths()) {
32
+ if (!existsSync(abs))
33
+ continue;
34
+ const raw = readJSONFile(abs);
35
+ if (!raw || Object.keys(raw).length === 0)
36
+ continue;
37
+ const entry = {
38
+ file_type: 'vscode_settings',
39
+ file_path: PORTABLE_CURSOR_USER_SETTINGS,
40
+ raw_content: raw,
41
+ };
42
+ const idx = configFiles.findIndex((c) => c.file_type === 'vscode_settings' &&
43
+ isPortableCursorUserSettingsUploadPath(c.file_path));
44
+ if (idx >= 0)
45
+ configFiles[idx] = entry;
46
+ else
47
+ configFiles.push(entry);
48
+ return;
49
+ }
50
+ }
@@ -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 };