@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.
Files changed (88) hide show
  1. package/README.md +46 -0
  2. package/dist/apply_deferred_vscdb.js +38 -0
  3. package/dist/bootstrap_constants.js +15 -0
  4. package/dist/cli/bash_script_generator.js +95 -0
  5. package/dist/cli.js +144 -0
  6. package/dist/cli_invocation_match.js +30 -0
  7. package/dist/compliance_check_runner.js +57 -0
  8. package/dist/compliance_prompt_gate.js +396 -0
  9. package/dist/dialog_prefs_cli.js +109 -0
  10. package/dist/endpoint_client/compress_payload.js +25 -0
  11. package/dist/endpoint_client/http_transport.js +90 -0
  12. package/dist/endpoint_client/index.js +3 -0
  13. package/dist/endpoint_client/registry_api.js +41 -0
  14. package/dist/endpoint_client/startup_api.js +44 -0
  15. package/dist/endpoint_client/types.js +4 -0
  16. package/dist/execute_trusted_restarts.js +60 -0
  17. package/dist/log_config_files/auth/auth_flow.js +22 -0
  18. package/dist/log_config_files/auth/auth_key_store.js +14 -0
  19. package/dist/log_config_files/collection/claude_desktop_extensions_collector.js +248 -0
  20. package/dist/log_config_files/collection/claude_known_projects_collector.js +42 -0
  21. package/dist/log_config_files/collection/claude_token_usage_collector.js +203 -0
  22. package/dist/log_config_files/collection/codex_token_usage_collector.js +342 -0
  23. package/dist/log_config_files/collection/config_collector.js +245 -0
  24. package/dist/log_config_files/collection/copilot_token_usage_collector.js +167 -0
  25. package/dist/log_config_files/collection/cowork_desktop_version_collector.js +60 -0
  26. package/dist/log_config_files/collection/cowork_session_whitelist.js +89 -0
  27. package/dist/log_config_files/collection/cursor_project_mcp_collector.js +230 -0
  28. package/dist/log_config_files/collection/cursor_project_workspace_activity.js +73 -0
  29. package/dist/log_config_files/collection/cursor_token_usage_collector.js +192 -0
  30. package/dist/log_config_files/collection/directory_collector.js +126 -0
  31. package/dist/log_config_files/collection/enrichment_helpers.js +53 -0
  32. package/dist/log_config_files/collection/ensure_cursor_user_settings_snapshot.js +50 -0
  33. package/dist/log_config_files/collection/file_type_rules.js +47 -0
  34. package/dist/log_config_files/collection/grok_cli_version_collector.js +53 -0
  35. package/dist/log_config_files/collection/hermes_token_usage_collector.js +241 -0
  36. package/dist/log_config_files/collection/mcp_tool_collector.js +106 -0
  37. package/dist/log_config_files/collection/metadata_merge.js +43 -0
  38. package/dist/log_config_files/collection/openclaw_helpers.js +55 -0
  39. package/dist/log_config_files/collection/openclaw_token_usage_collector.js +223 -0
  40. package/dist/log_config_files/collection/opencode_token_usage_collector.js +177 -0
  41. package/dist/log_config_files/collection/pi_token_usage_collector.js +239 -0
  42. package/dist/log_config_files/collection/plugin_collector.js +223 -0
  43. package/dist/log_config_files/collection/plugin_version_helpers.js +37 -0
  44. package/dist/log_config_files/collection/skills_cli_collector.js +162 -0
  45. package/dist/log_config_files/index.js +19 -0
  46. package/dist/log_config_files/paths/path_constants_helpers.js +71 -0
  47. package/dist/log_config_files/paths/pattern_resolver.js +326 -0
  48. package/dist/log_config_files/readers/composer_shadow_merge.js +67 -0
  49. package/dist/log_config_files/readers/cursor_shadow_merge_ruleset.js +46 -0
  50. package/dist/log_config_files/readers/file_readers.js +196 -0
  51. package/dist/log_config_files/readers/vscdb_config_builder.js +148 -0
  52. package/dist/log_config_files/readers/vscdb_reactive_storage.js +62 -0
  53. package/dist/log_config_files/readers/vscdb_reader.js +337 -0
  54. package/dist/log_config_files/runtime/client_event_reporter.js +347 -0
  55. package/dist/log_config_files/runtime/compliance_check.js +1013 -0
  56. package/dist/log_config_files/runtime/compliance_session_log.js +378 -0
  57. package/dist/log_config_files/runtime/dialog_preferences.js +31 -0
  58. package/dist/log_config_files/runtime/hardware_uuid.js +36 -0
  59. package/dist/log_config_files/runtime/hook_logger.js +267 -0
  60. package/dist/log_config_files/runtime/hook_type_for_request.js +30 -0
  61. package/dist/log_config_files/runtime/log_metadata.js +122 -0
  62. package/dist/log_config_files/runtime/main_runner.js +376 -0
  63. package/dist/log_config_files/runtime/management_storage.js +144 -0
  64. package/dist/log_config_files/runtime/ops_target_path.js +18 -0
  65. package/dist/log_config_files/runtime/remediation_apply_tracking.js +152 -0
  66. package/dist/log_config_files/runtime/remediation_config_path.js +140 -0
  67. package/dist/log_config_files/runtime/remediation_sync.js +1705 -0
  68. package/dist/log_config_files/runtime/secret_regex_scan.js +126 -0
  69. package/dist/log_config_files/runtime/sqlite_binary.js +124 -0
  70. package/dist/log_config_files/runtime/trusted_restarts.js +113 -0
  71. package/dist/log_config_files/runtime/workspace_repo.js +170 -0
  72. package/dist/log_config_files/runtime/worktree_absent.js +50 -0
  73. package/dist/log_config_files/runtime/worktree_scanner.js +137 -0
  74. package/dist/log_config_files/sender/batch_sender.js +283 -0
  75. package/dist/log_config_files/sender/endpoint_config.js +24 -0
  76. package/dist/log_config_files/sender/signing.js +1 -0
  77. package/dist/log_sensitive_paths_audit.js +89 -0
  78. package/dist/log_uuid/auth_key_store.js +37 -0
  79. package/dist/log_uuid/hardware_uuid.js +35 -0
  80. package/dist/log_uuid/index.js +11 -0
  81. package/dist/log_uuid/log_uuid_helper.js +30 -0
  82. package/dist/log_uuid/startup_sender.js +131 -0
  83. package/dist/log_uuid/user_profile.js +192 -0
  84. package/dist/remediation_change_preview.js +182 -0
  85. package/dist/tofu.js +22 -0
  86. package/dist/tofu_environment.js +36 -0
  87. package/dist/types/config_file_types.js +1 -0
  88. package/package.json +67 -0
