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.
Files changed (22) hide show
  1. package/dist/log_config_files/collection/claude_token_usage_collector.js +56 -9
  2. package/dist/log_config_files/collection/codex_token_usage_collector.js +97 -0
  3. package/dist/log_config_files/collection/config_collector.js +1 -0
  4. package/dist/log_config_files/collection/copilot_token_usage_collector.js +15 -2
  5. package/dist/log_config_files/collection/cursor_project_mcp_collector.js +3 -0
  6. package/dist/log_config_files/collection/cursor_token_usage_collector.js +14 -10
  7. package/dist/log_config_files/collection/grok_cli_version_collector.js +53 -0
  8. package/dist/log_config_files/collection/hermes_token_usage_collector.js +51 -29
  9. package/dist/log_config_files/collection/openclaw_token_usage_collector.js +62 -29
  10. package/dist/log_config_files/collection/opencode_token_usage_collector.js +2 -7
  11. package/dist/log_config_files/collection/pi_token_usage_collector.js +40 -4
  12. package/dist/log_config_files/readers/composer_shadow_merge.js +1 -1
  13. package/dist/log_config_files/readers/{cursor_shadow_merge_policy.js → cursor_shadow_merge_ruleset.js} +1 -1
  14. package/dist/log_config_files/readers/file_readers.js +18 -1
  15. package/dist/log_config_files/readers/vscdb_reactive_storage.js +1 -1
  16. package/dist/log_config_files/readers/vscdb_reader.js +2 -7
  17. package/dist/log_config_files/runtime/compliance_check.js +17 -7
  18. package/dist/log_config_files/runtime/main_runner.js +9 -1
  19. package/dist/log_config_files/runtime/remediation_apply_tracking.js +9 -1
  20. package/dist/log_config_files/runtime/remediation_sync.js +42 -36
  21. package/dist/log_config_files/runtime/sqlite_binary.js +32 -0
  22. 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[key];
29
- if (typeof value === 'number' && Number.isFinite(value))
28
+ const value = readTokenField(usage, key);
29
+ if (value > 0)
30
30
  totals[key] += value;
31
31
  }
32
32
  }
33
- /** Sum the token fields of one usage block (used for per-model attribution). */
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
- const value = usage[key];
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
- addUsage(totals, usage);
129
- if (model) {
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
- const usage = asObject(asObject(metrics).usage);
127
- const sum = num(usage.inputTokens) + num(usage.outputTokens) + num(usage.cacheReadTokens);
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 execFileSync(sqlite3, ['-readonly', '-noheader', dbPath], {
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). 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.
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 execFileSync(sqlite3, ['-readonly', '-noheader', dbPath], {
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"`) 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.
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 readConfiguredModel(home) {
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 `default:`.
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
- return stripYamlScalar(dm[1]);
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
- break; // found the top-level `model:` key; stop (avoid nested `model:` keys elsewhere)
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(30)
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 < 6)
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 configuredModel = readConfiguredModel(home);
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 && !configuredModel && !activeProvider)
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: configuredModel,
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
- /** Scoped read of openclaw.json's top-level `meta.lastTouchedVersion` key only. */
110
- function readVersion(openclawDir) {
116
+ /** Parses openclaw.json once; both readVersion and readAuthModesByProvider derive from this. */
117
+ function parseOpenclawJson(openclawDir) {
111
118
  try {
112
- const raw = JSON.parse(readFileSync(join(openclawDir, 'openclaw.json'), 'utf8'));
113
- const meta = raw.meta;
114
- return meta && typeof meta === 'object'
115
- ? str(meta.lastTouchedVersion)
116
- : '';
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(openclawDir) {
128
- try {
129
- const raw = JSON.parse(readFileSync(join(openclawDir, 'openclaw.json'), 'utf8'));
130
- const auth = raw.auth;
131
- const profiles = auth && typeof auth === 'object' ? auth.profiles : undefined;
132
- const out = {};
133
- if (profiles && typeof profiles === 'object') {
134
- for (const value of Object.values(profiles)) {
135
- if (!value || typeof value !== 'object')
136
- continue;
137
- const entry = value;
138
- const provider = str(entry.provider);
139
- const mode = str(entry.mode);
140
- if (provider && mode)
141
- out[provider] = mode;
142
- }
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
- catch {
147
- return {};
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(openclawDir),
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(openclawDir),
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 execFileSync(sqlite3, ['-readonly', '-noheader', dbPath], {
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`. We aggregate the
12
- * *numbers* (and model/provider ids) on the endpoint and emit only those — never
13
- * `message.content` (the actual prompt/response text) — so the backend gets token usage,
14
- * session count, and models/providers used without ingesting transcript content.
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 './cursor_shadow_merge_policy.js';
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, policy and uploads use composerState.<key> only — drop stale blob-root copies.
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
- export { readMCPConfig, readJSONFile, readMarkdownFile, readInstalledExtensions, parseJsonWithJsoncFallback, };
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 './cursor_shadow_merge_policy.js';
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 { execFileSync } from 'node:child_process';
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 execFileSync(bin, ['-noheader', dbPath], {
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(readFileSync(resolvedPath, 'utf8'));
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 !== 'json')
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 !== 'json') {
476
+ if (!isSupportedTextConfigFormat(compliance.file_format)) {
467
477
  skippedNonJson++;
468
- hookRunLog(`compliance_check: skipping non-json entry uuid=${entry.uuid}`);
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 === 'json' ? (spec.checks ?? []) : [];
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/cursor_shadow_merge_policy.js';
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 execFileSync(sqlite3, ['-noheader', dbPath], {
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
- execFileSync(sqlite3, [dbPath], {
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 = execFileSync(sqlite3, [dbPath], {
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
- return false;
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 ${payload.items.length} queued update(s)`);
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
- let configJson = {};
1454
+ const isToml = fixSpec?.file_format === 'toml';
1455
+ let configObj = {};
1462
1456
  if (existsSync(inst.config_file_path)) {
1463
- // OpenCode configs are JSONC; parse strict JSON first, then JSONC fallback so a
1464
- // comment-bearing opencode.json(c) keeps its existing settings instead of being reset to
1465
- // {} on a parse failure. NOTE: the write-back below is JSON.stringify, so comments and
1466
- // trailing commas in the original file are not preserved (same as every other agent's
1467
- // JSON config) — only the key/value settings are retained and patched.
1468
- const parsed = parseJsonWithJsoncFallback(readFileSync(inst.config_file_path, 'utf8'));
1469
- if (parsed !== null) {
1470
- configJson = parsed;
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
- hookRunLog(`remediation_enforce: could not parse existing file, starting fresh uuid=${inst.uuid}`);
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(configJson, check);
1483
+ applyCheck(configObj, check);
1478
1484
  }
1479
- syncFlatGlobalCursorIgnoreListKey(configJson);
1480
- const content = JSON.stringify(configJson, null, 2);
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.10",
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.15.2",
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
  }