@optimuslabs/harness-map-staging 1.5.16
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/README.md +46 -0
- package/dist/apply_deferred_vscdb.js +38 -0
- package/dist/bootstrap_constants.js +15 -0
- package/dist/cli/bash_script_generator.js +95 -0
- package/dist/cli.js +144 -0
- package/dist/cli_invocation_match.js +30 -0
- package/dist/compliance_check_runner.js +57 -0
- package/dist/compliance_prompt_gate.js +396 -0
- package/dist/dialog_prefs_cli.js +109 -0
- package/dist/endpoint_client/compress_payload.js +25 -0
- package/dist/endpoint_client/http_transport.js +90 -0
- package/dist/endpoint_client/index.js +3 -0
- package/dist/endpoint_client/registry_api.js +41 -0
- package/dist/endpoint_client/startup_api.js +44 -0
- package/dist/endpoint_client/types.js +4 -0
- package/dist/execute_trusted_restarts.js +60 -0
- package/dist/log_config_files/auth/auth_flow.js +22 -0
- package/dist/log_config_files/auth/auth_key_store.js +14 -0
- package/dist/log_config_files/collection/claude_desktop_extensions_collector.js +248 -0
- package/dist/log_config_files/collection/claude_known_projects_collector.js +42 -0
- package/dist/log_config_files/collection/claude_token_usage_collector.js +203 -0
- package/dist/log_config_files/collection/codex_token_usage_collector.js +342 -0
- package/dist/log_config_files/collection/config_collector.js +245 -0
- package/dist/log_config_files/collection/copilot_token_usage_collector.js +167 -0
- package/dist/log_config_files/collection/cowork_desktop_version_collector.js +60 -0
- package/dist/log_config_files/collection/cowork_session_whitelist.js +89 -0
- package/dist/log_config_files/collection/cursor_project_mcp_collector.js +230 -0
- package/dist/log_config_files/collection/cursor_project_workspace_activity.js +73 -0
- package/dist/log_config_files/collection/cursor_token_usage_collector.js +192 -0
- package/dist/log_config_files/collection/directory_collector.js +126 -0
- package/dist/log_config_files/collection/enrichment_helpers.js +53 -0
- package/dist/log_config_files/collection/ensure_cursor_user_settings_snapshot.js +50 -0
- package/dist/log_config_files/collection/file_type_rules.js +47 -0
- package/dist/log_config_files/collection/grok_cli_version_collector.js +53 -0
- package/dist/log_config_files/collection/hermes_token_usage_collector.js +241 -0
- package/dist/log_config_files/collection/mcp_tool_collector.js +106 -0
- package/dist/log_config_files/collection/metadata_merge.js +43 -0
- package/dist/log_config_files/collection/openclaw_helpers.js +55 -0
- package/dist/log_config_files/collection/openclaw_token_usage_collector.js +223 -0
- package/dist/log_config_files/collection/opencode_token_usage_collector.js +177 -0
- package/dist/log_config_files/collection/pi_token_usage_collector.js +239 -0
- package/dist/log_config_files/collection/plugin_collector.js +223 -0
- package/dist/log_config_files/collection/plugin_version_helpers.js +37 -0
- package/dist/log_config_files/collection/skills_cli_collector.js +162 -0
- package/dist/log_config_files/index.js +19 -0
- package/dist/log_config_files/paths/path_constants_helpers.js +71 -0
- package/dist/log_config_files/paths/pattern_resolver.js +326 -0
- package/dist/log_config_files/readers/composer_shadow_merge.js +67 -0
- package/dist/log_config_files/readers/cursor_shadow_merge_ruleset.js +46 -0
- package/dist/log_config_files/readers/file_readers.js +196 -0
- package/dist/log_config_files/readers/vscdb_config_builder.js +148 -0
- package/dist/log_config_files/readers/vscdb_reactive_storage.js +62 -0
- package/dist/log_config_files/readers/vscdb_reader.js +337 -0
- package/dist/log_config_files/runtime/client_event_reporter.js +347 -0
- package/dist/log_config_files/runtime/compliance_check.js +1013 -0
- package/dist/log_config_files/runtime/compliance_session_log.js +378 -0
- package/dist/log_config_files/runtime/dialog_preferences.js +31 -0
- package/dist/log_config_files/runtime/hardware_uuid.js +36 -0
- package/dist/log_config_files/runtime/hook_logger.js +267 -0
- package/dist/log_config_files/runtime/hook_type_for_request.js +30 -0
- package/dist/log_config_files/runtime/log_metadata.js +122 -0
- package/dist/log_config_files/runtime/main_runner.js +376 -0
- package/dist/log_config_files/runtime/management_storage.js +144 -0
- package/dist/log_config_files/runtime/ops_target_path.js +18 -0
- package/dist/log_config_files/runtime/remediation_apply_tracking.js +152 -0
- package/dist/log_config_files/runtime/remediation_config_path.js +140 -0
- package/dist/log_config_files/runtime/remediation_sync.js +1705 -0
- package/dist/log_config_files/runtime/secret_regex_scan.js +126 -0
- package/dist/log_config_files/runtime/sqlite_binary.js +124 -0
- package/dist/log_config_files/runtime/trusted_restarts.js +113 -0
- package/dist/log_config_files/runtime/workspace_repo.js +170 -0
- package/dist/log_config_files/runtime/worktree_absent.js +50 -0
- package/dist/log_config_files/runtime/worktree_scanner.js +137 -0
- package/dist/log_config_files/sender/batch_sender.js +283 -0
- package/dist/log_config_files/sender/endpoint_config.js +24 -0
- package/dist/log_config_files/sender/signing.js +1 -0
- package/dist/log_sensitive_paths_audit.js +89 -0
- package/dist/log_uuid/auth_key_store.js +37 -0
- package/dist/log_uuid/hardware_uuid.js +35 -0
- package/dist/log_uuid/index.js +11 -0
- package/dist/log_uuid/log_uuid_helper.js +30 -0
- package/dist/log_uuid/startup_sender.js +131 -0
- package/dist/log_uuid/user_profile.js +192 -0
- package/dist/remediation_change_preview.js +182 -0
- package/dist/tofu.js +22 -0
- package/dist/tofu_environment.js +36 -0
- package/dist/types/config_file_types.js +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { execSqlite3, resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
|
|
5
|
+
/**
|
|
6
|
+
* Token-usage / cost / session / version / identity aggregator for OpenCode.
|
|
7
|
+
*
|
|
8
|
+
* Unlike Claude/Copilot (JSONL transcripts), OpenCode stores everything in a SQLite DB at
|
|
9
|
+
* ~/.local/share/opencode/opencode.db. Each assistant row in the `message` table has a JSON
|
|
10
|
+
* `data` blob carrying `modelID`, `providerID`, `tokens` ({input,output,reasoning,cache:{read,write}})
|
|
11
|
+
* and `cost`; the `account` table carries the login `email`/`url`; the singleton `account_state`
|
|
12
|
+
* row carries `active_org_id` (an org identifier — no org *name* exists anywhere in the schema).
|
|
13
|
+
* We read those via the bundled `sqlite3` binary (same resolver the remediation path uses) and
|
|
14
|
+
* emit only the aggregated numbers (plus the login email / org id) — never message content. The
|
|
15
|
+
* DB is opened read-only.
|
|
16
|
+
*
|
|
17
|
+
* Bounded by DB-file mtime recency so a stale install isn't re-read every hook run.
|
|
18
|
+
*/
|
|
19
|
+
const TOKEN_USAGE_RECENCY_DAYS = 30;
|
|
20
|
+
const TOKEN_USAGE_FILE_TYPE = 'opencode_token_usage';
|
|
21
|
+
// ASCII record / unit separators: never appear in JSON or emails, so they delimit SELECTed
|
|
22
|
+
// rows/fields unambiguously. In SQL these are char(30) / char(31).
|
|
23
|
+
const ROW_SEP = '\x1e';
|
|
24
|
+
const FIELD_SEP = '\x1f';
|
|
25
|
+
function emptyTotals() {
|
|
26
|
+
return {
|
|
27
|
+
input_tokens: 0,
|
|
28
|
+
output_tokens: 0,
|
|
29
|
+
cache_read_input_tokens: 0,
|
|
30
|
+
cache_creation_input_tokens: 0,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function num(value) {
|
|
34
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
35
|
+
}
|
|
36
|
+
function asObject(value) {
|
|
37
|
+
return value && typeof value === 'object' ? value : {};
|
|
38
|
+
}
|
|
39
|
+
/** Run a read-only query and return raw stdout, or null when sqlite3/DB is unavailable. */
|
|
40
|
+
function querySqlite(sqlite3, dbPath, sql) {
|
|
41
|
+
try {
|
|
42
|
+
return execSqlite3(sqlite3, ['-readonly', '-noheader', dbPath], `.timeout 5000\n${sql}\n`);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** OpenCode CLI version from ~/.config/opencode/package.json (@opencode-ai/plugin pin), best-effort. */
|
|
49
|
+
function readOpencodeVersion(home) {
|
|
50
|
+
for (const rel of ['.config/opencode/package.json', '.cache/opencode/package.json']) {
|
|
51
|
+
try {
|
|
52
|
+
const pkg = JSON.parse(readFileSync(join(home, rel), 'utf8'));
|
|
53
|
+
const deps = asObject(pkg.dependencies);
|
|
54
|
+
const v = deps['@opencode-ai/plugin'] ?? deps['opencode'] ?? pkg.version;
|
|
55
|
+
if (typeof v === 'string' && v)
|
|
56
|
+
return v.replace(/^[\^~]/, '');
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* missing/unparseable — try next */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Aggregate OpenCode token usage / cost / sessions / models from the SQLite DB.
|
|
66
|
+
* Returns a single-element array (one per-machine aggregate) or [] when there is no usable DB.
|
|
67
|
+
*/
|
|
68
|
+
function collectOpencodeTokenUsage(home = homedir()) {
|
|
69
|
+
const dbPath = join(home, '.local', 'share', 'opencode', 'opencode.db');
|
|
70
|
+
if (!existsSync(dbPath))
|
|
71
|
+
return [];
|
|
72
|
+
const cutoffMs = Date.now() - TOKEN_USAGE_RECENCY_DAYS * 24 * 60 * 60 * 1000;
|
|
73
|
+
try {
|
|
74
|
+
if (statSync(dbPath).mtimeMs < cutoffMs)
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
const sqlite3 = resolveSqlite3Binary();
|
|
81
|
+
if (!sqlite3)
|
|
82
|
+
return [];
|
|
83
|
+
// Window aggregation to the recency horizon (message.time_created is epoch ms) so a touched-but-
|
|
84
|
+
// -stale DB (VACUUM, backup, one new session) doesn't re-sum all-time history — matches the
|
|
85
|
+
// 30-day window the Claude/Copilot collectors apply per transcript file. cutoffMs is a numeric
|
|
86
|
+
// constant we control, so inlining it into the SQL is injection-safe.
|
|
87
|
+
const cutoffClause = `WHERE time_created >= ${cutoffMs}`;
|
|
88
|
+
// Assistant message JSON blobs, one per row (char(30)-delimited so embedded newlines in the
|
|
89
|
+
// JSON don't split rows).
|
|
90
|
+
const messagesRaw = querySqlite(sqlite3, dbPath, `SELECT data || char(30) FROM message ${cutoffClause};`);
|
|
91
|
+
if (messagesRaw === null)
|
|
92
|
+
return [];
|
|
93
|
+
const totals = emptyTotals();
|
|
94
|
+
const models = new Set();
|
|
95
|
+
const providers = new Set();
|
|
96
|
+
const modelTokenSplit = {};
|
|
97
|
+
let cost = 0;
|
|
98
|
+
let sawData = false;
|
|
99
|
+
for (const blob of messagesRaw.split(ROW_SEP)) {
|
|
100
|
+
const trimmed = blob.trim();
|
|
101
|
+
if (!trimmed)
|
|
102
|
+
continue;
|
|
103
|
+
let msg;
|
|
104
|
+
try {
|
|
105
|
+
msg = JSON.parse(trimmed);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (msg.role !== 'assistant')
|
|
111
|
+
continue;
|
|
112
|
+
sawData = true;
|
|
113
|
+
const model = typeof msg.modelID === 'string' ? msg.modelID : '';
|
|
114
|
+
if (model)
|
|
115
|
+
models.add(model);
|
|
116
|
+
if (typeof msg.providerID === 'string' && msg.providerID)
|
|
117
|
+
providers.add(msg.providerID);
|
|
118
|
+
cost += num(msg.cost);
|
|
119
|
+
const tokens = asObject(msg.tokens);
|
|
120
|
+
const cache = asObject(tokens.cache);
|
|
121
|
+
const input = num(tokens.input);
|
|
122
|
+
const output = num(tokens.output) + num(tokens.reasoning);
|
|
123
|
+
const cacheRead = num(cache.read);
|
|
124
|
+
const cacheWrite = num(cache.write);
|
|
125
|
+
totals.input_tokens += input;
|
|
126
|
+
totals.output_tokens += output;
|
|
127
|
+
totals.cache_read_input_tokens += cacheRead;
|
|
128
|
+
totals.cache_creation_input_tokens += cacheWrite;
|
|
129
|
+
if (model) {
|
|
130
|
+
const sum = input + output + cacheRead + cacheWrite;
|
|
131
|
+
if (sum > 0)
|
|
132
|
+
modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + sum;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Session count = distinct sessions with activity in the window (the `session` table has no
|
|
136
|
+
// timestamp, so count distinct session_id from recent messages — consistent with the windowed
|
|
137
|
+
// token totals above).
|
|
138
|
+
const sessionCountRaw = querySqlite(sqlite3, dbPath, `SELECT count(DISTINCT session_id) FROM message ${cutoffClause};`);
|
|
139
|
+
const sessionCount = sessionCountRaw ? num(parseInt(sessionCountRaw.trim(), 10)) : 0;
|
|
140
|
+
const accountRaw = querySqlite(sqlite3, dbPath, "SELECT COALESCE(email, '') || char(31) || COALESCE(url, '') FROM account ORDER BY time_updated DESC LIMIT 1;");
|
|
141
|
+
let email = '';
|
|
142
|
+
let host = '';
|
|
143
|
+
if (accountRaw && accountRaw.trim()) {
|
|
144
|
+
const [rawEmail, rawUrl] = accountRaw.trim().split(FIELD_SEP);
|
|
145
|
+
email = (rawEmail ?? '').trim();
|
|
146
|
+
host = (rawUrl ?? '')
|
|
147
|
+
.trim()
|
|
148
|
+
.replace(/^https?:\/\//, '')
|
|
149
|
+
.replace(/\/$/, '');
|
|
150
|
+
}
|
|
151
|
+
// Singleton row; absent on older installs/schemas without account_state — querySqlite
|
|
152
|
+
// returns null on "no such table" and orgIdRaw stays empty in that case.
|
|
153
|
+
const orgIdRaw = querySqlite(sqlite3, dbPath, "SELECT COALESCE(active_org_id, '') FROM account_state LIMIT 1;");
|
|
154
|
+
const orgId = orgIdRaw ? orgIdRaw.trim() : '';
|
|
155
|
+
if (!sawData && !email)
|
|
156
|
+
return [];
|
|
157
|
+
return [
|
|
158
|
+
{
|
|
159
|
+
file_type: TOKEN_USAGE_FILE_TYPE,
|
|
160
|
+
file_path: dbPath, // stable synthetic path → one aggregate row per machine
|
|
161
|
+
raw_content: {
|
|
162
|
+
version: readOpencodeVersion(home),
|
|
163
|
+
session_count: sessionCount,
|
|
164
|
+
models: [...models].sort(),
|
|
165
|
+
providers: [...providers].sort(),
|
|
166
|
+
model_token_split: modelTokenSplit,
|
|
167
|
+
cost,
|
|
168
|
+
email,
|
|
169
|
+
host,
|
|
170
|
+
org_id: orgId,
|
|
171
|
+
window_days: TOKEN_USAGE_RECENCY_DAYS,
|
|
172
|
+
totals,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
export { collectOpencodeTokenUsage, TOKEN_USAGE_RECENCY_DAYS, TOKEN_USAGE_FILE_TYPE };
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
/**
|
|
5
|
+
* Session / token usage / model / provider aggregator for Pi (earendil-works, pi.dev).
|
|
6
|
+
*
|
|
7
|
+
* Pi writes one JSONL transcript per session under
|
|
8
|
+
* ~/.pi/agent/sessions/<sanitized-cwd>/<timestamp>_<uuid>.jsonl. The first line is a
|
|
9
|
+
* `type: "session"` header carrying the session `id`; assistant turns are `type: "message"`
|
|
10
|
+
* lines whose `message.usage` block carries `{input, output, cacheRead, cacheWrite,
|
|
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.
|
|
17
|
+
*
|
|
18
|
+
* Configured defaults come from a scoped read of settings.json's `defaultProvider` /
|
|
19
|
+
* `defaultModel` / `lastChangelogVersion` keys only — never any other key.
|
|
20
|
+
*
|
|
21
|
+
* auth.json holds literal per-provider `{type, key}` blobs (`{"anthropic": {"type": ...,
|
|
22
|
+
* "key": ...}}`), the same risk class as Hermes's/Codex's auth.json. We read only the `type`
|
|
23
|
+
* field per provider (e.g. `"api_key"`) — never `key`, the actual credential.
|
|
24
|
+
*
|
|
25
|
+
* `lastChangelogVersion` (confirmed against a real install: "0.80.3", matching `pi --version`
|
|
26
|
+
* exactly) is a best-effort CLI version signal, not a guaranteed-current one — it records the
|
|
27
|
+
* version Pi last showed its changelog for, which in practice tracks the installed version but
|
|
28
|
+
* could theoretically lag by one release in a narrow upgrade window. The session header's own
|
|
29
|
+
* `version` field is a JSONL schema version (small int, e.g. `3`), not the CLI version, and is
|
|
30
|
+
* not used here.
|
|
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
|
+
*
|
|
40
|
+
* Bounded by mtime recency so a large session history is not fully re-parsed on every run.
|
|
41
|
+
*/
|
|
42
|
+
const TOKEN_USAGE_RECENCY_DAYS = 30;
|
|
43
|
+
const TOKEN_USAGE_FILE_TYPE = 'pi_token_usage';
|
|
44
|
+
function emptyTotals() {
|
|
45
|
+
return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
|
|
46
|
+
}
|
|
47
|
+
function num(value) {
|
|
48
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
49
|
+
}
|
|
50
|
+
function str(value) {
|
|
51
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
52
|
+
}
|
|
53
|
+
function asObject(value) {
|
|
54
|
+
return value && typeof value === 'object' ? value : {};
|
|
55
|
+
}
|
|
56
|
+
/** Recursively collect *.jsonl files under a dir whose mtime is within the recency window. */
|
|
57
|
+
function recentJsonlFiles(dir, cutoffMs) {
|
|
58
|
+
const out = [];
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const full = join(dir, entry.name);
|
|
68
|
+
if (entry.isDirectory()) {
|
|
69
|
+
out.push(...recentJsonlFiles(full, cutoffMs));
|
|
70
|
+
}
|
|
71
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
72
|
+
try {
|
|
73
|
+
if (statSync(full).mtimeMs >= cutoffMs)
|
|
74
|
+
out.push(full);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
/* unreadable file — skip */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/** Scoped read of settings.json's `defaultProvider` / `defaultModel` / `lastChangelogVersion` keys only. */
|
|
84
|
+
function readDefaults(home) {
|
|
85
|
+
try {
|
|
86
|
+
const raw = JSON.parse(readFileSync(join(home, '.pi', 'agent', 'settings.json'), 'utf8'));
|
|
87
|
+
return {
|
|
88
|
+
provider: str(raw.defaultProvider),
|
|
89
|
+
model: str(raw.defaultModel),
|
|
90
|
+
version: str(raw.lastChangelogVersion),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return { provider: '', model: '', version: '' };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Scoped read of auth.json's per-provider `type` field only — never the sibling `key`
|
|
99
|
+
* credential. Returns a provider -> type map (e.g. `{"anthropic": "api_key"}`).
|
|
100
|
+
*/
|
|
101
|
+
function readAuthTypesByProvider(home) {
|
|
102
|
+
try {
|
|
103
|
+
const raw = JSON.parse(readFileSync(join(home, '.pi', 'agent', 'auth.json'), 'utf8'));
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const [provider, value] of Object.entries(raw)) {
|
|
106
|
+
if (!value || typeof value !== 'object')
|
|
107
|
+
continue;
|
|
108
|
+
const type = str(value.type);
|
|
109
|
+
if (type)
|
|
110
|
+
out[provider] = type;
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
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
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Aggregate Pi session count / token usage / models / providers across recent session
|
|
142
|
+
* transcripts. Returns a single-element array (one per-machine aggregate) or [] when there is
|
|
143
|
+
* no recent data.
|
|
144
|
+
*/
|
|
145
|
+
function collectPiTokenUsage(home = homedir()) {
|
|
146
|
+
const sessionsDir = join(home, '.pi', 'agent', 'sessions');
|
|
147
|
+
if (!existsSync(sessionsDir))
|
|
148
|
+
return [];
|
|
149
|
+
const cutoffMs = Date.now() - TOKEN_USAGE_RECENCY_DAYS * 24 * 60 * 60 * 1000;
|
|
150
|
+
const files = recentJsonlFiles(sessionsDir, cutoffMs);
|
|
151
|
+
if (files.length === 0)
|
|
152
|
+
return [];
|
|
153
|
+
const totals = emptyTotals();
|
|
154
|
+
const sessions = new Set();
|
|
155
|
+
const models = new Set();
|
|
156
|
+
const providers = new Set();
|
|
157
|
+
const modelTokenSplit = {};
|
|
158
|
+
let cost = 0;
|
|
159
|
+
let sawData = false;
|
|
160
|
+
for (const file of files) {
|
|
161
|
+
let content;
|
|
162
|
+
try {
|
|
163
|
+
content = readFileSync(file, 'utf8');
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
for (const line of content.split('\n')) {
|
|
169
|
+
const trimmed = line.trim();
|
|
170
|
+
if (!trimmed)
|
|
171
|
+
continue;
|
|
172
|
+
let entry;
|
|
173
|
+
try {
|
|
174
|
+
entry = JSON.parse(trimmed);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (entry.type === 'session') {
|
|
180
|
+
const sessionId = str(entry.id);
|
|
181
|
+
if (sessionId)
|
|
182
|
+
sessions.add(sessionId);
|
|
183
|
+
sawData = true;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (entry.type !== 'message')
|
|
187
|
+
continue;
|
|
188
|
+
const message = asObject(entry.message);
|
|
189
|
+
const usage = asObject(message.usage);
|
|
190
|
+
if (Object.keys(usage).length === 0)
|
|
191
|
+
continue;
|
|
192
|
+
sawData = true;
|
|
193
|
+
const model = str(message.model);
|
|
194
|
+
const provider = str(message.provider);
|
|
195
|
+
if (model)
|
|
196
|
+
models.add(model);
|
|
197
|
+
if (provider)
|
|
198
|
+
providers.add(provider);
|
|
199
|
+
const input = num(usage.input);
|
|
200
|
+
// reasoning tokens fold into output_tokens — TokenTotals has no separate reasoning
|
|
201
|
+
// field (matches Codex's/Hermes's reasoning fold-in convention).
|
|
202
|
+
const output = num(usage.output) + num(usage.reasoning);
|
|
203
|
+
const cacheRead = num(usage.cacheRead);
|
|
204
|
+
totals.input_tokens += input;
|
|
205
|
+
totals.output_tokens += output;
|
|
206
|
+
totals.cache_read_input_tokens += cacheRead;
|
|
207
|
+
cost += num(asObject(usage.cost).total);
|
|
208
|
+
const sum = input + output + cacheRead;
|
|
209
|
+
if (model && sum > 0) {
|
|
210
|
+
modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + sum;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (!sawData)
|
|
215
|
+
return [];
|
|
216
|
+
const defaults = readDefaults(home);
|
|
217
|
+
return [
|
|
218
|
+
{
|
|
219
|
+
file_type: TOKEN_USAGE_FILE_TYPE,
|
|
220
|
+
// Stable synthetic path → one aggregate row per machine.
|
|
221
|
+
file_path: sessionsDir,
|
|
222
|
+
raw_content: {
|
|
223
|
+
version: defaults.version,
|
|
224
|
+
session_count: sessions.size,
|
|
225
|
+
models: [...models].sort(),
|
|
226
|
+
providers: [...providers].sort(),
|
|
227
|
+
model_token_split: modelTokenSplit,
|
|
228
|
+
configured_model: defaults.model,
|
|
229
|
+
configured_provider: defaults.provider,
|
|
230
|
+
window_days: TOKEN_USAGE_RECENCY_DAYS,
|
|
231
|
+
totals,
|
|
232
|
+
cost,
|
|
233
|
+
auth_types_by_provider: readAuthTypesByProvider(home),
|
|
234
|
+
custom_provider_base_urls: readCustomProviderBaseUrls(home),
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
export { collectPiTokenUsage, TOKEN_USAGE_RECENCY_DAYS, TOKEN_USAGE_FILE_TYPE };
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { extname, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { readJSONFile, readMarkdownFile } from '../readers/file_readers.js';
|
|
6
|
+
import { getInstalledPluginsPath, getPluginHooksPath, getPluginMcpPaths, getPluginSkillFilename, getPluginSkillsDir } from '../paths/path_constants_helpers.js';
|
|
7
|
+
import { versionFromPluginCachePath, parseInstalledPluginKey } from './plugin_version_helpers.js';
|
|
8
|
+
/** Directories never worth shipping: git history is huge and already-compressed, deps are not the skill. */
|
|
9
|
+
const SKILL_SKIP_DIRS = new Set(['.git', 'node_modules']);
|
|
10
|
+
/** Fonts are the single largest thing in skill folders (canvas-design ships 54) and carry no signal. */
|
|
11
|
+
const SKILL_SKIP_EXTS = new Set(['.ttf', '.otf', '.woff', '.woff2']);
|
|
12
|
+
/** Inlined as text; everything else is recorded as path + size + hash. */
|
|
13
|
+
const SKILL_TEXT_EXTS = new Set([
|
|
14
|
+
'.md', '.txt', '.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.jsonc',
|
|
15
|
+
'.sh', '.bash', '.zsh', '.yaml', '.yml', '.xml', '.xsd', '.html', '.css', '.toml',
|
|
16
|
+
'.lock', '.csv', '.sql', '.rb', '.go', '.java', '.php', '.rs',
|
|
17
|
+
]);
|
|
18
|
+
const SKILL_MAX_FILE_BYTES = 1024 * 1024;
|
|
19
|
+
const SKILL_MAX_TOTAL_BYTES = 8 * 1024 * 1024;
|
|
20
|
+
const SKILL_MAX_FILES = 500;
|
|
21
|
+
const SKILL_MAX_DEPTH = 8;
|
|
22
|
+
/** Hidden files are collected, but credential-shaped ones are recorded without their contents. */
|
|
23
|
+
function isSecretName(name) {
|
|
24
|
+
const lower = name.toLowerCase();
|
|
25
|
+
return lower === '.env' || lower.startsWith('.env.') || lower === '.npmrc' || lower === '.netrc'
|
|
26
|
+
|| lower === '.pgpass' || lower.endsWith('.pem') || lower.endsWith('.key');
|
|
27
|
+
}
|
|
28
|
+
function skillMetaRecord(filePath, size, sha256, ver) {
|
|
29
|
+
return {
|
|
30
|
+
file_type: 'claude_skill',
|
|
31
|
+
file_path: filePath,
|
|
32
|
+
collect_style: 'metadata',
|
|
33
|
+
raw_content: { source: 'file', size, ...(sha256 ? { sha256 } : {}), ...(ver ? { version: ver } : {}) },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function collectSkillDirFiles(dir, results, constants, skillFilename, budget, depth) {
|
|
37
|
+
if (depth > SKILL_MAX_DEPTH)
|
|
38
|
+
return;
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
console.warn(`Error reading skill dir ${dir}:`, err instanceof Error ? err.message : String(err));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const ent of entries) {
|
|
48
|
+
if (budget.files >= SKILL_MAX_FILES || budget.bytes >= SKILL_MAX_TOTAL_BYTES)
|
|
49
|
+
return;
|
|
50
|
+
const full = join(dir, ent.name);
|
|
51
|
+
if (ent.isDirectory()) {
|
|
52
|
+
if (!SKILL_SKIP_DIRS.has(ent.name))
|
|
53
|
+
collectSkillDirFiles(full, results, constants, skillFilename, budget, depth + 1);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Symlinks report isSymbolicLink(), not isFile(), so this also keeps the walk inside the skill dir.
|
|
57
|
+
if (!ent.isFile())
|
|
58
|
+
continue;
|
|
59
|
+
const ext = extname(ent.name).toLowerCase();
|
|
60
|
+
if (SKILL_SKIP_EXTS.has(ext))
|
|
61
|
+
continue;
|
|
62
|
+
let size;
|
|
63
|
+
try {
|
|
64
|
+
size = statSync(full).size;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const ver = versionFromPluginCachePath(full, constants);
|
|
70
|
+
if (size > SKILL_MAX_FILE_BYTES) {
|
|
71
|
+
results.push(skillMetaRecord(full, size, '', ver));
|
|
72
|
+
budget.files += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!SKILL_TEXT_EXTS.has(ext) && ent.name !== skillFilename) {
|
|
76
|
+
let sha256 = '';
|
|
77
|
+
try {
|
|
78
|
+
sha256 = createHash('sha256').update(readFileSync(full)).digest('hex');
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
results.push(skillMetaRecord(full, size, sha256, ver));
|
|
84
|
+
budget.files += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (isSecretName(ent.name)) {
|
|
88
|
+
results.push(skillMetaRecord(full, size, '', ver));
|
|
89
|
+
budget.files += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const content = readMarkdownFile(full);
|
|
93
|
+
if (content === null)
|
|
94
|
+
continue;
|
|
95
|
+
results.push({ file_type: 'claude_skill', file_path: full, raw_content: { content, source: 'file', ...(ver ? { version: ver } : {}) } });
|
|
96
|
+
budget.files += 1;
|
|
97
|
+
budget.bytes += Buffer.byteLength(content);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function collectSkillFiles(skillsDir, results, constants) {
|
|
101
|
+
const skillFilename = getPluginSkillFilename(constants);
|
|
102
|
+
try {
|
|
103
|
+
for (const d of readdirSync(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory())) {
|
|
104
|
+
const skillRoot = join(skillsDir, d.name);
|
|
105
|
+
if (!existsSync(join(skillRoot, skillFilename)))
|
|
106
|
+
continue;
|
|
107
|
+
const budget = { files: 0, bytes: 0 };
|
|
108
|
+
collectSkillDirFiles(skillRoot, results, constants, skillFilename, budget, 0);
|
|
109
|
+
if (budget.files >= SKILL_MAX_FILES || budget.bytes >= SKILL_MAX_TOTAL_BYTES) {
|
|
110
|
+
console.warn(`Skill ${skillRoot} hit collection cap (${budget.files} files, ${budget.bytes} bytes)`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
console.warn(`Error reading skills dir ${skillsDir}:`, err instanceof Error ? err.message : String(err));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function collectMcpFile(installPath, results, constants) {
|
|
119
|
+
const mcpPaths = getPluginMcpPaths(constants);
|
|
120
|
+
for (const rel of mcpPaths) {
|
|
121
|
+
const mcpPath = join(installPath, rel);
|
|
122
|
+
if (!existsSync(mcpPath))
|
|
123
|
+
continue;
|
|
124
|
+
const content = readJSONFile(mcpPath);
|
|
125
|
+
if (content !== null) {
|
|
126
|
+
const ver = versionFromPluginCachePath(mcpPath, constants);
|
|
127
|
+
results.push({ file_type: 'claude_plugin_mcp', file_path: mcpPath, raw_content: typeof content === 'object' ? { ...content, ...(ver ? { version: ver } : {}) } : content });
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function collectPluginInstallEntries(pluginKey, entries, results, constants) {
|
|
133
|
+
const { name: pluginName, publisher } = parseInstalledPluginKey(pluginKey);
|
|
134
|
+
const skillsDirName = getPluginSkillsDir(constants);
|
|
135
|
+
const hooksRel = getPluginHooksPath(constants);
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
const installPath = entry?.installPath;
|
|
138
|
+
if (!installPath || typeof installPath !== 'string' || !existsSync(installPath))
|
|
139
|
+
continue;
|
|
140
|
+
const version = (entry?.version != null && entry?.version !== '') ? String(entry.version).trim() : '';
|
|
141
|
+
results.push({ file_type: 'claude_plugin_manifest', file_path: installPath, raw_content: { name: pluginName || pluginKey, version, publisher: publisher || '' } });
|
|
142
|
+
const skillsPath = join(installPath, skillsDirName);
|
|
143
|
+
if (existsSync(skillsPath))
|
|
144
|
+
collectSkillFiles(skillsPath, results, constants);
|
|
145
|
+
const hooksPath = join(installPath, hooksRel);
|
|
146
|
+
if (existsSync(hooksPath)) {
|
|
147
|
+
const content = readJSONFile(hooksPath);
|
|
148
|
+
if (content !== null) {
|
|
149
|
+
const ver = versionFromPluginCachePath(hooksPath, constants);
|
|
150
|
+
results.push({ file_type: 'claude_hooks', file_path: hooksPath, raw_content: typeof content === 'object' ? { ...content, ...(ver ? { version: ver } : {}) } : content });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
collectMcpFile(installPath, results, constants);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const PLUGIN_CACHE_MCP_FILENAME = '.mcp.json';
|
|
157
|
+
function walkPluginCacheForMcp(dir, results, seenMcpPaths, constants, depth) {
|
|
158
|
+
if (depth > 12)
|
|
159
|
+
return;
|
|
160
|
+
try {
|
|
161
|
+
for (const ent of readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() || d.isFile())) {
|
|
162
|
+
const full = join(dir, ent.name);
|
|
163
|
+
if (ent.isDirectory()) {
|
|
164
|
+
walkPluginCacheForMcp(full, results, seenMcpPaths, constants, depth + 1);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!ent.isFile() || ent.name !== PLUGIN_CACHE_MCP_FILENAME)
|
|
168
|
+
continue;
|
|
169
|
+
if (seenMcpPaths.has(full))
|
|
170
|
+
continue;
|
|
171
|
+
const content = readJSONFile(full);
|
|
172
|
+
if (content === null)
|
|
173
|
+
continue;
|
|
174
|
+
seenMcpPaths.add(full);
|
|
175
|
+
const ver = versionFromPluginCachePath(full, constants);
|
|
176
|
+
results.push({
|
|
177
|
+
file_type: 'claude_plugin_mcp',
|
|
178
|
+
file_path: full,
|
|
179
|
+
raw_content: typeof content === 'object' ? { ...content, ...(ver ? { version: ver } : {}) } : content,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** All plugin MCP manifests under ~/.claude/plugins/cache, whether or not the plugin is enabled. */
|
|
188
|
+
function collectPluginCacheMcpFiles(constants, home = homedir()) {
|
|
189
|
+
const cacheRoot = join(home, '.claude', 'plugins', 'cache');
|
|
190
|
+
if (!existsSync(cacheRoot))
|
|
191
|
+
return [];
|
|
192
|
+
const results = [];
|
|
193
|
+
const seenMcpPaths = new Set();
|
|
194
|
+
walkPluginCacheForMcp(cacheRoot, results, seenMcpPaths, constants, 0);
|
|
195
|
+
return results;
|
|
196
|
+
}
|
|
197
|
+
function collectConfigFilesFromInstalledPlugins(constants) {
|
|
198
|
+
const home = homedir();
|
|
199
|
+
const installedPluginsPath = getInstalledPluginsPath(home, constants);
|
|
200
|
+
const results = [];
|
|
201
|
+
if (!existsSync(installedPluginsPath))
|
|
202
|
+
return results;
|
|
203
|
+
let manifest;
|
|
204
|
+
try {
|
|
205
|
+
const raw = readJSONFile(installedPluginsPath);
|
|
206
|
+
if (!raw)
|
|
207
|
+
return results;
|
|
208
|
+
manifest = raw;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return results;
|
|
212
|
+
}
|
|
213
|
+
const plugins = manifest?.plugins;
|
|
214
|
+
if (!plugins || typeof plugins !== 'object')
|
|
215
|
+
return results;
|
|
216
|
+
results.push({ file_type: 'claude_plugin_manifest', file_path: installedPluginsPath, raw_content: manifest });
|
|
217
|
+
for (const [pluginKey, entries] of Object.entries(plugins)) {
|
|
218
|
+
if (Array.isArray(entries))
|
|
219
|
+
collectPluginInstallEntries(pluginKey, entries, results, constants);
|
|
220
|
+
}
|
|
221
|
+
return results;
|
|
222
|
+
}
|
|
223
|
+
export { collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Needles used to identify the plugin segment within a cache path. Requires backend client_path_constants. */
|
|
2
|
+
function getPluginPathNeedles(constants) {
|
|
3
|
+
const needles = [];
|
|
4
|
+
if (constants.plugin_skills_dir)
|
|
5
|
+
needles.push(`/${constants.plugin_skills_dir}/`);
|
|
6
|
+
if (constants.plugin_hooks_path)
|
|
7
|
+
needles.push(`/${constants.plugin_hooks_path}`);
|
|
8
|
+
for (const p of constants.plugin_mcp_paths ?? [])
|
|
9
|
+
needles.push(`/${p}`);
|
|
10
|
+
if (!needles.length)
|
|
11
|
+
throw new Error('client_path_constants missing plugin path keys (plugin_skills_dir, plugin_hooks_path, plugin_mcp_paths)');
|
|
12
|
+
return needles;
|
|
13
|
+
}
|
|
14
|
+
function versionFromPluginCachePath(filePath, constants) {
|
|
15
|
+
const norm = filePath.replace(/\\/g, '/');
|
|
16
|
+
if (!norm.includes('plugins/cache/'))
|
|
17
|
+
return '';
|
|
18
|
+
const needles = getPluginPathNeedles(constants);
|
|
19
|
+
let segmentEnd = -1;
|
|
20
|
+
for (const needle of needles) {
|
|
21
|
+
const idx = norm.indexOf(needle);
|
|
22
|
+
if (idx !== -1 && (segmentEnd === -1 || idx < segmentEnd))
|
|
23
|
+
segmentEnd = idx;
|
|
24
|
+
}
|
|
25
|
+
if (segmentEnd === -1)
|
|
26
|
+
return '';
|
|
27
|
+
const before = norm.slice(0, segmentEnd);
|
|
28
|
+
const lastSlash = before.lastIndexOf('/');
|
|
29
|
+
return lastSlash === -1 ? '' : before.slice(lastSlash + 1).trim();
|
|
30
|
+
}
|
|
31
|
+
function parseInstalledPluginKey(pluginKey) {
|
|
32
|
+
const at = pluginKey.indexOf('@');
|
|
33
|
+
if (at === -1)
|
|
34
|
+
return { name: pluginKey.trim(), publisher: '' };
|
|
35
|
+
return { name: pluginKey.slice(0, at).trim(), publisher: pluginKey.slice(at + 1).trim() };
|
|
36
|
+
}
|
|
37
|
+
export { versionFromPluginCachePath, parseInstalledPluginKey };
|