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.
- package/dist/compliance_prompt_gate.js +29 -16
- 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 +20 -0
- package/dist/log_config_files/collection/copilot_token_usage_collector.js +154 -0
- package/dist/log_config_files/collection/cursor_token_usage_collector.js +188 -0
- package/dist/log_config_files/collection/ensure_cursor_user_settings_snapshot.js +50 -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 +155 -0
- package/dist/log_config_files/collection/opencode_token_usage_collector.js +175 -0
- package/dist/log_config_files/collection/pi_token_usage_collector.js +180 -0
- package/dist/log_config_files/paths/pattern_resolver.js +1 -1
- package/dist/log_config_files/runtime/client_event_reporter.js +11 -1
- package/dist/log_config_files/runtime/compliance_check.js +67 -20
- package/dist/log_config_files/runtime/main_runner.js +67 -1
- package/dist/log_config_files/runtime/remediation_apply_tracking.js +2 -0
- package/dist/log_config_files/runtime/remediation_config_path.js +12 -1
- package/dist/log_config_files/runtime/remediation_sync.js +75 -7
- package/package.json +1 -1
|
@@ -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 };
|