@@ -0,0 +1,241 @@
1
+ import { existsSync, readFileSync } 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
+ * Session / token usage / model / version aggregator for Hermes Agent (Nous Research).
7
+ *
8
+ * Hermes keeps all state under ~/.hermes/. Three sources feed this aggregate, each read
9
+ * narrowly to avoid ever touching credential material:
10
+ * - state.db (SQLite) `sessions` table — real per-session model + token columns
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.
21
+ * - auth.json — only the top-level `active_provider` key (e.g. "nous") is read; the
22
+ * per-provider blocks under `providers:` hold OAuth/API-key secrets and are never
23
+ * touched. "nous" is Nous Portal OAuth; anything else is a traditional API-key
24
+ * provider (see hermes_cli/auth.py's ProviderConfig registry).
25
+ *
26
+ * There is no on-disk record of email/org/plan — those only exist inside the live OAuth
27
+ * access_token's JWT claims (the actual bearer credential, not a side identity token like
28
+ * Codex's id_token), so this collector does not decode it. Version comes from the bundled
29
+ * source checkout's hermes_cli/__init__.py (best-effort; empty string if unavailable).
30
+ */
31
+ const TOKEN_USAGE_RECENCY_DAYS = 30;
32
+ const TOKEN_USAGE_FILE_TYPE = 'hermes_token_usage';
33
+ const ROW_SEP = '\x1e';
34
+ const FIELD_SEP = '\x1f';
35
+ const PROVIDER_NOUS = 'nous';
36
+ const AUTH_NOUS_OAUTH = 'nous_oauth';
37
+ const AUTH_API_KEY = 'api_key';
38
+ function emptyTotals() {
39
+ return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
40
+ }
41
+ function num(value) {
42
+ if (typeof value === 'number' && Number.isFinite(value))
43
+ return value;
44
+ if (typeof value === 'string') {
45
+ const n = parseFloat(value);
46
+ return Number.isFinite(n) ? n : 0;
47
+ }
48
+ return 0;
49
+ }
50
+ function querySqlite(sqlite3, dbPath, sql) {
51
+ try {
52
+ return execSqlite3(sqlite3, ['-readonly', '-noheader', dbPath], `.timeout 5000\n${sql}\n`);
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /**
59
+ * Extract a plain YAML scalar value, stripping quotes and a trailing inline comment.
60
+ * A quoted value's content ends at the matching close-quote (anything after, including a
61
+ * comment, is discarded). An unquoted value's comment starts at a `#` preceded by whitespace
62
+ * or at the start of the string (a bare `#` glued to non-space text is part of the value, per
63
+ * the YAML spec's plain-scalar comment rule).
64
+ */
65
+ function stripYamlScalar(value) {
66
+ const trimmed = value.trim();
67
+ if (trimmed.startsWith('"')) {
68
+ const end = trimmed.indexOf('"', 1);
69
+ return end !== -1 ? trimmed.slice(1, end) : trimmed.slice(1);
70
+ }
71
+ if (trimmed.startsWith("'")) {
72
+ const end = trimmed.indexOf("'", 1);
73
+ return end !== -1 ? trimmed.slice(1, end) : trimmed.slice(1);
74
+ }
75
+ const commentIdx = trimmed.search(/(?:^|\s)#/);
76
+ return (commentIdx === -1 ? trimmed : trimmed.slice(0, commentIdx)).trim();
77
+ }
78
+ const EMPTY_MODEL_CONFIG = { model: '', provider: '', baseUrl: '' };
79
+ /**
80
+ * Scoped read of config.yaml's top-level `model` key only. Handles both the plain-string
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.
85
+ */
86
+ function readModelConfig(home) {
87
+ let text;
88
+ try {
89
+ text = readFileSync(join(home, '.hermes', 'config.yaml'), 'utf8');
90
+ }
91
+ catch {
92
+ return EMPTY_MODEL_CONFIG;
93
+ }
94
+ const lines = text.split('\n');
95
+ for (let i = 0; i < lines.length; i++) {
96
+ const m = lines[i].match(/^model:\s*(.*)$/);
97
+ if (!m)
98
+ continue;
99
+ const inline = stripYamlScalar(m[1]);
100
+ if (inline)
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 };
106
+ for (let j = i + 1; j < lines.length; j++) {
107
+ const next = lines[j];
108
+ if (next.trim() === '')
109
+ continue;
110
+ if (/^\S/.test(next))
111
+ break; // dedented — end of the `model:` block
112
+ const dm = next.match(/^\s+default:\s*(.*)$/);
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
+ }
126
+ }
127
+ return result;
128
+ }
129
+ return EMPTY_MODEL_CONFIG;
130
+ }
131
+ /** Scoped read of auth.json's top-level `active_provider` key only — never the `providers` block. */
132
+ function readActiveProvider(home) {
133
+ try {
134
+ const raw = JSON.parse(readFileSync(join(home, '.hermes', 'auth.json'), 'utf8'));
135
+ return typeof raw.active_provider === 'string' ? raw.active_provider : '';
136
+ }
137
+ catch {
138
+ return '';
139
+ }
140
+ }
141
+ /** Best-effort CLI version from the bundled source checkout; empty string if unavailable. */
142
+ function readHermesVersion(home) {
143
+ try {
144
+ const text = readFileSync(join(home, '.hermes', 'hermes-agent', 'hermes_cli', '__init__.py'), 'utf8');
145
+ const m = text.match(/^__version__\s*=\s*["']([^"']+)["']/m);
146
+ return m ? m[1] : '';
147
+ }
148
+ catch {
149
+ return '';
150
+ }
151
+ }
152
+ /**
153
+ * Aggregate Hermes session count / token usage / models / version / active provider.
154
+ * Returns a single-element array (one per-machine aggregate) or [] when no usable DB exists.
155
+ */
156
+ function collectHermesTokenUsage(home = homedir()) {
157
+ const dbPath = join(home, '.hermes', 'state.db');
158
+ if (!existsSync(dbPath))
159
+ return [];
160
+ const sqlite3 = resolveSqlite3Binary();
161
+ if (!sqlite3)
162
+ return [];
163
+ // started_at is Unix epoch *seconds* (REAL), unlike the epoch-ms convention elsewhere.
164
+ const cutoffSeconds = Math.floor(Date.now() / 1000) - TOKEN_USAGE_RECENCY_DAYS * 24 * 60 * 60;
165
+ const usageRaw = querySqlite(sqlite3, dbPath, `SELECT
166
+ COALESCE(model, 'default') || char(31) ||
167
+ COUNT(*) || char(31) ||
168
+ COALESCE(SUM(input_tokens), 0) || char(31) ||
169
+ COALESCE(SUM(output_tokens), 0) || char(31) ||
170
+ COALESCE(SUM(reasoning_tokens), 0) || char(31) ||
171
+ COALESCE(SUM(cache_read_tokens), 0) || char(31) ||
172
+ COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0) || char(30)
173
+ FROM sessions
174
+ WHERE started_at >= ${cutoffSeconds}
175
+ GROUP BY model;`);
176
+ const models = new Set();
177
+ const totals = emptyTotals();
178
+ const modelTokenSplit = {};
179
+ let sessionCount = 0;
180
+ let cost = 0;
181
+ if (usageRaw) {
182
+ for (const row of usageRaw.split(ROW_SEP)) {
183
+ const trimmed = row.trim();
184
+ if (!trimmed)
185
+ continue;
186
+ const fields = trimmed.split(FIELD_SEP);
187
+ if (fields.length < 7)
188
+ continue;
189
+ const [model, count, input, output, reasoning, cacheRead, modelCost] = fields;
190
+ const cnt = num(count);
191
+ sessionCount += cnt;
192
+ if (model)
193
+ models.add(model);
194
+ const modelInput = num(input);
195
+ // reasoning_tokens folds into output_tokens — TokenTotals has no separate reasoning
196
+ // field (matches Codex's reasoning_output_tokens fold-in), so a reasoning/non-reasoning
197
+ // split isn't distinguishable downstream.
198
+ const modelOutput = num(output) + num(reasoning);
199
+ const modelCacheRead = num(cacheRead);
200
+ totals.input_tokens += modelInput;
201
+ totals.output_tokens += modelOutput;
202
+ totals.cache_read_input_tokens += modelCacheRead;
203
+ cost += num(modelCost);
204
+ const modelSum = modelInput + modelOutput + modelCacheRead;
205
+ if (model && modelSum > 0) {
206
+ modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + modelSum;
207
+ }
208
+ }
209
+ }
210
+ const modelConfig = readModelConfig(home);
211
+ const activeProvider = readActiveProvider(home);
212
+ const version = readHermesVersion(home);
213
+ const authMethod = activeProvider
214
+ ? activeProvider === PROVIDER_NOUS
215
+ ? AUTH_NOUS_OAUTH
216
+ : AUTH_API_KEY
217
+ : '';
218
+ if (sessionCount === 0 && !modelConfig.model && !activeProvider)
219
+ return [];
220
+ return [
221
+ {
222
+ file_type: TOKEN_USAGE_FILE_TYPE,
223
+ file_path: dbPath,
224
+ raw_content: {
225
+ version,
226
+ session_count: sessionCount,
227
+ models: [...models].sort(),
228
+ model_token_split: modelTokenSplit,
229
+ configured_model: modelConfig.model,
230
+ configured_model_provider: modelConfig.provider,
231
+ configured_model_base_url: modelConfig.baseUrl,
232
+ window_days: TOKEN_USAGE_RECENCY_DAYS,
233
+ totals,
234
+ cost,
235
+ active_provider: activeProvider,
236
+ auth_method: authMethod,
237
+ },
238
+ },
239
+ ];
240
+ }
241
+ export { collectHermesTokenUsage, TOKEN_USAGE_RECENCY_DAYS, TOKEN_USAGE_FILE_TYPE };
@@ -0,0 +1,106 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { readJSONFile } from '../readers/file_readers.js';
5
+ import { normalizePathSkipPrefixes } from '../paths/pattern_resolver.js';
6
+ import { getCursorProjectsPath } from '../paths/path_constants_helpers.js';
7
+ import { isInternalCursorWorkspaceSlug } from './cursor_project_mcp_collector.js';
8
+ import { isStaleCursorProjectWorkspace } from './cursor_project_workspace_activity.js';
9
+ import { readVscdbItemTableJson } from '../readers/vscdb_reader.js';
10
+ /**
11
+ * Read workspaceMetadata.entries from the Cursor global state.vscdb and emit one
12
+ * cursor_workspace_vscdb ConfigFileData per workspace entry.
13
+ *
14
+ * Cursor maintains a canonical mapping of workspace hash → display path in the global
15
+ * state.vscdb under "workspaceMetadata.entries". Each entry has:
16
+ * { workspaceId: "<hash>", displayPath: "~/my-project", folderUri: "file:///Users/..." }
17
+ *
18
+ * The per-workspace workspaceStorage/<hash>/state.vscdb holds cursor/disabledMcpServers.
19
+ * Together they give us: which servers are disabled per workspace + a human-readable label.
20
+ */
21
+ function collectWorkspaceVscdbs(spec, home = homedir()) {
22
+ if (!spec.global_vscdb_path_segments?.length || !spec.global_workspace_list_key) {
23
+ return [];
24
+ }
25
+ const globalDbPath = join(home, ...spec.global_vscdb_path_segments);
26
+ if (!existsSync(globalDbPath))
27
+ return [];
28
+ const globalResult = readVscdbItemTableJson(globalDbPath, spec.global_workspace_list_key);
29
+ if (!globalResult)
30
+ return [];
31
+ const rawValue = globalResult[spec.global_workspace_list_key];
32
+ // The stored value is {"entries": [...]}; unwrap it.
33
+ const entries = Array.isArray(rawValue)
34
+ ? rawValue
35
+ : Array.isArray(rawValue?.entries)
36
+ ? rawValue.entries
37
+ : [];
38
+ if (!entries.length)
39
+ return [];
40
+ const workspaceStoragePath = join(home, ...spec.workspace_storage_path_segments);
41
+ const output = [];
42
+ for (const entry of entries) {
43
+ if (!entry || typeof entry !== 'object')
44
+ continue;
45
+ const e = entry;
46
+ const workspaceId = typeof e.workspaceId === 'string' ? e.workspaceId : null;
47
+ if (!workspaceId)
48
+ continue;
49
+ const dbPath = join(workspaceStoragePath, workspaceId, spec.vscdb_filename);
50
+ if (!existsSync(dbPath))
51
+ continue;
52
+ const perWorkspaceResult = readVscdbItemTableJson(dbPath, spec.item_table_key);
53
+ const disabled = perWorkspaceResult?.[spec.item_table_key];
54
+ const rawContent = {
55
+ [spec.item_table_key]: Array.isArray(disabled) ? disabled : [],
56
+ };
57
+ if (typeof e.displayPath === 'string')
58
+ rawContent['displayPath'] = e.displayPath;
59
+ if (typeof e.folderUri === 'string')
60
+ rawContent['folderUri'] = e.folderUri;
61
+ rawContent['workspaceId'] = workspaceId;
62
+ output.push({
63
+ file_type: spec.file_type,
64
+ file_path: `${dbPath}#${spec.item_table_key}`,
65
+ raw_content: rawContent,
66
+ });
67
+ }
68
+ return output;
69
+ }
70
+ function collectMcpToolFiles(pathSkipPrefixes = [], constants) {
71
+ const result = [];
72
+ const skipPrefixes = normalizePathSkipPrefixes(pathSkipPrefixes);
73
+ const home = homedir();
74
+ const cursorProjectsPath = getCursorProjectsPath(home, constants);
75
+ try {
76
+ if (!existsSync(cursorProjectsPath))
77
+ return result;
78
+ for (const projectDir of readdirSync(cursorProjectsPath, { withFileTypes: true }).filter((d) => d.isDirectory())) {
79
+ if (skipPrefixes.some((p) => projectDir.name.startsWith(p)))
80
+ continue;
81
+ if (isInternalCursorWorkspaceSlug(projectDir.name))
82
+ continue;
83
+ const projectPath = join(cursorProjectsPath, projectDir.name);
84
+ if (isStaleCursorProjectWorkspace(projectPath))
85
+ continue;
86
+ const mcpsPath = join(projectPath, 'mcps');
87
+ if (!existsSync(mcpsPath))
88
+ continue;
89
+ for (const serverDir of readdirSync(mcpsPath, { withFileTypes: true }).filter((d) => d.isDirectory())) {
90
+ const toolsPath = join(mcpsPath, serverDir.name, 'tools');
91
+ if (!existsSync(toolsPath))
92
+ continue;
93
+ for (const toolFile of readdirSync(toolsPath).filter((n) => n.endsWith('.json'))) {
94
+ const content = readJSONFile(join(toolsPath, toolFile));
95
+ if (content)
96
+ result.push({ file_type: 'mcp_tool', file_path: `${projectDir.name}/mcps/${serverDir.name}/tools/${toolFile}`, raw_content: content });
97
+ }
98
+ }
99
+ }
100
+ }
101
+ catch (error) {
102
+ console.warn('Error collecting MCP tool files:', error instanceof Error ? error.message : String(error));
103
+ }
104
+ return result;
105
+ }
106
+ export { collectMcpToolFiles, collectWorkspaceVscdbs };
@@ -0,0 +1,43 @@
1
+ /** When several install/log paths exist for one agent, keep a single metadata row (newest mtime). */
2
+ const METADATA_COLLAPSE_BY_FILE_TYPE = new Set(['opencode_presence', 'opencode_log']);
3
+ function metadataLastModifiedMs(entry) {
4
+ const raw = entry.raw_content;
5
+ if (!raw || typeof raw !== 'object')
6
+ return 0;
7
+ const lm = raw.last_modified;
8
+ if (typeof lm !== 'string')
9
+ return 0;
10
+ const ms = Date.parse(lm);
11
+ return Number.isFinite(ms) ? ms : 0;
12
+ }
13
+ /**
14
+ * Collapse multiple metadata uploads of the same file_type to one row: highest last_modified wins.
15
+ */
16
+ function collapseMetadataByFileType(files) {
17
+ const passthrough = [];
18
+ const groups = new Map();
19
+ for (const f of files) {
20
+ if (f.collect_style === 'metadata' && METADATA_COLLAPSE_BY_FILE_TYPE.has(f.file_type)) {
21
+ const list = groups.get(f.file_type) ?? [];
22
+ list.push(f);
23
+ groups.set(f.file_type, list);
24
+ }
25
+ else {
26
+ passthrough.push(f);
27
+ }
28
+ }
29
+ const merged = [...passthrough];
30
+ for (const [, list] of groups) {
31
+ if (list.length === 0)
32
+ continue;
33
+ let best = list[0];
34
+ for (let i = 1; i < list.length; i++) {
35
+ if (metadataLastModifiedMs(list[i]) > metadataLastModifiedMs(best)) {
36
+ best = list[i];
37
+ }
38
+ }
39
+ merged.push(best);
40
+ }
41
+ return merged;
42
+ }
43
+ export { collapseMetadataByFileType, METADATA_COLLAPSE_BY_FILE_TYPE };
@@ -0,0 +1,55 @@
1
+ import { join } from 'node:path';
2
+ import { readJSONFile } from '../readers/file_readers.js';
3
+ import { getByPath } from './enrichment_helpers.js';
4
+ function getInstallsEntries(raw, recipe) {
5
+ const installsPath = recipe.installs_path ?? recipe.installs_path_fallback;
6
+ if (!installsPath)
7
+ return [];
8
+ let installs = getByPath(raw, installsPath);
9
+ if (!installs && recipe.installs_path_fallback && recipe.installs_path_fallback !== installsPath) {
10
+ installs = getByPath(raw, recipe.installs_path_fallback);
11
+ }
12
+ if (!installs)
13
+ return [];
14
+ if (Array.isArray(installs)) {
15
+ return installs.filter((e) => typeof e === 'object' && e !== null);
16
+ }
17
+ if (typeof installs === 'object' && installs !== null) {
18
+ return Object.values(installs).filter((e) => typeof e === 'object' && e !== null);
19
+ }
20
+ return [];
21
+ }
22
+ function resolvePathFromTemplate(entry, pathTemplate) {
23
+ const installPath = entry.installPath?.trim() ?? '';
24
+ const sourcePath = entry.sourcePath?.trim() ?? '';
25
+ return pathTemplate
26
+ .replace(/\{\{installPath\}\}/g, installPath)
27
+ .replace(/\{\{sourcePath\}\}/g, sourcePath);
28
+ }
29
+ /**
30
+ * For file types whose enrich recipe has derived_files: from each install entry in raw,
31
+ * resolve path from path_template ({{installPath}}, {{sourcePath}}), read file, push with recipe file_type.
32
+ * Driven by backend FILE_TYPE_ENRICHMENT.derived_files; no openclaw-specific logic here.
33
+ */
34
+ function pushDerivedFilesFromRecipe(raw, configFiles, recipe) {
35
+ const derived = recipe.derived_files;
36
+ if (!derived?.length)
37
+ return;
38
+ const entries = getInstallsEntries(raw, recipe);
39
+ for (const entry of entries) {
40
+ for (const spec of derived) {
41
+ let path = resolvePathFromTemplate(entry, spec.path_template).replace(/\/+/g, '/').trim();
42
+ if (!path && spec.path_template_fallback) {
43
+ path = resolvePathFromTemplate(entry, spec.path_template_fallback).replace(/\/+/g, '/').trim();
44
+ }
45
+ if (!path)
46
+ continue;
47
+ const fullPath = path.startsWith('/') ? path : join(process.cwd(), path);
48
+ const content = readJSONFile(fullPath);
49
+ if (content !== null) {
50
+ configFiles.push({ file_type: spec.file_type, file_path: fullPath, raw_content: content });
51
+ }
52
+ }
53
+ }
54
+ }
55
+ export { pushDerivedFilesFromRecipe };
@@ -0,0 +1,223 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ /**
5
+ * Session / token usage / model / version aggregator for OpenClaw
6
+ * (github.com/openclaw/openclaw — a multi-channel AI gateway, unrelated to Hermes/Nous Research
7
+ * despite Hermes shipping a `hermes claw migrate` path to import from it).
8
+ *
9
+ * OpenClaw keeps a session store per agent at
10
+ * ~/.openclaw/agents/<agentId>/sessions/sessions.json — a sessionKey -> SessionEntry map
11
+ * (see docs/reference/session-management-compaction.md in the openclaw package: "Session
12
+ * store schema"). SessionEntry carries both routing/identity fields (displayName, channel,
13
+ * groupChannel, groupId, lastAccountId, lastChannel, lastThreadId, lastTo, deliveryContext,
14
+ * origin — who the session's conversation partner was) and numeric/model fields (inputTokens,
15
+ * outputTokens, cacheRead, cacheWrite, totalTokens, model, modelProvider). This collector reads
16
+ * ONLY the numeric/model fields — the existing openclaw_sessions file type registration already
17
+ * treats this file's content as sensitive (collect_style="metadata", path+mtime only), so the
18
+ * routing/identity fields are never touched or returned here.
19
+ *
20
+ * Version comes from a scoped read of openclaw.json's top-level `meta.lastTouchedVersion` key
21
+ * only — never any other key (openclaw.json's other sections can carry messaging/provider
22
+ * settings).
23
+ *
24
+ * Auth method comes from a scoped read of openclaw.json's `auth.profiles.*` entries — each
25
+ * profile is `{provider, mode}` (e.g. `{"provider": "anthropic", "mode": "api_key"}`); we read
26
+ * only those two keys, never any credential material (profiles carry no token/key fields to
27
+ * begin with). Exposed per-provider so the extractor can match it against the provider actually
28
+ * observed in session usage.
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
+ *
37
+ * Only checks ~/.openclaw (the canonical root); the moltbot/clawdbot compatible-fork roots
38
+ * tracked by the presence/log file types are not covered here.
39
+ */
40
+ const TOKEN_USAGE_RECENCY_DAYS = 30;
41
+ const TOKEN_USAGE_FILE_TYPE = 'openclaw_token_usage';
42
+ function emptyTotals() {
43
+ return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
44
+ }
45
+ function num(value) {
46
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
47
+ }
48
+ function str(value) {
49
+ return typeof value === 'string' ? value.trim() : '';
50
+ }
51
+ /** Every ~/.openclaw/agents/<agentId>/sessions/sessions.json that exists. */
52
+ function discoverSessionsStores(openclawDir) {
53
+ const agentsDir = join(openclawDir, 'agents');
54
+ let entries;
55
+ try {
56
+ entries = readdirSync(agentsDir, { withFileTypes: true });
57
+ }
58
+ catch {
59
+ return [];
60
+ }
61
+ const out = [];
62
+ for (const entry of entries) {
63
+ if (!entry.isDirectory())
64
+ continue;
65
+ const storePath = join(agentsDir, entry.name, 'sessions', 'sessions.json');
66
+ if (existsSync(storePath))
67
+ out.push(storePath);
68
+ }
69
+ return out;
70
+ }
71
+ /**
72
+ * Scoped read of one sessions.json store: only numeric/model fields per SessionEntry, bounded
73
+ * to the recency window. See the module docstring for the fields this deliberately never reads.
74
+ */
75
+ function readSessionsStore(storePath, cutoffMs, acc) {
76
+ let raw;
77
+ try {
78
+ raw = JSON.parse(readFileSync(storePath, 'utf8'));
79
+ }
80
+ catch {
81
+ return;
82
+ }
83
+ if (!raw || typeof raw !== 'object')
84
+ return;
85
+ for (const value of Object.values(raw)) {
86
+ if (!value || typeof value !== 'object')
87
+ continue;
88
+ const entry = value;
89
+ // updatedAt is Unix epoch *milliseconds*, matching cutoffMs below (unlike Hermes's
90
+ // started_at, which is epoch seconds). Confirmed against a real sessions.json: a raw
91
+ // value of 1772443946772 resolves to 2026-03-02T09:32:26.772Z, the correct magnitude
92
+ // for epoch-ms — a seconds value at that date would be ~10 digits, not 13.
93
+ if (num(entry.updatedAt) < cutoffMs)
94
+ continue;
95
+ const sessionId = str(entry.sessionId);
96
+ if (sessionId)
97
+ acc.sessionIds.add(sessionId);
98
+ const model = str(entry.model);
99
+ const provider = str(entry.modelProvider);
100
+ if (model)
101
+ acc.models.add(model);
102
+ if (provider)
103
+ acc.providers.add(provider);
104
+ const input = num(entry.inputTokens);
105
+ const output = num(entry.outputTokens);
106
+ const cacheRead = num(entry.cacheRead);
107
+ acc.totals.input_tokens += input;
108
+ acc.totals.output_tokens += output;
109
+ acc.totals.cache_read_input_tokens += cacheRead;
110
+ const sum = input + output + cacheRead;
111
+ if (model && sum > 0) {
112
+ acc.modelTokenSplit[model] = (acc.modelTokenSplit[model] ?? 0) + sum;
113
+ }
114
+ }
115
+ }
116
+ /** Parses openclaw.json once; both readVersion and readAuthModesByProvider derive from this. */
117
+ function parseOpenclawJson(openclawDir) {
118
+ try {
119
+ return JSON.parse(readFileSync(join(openclawDir, 'openclaw.json'), 'utf8'));
120
+ }
121
+ catch {
122
+ return null;
123
+ }
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
+ }
132
+ /**
133
+ * Scoped read of openclaw.json's `auth.profiles` — provider + mode only, never credentials
134
+ * (the profiles carry no token/key fields). Returns a provider -> mode map (e.g.
135
+ * `{"anthropic": "api_key"}`); a provider with multiple profiles keeps the last one read.
136
+ */
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;
150
+ }
151
+ }
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
+ }
177
+ }
178
+ return out;
179
+ }
180
+ /**
181
+ * Aggregate OpenClaw session count / token usage / models / providers / version across every
182
+ * agent dir's sessions.json store. Returns a single-element array (one per-machine aggregate)
183
+ * or [] when there is no recent session data.
184
+ */
185
+ function collectOpenclawTokenUsage(home = homedir()) {
186
+ const openclawDir = join(home, '.openclaw');
187
+ const stores = discoverSessionsStores(openclawDir);
188
+ if (stores.length === 0)
189
+ return [];
190
+ const cutoffMs = Date.now() - TOKEN_USAGE_RECENCY_DAYS * 24 * 60 * 60 * 1000;
191
+ const acc = {
192
+ sessionIds: new Set(),
193
+ models: new Set(),
194
+ providers: new Set(),
195
+ totals: emptyTotals(),
196
+ modelTokenSplit: {},
197
+ };
198
+ for (const store of stores) {
199
+ readSessionsStore(store, cutoffMs, acc);
200
+ }
201
+ if (acc.sessionIds.size === 0)
202
+ return [];
203
+ const openclawJson = parseOpenclawJson(openclawDir);
204
+ return [
205
+ {
206
+ file_type: TOKEN_USAGE_FILE_TYPE,
207
+ file_path: openclawDir,
208
+ raw_content: {
209
+ version: readVersion(openclawJson),
210
+ session_count: acc.sessionIds.size,
211
+ models: [...acc.models].sort(),
212
+ providers: [...acc.providers].sort(),
213
+ model_token_split: acc.modelTokenSplit,
214
+ window_days: TOKEN_USAGE_RECENCY_DAYS,
215
+ totals: acc.totals,
216
+ auth_modes_by_provider: readAuthModesByProvider(openclawJson),
217
+ primary_model_provider: readPrimaryModelProvider(openclawJson),
218
+ model_providers: readModelProviders(openclawJson),
219
+ },
220
+ },
221
+ ];
222
+ }
223
+ export { collectOpenclawTokenUsage, TOKEN_USAGE_RECENCY_DAYS, TOKEN_USAGE_FILE_TYPE };