log-llm-config 1.5.10 → 1.5.11
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/codex_token_usage_collector.js +97 -0
- package/dist/log_config_files/collection/copilot_token_usage_collector.js +15 -2
- package/dist/log_config_files/collection/cursor_token_usage_collector.js +14 -10
- 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/vscdb_reactive_storage.js +1 -1
- package/dist/log_config_files/readers/vscdb_reader.js +2 -7
- package/dist/log_config_files/runtime/remediation_sync.js +5 -18
- package/dist/log_config_files/runtime/sqlite_binary.js +32 -0
- package/package.json +1 -1
|
@@ -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
|
},
|
|
@@ -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
|
}
|
|
@@ -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,
|
|
@@ -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))
|
|
@@ -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
|
|
@@ -1,7 +1,6 @@
|
|
|
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
5
|
import { parseJsonWithJsoncFallback } from '../readers/file_readers.js';
|
|
7
6
|
import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.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);
|
|
@@ -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
|
+
}
|