log-llm-config 1.5.10 → 1.5.14
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/log_config_files/collection/claude_token_usage_collector.js +56 -9
- package/dist/log_config_files/collection/codex_token_usage_collector.js +97 -0
- package/dist/log_config_files/collection/config_collector.js +1 -0
- package/dist/log_config_files/collection/copilot_token_usage_collector.js +15 -2
- package/dist/log_config_files/collection/cursor_project_mcp_collector.js +3 -0
- package/dist/log_config_files/collection/cursor_token_usage_collector.js +14 -10
- package/dist/log_config_files/collection/grok_cli_version_collector.js +53 -0
- package/dist/log_config_files/collection/hermes_token_usage_collector.js +51 -29
- package/dist/log_config_files/collection/openclaw_token_usage_collector.js +62 -29
- package/dist/log_config_files/collection/opencode_token_usage_collector.js +2 -7
- package/dist/log_config_files/collection/pi_token_usage_collector.js +40 -4
- package/dist/log_config_files/readers/composer_shadow_merge.js +1 -1
- package/dist/log_config_files/readers/{cursor_shadow_merge_policy.js → cursor_shadow_merge_ruleset.js} +1 -1
- package/dist/log_config_files/readers/file_readers.js +18 -1
- package/dist/log_config_files/readers/vscdb_reactive_storage.js +1 -1
- package/dist/log_config_files/readers/vscdb_reader.js +2 -7
- package/dist/log_config_files/runtime/compliance_check.js +17 -7
- package/dist/log_config_files/runtime/main_runner.js +9 -1
- package/dist/log_config_files/runtime/remediation_apply_tracking.js +9 -1
- package/dist/log_config_files/runtime/remediation_sync.js +42 -36
- package/dist/log_config_files/runtime/sqlite_binary.js +32 -0
- package/package.json +4 -3
|
@@ -25,18 +25,34 @@ function emptyTotals() {
|
|
|
25
25
|
}
|
|
26
26
|
function addUsage(totals, usage) {
|
|
27
27
|
for (const key of Object.keys(totals)) {
|
|
28
|
-
const value = usage
|
|
29
|
-
if (
|
|
28
|
+
const value = readTokenField(usage, key);
|
|
29
|
+
if (value > 0)
|
|
30
30
|
totals[key] += value;
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
/**
|
|
33
|
+
/** Read one token field, preferring the nested 5m+1h cache-creation sum over the flat field. */
|
|
34
|
+
function readTokenField(usage, key) {
|
|
35
|
+
if (key === 'cache_creation_input_tokens') {
|
|
36
|
+
const nested = usage.cache_creation;
|
|
37
|
+
if (nested && typeof nested === 'object') {
|
|
38
|
+
const n = nested;
|
|
39
|
+
const has5m = typeof n.ephemeral_5m_input_tokens === 'number' && Number.isFinite(n.ephemeral_5m_input_tokens);
|
|
40
|
+
const has1h = typeof n.ephemeral_1h_input_tokens === 'number' && Number.isFinite(n.ephemeral_1h_input_tokens);
|
|
41
|
+
// Fall back to the flat field only when the nested block is truly absent — a
|
|
42
|
+
// legitimately-zero nested sum (both fields present as 0) must not be overridden by it.
|
|
43
|
+
if (has5m || has1h) {
|
|
44
|
+
return (has5m ? n.ephemeral_5m_input_tokens : 0) + (has1h ? n.ephemeral_1h_input_tokens : 0);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const value = usage[key];
|
|
49
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
50
|
+
}
|
|
51
|
+
/** Sum the token fields of one usage block (used for per-model attribution and dedup tiebreaks). */
|
|
34
52
|
function usageSum(usage) {
|
|
35
53
|
let sum = 0;
|
|
36
54
|
for (const key of ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']) {
|
|
37
|
-
|
|
38
|
-
if (typeof value === 'number' && Number.isFinite(value))
|
|
39
|
-
sum += value;
|
|
55
|
+
sum += readTokenField(usage, key);
|
|
40
56
|
}
|
|
41
57
|
return sum;
|
|
42
58
|
}
|
|
@@ -86,6 +102,14 @@ function collectClaudeTokenUsage(home = homedir()) {
|
|
|
86
102
|
let version = '';
|
|
87
103
|
let versionAt = '';
|
|
88
104
|
let sawData = false;
|
|
105
|
+
// Claude Code writes one JSONL line per streaming snapshot of the same assistant message,
|
|
106
|
+
// so message.id/requestId repeat with a growing usage total; only the final snapshot is
|
|
107
|
+
// the true total. Dedup by (message.id, requestId), taking the per-field max across all
|
|
108
|
+
// snapshots for that key (order-independent — robust to out-of-order lines, and safe even
|
|
109
|
+
// if a snapshot has a genuinely-lower or reset value in one field while another snapshot in
|
|
110
|
+
// the same group peaks a different field). Entries without a message.id can't be deduped and
|
|
111
|
+
// are accumulated directly.
|
|
112
|
+
const dedupedUsage = new Map();
|
|
89
113
|
for (const file of files) {
|
|
90
114
|
let content;
|
|
91
115
|
try {
|
|
@@ -125,16 +149,39 @@ function collectClaudeTokenUsage(home = homedir()) {
|
|
|
125
149
|
models.add(model);
|
|
126
150
|
if (msg.usage && typeof msg.usage === 'object') {
|
|
127
151
|
const usage = msg.usage;
|
|
128
|
-
|
|
129
|
-
if (
|
|
152
|
+
const messageId = typeof msg.id === 'string' ? msg.id : '';
|
|
153
|
+
if (messageId) {
|
|
154
|
+
const requestId = typeof entry.requestId === 'string' ? entry.requestId : '';
|
|
155
|
+
const dedupeKey = `${messageId} ${requestId}`;
|
|
156
|
+
const existing = dedupedUsage.get(dedupeKey) ?? { totals: emptyTotals(), model: '' };
|
|
157
|
+
for (const key of Object.keys(existing.totals)) {
|
|
158
|
+
const value = readTokenField(usage, key);
|
|
159
|
+
if (value > existing.totals[key])
|
|
160
|
+
existing.totals[key] = value;
|
|
161
|
+
}
|
|
162
|
+
if (model)
|
|
163
|
+
existing.model = model;
|
|
164
|
+
dedupedUsage.set(dedupeKey, existing);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
addUsage(totals, usage);
|
|
130
168
|
const sum = usageSum(usage);
|
|
131
|
-
if (sum > 0)
|
|
169
|
+
if (model && sum > 0)
|
|
132
170
|
modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + sum;
|
|
133
171
|
}
|
|
134
172
|
}
|
|
135
173
|
}
|
|
136
174
|
}
|
|
137
175
|
}
|
|
176
|
+
for (const { totals: merged, model } of dedupedUsage.values()) {
|
|
177
|
+
let sum = 0;
|
|
178
|
+
for (const key of Object.keys(totals)) {
|
|
179
|
+
totals[key] += merged[key];
|
|
180
|
+
sum += merged[key];
|
|
181
|
+
}
|
|
182
|
+
if (model && sum > 0)
|
|
183
|
+
modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + sum;
|
|
184
|
+
}
|
|
138
185
|
if (!sawData)
|
|
139
186
|
return [];
|
|
140
187
|
return [
|
|
@@ -14,6 +14,13 @@ import { homedir } from 'node:os';
|
|
|
14
14
|
* ONLY those (email, name, plan, account) — never the token, access_token, or API key. Configured
|
|
15
15
|
* model is read from ~/.codex/config.toml. Numbers + identity claims only — no transcript content.
|
|
16
16
|
*
|
|
17
|
+
* Routing config also comes from a scoped, section-aware line scan of config.toml (see
|
|
18
|
+
* readCodexRoutingConfig): top-level `model_provider`/`openai_base_url`/`oss_provider`, and each
|
|
19
|
+
* `[model_providers.<id>]` table's `base_url`/`wire_api` keys only — never `env_key`,
|
|
20
|
+
* `experimental_bearer_token`, `auth.*`, or any other key that can carry credential material or
|
|
21
|
+
* a command string. `[model_providers.amazon-bedrock(.aws)]`'s mere presence is recorded (not its
|
|
22
|
+
* region/profile values) since selecting that built-in provider id is itself the Bedrock signal.
|
|
23
|
+
*
|
|
17
24
|
* Bounded by rollout-file mtime recency so a long session history isn't fully re-parsed each run.
|
|
18
25
|
*/
|
|
19
26
|
const TOKEN_USAGE_RECENCY_DAYS = 30;
|
|
@@ -117,6 +124,90 @@ function readConfiguredModel(home) {
|
|
|
117
124
|
return '';
|
|
118
125
|
}
|
|
119
126
|
}
|
|
127
|
+
function emptyCodexRoutingConfig() {
|
|
128
|
+
return {
|
|
129
|
+
modelProvider: '',
|
|
130
|
+
openaiBaseUrl: '',
|
|
131
|
+
ossProvider: '',
|
|
132
|
+
hasBedrockConfig: false,
|
|
133
|
+
customProviders: {},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Strip quotes from a TOML string value and any trailing inline comment. */
|
|
137
|
+
function stripTomlString(value) {
|
|
138
|
+
const trimmed = value.trim();
|
|
139
|
+
if (trimmed.startsWith('"')) {
|
|
140
|
+
const end = trimmed.indexOf('"', 1);
|
|
141
|
+
return end !== -1 ? trimmed.slice(1, end) : trimmed.slice(1);
|
|
142
|
+
}
|
|
143
|
+
if (trimmed.startsWith("'")) {
|
|
144
|
+
const end = trimmed.indexOf("'", 1);
|
|
145
|
+
return end !== -1 ? trimmed.slice(1, end) : trimmed.slice(1);
|
|
146
|
+
}
|
|
147
|
+
const commentIdx = trimmed.indexOf('#');
|
|
148
|
+
return (commentIdx === -1 ? trimmed : trimmed.slice(0, commentIdx)).trim();
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Scoped, section-aware scan of config.toml for routing-relevant keys ONLY:
|
|
152
|
+
* - top-level `model_provider` / `openai_base_url` / `oss_provider`
|
|
153
|
+
* - each `[model_providers.<id>]` table's `base_url` / `wire_api` keys
|
|
154
|
+
* - `[model_providers.amazon-bedrock]` (or its `.aws` sub-table) presence only — never its
|
|
155
|
+
* region/profile values
|
|
156
|
+
* Deliberately not a full TOML parse: every other key in every other section (MCP servers,
|
|
157
|
+
* hooks, marketplaces, `model_providers.<id>.env_key`/`auth.*`/`experimental_bearer_token`, ...)
|
|
158
|
+
* is never read, since some of those can carry credential material or shell commands.
|
|
159
|
+
*/
|
|
160
|
+
function readCodexRoutingConfig(home) {
|
|
161
|
+
const result = emptyCodexRoutingConfig();
|
|
162
|
+
let text;
|
|
163
|
+
try {
|
|
164
|
+
text = readFileSync(join(home, '.codex', 'config.toml'), 'utf8');
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
let currentProviderId = '';
|
|
170
|
+
for (const rawLine of text.split('\n')) {
|
|
171
|
+
const line = rawLine.trim();
|
|
172
|
+
if (!line || line.startsWith('#'))
|
|
173
|
+
continue;
|
|
174
|
+
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
|
|
175
|
+
if (sectionMatch) {
|
|
176
|
+
const section = sectionMatch[1].trim();
|
|
177
|
+
currentProviderId = '';
|
|
178
|
+
if (/^model_providers\.amazon-bedrock(\.aws)?$/.test(section)) {
|
|
179
|
+
result.hasBedrockConfig = true;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const providerMatch = section.match(/^model_providers\.([^.]+)$/);
|
|
183
|
+
if (providerMatch)
|
|
184
|
+
currentProviderId = providerMatch[1].replace(/^["']|["']$/g, '');
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.*)$/);
|
|
188
|
+
if (!kv)
|
|
189
|
+
continue;
|
|
190
|
+
const [, key, rawValue] = kv;
|
|
191
|
+
if (!currentProviderId) {
|
|
192
|
+
if (key === 'model_provider')
|
|
193
|
+
result.modelProvider = stripTomlString(rawValue);
|
|
194
|
+
else if (key === 'openai_base_url')
|
|
195
|
+
result.openaiBaseUrl = stripTomlString(rawValue);
|
|
196
|
+
else if (key === 'oss_provider')
|
|
197
|
+
result.ossProvider = stripTomlString(rawValue);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (key === 'base_url' || key === 'wire_api') {
|
|
201
|
+
const entry = result.customProviders[currentProviderId] ?? { baseUrl: '', wireApi: '' };
|
|
202
|
+
if (key === 'base_url')
|
|
203
|
+
entry.baseUrl = stripTomlString(rawValue);
|
|
204
|
+
else
|
|
205
|
+
entry.wireApi = stripTomlString(rawValue);
|
|
206
|
+
result.customProviders[currentProviderId] = entry;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
120
211
|
/**
|
|
121
212
|
* Aggregate Codex token usage / sessions / version across recent rollout transcripts + identity.
|
|
122
213
|
* Returns a single-element array (one per-machine aggregate) or [] when there is no recent data.
|
|
@@ -221,6 +312,7 @@ function collectCodexTokenUsage(home = homedir()) {
|
|
|
221
312
|
}
|
|
222
313
|
const identity = readCodexIdentity(home);
|
|
223
314
|
const configuredModel = readConfiguredModel(home);
|
|
315
|
+
const routingConfig = readCodexRoutingConfig(home);
|
|
224
316
|
const sawUsage = files.length > 0;
|
|
225
317
|
if (!sawUsage && !identity.email)
|
|
226
318
|
return [];
|
|
@@ -237,6 +329,11 @@ function collectCodexTokenUsage(home = homedir()) {
|
|
|
237
329
|
configured_model: configuredModel,
|
|
238
330
|
window_days: TOKEN_USAGE_RECENCY_DAYS,
|
|
239
331
|
totals,
|
|
332
|
+
model_provider: routingConfig.modelProvider,
|
|
333
|
+
openai_base_url: routingConfig.openaiBaseUrl,
|
|
334
|
+
oss_provider: routingConfig.ossProvider,
|
|
335
|
+
has_bedrock_config: routingConfig.hasBedrockConfig,
|
|
336
|
+
custom_model_providers: routingConfig.customProviders,
|
|
240
337
|
...identity, // auth_mode, email, name, plan, account_id, org_name (claims only)
|
|
241
338
|
},
|
|
242
339
|
},
|
|
@@ -236,6 +236,7 @@ export { collectHermesTokenUsage } from './hermes_token_usage_collector.js';
|
|
|
236
236
|
export { collectOpenclawTokenUsage } from './openclaw_token_usage_collector.js';
|
|
237
237
|
export { collectPiTokenUsage } from './pi_token_usage_collector.js';
|
|
238
238
|
export { collectCoworkDesktopVersion } from './cowork_desktop_version_collector.js';
|
|
239
|
+
export { collectGrokCliVersion } from './grok_cli_version_collector.js';
|
|
239
240
|
export { collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, mergeClaudeDesktopExtensionEnableSettings, } from './claude_desktop_extensions_collector.js';
|
|
240
241
|
export { collectCursorProjectWorkspaceMcpConfigs } from './cursor_project_mcp_collector.js';
|
|
241
242
|
export { determineFileTypeFromPath } from './file_type_rules.js';
|
|
@@ -10,6 +10,15 @@ import { homedir } from 'node:os';
|
|
|
10
10
|
* `totalPremiumRequests`, and per-model `modelMetrics`. We aggregate the *numbers* (and version /
|
|
11
11
|
* model ids) on the endpoint and emit only those — never transcript content.
|
|
12
12
|
*
|
|
13
|
+
* Per-model split uses each model's `modelMetrics.<model>.tokenDetails` — NOT its sibling
|
|
14
|
+
* `modelMetrics.<model>.usage` — because `usage.{input,output,cacheRead}Tokens` counts a larger,
|
|
15
|
+
* differently-scoped quantity (confirmed on a real transcript: usage.inputTokens was ~25x the
|
|
16
|
+
* session's own top-level tokenDetails.input.tokenCount for the same shutdown event, apparently
|
|
17
|
+
* including reprocessed/cumulative context rather than the exchange delta). Each model's nested
|
|
18
|
+
* `tokenDetails` mirrors the top-level `tokenDetails` shape and, for a single-model session,
|
|
19
|
+
* its exact values — so summing nested tokenDetails per model stays consistent with the
|
|
20
|
+
* top-level `totals` this same function computes, unlike `usage`.
|
|
21
|
+
*
|
|
13
22
|
* Bounded by mtime recency so a large session history is not fully re-parsed on every hook run.
|
|
14
23
|
*/
|
|
15
24
|
const TOKEN_USAGE_RECENCY_DAYS = 30;
|
|
@@ -123,8 +132,12 @@ function collectCopilotTokenUsage(home = homedir()) {
|
|
|
123
132
|
const modelMetrics = asObject(data.modelMetrics);
|
|
124
133
|
for (const [model, metrics] of Object.entries(modelMetrics)) {
|
|
125
134
|
models.add(model);
|
|
126
|
-
|
|
127
|
-
|
|
135
|
+
// tokenDetails, not usage — see the module docstring's note on why usage.* isn't
|
|
136
|
+
// comparable to the top-level tokenDetails-derived `totals`.
|
|
137
|
+
const modelTokenDetails = asObject(asObject(metrics).tokenDetails);
|
|
138
|
+
const sum = num(asObject(modelTokenDetails.input).tokenCount) +
|
|
139
|
+
num(asObject(modelTokenDetails.output).tokenCount) +
|
|
140
|
+
num(asObject(modelTokenDetails.cache_read).tokenCount);
|
|
128
141
|
if (sum > 0)
|
|
129
142
|
modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + sum;
|
|
130
143
|
}
|
|
@@ -188,6 +188,9 @@ function collectCursorProjectWorkspaceMcpConfigs(pathSkipPrefixes = [], constant
|
|
|
188
188
|
cursor_source: 'mcps',
|
|
189
189
|
server_identifier: serverDir.name,
|
|
190
190
|
};
|
|
191
|
+
if (metaObj) {
|
|
192
|
+
entry.cursor_server_metadata = metaObj;
|
|
193
|
+
}
|
|
191
194
|
const cacheTools = cursorMcpCacheToolsForServer(mcpToolCache, label, serverDir.name);
|
|
192
195
|
if (cacheTools !== undefined) {
|
|
193
196
|
entry.cursor_mcp_cache_tools = cacheTools;
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
1
|
import { existsSync } from 'node:fs';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import { homedir } from 'node:os';
|
|
5
|
-
import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
|
|
4
|
+
import { execSqlite3, resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
|
|
6
5
|
/**
|
|
7
6
|
* Session / version / identity aggregator for Cursor.
|
|
8
7
|
*
|
|
@@ -15,7 +14,9 @@ import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
|
|
|
15
14
|
*
|
|
16
15
|
* We read identity (email, plan, version) from ItemTable and aggregate session counts +
|
|
17
16
|
* 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
|
|
17
|
+
* Token counts are no longer written by Cursor 3.8+ so totals are always 0 — `model_token_split`
|
|
18
|
+
* therefore carries per-model composer COUNTs, not tokens (same convention as Cowork's
|
|
19
|
+
* session-count split), so the Agent Profile can still surface a "Most used model".
|
|
19
20
|
* No message content is read — only model names and timestamps.
|
|
20
21
|
*/
|
|
21
22
|
const TOKEN_USAGE_RECENCY_DAYS = 30;
|
|
@@ -36,11 +37,7 @@ function vscdbPath(home) {
|
|
|
36
37
|
}
|
|
37
38
|
function querySqlite(sqlite3, dbPath, sql) {
|
|
38
39
|
try {
|
|
39
|
-
return
|
|
40
|
-
input: `.timeout 5000\n${sql}\n`,
|
|
41
|
-
encoding: 'utf8',
|
|
42
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
43
|
-
});
|
|
40
|
+
return execSqlite3(sqlite3, ['-readonly', '-noheader', dbPath], `.timeout 5000\n${sql}\n`);
|
|
44
41
|
}
|
|
45
42
|
catch {
|
|
46
43
|
return null;
|
|
@@ -145,6 +142,11 @@ function collectCursorTokenUsage(home = homedir()) {
|
|
|
145
142
|
GROUP BY json_extract(value, '$.modelConfig.modelName');`);
|
|
146
143
|
const models = new Set();
|
|
147
144
|
let sessionCount = 0;
|
|
145
|
+
// Cursor 3.8+ doesn't write token counts, so there's no real per-model token weighting — the
|
|
146
|
+
// per-model composer COUNT from the query above is the only usage-weight signal available.
|
|
147
|
+
// Mirrors Cowork's session-count convention (see cowork_extract.py): "Most used model" is
|
|
148
|
+
// derived from whichever model has the most composers, not the most tokens.
|
|
149
|
+
const modelComposerCounts = {};
|
|
148
150
|
if (usageRaw) {
|
|
149
151
|
for (const row of usageRaw.split(ROW_SEP)) {
|
|
150
152
|
const trimmed = row.trim();
|
|
@@ -156,8 +158,10 @@ function collectCursorTokenUsage(home = homedir()) {
|
|
|
156
158
|
const model = trimmed.slice(0, sep).trim();
|
|
157
159
|
const cnt = num(trimmed.slice(sep + 1));
|
|
158
160
|
sessionCount += cnt;
|
|
159
|
-
if (model)
|
|
161
|
+
if (model) {
|
|
160
162
|
models.add(model);
|
|
163
|
+
modelComposerCounts[model] = (modelComposerCounts[model] ?? 0) + cnt;
|
|
164
|
+
}
|
|
161
165
|
}
|
|
162
166
|
}
|
|
163
167
|
if (sessionCount === 0 && !email && !version)
|
|
@@ -170,7 +174,7 @@ function collectCursorTokenUsage(home = homedir()) {
|
|
|
170
174
|
version,
|
|
171
175
|
session_count: sessionCount,
|
|
172
176
|
models: [...models].sort(),
|
|
173
|
-
model_token_split:
|
|
177
|
+
model_token_split: modelComposerCounts,
|
|
174
178
|
window_days: TOKEN_USAGE_RECENCY_DAYS,
|
|
175
179
|
totals: {
|
|
176
180
|
input_tokens: 0,
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
* Grok CLI version, read directly from the installed binary.
|
|
7
|
+
*
|
|
8
|
+
* Grok ships as a standalone native binary (not an npm/pip package, no manifest to read a
|
|
9
|
+
* version out of), installed at ~/.grok/bin/grok and typically symlinked onto PATH. The only
|
|
10
|
+
* way to get its version is to run it: `grok --version` prints e.g. "grok 0.2.114 (0c785038798)".
|
|
11
|
+
*/
|
|
12
|
+
const FILE_TYPE = 'grok_cli_version';
|
|
13
|
+
const VERSION_RE = /^grok\s+(\d+\.\d+\.\d+)(?:\s+\(([0-9a-f]+)\))?/i;
|
|
14
|
+
function grokBinPath(home) {
|
|
15
|
+
return join(home, '.grok', 'bin', 'grok');
|
|
16
|
+
}
|
|
17
|
+
/** `<binPath> --version`, parsed into {version, build, raw}, or null if unavailable/unparsable. */
|
|
18
|
+
function readGrokVersion(binPath) {
|
|
19
|
+
let stdout;
|
|
20
|
+
try {
|
|
21
|
+
stdout = execFileSync(binPath, ['--version'], {
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
24
|
+
timeout: 5000,
|
|
25
|
+
}).trim();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const match = VERSION_RE.exec(stdout);
|
|
31
|
+
if (!match)
|
|
32
|
+
return null;
|
|
33
|
+
return { version: match[1], build: match[2] ?? '', raw: stdout };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Emit the Grok CLI version as a single-element aggregate, or [] when Grok isn't installed
|
|
37
|
+
* or its version can't be determined.
|
|
38
|
+
*/
|
|
39
|
+
function collectGrokCliVersion(home = homedir(), binPath = grokBinPath(home)) {
|
|
40
|
+
if (!existsSync(binPath))
|
|
41
|
+
return [];
|
|
42
|
+
const parsed = readGrokVersion(binPath);
|
|
43
|
+
if (!parsed)
|
|
44
|
+
return [];
|
|
45
|
+
return [
|
|
46
|
+
{
|
|
47
|
+
file_type: FILE_TYPE,
|
|
48
|
+
file_path: binPath,
|
|
49
|
+
raw_content: parsed,
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
export { collectGrokCliVersion, FILE_TYPE as GROK_CLI_VERSION_FILE_TYPE };
|
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import { homedir } from 'node:os';
|
|
5
|
-
import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
|
|
4
|
+
import { execSqlite3, resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
|
|
6
5
|
/**
|
|
7
6
|
* Session / token usage / model / version aggregator for Hermes Agent (Nous Research).
|
|
8
7
|
*
|
|
9
8
|
* Hermes keeps all state under ~/.hermes/. Three sources feed this aggregate, each read
|
|
10
9
|
* narrowly to avoid ever touching credential material:
|
|
11
10
|
* - state.db (SQLite) `sessions` table — real per-session model + token columns
|
|
12
|
-
* (input_tokens, output_tokens, reasoning_tokens, cache_read_tokens)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* (input_tokens, output_tokens, reasoning_tokens, cache_read_tokens) plus real USD cost
|
|
12
|
+
* columns (actual_cost_usd, falling back to estimated_cost_usd — Hermes computes this
|
|
13
|
+
* itself via a provider pricing-table lookup, cost_source="provider_models_api" on a real
|
|
14
|
+
* install). started_at is a Unix epoch *seconds* REAL column, bounded to the last 30 days.
|
|
15
|
+
* - config.yaml — only the top-level `model` key is extracted: the plain-string form
|
|
16
|
+
* (`model: "name"`), or the block form's `default`/`provider`/`base_url` sub-keys
|
|
17
|
+
* (`model:\n default: "name"\n provider: openrouter\n base_url: ''`). config.yaml can
|
|
18
|
+
* hold literal provider API keys under `providers:`, so this is a scoped line-scan, never a
|
|
19
|
+
* full YAML parse — `provider`/`base_url` live directly under `model:`, not `providers:`,
|
|
20
|
+
* so reading them carries no credential risk.
|
|
17
21
|
* - auth.json — only the top-level `active_provider` key (e.g. "nous") is read; the
|
|
18
22
|
* per-provider blocks under `providers:` hold OAuth/API-key secrets and are never
|
|
19
23
|
* touched. "nous" is Nous Portal OAuth; anything else is a traditional API-key
|
|
@@ -45,11 +49,7 @@ function num(value) {
|
|
|
45
49
|
}
|
|
46
50
|
function querySqlite(sqlite3, dbPath, sql) {
|
|
47
51
|
try {
|
|
48
|
-
return
|
|
49
|
-
input: `.timeout 5000\n${sql}\n`,
|
|
50
|
-
encoding: 'utf8',
|
|
51
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
52
|
-
});
|
|
52
|
+
return execSqlite3(sqlite3, ['-readonly', '-noheader', dbPath], `.timeout 5000\n${sql}\n`);
|
|
53
53
|
}
|
|
54
54
|
catch {
|
|
55
55
|
return null;
|
|
@@ -75,19 +75,21 @@ function stripYamlScalar(value) {
|
|
|
75
75
|
const commentIdx = trimmed.search(/(?:^|\s)#/);
|
|
76
76
|
return (commentIdx === -1 ? trimmed : trimmed.slice(0, commentIdx)).trim();
|
|
77
77
|
}
|
|
78
|
+
const EMPTY_MODEL_CONFIG = { model: '', provider: '', baseUrl: '' };
|
|
78
79
|
/**
|
|
79
80
|
* Scoped read of config.yaml's top-level `model` key only. Handles both the plain-string
|
|
80
|
-
* form (`model: "name"
|
|
81
|
-
*
|
|
82
|
-
* literal API keys, so this
|
|
81
|
+
* form (`model: "name"`, which carries no provider/base_url) and the block form
|
|
82
|
+
* (`model:\n default: "name"\n provider: openrouter\n base_url: ''`). Never parses or
|
|
83
|
+
* returns any other key — config.yaml's `providers:` block can carry literal API keys, so this
|
|
84
|
+
* deliberately avoids a full YAML parse.
|
|
83
85
|
*/
|
|
84
|
-
function
|
|
86
|
+
function readModelConfig(home) {
|
|
85
87
|
let text;
|
|
86
88
|
try {
|
|
87
89
|
text = readFileSync(join(home, '.hermes', 'config.yaml'), 'utf8');
|
|
88
90
|
}
|
|
89
91
|
catch {
|
|
90
|
-
return
|
|
92
|
+
return EMPTY_MODEL_CONFIG;
|
|
91
93
|
}
|
|
92
94
|
const lines = text.split('\n');
|
|
93
95
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -96,8 +98,11 @@ function readConfiguredModel(home) {
|
|
|
96
98
|
continue;
|
|
97
99
|
const inline = stripYamlScalar(m[1]);
|
|
98
100
|
if (inline)
|
|
99
|
-
return inline;
|
|
100
|
-
// Block form: scan indented lines immediately below for
|
|
101
|
+
return { ...EMPTY_MODEL_CONFIG, model: inline };
|
|
102
|
+
// Block form: scan indented lines immediately below for default/provider/base_url —
|
|
103
|
+
// order isn't fixed, so keep scanning until the block dedents rather than stopping at the
|
|
104
|
+
// first match.
|
|
105
|
+
const result = { ...EMPTY_MODEL_CONFIG };
|
|
101
106
|
for (let j = i + 1; j < lines.length; j++) {
|
|
102
107
|
const next = lines[j];
|
|
103
108
|
if (next.trim() === '')
|
|
@@ -105,12 +110,23 @@ function readConfiguredModel(home) {
|
|
|
105
110
|
if (/^\S/.test(next))
|
|
106
111
|
break; // dedented — end of the `model:` block
|
|
107
112
|
const dm = next.match(/^\s+default:\s*(.*)$/);
|
|
108
|
-
if (dm)
|
|
109
|
-
|
|
113
|
+
if (dm) {
|
|
114
|
+
result.model = stripYamlScalar(dm[1]);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const pm = next.match(/^\s+provider:\s*(.*)$/);
|
|
118
|
+
if (pm) {
|
|
119
|
+
result.provider = stripYamlScalar(pm[1]);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const bm = next.match(/^\s+base_url:\s*(.*)$/);
|
|
123
|
+
if (bm) {
|
|
124
|
+
result.baseUrl = stripYamlScalar(bm[1]);
|
|
125
|
+
}
|
|
110
126
|
}
|
|
111
|
-
|
|
127
|
+
return result;
|
|
112
128
|
}
|
|
113
|
-
return
|
|
129
|
+
return EMPTY_MODEL_CONFIG;
|
|
114
130
|
}
|
|
115
131
|
/** Scoped read of auth.json's top-level `active_provider` key only — never the `providers` block. */
|
|
116
132
|
function readActiveProvider(home) {
|
|
@@ -152,7 +168,8 @@ function collectHermesTokenUsage(home = homedir()) {
|
|
|
152
168
|
COALESCE(SUM(input_tokens), 0) || char(31) ||
|
|
153
169
|
COALESCE(SUM(output_tokens), 0) || char(31) ||
|
|
154
170
|
COALESCE(SUM(reasoning_tokens), 0) || char(31) ||
|
|
155
|
-
COALESCE(SUM(cache_read_tokens), 0) || char(
|
|
171
|
+
COALESCE(SUM(cache_read_tokens), 0) || char(31) ||
|
|
172
|
+
COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0) || char(30)
|
|
156
173
|
FROM sessions
|
|
157
174
|
WHERE started_at >= ${cutoffSeconds}
|
|
158
175
|
GROUP BY model;`);
|
|
@@ -160,15 +177,16 @@ function collectHermesTokenUsage(home = homedir()) {
|
|
|
160
177
|
const totals = emptyTotals();
|
|
161
178
|
const modelTokenSplit = {};
|
|
162
179
|
let sessionCount = 0;
|
|
180
|
+
let cost = 0;
|
|
163
181
|
if (usageRaw) {
|
|
164
182
|
for (const row of usageRaw.split(ROW_SEP)) {
|
|
165
183
|
const trimmed = row.trim();
|
|
166
184
|
if (!trimmed)
|
|
167
185
|
continue;
|
|
168
186
|
const fields = trimmed.split(FIELD_SEP);
|
|
169
|
-
if (fields.length <
|
|
187
|
+
if (fields.length < 7)
|
|
170
188
|
continue;
|
|
171
|
-
const [model, count, input, output, reasoning, cacheRead] = fields;
|
|
189
|
+
const [model, count, input, output, reasoning, cacheRead, modelCost] = fields;
|
|
172
190
|
const cnt = num(count);
|
|
173
191
|
sessionCount += cnt;
|
|
174
192
|
if (model)
|
|
@@ -182,13 +200,14 @@ function collectHermesTokenUsage(home = homedir()) {
|
|
|
182
200
|
totals.input_tokens += modelInput;
|
|
183
201
|
totals.output_tokens += modelOutput;
|
|
184
202
|
totals.cache_read_input_tokens += modelCacheRead;
|
|
203
|
+
cost += num(modelCost);
|
|
185
204
|
const modelSum = modelInput + modelOutput + modelCacheRead;
|
|
186
205
|
if (model && modelSum > 0) {
|
|
187
206
|
modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + modelSum;
|
|
188
207
|
}
|
|
189
208
|
}
|
|
190
209
|
}
|
|
191
|
-
const
|
|
210
|
+
const modelConfig = readModelConfig(home);
|
|
192
211
|
const activeProvider = readActiveProvider(home);
|
|
193
212
|
const version = readHermesVersion(home);
|
|
194
213
|
const authMethod = activeProvider
|
|
@@ -196,7 +215,7 @@ function collectHermesTokenUsage(home = homedir()) {
|
|
|
196
215
|
? AUTH_NOUS_OAUTH
|
|
197
216
|
: AUTH_API_KEY
|
|
198
217
|
: '';
|
|
199
|
-
if (sessionCount === 0 && !
|
|
218
|
+
if (sessionCount === 0 && !modelConfig.model && !activeProvider)
|
|
200
219
|
return [];
|
|
201
220
|
return [
|
|
202
221
|
{
|
|
@@ -207,9 +226,12 @@ function collectHermesTokenUsage(home = homedir()) {
|
|
|
207
226
|
session_count: sessionCount,
|
|
208
227
|
models: [...models].sort(),
|
|
209
228
|
model_token_split: modelTokenSplit,
|
|
210
|
-
configured_model:
|
|
229
|
+
configured_model: modelConfig.model,
|
|
230
|
+
configured_model_provider: modelConfig.provider,
|
|
231
|
+
configured_model_base_url: modelConfig.baseUrl,
|
|
211
232
|
window_days: TOKEN_USAGE_RECENCY_DAYS,
|
|
212
233
|
totals,
|
|
234
|
+
cost,
|
|
213
235
|
active_provider: activeProvider,
|
|
214
236
|
auth_method: authMethod,
|
|
215
237
|
},
|
|
@@ -27,6 +27,13 @@ import { homedir } from 'node:os';
|
|
|
27
27
|
* begin with). Exposed per-provider so the extractor can match it against the provider actually
|
|
28
28
|
* observed in session usage.
|
|
29
29
|
*
|
|
30
|
+
* Routing config comes from two more scoped reads of openclaw.json:
|
|
31
|
+
* - `agents.defaults.model.primary` (format `"provider/model"`) — the configured provider id,
|
|
32
|
+
* same precedence convention as other agents' `"provider/model"` pinning.
|
|
33
|
+
* - `models.providers.<id>` tables — `baseUrl`/`auth` (a strategy string like `"aws-sdk"`)
|
|
34
|
+
* only, never `apiKey`, `headers`, or the `models` catalog (this is a DIFFERENT top-level
|
|
35
|
+
* key than `auth.profiles` above; a real install can have one without the other).
|
|
36
|
+
*
|
|
30
37
|
* Only checks ~/.openclaw (the canonical root); the moltbot/clawdbot compatible-fork roots
|
|
31
38
|
* tracked by the presence/log file types are not covered here.
|
|
32
39
|
*/
|
|
@@ -106,46 +113,69 @@ function readSessionsStore(storePath, cutoffMs, acc) {
|
|
|
106
113
|
}
|
|
107
114
|
}
|
|
108
115
|
}
|
|
109
|
-
/**
|
|
110
|
-
function
|
|
116
|
+
/** Parses openclaw.json once; both readVersion and readAuthModesByProvider derive from this. */
|
|
117
|
+
function parseOpenclawJson(openclawDir) {
|
|
111
118
|
try {
|
|
112
|
-
|
|
113
|
-
const meta = raw.meta;
|
|
114
|
-
return meta && typeof meta === 'object'
|
|
115
|
-
? str(meta.lastTouchedVersion)
|
|
116
|
-
: '';
|
|
119
|
+
return JSON.parse(readFileSync(join(openclawDir, 'openclaw.json'), 'utf8'));
|
|
117
120
|
}
|
|
118
121
|
catch {
|
|
119
|
-
return
|
|
122
|
+
return null;
|
|
120
123
|
}
|
|
121
124
|
}
|
|
125
|
+
/** Scoped read of openclaw.json's top-level `meta.lastTouchedVersion` key only. */
|
|
126
|
+
function readVersion(raw) {
|
|
127
|
+
const meta = raw?.meta;
|
|
128
|
+
return meta && typeof meta === 'object'
|
|
129
|
+
? str(meta.lastTouchedVersion)
|
|
130
|
+
: '';
|
|
131
|
+
}
|
|
122
132
|
/**
|
|
123
133
|
* Scoped read of openclaw.json's `auth.profiles` — provider + mode only, never credentials
|
|
124
134
|
* (the profiles carry no token/key fields). Returns a provider -> mode map (e.g.
|
|
125
135
|
* `{"anthropic": "api_key"}`); a provider with multiple profiles keeps the last one read.
|
|
126
136
|
*/
|
|
127
|
-
function readAuthModesByProvider(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (provider && mode)
|
|
141
|
-
out[provider] = mode;
|
|
142
|
-
}
|
|
137
|
+
function readAuthModesByProvider(raw) {
|
|
138
|
+
const auth = raw?.auth;
|
|
139
|
+
const profiles = auth && typeof auth === 'object' ? auth.profiles : undefined;
|
|
140
|
+
const out = {};
|
|
141
|
+
if (profiles && typeof profiles === 'object') {
|
|
142
|
+
for (const value of Object.values(profiles)) {
|
|
143
|
+
if (!value || typeof value !== 'object')
|
|
144
|
+
continue;
|
|
145
|
+
const entry = value;
|
|
146
|
+
const provider = str(entry.provider);
|
|
147
|
+
const mode = str(entry.mode);
|
|
148
|
+
if (provider && mode)
|
|
149
|
+
out[provider] = mode;
|
|
143
150
|
}
|
|
144
|
-
return out;
|
|
145
151
|
}
|
|
146
|
-
|
|
147
|
-
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
/** Scoped read of openclaw.json's `agents.defaults.model.primary` key only (format "provider/model"). */
|
|
155
|
+
function readPrimaryModelProvider(raw) {
|
|
156
|
+
const agents = raw?.agents;
|
|
157
|
+
const defaults = agents && typeof agents === 'object' ? agents.defaults : undefined;
|
|
158
|
+
const model = defaults && typeof defaults === 'object' ? defaults.model : undefined;
|
|
159
|
+
const primary = model && typeof model === 'object' ? str(model.primary) : '';
|
|
160
|
+
return primary.includes('/') ? primary.split('/', 1)[0] : '';
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Scoped read of openclaw.json's top-level `models.providers.<id>` tables — `baseUrl`/`auth`
|
|
164
|
+
* (a strategy string, e.g. `"aws-sdk"`) only, never `apiKey`, `headers`, or the `models` catalog.
|
|
165
|
+
*/
|
|
166
|
+
function readModelProviders(raw) {
|
|
167
|
+
const models = raw?.models;
|
|
168
|
+
const providers = models && typeof models === 'object' ? models.providers : undefined;
|
|
169
|
+
const out = {};
|
|
170
|
+
if (providers && typeof providers === 'object') {
|
|
171
|
+
for (const [id, cfg] of Object.entries(providers)) {
|
|
172
|
+
if (!cfg || typeof cfg !== 'object')
|
|
173
|
+
continue;
|
|
174
|
+
const entry = cfg;
|
|
175
|
+
out[id] = { baseUrl: str(entry.baseUrl), auth: str(entry.auth) };
|
|
176
|
+
}
|
|
148
177
|
}
|
|
178
|
+
return out;
|
|
149
179
|
}
|
|
150
180
|
/**
|
|
151
181
|
* Aggregate OpenClaw session count / token usage / models / providers / version across every
|
|
@@ -170,19 +200,22 @@ function collectOpenclawTokenUsage(home = homedir()) {
|
|
|
170
200
|
}
|
|
171
201
|
if (acc.sessionIds.size === 0)
|
|
172
202
|
return [];
|
|
203
|
+
const openclawJson = parseOpenclawJson(openclawDir);
|
|
173
204
|
return [
|
|
174
205
|
{
|
|
175
206
|
file_type: TOKEN_USAGE_FILE_TYPE,
|
|
176
207
|
file_path: openclawDir,
|
|
177
208
|
raw_content: {
|
|
178
|
-
version: readVersion(
|
|
209
|
+
version: readVersion(openclawJson),
|
|
179
210
|
session_count: acc.sessionIds.size,
|
|
180
211
|
models: [...acc.models].sort(),
|
|
181
212
|
providers: [...acc.providers].sort(),
|
|
182
213
|
model_token_split: acc.modelTokenSplit,
|
|
183
214
|
window_days: TOKEN_USAGE_RECENCY_DAYS,
|
|
184
215
|
totals: acc.totals,
|
|
185
|
-
auth_modes_by_provider: readAuthModesByProvider(
|
|
216
|
+
auth_modes_by_provider: readAuthModesByProvider(openclawJson),
|
|
217
|
+
primary_model_provider: readPrimaryModelProvider(openclawJson),
|
|
218
|
+
model_providers: readModelProviders(openclawJson),
|
|
186
219
|
},
|
|
187
220
|
},
|
|
188
221
|
];
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
1
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import { homedir } from 'node:os';
|
|
5
|
-
import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
|
|
4
|
+
import { execSqlite3, resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
|
|
6
5
|
/**
|
|
7
6
|
* Token-usage / cost / session / version / identity aggregator for OpenCode.
|
|
8
7
|
*
|
|
@@ -40,11 +39,7 @@ function asObject(value) {
|
|
|
40
39
|
/** Run a read-only query and return raw stdout, or null when sqlite3/DB is unavailable. */
|
|
41
40
|
function querySqlite(sqlite3, dbPath, sql) {
|
|
42
41
|
try {
|
|
43
|
-
return
|
|
44
|
-
input: `.timeout 5000\n${sql}\n`,
|
|
45
|
-
encoding: 'utf8',
|
|
46
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
47
|
-
});
|
|
42
|
+
return execSqlite3(sqlite3, ['-readonly', '-noheader', dbPath], `.timeout 5000\n${sql}\n`);
|
|
48
43
|
}
|
|
49
44
|
catch {
|
|
50
45
|
return null;
|
|
@@ -8,10 +8,12 @@ import { homedir } from 'node:os';
|
|
|
8
8
|
* ~/.pi/agent/sessions/<sanitized-cwd>/<timestamp>_<uuid>.jsonl. The first line is a
|
|
9
9
|
* `type: "session"` header carrying the session `id`; assistant turns are `type: "message"`
|
|
10
10
|
* lines whose `message.usage` block carries `{input, output, cacheRead, cacheWrite,
|
|
11
|
-
* totalTokens, reasoning?}` alongside `message.model` / `message.provider`.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* totalTokens, reasoning?, cost?}` alongside `message.model` / `message.provider`. `usage.cost`
|
|
12
|
+
* (confirmed on a real install) is `{input, output, cacheRead, cacheWrite, total}` in USD, computed
|
|
13
|
+
* by Pi itself per message — we sum only `cost.total`. We aggregate the *numbers* (and model/
|
|
14
|
+
* provider ids) on the endpoint and emit only those — never `message.content` (the actual
|
|
15
|
+
* prompt/response text) — so the backend gets token usage, cost, session count, and models/
|
|
16
|
+
* providers used without ingesting transcript content.
|
|
15
17
|
*
|
|
16
18
|
* Configured defaults come from a scoped read of settings.json's `defaultProvider` /
|
|
17
19
|
* `defaultModel` / `lastChangelogVersion` keys only — never any other key.
|
|
@@ -27,6 +29,14 @@ import { homedir } from 'node:os';
|
|
|
27
29
|
* `version` field is a JSONL schema version (small int, e.g. `3`), not the CLI version, and is
|
|
28
30
|
* not used here.
|
|
29
31
|
*
|
|
32
|
+
* `~/.pi/agent/models.json` handles custom providers (Ollama, LM Studio, vLLM, or any OpenAI/
|
|
33
|
+
* Anthropic-compatible API per Pi's own docs) — but no official doc example shows its actual key
|
|
34
|
+
* shape (unlike auth.json/settings.json above, which have confirmed real examples). We read only
|
|
35
|
+
* a best-effort guessed `{ "<id>": { "baseURL": "..." } }` shape (mirroring the `baseUrl`/
|
|
36
|
+
* `baseURL` convention every other agent's custom-provider config uses) and treat anything else
|
|
37
|
+
* in the file as unknown; this is explicitly unconfirmed and may need correcting against a real
|
|
38
|
+
* install with the file actually populated.
|
|
39
|
+
*
|
|
30
40
|
* Bounded by mtime recency so a large session history is not fully re-parsed on every run.
|
|
31
41
|
*/
|
|
32
42
|
const TOKEN_USAGE_RECENCY_DAYS = 30;
|
|
@@ -105,6 +115,28 @@ function readAuthTypesByProvider(home) {
|
|
|
105
115
|
return {};
|
|
106
116
|
}
|
|
107
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Best-effort, UNCONFIRMED scoped read of models.json's custom-provider `baseURL` values —
|
|
120
|
+
* see the module docstring's caveat. Returns a provider id -> base URL map.
|
|
121
|
+
*/
|
|
122
|
+
function readCustomProviderBaseUrls(home) {
|
|
123
|
+
try {
|
|
124
|
+
const raw = JSON.parse(readFileSync(join(home, '.pi', 'agent', 'models.json'), 'utf8'));
|
|
125
|
+
const out = {};
|
|
126
|
+
for (const [providerId, value] of Object.entries(raw)) {
|
|
127
|
+
if (!value || typeof value !== 'object')
|
|
128
|
+
continue;
|
|
129
|
+
const entry = value;
|
|
130
|
+
const baseUrl = str(entry.baseURL) || str(entry.baseUrl);
|
|
131
|
+
if (baseUrl)
|
|
132
|
+
out[providerId] = baseUrl;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
108
140
|
/**
|
|
109
141
|
* Aggregate Pi session count / token usage / models / providers across recent session
|
|
110
142
|
* transcripts. Returns a single-element array (one per-machine aggregate) or [] when there is
|
|
@@ -123,6 +155,7 @@ function collectPiTokenUsage(home = homedir()) {
|
|
|
123
155
|
const models = new Set();
|
|
124
156
|
const providers = new Set();
|
|
125
157
|
const modelTokenSplit = {};
|
|
158
|
+
let cost = 0;
|
|
126
159
|
let sawData = false;
|
|
127
160
|
for (const file of files) {
|
|
128
161
|
let content;
|
|
@@ -171,6 +204,7 @@ function collectPiTokenUsage(home = homedir()) {
|
|
|
171
204
|
totals.input_tokens += input;
|
|
172
205
|
totals.output_tokens += output;
|
|
173
206
|
totals.cache_read_input_tokens += cacheRead;
|
|
207
|
+
cost += num(asObject(usage.cost).total);
|
|
174
208
|
const sum = input + output + cacheRead;
|
|
175
209
|
if (model && sum > 0) {
|
|
176
210
|
modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + sum;
|
|
@@ -195,7 +229,9 @@ function collectPiTokenUsage(home = homedir()) {
|
|
|
195
229
|
configured_provider: defaults.provider,
|
|
196
230
|
window_days: TOKEN_USAGE_RECENCY_DAYS,
|
|
197
231
|
totals,
|
|
232
|
+
cost,
|
|
198
233
|
auth_types_by_provider: readAuthTypesByProvider(home),
|
|
234
|
+
custom_provider_base_urls: readCustomProviderBaseUrls(home),
|
|
199
235
|
},
|
|
200
236
|
},
|
|
201
237
|
];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CURSOR_SHADOW_KEYS_NESTED_WINS, coalesceCursorEnabledShadowValue, stripShadowKeysFromReactiveBlobUpload, } from './
|
|
1
|
+
import { CURSOR_SHADOW_KEYS_NESTED_WINS, coalesceCursorEnabledShadowValue, stripShadowKeysFromReactiveBlobUpload, } from './cursor_shadow_merge_ruleset.js';
|
|
2
2
|
const CURSOR_SHADOW_KEYS_MERGE_ENABLED_OR = new Set([
|
|
3
3
|
'isWebSearchToolEnabled',
|
|
4
4
|
'isWebSearchToolEnabled2',
|
|
@@ -35,7 +35,7 @@ export function coalesceCursorEnabledShadowValue(root, nested) {
|
|
|
35
35
|
return nested;
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
|
-
* After merge,
|
|
38
|
+
* After merge, ruleset scanning and uploads use composerState.<key> only — drop stale blob-root copies.
|
|
39
39
|
*/
|
|
40
40
|
export function stripShadowKeysFromReactiveBlobUpload(stateData) {
|
|
41
41
|
if (!('composerState' in stateData))
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { parse as parseToml, stringify as stringifyTomlValue } from 'smol-toml';
|
|
2
3
|
function stripJsoncComments(input) {
|
|
3
4
|
let out = '';
|
|
4
5
|
let inString = false;
|
|
@@ -176,4 +177,20 @@ function readInstalledExtensions(extensionsCachePath) {
|
|
|
176
177
|
}
|
|
177
178
|
return extensions;
|
|
178
179
|
}
|
|
179
|
-
|
|
180
|
+
/** Parse TOML text into a nested object. Returns null on parse failure. */
|
|
181
|
+
function parseTomlText(raw) {
|
|
182
|
+
try {
|
|
183
|
+
const parsed = parseToml(raw);
|
|
184
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
185
|
+
? parsed
|
|
186
|
+
: null;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Serialize a nested object to TOML (used when writing remediated .toml configs). */
|
|
193
|
+
function stringifyToml(value) {
|
|
194
|
+
return stringifyTomlValue(value);
|
|
195
|
+
}
|
|
196
|
+
export { readMCPConfig, readJSONFile, readMarkdownFile, readInstalledExtensions, parseJsonWithJsoncFallback, parseTomlText, stringifyToml, };
|
|
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { readFileCollectionVscdbContract } from '../runtime/management_storage.js';
|
|
4
4
|
import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
|
|
5
|
-
import { CURSOR_COMPOSER_SHADOW_KEYS } from './
|
|
5
|
+
import { CURSOR_COMPOSER_SHADOW_KEYS } from './cursor_shadow_merge_ruleset.js';
|
|
6
6
|
/** Suffix shared by Cursor reactive-storage ItemTable keys (see file_path_registry cursor agent). */
|
|
7
7
|
export const REACTIVE_STORAGE_ITEM_KEY_SUFFIX = 'reactiveStorageServiceImpl.persistentStorage.applicationUser';
|
|
8
8
|
function querySqliteValue(dbPath, key) {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
|
|
2
|
+
import { execSqlite3, resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
|
|
4
3
|
import { mergeCursorShadowKeyValue, mergeShadowKeysIntoComposerState, shadowKeysFromReadQueries, } from './composer_shadow_merge.js';
|
|
5
4
|
import { composerShadowKeysFromContract, reactiveStorageItemKeyForVscdb, } from './vscdb_reactive_storage.js';
|
|
6
5
|
import { readFileCollectionVscdbContract, sanitizeFileCollectionVscdbContract, writeFileCollectionVscdbContract, } from '../runtime/management_storage.js';
|
|
@@ -19,11 +18,7 @@ function querySqlite(dbPath, key) {
|
|
|
19
18
|
throw new Error('sqlite3 not found');
|
|
20
19
|
const safe = key.replace(/'/g, "''");
|
|
21
20
|
const script = `.timeout 60000\nSELECT value FROM ItemTable WHERE key='${safe}';\n`;
|
|
22
|
-
return
|
|
23
|
-
input: script,
|
|
24
|
-
encoding: 'utf8',
|
|
25
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
26
|
-
}).trim();
|
|
21
|
+
return execSqlite3(bin, ['-noheader', dbPath], script).trim();
|
|
27
22
|
}
|
|
28
23
|
/**
|
|
29
24
|
* Cursor keeps live agent settings (e.g. yoloCommandAllowlist) on nested composerState inside
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { existsSync, readFileSync } from 'node:fs';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
|
-
import { parseJsonWithJsoncFallback } from '../readers/file_readers.js';
|
|
16
|
+
import { parseJsonWithJsoncFallback, parseTomlText } from '../readers/file_readers.js';
|
|
17
17
|
import { mergeComposerShadowKeysFromReactiveBlob, readVscdbItemTableJson, } from '../readers/vscdb_reader.js';
|
|
18
18
|
import { readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
|
|
19
19
|
import { resolveRemediationConfigPath, resolveRemediationUploadFileType } from './remediation_config_path.js';
|
|
@@ -280,7 +280,7 @@ function shouldMergeComposerShadowKeys(itemKeyFromPath, checkSettingPaths) {
|
|
|
280
280
|
}
|
|
281
281
|
/** @deprecated Import from vscdb_reader — re-export for existing tests. */
|
|
282
282
|
export { mergeComposerShadowKeysFromReactiveBlob } from '../readers/vscdb_reader.js';
|
|
283
|
-
/** Plain JSON file or virtual `…/state.vscdb#itemKey` path for ItemTable-backed settings. */
|
|
283
|
+
/** Plain JSON/TOML file or virtual `…/state.vscdb#itemKey` path for ItemTable-backed settings. */
|
|
284
284
|
function loadRemediationConfigJson(configFilePath, checkSettingPaths = []) {
|
|
285
285
|
const resolvedPath = resolveRemediationConfigPath(configFilePath);
|
|
286
286
|
const hashIdx = resolvedPath.indexOf('#');
|
|
@@ -306,13 +306,23 @@ function loadRemediationConfigJson(configFilePath, checkSettingPaths = []) {
|
|
|
306
306
|
}
|
|
307
307
|
if (!existsSync(resolvedPath))
|
|
308
308
|
return { ok: false, reason: 'file_not_found' };
|
|
309
|
+
const rawText = readFileSync(resolvedPath, 'utf8');
|
|
310
|
+
if (resolvedPath.toLowerCase().endsWith('.toml')) {
|
|
311
|
+
const parsedToml = parseTomlText(rawText);
|
|
312
|
+
if (parsedToml === null)
|
|
313
|
+
return { ok: false, reason: 'parse_error' };
|
|
314
|
+
return { ok: true, json: parsedToml };
|
|
315
|
+
}
|
|
309
316
|
// OpenCode configs are JSONC (comments / trailing commas); parse strict JSON first, then
|
|
310
317
|
// fall back to JSONC sanitization so comment-bearing files are not skipped as parse_error.
|
|
311
|
-
const parsed = parseJsonWithJsoncFallback(
|
|
318
|
+
const parsed = parseJsonWithJsoncFallback(rawText);
|
|
312
319
|
if (parsed === null)
|
|
313
320
|
return { ok: false, reason: 'parse_error' };
|
|
314
321
|
return { ok: true, json: parsed };
|
|
315
322
|
}
|
|
323
|
+
function isSupportedTextConfigFormat(fileFormat) {
|
|
324
|
+
return fileFormat === 'json' || fileFormat === 'toml';
|
|
325
|
+
}
|
|
316
326
|
/**
|
|
317
327
|
* Evaluate all checks in a secondary group against the group's config file.
|
|
318
328
|
* Returns true only if every check passes (ops-based). Non-ops checks are treated as failing.
|
|
@@ -354,7 +364,7 @@ function violationFromCheck(entry, compliance, check, expected) {
|
|
|
354
364
|
/** Evaluate one manifest row against on-disk config (used by gate + post-restart verify). */
|
|
355
365
|
export function evaluateManifestEntryCompliance(entry) {
|
|
356
366
|
const compliance = entry.fix ?? entry.compliance;
|
|
357
|
-
if (!compliance || compliance.file_format
|
|
367
|
+
if (!compliance || !isSupportedTextConfigFormat(compliance.file_format))
|
|
358
368
|
return { violations: [] };
|
|
359
369
|
const checks = compliance.checks ?? [];
|
|
360
370
|
if (checks.length === 0)
|
|
@@ -463,9 +473,9 @@ export function runLocalRemediationComplianceCheck(agent = 'cursor') {
|
|
|
463
473
|
skippedNoCompliance++;
|
|
464
474
|
continue;
|
|
465
475
|
}
|
|
466
|
-
if (compliance.file_format
|
|
476
|
+
if (!isSupportedTextConfigFormat(compliance.file_format)) {
|
|
467
477
|
skippedNonJson++;
|
|
468
|
-
hookRunLog(`compliance_check: skipping
|
|
478
|
+
hookRunLog(`compliance_check: skipping unsupported file_format=${compliance.file_format} uuid=${entry.uuid}`);
|
|
469
479
|
continue;
|
|
470
480
|
}
|
|
471
481
|
if ((compliance.checks ?? []).length === 0) {
|
|
@@ -795,7 +805,7 @@ export function pruneSatisfiedOneTimeRemediations(agent = 'cursor') {
|
|
|
795
805
|
continue;
|
|
796
806
|
}
|
|
797
807
|
const spec = remediationFixSpec(inst);
|
|
798
|
-
const checks = spec?.file_format
|
|
808
|
+
const checks = isSupportedTextConfigFormat(spec?.file_format) ? (spec?.checks ?? []) : [];
|
|
799
809
|
if (checks.length === 0) {
|
|
800
810
|
remaining.push(raw);
|
|
801
811
|
continue;
|
|
@@ -14,7 +14,7 @@ import { ensureAuthentication } from '../auth/auth_flow.js';
|
|
|
14
14
|
import { readJSONFile, readMarkdownFile } from '../readers/file_readers.js';
|
|
15
15
|
import { isVscdbVirtualPath, tryReadVscdbVirtualFile, summarizeComposerPayloadForDiagnostics, } from '../readers/vscdb_config_builder.js';
|
|
16
16
|
import { persistVscdbComposerContractFromPatternsResponse } from '../readers/vscdb_reader.js';
|
|
17
|
-
import { collectConfigFilesFromPatterns, collectMcpToolFiles, collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles, collectMcpFromClaudeJsonProjects, collectClaudeTokenUsage, collectCopilotTokenUsage, collectOpencodeTokenUsage, collectCodexTokenUsage, collectCursorTokenUsage, collectHermesTokenUsage, collectOpenclawTokenUsage, collectPiTokenUsage, collectCoworkDesktopVersion, collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, determineFileTypeFromPath, } from '../collection/config_collector.js';
|
|
17
|
+
import { collectConfigFilesFromPatterns, collectMcpToolFiles, collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles, collectMcpFromClaudeJsonProjects, collectClaudeTokenUsage, collectCopilotTokenUsage, collectOpencodeTokenUsage, collectCodexTokenUsage, collectCursorTokenUsage, collectHermesTokenUsage, collectOpenclawTokenUsage, collectPiTokenUsage, collectCoworkDesktopVersion, collectGrokCliVersion, collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, determineFileTypeFromPath, } from '../collection/config_collector.js';
|
|
18
18
|
import { ensureCursorUserSettingsSnapshotInBatch } from '../collection/ensure_cursor_user_settings_snapshot.js';
|
|
19
19
|
import { collectSkillsCliInstalled } from '../collection/skills_cli_collector.js';
|
|
20
20
|
import { collectWorkspaceVscdbs } from '../collection/mcp_tool_collector.js';
|
|
@@ -170,6 +170,14 @@ async function collectAllConfigFiles(endpointBase) {
|
|
|
170
170
|
configFiles.push(entry);
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
|
+
hookRunLog(`reading Grok CLI version`);
|
|
174
|
+
for (const entry of collectGrokCliVersion(HOME_DIR)) {
|
|
175
|
+
const key = `${entry.file_type}\t${entry.file_path}`;
|
|
176
|
+
if (!existingPaths.has(key)) {
|
|
177
|
+
existingPaths.add(key);
|
|
178
|
+
configFiles.push(entry);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
173
181
|
hookRunLog(`merging disk-only Claude Desktop extensions into extensions-installations.json`);
|
|
174
182
|
const diskExtMerge = enrichClaudeDesktopExtensionsInstallationsUpload(configFiles, HOME_DIR);
|
|
175
183
|
hookRunLog(`claude desktop extensions: merged=${diskExtMerge.mergedCount} had_registry_upload=${diskExtMerge.hadRegistryUpload}`);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, readFileSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
-
import { atomicWriteJson, getRemediationStateDir } from './management_storage.js';
|
|
8
|
+
import { atomicWriteJson, getDeferredVscdbApplyPath, getRemediationStateDir, } from './management_storage.js';
|
|
9
9
|
import { hookRunLog } from './hook_logger.js';
|
|
10
10
|
export const REMEDIATION_APPLY_TRACKING_BASENAME = 'remediation_apply_tracking.json';
|
|
11
11
|
/** Consecutive post-restart verification failures before we stop autofix for a UUID. */
|
|
@@ -116,6 +116,9 @@ export function recordRemediationVerificationFailure(uuid, reason) {
|
|
|
116
116
|
* supported for unit tests only — do not use a global violation list from compliance_check
|
|
117
117
|
* in production: shadow-key merge bugs once made that list empty while the setting was still
|
|
118
118
|
* wrong, which incorrectly marked remediations verified.
|
|
119
|
+
*
|
|
120
|
+
* If the deferred vscdb queue file is still on disk, Cursor restart / apply_deferred_vscdb has
|
|
121
|
+
* not completed — leave pending and do not quarantine (setting unchanged is expected).
|
|
119
122
|
*/
|
|
120
123
|
export function processPendingPostRestartVerifications(violationProbe) {
|
|
121
124
|
const isStillViolating = typeof violationProbe === 'function'
|
|
@@ -123,6 +126,7 @@ export function processPendingPostRestartVerifications(violationProbe) {
|
|
|
123
126
|
: (uuid) => violationProbe.has(uuid);
|
|
124
127
|
const file = readRemediationApplyTrackingFile();
|
|
125
128
|
const outcomes = [];
|
|
129
|
+
const deferredQueuePending = existsSync(getDeferredVscdbApplyPath());
|
|
126
130
|
for (const [uuid, entry] of Object.entries(file.entries)) {
|
|
127
131
|
if (!entry.pending_post_restart_verify)
|
|
128
132
|
continue;
|
|
@@ -131,6 +135,10 @@ export function processPendingPostRestartVerifications(violationProbe) {
|
|
|
131
135
|
outcomes.push({ uuid, status: 'verified', consecutive_failures: 0 });
|
|
132
136
|
continue;
|
|
133
137
|
}
|
|
138
|
+
if (deferredQueuePending) {
|
|
139
|
+
hookRunLog(`remediation_tracking: defer verify uuid=${uuid} — deferred vscdb queue still pending (restart/apply not done yet)`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
134
142
|
const reason = 'Setting unchanged after apply — the agent may have changed its config format and auto-fix cannot apply this policy.';
|
|
135
143
|
const { entry: updated, newlyQuarantined } = recordRemediationVerificationFailure(uuid, reason);
|
|
136
144
|
outcomes.push({
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
2
2
|
import { delimiter, dirname, join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { execFileSync } from 'node:child_process';
|
|
5
4
|
import { executeBody } from '../../endpoint_client/http_transport.js';
|
|
6
|
-
import { parseJsonWithJsoncFallback } from '../readers/file_readers.js';
|
|
5
|
+
import { parseJsonWithJsoncFallback, parseTomlText, stringifyToml } from '../readers/file_readers.js';
|
|
7
6
|
import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
|
|
8
7
|
import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
|
|
9
8
|
import { readStoredAuthKey } from '../auth/auth_key_store.js';
|
|
@@ -19,8 +18,8 @@ import { reportAbsentWorktrees } from './worktree_absent.js';
|
|
|
19
18
|
import { buildApiUrl, getFileCollectionPatterns } from '../../endpoint_client/registry_api.js';
|
|
20
19
|
import { resolveRemediationConfigPath } from './remediation_config_path.js';
|
|
21
20
|
import { resolveOpsTargetPath } from './ops_target_path.js';
|
|
22
|
-
import { resolveSqlite3Binary } from './sqlite_binary.js';
|
|
23
|
-
import { CURSOR_COMPOSER_SHADOW_KEYS } from '../readers/
|
|
21
|
+
import { execSqlite3, resolveSqlite3Binary } from './sqlite_binary.js';
|
|
22
|
+
import { CURSOR_COMPOSER_SHADOW_KEYS } from '../readers/cursor_shadow_merge_ruleset.js';
|
|
24
23
|
import { REACTIVE_STORAGE_ITEM_KEY_SUFFIX, composerShadowKeysFromContract, reactiveStorageItemKeyForVscdb, } from '../readers/vscdb_reactive_storage.js';
|
|
25
24
|
/** Best-effort detail from execFileSync failures (stderr, exit code, errno). */
|
|
26
25
|
function formatNodeChildException(err) {
|
|
@@ -936,11 +935,7 @@ function sqliteSelectValueCell(dbPath, table, key_column, value_column, target_k
|
|
|
936
935
|
const safeName = target_key.replace(/'/g, "''");
|
|
937
936
|
const script = `.timeout 60000\nSELECT ${value_column} FROM ${table} WHERE ${key_column}='${safeName}';\n`;
|
|
938
937
|
try {
|
|
939
|
-
return
|
|
940
|
-
input: script,
|
|
941
|
-
encoding: 'utf8',
|
|
942
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
943
|
-
}).trim();
|
|
938
|
+
return execSqlite3(sqlite3, ['-noheader', dbPath], script).trim();
|
|
944
939
|
}
|
|
945
940
|
catch (err) {
|
|
946
941
|
const { short, long } = formatNodeChildException(err);
|
|
@@ -955,11 +950,7 @@ function sqliteExecWithTimeout(dbPath, sqlBody) {
|
|
|
955
950
|
throw new Error('sqlite3 not found');
|
|
956
951
|
const script = `.timeout 60000\n${sqlBody}\n`;
|
|
957
952
|
try {
|
|
958
|
-
|
|
959
|
-
input: script,
|
|
960
|
-
encoding: 'utf8',
|
|
961
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
962
|
-
});
|
|
953
|
+
execSqlite3(sqlite3, [dbPath], script);
|
|
963
954
|
}
|
|
964
955
|
catch (err) {
|
|
965
956
|
const { long } = formatNodeChildException(err);
|
|
@@ -976,11 +967,7 @@ function sqliteRunUpdateReturningChanges(dbPath, updateSql) {
|
|
|
976
967
|
const script = `.timeout 60000\n${updateSql}\nSELECT changes();\n`;
|
|
977
968
|
let out;
|
|
978
969
|
try {
|
|
979
|
-
out =
|
|
980
|
-
input: script,
|
|
981
|
-
encoding: 'utf8',
|
|
982
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
983
|
-
}).trim();
|
|
970
|
+
out = execSqlite3(sqlite3, [dbPath], script).trim();
|
|
984
971
|
}
|
|
985
972
|
catch (err) {
|
|
986
973
|
const { long } = formatNodeChildException(err);
|
|
@@ -1044,10 +1031,15 @@ export async function applyDeferredVscdbFromDisk() {
|
|
|
1044
1031
|
return true;
|
|
1045
1032
|
}
|
|
1046
1033
|
try {
|
|
1034
|
+
let applied = 0;
|
|
1035
|
+
let skippedMissing = 0;
|
|
1047
1036
|
for (const it of payload.items) {
|
|
1037
|
+
// Workspace storage can disappear between queue and restart (Cursor GC). Skip those
|
|
1038
|
+
// items instead of aborting the whole batch — other workspaces still need the write.
|
|
1048
1039
|
if (!existsSync(it.dbPath)) {
|
|
1049
|
-
hookRunLog(`deferred_vscdb: database missing ${it.dbPath}`);
|
|
1050
|
-
|
|
1040
|
+
hookRunLog(`deferred_vscdb: database missing — skipping ${it.dbPath}`);
|
|
1041
|
+
skippedMissing++;
|
|
1042
|
+
continue;
|
|
1051
1043
|
}
|
|
1052
1044
|
if (!assertSafeSqliteIdentifiersForItemTable(it.table, it.key_column, it.value_column)) {
|
|
1053
1045
|
return false;
|
|
@@ -1060,8 +1052,9 @@ export async function applyDeferredVscdbFromDisk() {
|
|
|
1060
1052
|
hookRunLog(`deferred_vscdb: INSERT OR REPLACE changed 0 rows key=${it.target_key} db=${it.dbPath} — keeping queue file`);
|
|
1061
1053
|
return false;
|
|
1062
1054
|
}
|
|
1055
|
+
applied++;
|
|
1063
1056
|
}
|
|
1064
|
-
hookRunLog(`deferred_vscdb: applied ${
|
|
1057
|
+
hookRunLog(`deferred_vscdb: applied ${applied} queued update(s) skipped_missing=${skippedMissing}`);
|
|
1065
1058
|
const authKey = readStoredAuthKey();
|
|
1066
1059
|
for (const u of postApplyUploads) {
|
|
1067
1060
|
if (!authKey) {
|
|
@@ -1435,7 +1428,7 @@ export function enforceRemediation(instruction) {
|
|
|
1435
1428
|
}
|
|
1436
1429
|
return { ok: true, deferredSqlite: false };
|
|
1437
1430
|
}
|
|
1438
|
-
if (fixSpec?.file_format !== 'json') {
|
|
1431
|
+
if (fixSpec?.file_format !== 'json' && fixSpec?.file_format !== 'toml') {
|
|
1439
1432
|
return fail(`unsupported file format: ${String(fixSpec?.file_format ?? 'undefined')}`);
|
|
1440
1433
|
}
|
|
1441
1434
|
const worktreeRoot = parseWorktreeRootFromPath(inst.config_file_path);
|
|
@@ -1458,26 +1451,39 @@ export function enforceRemediation(instruction) {
|
|
|
1458
1451
|
else if (!existsSync(dir)) {
|
|
1459
1452
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1460
1453
|
}
|
|
1461
|
-
|
|
1454
|
+
const isToml = fixSpec?.file_format === 'toml';
|
|
1455
|
+
let configObj = {};
|
|
1462
1456
|
if (existsSync(inst.config_file_path)) {
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1457
|
+
const rawText = readFileSync(inst.config_file_path, 'utf8');
|
|
1458
|
+
if (isToml) {
|
|
1459
|
+
const parsed = parseTomlText(rawText);
|
|
1460
|
+
if (parsed !== null) {
|
|
1461
|
+
configObj = parsed;
|
|
1462
|
+
}
|
|
1463
|
+
else {
|
|
1464
|
+
hookRunLog(`remediation_enforce: could not parse existing TOML file, starting fresh uuid=${inst.uuid}`);
|
|
1465
|
+
}
|
|
1471
1466
|
}
|
|
1472
1467
|
else {
|
|
1473
|
-
|
|
1468
|
+
// OpenCode configs are JSONC; parse strict JSON first, then JSONC fallback so a
|
|
1469
|
+
// comment-bearing opencode.json(c) keeps its existing settings instead of being reset to
|
|
1470
|
+
// {} on a parse failure. NOTE: the write-back below is JSON.stringify, so comments and
|
|
1471
|
+
// trailing commas in the original file are not preserved (same as every other agent's
|
|
1472
|
+
// JSON config) — only the key/value settings are retained and patched.
|
|
1473
|
+
const parsed = parseJsonWithJsoncFallback(rawText);
|
|
1474
|
+
if (parsed !== null) {
|
|
1475
|
+
configObj = parsed;
|
|
1476
|
+
}
|
|
1477
|
+
else {
|
|
1478
|
+
hookRunLog(`remediation_enforce: could not parse existing file, starting fresh uuid=${inst.uuid}`);
|
|
1479
|
+
}
|
|
1474
1480
|
}
|
|
1475
1481
|
}
|
|
1476
1482
|
for (const check of checks) {
|
|
1477
|
-
applyCheck(
|
|
1483
|
+
applyCheck(configObj, check);
|
|
1478
1484
|
}
|
|
1479
|
-
syncFlatGlobalCursorIgnoreListKey(
|
|
1480
|
-
const content = JSON.stringify(
|
|
1485
|
+
syncFlatGlobalCursorIgnoreListKey(configObj);
|
|
1486
|
+
const content = isToml ? stringifyToml(configObj) : JSON.stringify(configObj, null, 2);
|
|
1481
1487
|
const tmp = `${inst.config_file_path}.tmp`;
|
|
1482
1488
|
writeFileSync(tmp, content, 'utf8');
|
|
1483
1489
|
renameSync(tmp, inst.config_file_path);
|
|
@@ -90,3 +90,35 @@ export function resolveSqlite3Binary() {
|
|
|
90
90
|
export function clearSqlite3BinaryCacheForTests() {
|
|
91
91
|
cached = undefined;
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Run the sqlite3 CLI with `-escape off` inserted before the trailing dbPath argument, so raw
|
|
95
|
+
* control-byte output (our char(30)/char(31) row/field separators, or any control byte in a
|
|
96
|
+
* stored value) survives instead of being rendered as printable caret-notation text — the
|
|
97
|
+
* default in sqlite3 CLI builds that support `-escape` (added alongside the `-escape symbol`
|
|
98
|
+
* default; confirmed present in a macOS-bundled 3.51.0 from 2025-06).
|
|
99
|
+
*
|
|
100
|
+
* Falls back to the same call without `-escape off` if the flag itself isn't recognized (an
|
|
101
|
+
* older CLI predating the flag also predates the `-escape symbol` default that motivates it, so
|
|
102
|
+
* dropping the flag there is safe, not a silent behavior change). `argsWithDbPathLast`'s last
|
|
103
|
+
* element must be the database path.
|
|
104
|
+
*/
|
|
105
|
+
export function execSqlite3(sqlite3, argsWithDbPathLast, input) {
|
|
106
|
+
const dbPath = argsWithDbPathLast[argsWithDbPathLast.length - 1];
|
|
107
|
+
const leadingArgs = argsWithDbPathLast.slice(0, -1);
|
|
108
|
+
const options = {
|
|
109
|
+
input,
|
|
110
|
+
encoding: 'utf8',
|
|
111
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
return execFileSync(sqlite3, [...leadingArgs, '-escape', 'off', dbPath], options);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
try {
|
|
118
|
+
return execFileSync(sqlite3, argsWithDbPathLast, options);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "log-llm-config",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.14",
|
|
4
4
|
"description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -56,8 +56,9 @@
|
|
|
56
56
|
"vitest": "^4.1.8"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"axios": "^1.
|
|
59
|
+
"axios": "^1.18.1",
|
|
60
60
|
"canonicalize": "^2.1.0",
|
|
61
|
-
"optimus-tofu": "^0.1.18"
|
|
61
|
+
"optimus-tofu": "^0.1.18",
|
|
62
|
+
"smol-toml": "^1.7.1"
|
|
62
63
|
}
|
|
63
64
|
}
|