idlewatch 0.1.0

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 (110) hide show
  1. package/.env.example +73 -0
  2. package/.github/workflows/ci.yml +99 -0
  3. package/.github/workflows/release-macos-trusted.yml +103 -0
  4. package/README.md +336 -0
  5. package/bin/idlewatch-agent.js +1053 -0
  6. package/docs/onboarding-external.md +58 -0
  7. package/docs/packaging/macos-dmg.md +199 -0
  8. package/docs/packaging/macos-launch-agent.md +70 -0
  9. package/docs/qa/archive/mac-qa-log-2026-02-17.md +5838 -0
  10. package/docs/qa/mac-qa-log.md +2864 -0
  11. package/docs/telemetry/idle-stale-policy.md +57 -0
  12. package/docs/telemetry/openclaw-mapping.md +80 -0
  13. package/package.json +76 -0
  14. package/scripts/build-dmg.sh +65 -0
  15. package/scripts/install-macos-launch-agent.sh +78 -0
  16. package/scripts/lib/telemetry-row-parser.mjs +100 -0
  17. package/scripts/package-macos.sh +228 -0
  18. package/scripts/uninstall-macos-launch-agent.sh +30 -0
  19. package/scripts/validate-all.sh +142 -0
  20. package/scripts/validate-bin.mjs +25 -0
  21. package/scripts/validate-dmg-checksum.sh +37 -0
  22. package/scripts/validate-dmg-install.sh +155 -0
  23. package/scripts/validate-dry-run-schema.mjs +257 -0
  24. package/scripts/validate-onboarding.mjs +63 -0
  25. package/scripts/validate-openclaw-cache-recovery-e2e.mjs +113 -0
  26. package/scripts/validate-openclaw-release-gates.mjs +51 -0
  27. package/scripts/validate-openclaw-stats-ingestion.mjs +372 -0
  28. package/scripts/validate-openclaw-usage-health.mjs +95 -0
  29. package/scripts/validate-packaged-artifact.mjs +233 -0
  30. package/scripts/validate-packaged-bundled-runtime.sh +191 -0
  31. package/scripts/validate-packaged-metadata.sh +43 -0
  32. package/scripts/validate-packaged-openclaw-cache-recovery-e2e.mjs +153 -0
  33. package/scripts/validate-packaged-openclaw-release-gates.mjs +72 -0
  34. package/scripts/validate-packaged-openclaw-stats-ingestion.mjs +402 -0
  35. package/scripts/validate-packaged-sourcemaps.mjs +82 -0
  36. package/scripts/validate-packaged-usage-alert-rate-e2e.mjs +98 -0
  37. package/scripts/validate-packaged-usage-probe-noise-e2e.mjs +87 -0
  38. package/scripts/validate-packaged-usage-recovery-e2e.mjs +90 -0
  39. package/scripts/validate-trusted-prereqs.sh +44 -0
  40. package/scripts/validate-usage-alert-rate-e2e.mjs +91 -0
  41. package/scripts/validate-usage-freshness-e2e.mjs +81 -0
  42. package/skill/SKILL.md +43 -0
  43. package/src/config.js +100 -0
  44. package/src/enrollment.js +176 -0
  45. package/src/gpu.js +115 -0
  46. package/src/memory.js +67 -0
  47. package/src/openclaw-cache.js +51 -0
  48. package/src/openclaw-usage.js +1020 -0
  49. package/src/telemetry-mapping.js +54 -0
  50. package/src/usage-alert.js +41 -0
  51. package/src/usage-freshness.js +31 -0
  52. package/test/config.test.mjs +112 -0
  53. package/test/fixtures/gpu-agx.txt +2 -0
  54. package/test/fixtures/gpu-iogpu.txt +2 -0
  55. package/test/fixtures/gpu-top-grep.txt +2 -0
  56. package/test/fixtures/openclaw-fleet-sample-v1.json +68 -0
  57. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-iso-ts.txt +2 -0
  58. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-newest.txt +2 -0
  59. package/test/fixtures/openclaw-mixed-equal-score-status-vs-generic-string-ts.txt +2 -0
  60. package/test/fixtures/openclaw-mixed-status-then-generic-output.txt +2 -0
  61. package/test/fixtures/openclaw-stats-current-wrapper.json +12 -0
  62. package/test/fixtures/openclaw-stats-current-wrapper2.json +15 -0
  63. package/test/fixtures/openclaw-stats-data-wrapper.json +21 -0
  64. package/test/fixtures/openclaw-stats-nested-session-wrapper.json +23 -0
  65. package/test/fixtures/openclaw-stats-payload-wrapper.json +1 -0
  66. package/test/fixtures/openclaw-stats-status-current-wrapper.json +19 -0
  67. package/test/fixtures/openclaw-stats.json +17 -0
  68. package/test/fixtures/openclaw-status-ansi-complex-noise.txt +3 -0
  69. package/test/fixtures/openclaw-status-ansi-noise.txt +2 -0
  70. package/test/fixtures/openclaw-status-control-noise.txt +1 -0
  71. package/test/fixtures/openclaw-status-data-wrapper.json +20 -0
  72. package/test/fixtures/openclaw-status-dcs-noise.txt +1 -0
  73. package/test/fixtures/openclaw-status-epoch-seconds.json +15 -0
  74. package/test/fixtures/openclaw-status-mixed-noise.txt +1 -0
  75. package/test/fixtures/openclaw-status-multi-json.txt +3 -0
  76. package/test/fixtures/openclaw-status-nested-recent.json +19 -0
  77. package/test/fixtures/openclaw-status-noisy-default-then-usage.txt +2 -0
  78. package/test/fixtures/openclaw-status-noisy.txt +3 -0
  79. package/test/fixtures/openclaw-status-osc-noise.txt +1 -0
  80. package/test/fixtures/openclaw-status-result-session.json +15 -0
  81. package/test/fixtures/openclaw-status-session-map-with-defaults.json +23 -0
  82. package/test/fixtures/openclaw-status-session-map.json +28 -0
  83. package/test/fixtures/openclaw-status-session-model-name.json +18 -0
  84. package/test/fixtures/openclaw-status-snake-session-wrapper.json +13 -0
  85. package/test/fixtures/openclaw-status-stats-current-sessions-snake-tokens.json +25 -0
  86. package/test/fixtures/openclaw-status-stats-current-sessions.json +28 -0
  87. package/test/fixtures/openclaw-status-stats-current-usage-time-camelcase.json +19 -0
  88. package/test/fixtures/openclaw-status-stats-session-default-model.json +27 -0
  89. package/test/fixtures/openclaw-status-status-wrapper.json +13 -0
  90. package/test/fixtures/openclaw-status-strings.json +38 -0
  91. package/test/fixtures/openclaw-status-ts-ms-alias.json +14 -0
  92. package/test/fixtures/openclaw-status-updated-at-ms-alias.json +14 -0
  93. package/test/fixtures/openclaw-status-usage-timestamp-ms-alias.json +14 -0
  94. package/test/fixtures/openclaw-status-usage-ts-alias.json +14 -0
  95. package/test/fixtures/openclaw-status-wrap-session-object.json +24 -0
  96. package/test/fixtures/openclaw-status.json +41 -0
  97. package/test/fixtures/openclaw-usage-model-name-generic.json +9 -0
  98. package/test/gpu.test.mjs +58 -0
  99. package/test/memory.test.mjs +35 -0
  100. package/test/openclaw-cache.test.mjs +48 -0
  101. package/test/openclaw-env.test.mjs +365 -0
  102. package/test/openclaw-usage.test.mjs +555 -0
  103. package/test/telemetry-mapping.test.mjs +69 -0
  104. package/test/telemetry-row-parser.test.mjs +44 -0
  105. package/test/usage-alert.test.mjs +73 -0
  106. package/test/usage-freshness.test.mjs +63 -0
  107. package/test/validate-dry-run-schema.test.mjs +146 -0
  108. package/tui/Cargo.lock +801 -0
  109. package/tui/Cargo.toml +11 -0
  110. package/tui/src/main.rs +368 -0
@@ -0,0 +1,1020 @@
1
+ function pickNumber(...vals) {
2
+ for (const val of vals) {
3
+ if (typeof val === 'number' && Number.isFinite(val)) return val
4
+ if (typeof val === 'string') {
5
+ const normalized = val.trim()
6
+ if (!normalized) continue
7
+ const parsed = Number(normalized)
8
+ if (Number.isFinite(parsed)) return parsed
9
+
10
+ const asDate = Date.parse(normalized)
11
+ if (!Number.isNaN(asDate)) return asDate
12
+ }
13
+ }
14
+ return null
15
+ }
16
+
17
+ function pickTimestamp(...vals) {
18
+ for (const val of vals) {
19
+ if (val === null || typeof val === 'undefined') continue
20
+
21
+ if (typeof val === 'number' && Number.isFinite(val)) {
22
+ const numeric = val
23
+ if (Number.isInteger(numeric) && numeric > 0 && numeric < 1_000_000_000_000) return numeric * 1000
24
+ return numeric
25
+ }
26
+
27
+ if (typeof val === 'string') {
28
+ const normalized = val.trim()
29
+ if (!normalized) continue
30
+
31
+ const parsed = Number(normalized)
32
+ if (Number.isFinite(parsed)) {
33
+ if (Number.isInteger(parsed) && parsed > 0 && parsed < 1_000_000_000_000) return parsed * 1000
34
+ return parsed
35
+ }
36
+
37
+ const asDate = Date.parse(normalized)
38
+ if (!Number.isNaN(asDate)) return asDate
39
+ }
40
+ }
41
+ return null
42
+ }
43
+
44
+ function pickString(...vals) {
45
+ for (const val of vals) {
46
+ if (typeof val === 'string' && val.trim()) return val
47
+ if (val instanceof String && val.toString().trim()) return val.toString().trim()
48
+ }
49
+ return null
50
+ }
51
+
52
+ function isFreshTokenMarker(value) {
53
+ if (value === false || value === 0) return false
54
+ if (value === null || typeof value === 'undefined') return true
55
+ if (typeof value === 'string') {
56
+ const normalized = value.trim().toLowerCase()
57
+ if (!normalized) return true
58
+ if (['false', '0', 'no', 'off', 'stale'].includes(normalized)) return false
59
+ if (['true', '1', 'yes', 'on', 'fresh'].includes(normalized)) return true
60
+ }
61
+ return Boolean(value)
62
+ }
63
+
64
+ function pickNestedTotalsTokens(candidate) {
65
+ if (!candidate || typeof candidate !== 'object') return null
66
+
67
+ const totalsRoot = candidate.totals || candidate.summary || candidate.usageTotals || candidate.usage?.totals || candidate.usage?.summary
68
+ const usageTotals = candidate.usage || {}
69
+
70
+ return pickNumber(
71
+ totalsRoot?.totalTokens,
72
+ totalsRoot?.total_tokens,
73
+ totalsRoot?.total,
74
+ totalsRoot?.tokens?.total,
75
+ totalsRoot?.tokens?.sum,
76
+ totalsRoot?.tokenCount,
77
+ totalsRoot?.token_count,
78
+ totalsRoot?.count,
79
+ totalsRoot?.cumulativeTokens,
80
+ totalsRoot?.usageTokens,
81
+ totalsRoot?.cumulative,
82
+ candidate.totalTokens,
83
+ candidate.total_tokens,
84
+ candidate.total_tokens_count,
85
+ usageTotals.totalTokens,
86
+ usageTotals.total_tokens,
87
+ usageTotals.tokenCount,
88
+ usageTotals.total,
89
+ usageTotals.tokens?.total,
90
+ usageTotals.tokens?.sum,
91
+ usageTotals.inputTokens && usageTotals.outputTokens ? usageTotals.inputTokens + usageTotals.outputTokens : null,
92
+ candidate.inputTokens && candidate.outputTokens ? candidate.inputTokens + candidate.outputTokens : null,
93
+ candidate.tokenUsage?.total,
94
+ candidate.token_usage?.total
95
+ )
96
+ }
97
+
98
+ function deriveTokensPerMinute(session) {
99
+ const totalTokens = pickNumber(session?.totalTokens, session?.total_tokens, pickNestedTotalsTokens(session))
100
+ const ageMs = pickNumber(session?.ageMs, session?.age)
101
+ if (totalTokens === null || ageMs === null || ageMs <= 0) return null
102
+
103
+ const minutes = ageMs / 60000
104
+ if (!Number.isFinite(minutes) || minutes <= 0) return null
105
+ return Number((totalTokens / minutes).toFixed(2))
106
+ }
107
+
108
+ function pickNewestSession(sessions = []) {
109
+ return sessions.reduce((best, candidate) => {
110
+ if (!best) return candidate
111
+
112
+ const pickSessionTs = (item) => {
113
+ const age = pickNumber(item?.age, item?.ageMs)
114
+ const absolute = pickTimestamp(item?.updatedAt, item?.updated_at, item?.updatedAtMs, item?.createdAt, item?.created_at, item?.ts, item?.time)
115
+ return absolute ?? (Number.isFinite(age) && age >= 0 ? Date.now() - age : null)
116
+ }
117
+
118
+ const bestTs = pickSessionTs(best)
119
+ const candidateTs = pickSessionTs(candidate)
120
+
121
+ if (candidateTs === null) return best
122
+ if (bestTs === null || candidateTs > bestTs) return candidate
123
+ return best
124
+ }, null)
125
+ }
126
+
127
+ function pickBestRecentSession(recent = []) {
128
+ if (!Array.isArray(recent) || recent.length === 0) return null
129
+
130
+ const withFreshTokens = recent.filter((session) => {
131
+ const totalTokens = pickNumber(session?.totalTokens, session?.total_tokens, pickNestedTotalsTokens(session))
132
+ return totalTokens !== null && isFreshTokenMarker(session?.totalTokensFresh)
133
+ })
134
+ const freshestWithTokens = pickNewestSession(withFreshTokens)
135
+ if (freshestWithTokens) return freshestWithTokens
136
+
137
+ const anyWithTokens = recent.filter((session) => pickNumber(session?.totalTokens, session?.total_tokens, pickNestedTotalsTokens(session)) !== null)
138
+ const newestWithTokens = pickNewestSession(anyWithTokens)
139
+ if (newestWithTokens) return newestWithTokens
140
+
141
+ return pickNewestSession(recent) || recent[0]
142
+ }
143
+
144
+ function extractOpenClawNoise(raw) {
145
+ const esc = String.fromCharCode(0x1b)
146
+ return String(raw)
147
+ .replace(/\x1b\][^\x07\x1b]*\x07/g, '')
148
+ .replace(/\x1b\][^\x07\x1b]*\x1b\\/g, '')
149
+ .replace(/\x9b\][^\x07\x1b]*\x07/g, '')
150
+ .replace(/\x9b[^\x07\x1b]*\x1b\\/g, '')
151
+ .replace(/\x1b[PXZ^_].*?(?:\x1b\\|\x9c)/gs, '')
152
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
153
+ .replace(new RegExp(`${esc}\[[0-9;?]*[ -/]*[@-~]`, 'g'), '')
154
+ .replace(new RegExp(`${esc}[^m]*m`, 'g'), '')
155
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
156
+ .replace(/\r/g, '')
157
+ }
158
+
159
+
160
+ function extractJsonCandidates(raw) {
161
+ const text = extractOpenClawNoise(raw)
162
+ const candidates = []
163
+
164
+ for (let start = 0; start < text.length; start += 1) {
165
+ const open = text[start]
166
+ if (open !== '{' && open !== '[') continue
167
+
168
+ const close = open === '{' ? '}' : ']'
169
+ let depth = 0
170
+ let inString = false
171
+ let escaped = false
172
+ let end = -1
173
+
174
+ for (let i = start; i < text.length; i++) {
175
+ const ch = text[i]
176
+
177
+ if (inString) {
178
+ if (escaped) {
179
+ escaped = false
180
+ } else if (ch === '\\') {
181
+ escaped = true
182
+ } else if (ch === '"') {
183
+ inString = false
184
+ }
185
+ continue
186
+ }
187
+
188
+ if (ch === '"') {
189
+ inString = true
190
+ continue
191
+ }
192
+
193
+ if (ch === open) {
194
+ depth += 1
195
+ continue
196
+ }
197
+
198
+ if (ch === close) {
199
+ depth -= 1
200
+ if (depth === 0) {
201
+ end = i
202
+ break
203
+ }
204
+
205
+ if (depth < 0) {
206
+ break
207
+ }
208
+ }
209
+ }
210
+
211
+ if (end > start) {
212
+ candidates.push(text.slice(start, end + 1))
213
+ start = end
214
+ }
215
+ }
216
+
217
+ return candidates
218
+ }
219
+
220
+ const OPENCLAW_ALIAS_KEY_MAP = {
221
+ current_session: 'currentSession',
222
+ active_session: 'activeSession',
223
+ recent_sessions: 'recentSessions',
224
+ active_sessions: 'activeSessions',
225
+ default_model: 'defaultModel',
226
+ model_name: 'modelName',
227
+ session_id: 'sessionId',
228
+ agent_id: 'agentId',
229
+ usage_ts: 'usageTs',
230
+ usage_ts_ms: 'usageTsMs',
231
+ usage_timestamp: 'usageTimestamp',
232
+ usage_timestamp_ms: 'usageTsMs',
233
+ usageTimestamp: 'usageTimestamp',
234
+ usageTimestampMs: 'usageTsMs',
235
+ usageTime: 'usageTimestamp',
236
+ usageTimeMs: 'usageTsMs',
237
+ usage_time: 'usageTimestamp',
238
+ updated_at_ms: 'updatedAtMs',
239
+ ts_ms: 'tsMs'
240
+ }
241
+
242
+ function normalizeOpenClawAliases(value) {
243
+ if (Array.isArray(value)) return value.map((item) => normalizeOpenClawAliases(item))
244
+ if (!value || typeof value !== 'object') return value
245
+
246
+ const normalized = {}
247
+ for (const [key, rawValue] of Object.entries(value)) {
248
+ const child = normalizeOpenClawAliases(rawValue)
249
+ const alias = OPENCLAW_ALIAS_KEY_MAP[key]
250
+
251
+ if (alias && typeof normalized[alias] === 'undefined') {
252
+ normalized[alias] = child
253
+ continue
254
+ }
255
+
256
+ if (typeof normalized[key] === 'undefined') {
257
+ normalized[key] = child
258
+ }
259
+ }
260
+
261
+ return normalized
262
+ }
263
+
264
+ function hasAnySessionSignal(value) {
265
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
266
+
267
+ const directSignals = [
268
+ value.sessionId,
269
+ value.session_id,
270
+ value.id,
271
+ value.agentId,
272
+ value.agent_id,
273
+ value.model,
274
+ value.modelName,
275
+ value.model_name,
276
+ value.totalTokens,
277
+ value.total_tokens,
278
+ value.inputTokens,
279
+ value.outputTokens,
280
+ value.age,
281
+ value.ageMs,
282
+ value.updatedAt,
283
+ value.updated_at,
284
+ value.updatedAtMs,
285
+ value.updated_at_ms,
286
+ value.ts,
287
+ value.time,
288
+ value.timestamp,
289
+ value.timestampMs,
290
+ value.tsMs,
291
+ value.usageTs,
292
+ value.usageTsMs,
293
+ value.usage_timestamp,
294
+ value.usage_timestamp_ms,
295
+ value.usageTimestamp,
296
+ value.usageTimestampMs,
297
+ value.usage_time
298
+ ]
299
+
300
+ if (directSignals.some((field) => Number.isFinite(Number(field)) || (typeof field === 'string' && field.trim().length > 0) || field === true || field === false)) {
301
+ return true
302
+ }
303
+
304
+ return (
305
+ hasAnySessionSignal(value?.usage) ||
306
+ hasAnySessionSignal(value?.usageTotals) ||
307
+ hasAnySessionSignal(value?.result) ||
308
+ hasAnySessionSignal(value?.session)
309
+ )
310
+ }
311
+
312
+ function extractUsageEnvelope(value, depth = 0) {
313
+ if (!value || typeof value !== 'object') return null
314
+ if (Array.isArray(value)) return value.length > 0 ? value : null
315
+ if (depth > 6) return null
316
+
317
+ const nextKeys = [
318
+ 'current',
319
+ 'currentSession',
320
+ 'activeSession',
321
+ 'session',
322
+ 'active',
323
+ 'recent',
324
+ 'recentSessions',
325
+ 'activeSessions',
326
+ 'sessions',
327
+ 'stats',
328
+ 'sessionUsage',
329
+ 'usage',
330
+ 'data',
331
+ 'result',
332
+ 'status',
333
+ 'payload',
334
+ 'defaultModel',
335
+ 'default_model',
336
+ 'session_id',
337
+ 'agent_id'
338
+ ]
339
+
340
+ for (const key of nextKeys) {
341
+ const next = value[key]
342
+ const normalized = extractUsageEnvelope(next, depth + 1)
343
+ if (normalized) return normalized
344
+ }
345
+
346
+ if (hasAnySessionSignal(value)) return value
347
+
348
+ return null
349
+ }
350
+
351
+ function coerceSessionCandidates(value, options = {}) {
352
+ const skipKeys = new Set(options.skipKeys || [])
353
+
354
+ if (Array.isArray(value)) {
355
+ return value.length > 0 ? value : null
356
+ }
357
+
358
+ if (value && typeof value === 'object') {
359
+ if (hasAnySessionSignal(value)) return [value]
360
+
361
+ const fromObject = Object.entries(value)
362
+ .filter(([key, entry]) => !skipKeys.has(key))
363
+ .map((entry) => entry[1])
364
+ .filter((entry) => {
365
+ if (!entry || typeof entry !== 'object') return false
366
+ if (Array.isArray(entry)) return entry.length > 0
367
+ if (!hasAnySessionSignal(entry)) return false
368
+ return true
369
+ })
370
+
371
+ if (fromObject.length > 0) {
372
+ // Flatten nested arrays (e.g. when a sessions array is a value in an object map)
373
+ const flattened = fromObject.flatMap((entry) => (Array.isArray(entry) ? entry : [entry]))
374
+ return flattened.length > 0 ? flattened : fromObject
375
+ }
376
+ }
377
+
378
+ return null
379
+ }
380
+
381
+ /**
382
+ * Resolve a dot-separated path on an object, returning undefined if any
383
+ * segment is nullish. E.g. deepGet(obj, 'a.b.c') === obj?.a?.b?.c
384
+ */
385
+ function deepGet(obj, path) {
386
+ let cur = obj
387
+ const segments = path.split('.')
388
+ for (let i = 0; i < segments.length; i++) {
389
+ if (cur == null || typeof cur !== 'object') return undefined
390
+ cur = cur[segments[i]]
391
+ }
392
+ return cur
393
+ }
394
+
395
+ /**
396
+ * Check whether any combination of wrapper-prefix + leaf-key is truthy on
397
+ * `value`. `prefixes` are dot-separated paths (empty string = root).
398
+ */
399
+ function hasTruthyAtAnyPath(value, prefixes, leafKeys) {
400
+ for (const prefix of prefixes) {
401
+ const base = prefix ? deepGet(value, prefix) : value
402
+ if (base == null || typeof base !== 'object') continue
403
+ for (const leaf of leafKeys) {
404
+ if (base[leaf]) return true
405
+ }
406
+ }
407
+ return false
408
+ }
409
+
410
+ // Wrapper prefixes used for envelope detection (up to 4 levels deep).
411
+ const SESSION_ENVELOPE_PREFIXES = [
412
+ '', 'result', 'result.data', 'data', 'data.result',
413
+ 'status', 'status.result', 'status.result.data', 'status.data',
414
+ 'payload', 'payload.result', 'payload.result.data', 'payload.data',
415
+ 'payload.data.result', 'payload.data.result.data',
416
+ 'payload.status', 'payload.status.result', 'payload.status.result.data'
417
+ ]
418
+
419
+ const SESSION_LEAF_KEYS = ['sessions', 'session', 'activeSession', 'currentSession', 'current', 'active', 'recentSessions', 'recent', 'activeSessions']
420
+
421
+ function isExplicitSessionEnvelope(value) {
422
+ if (!value || typeof value !== 'object') return false
423
+ return hasTruthyAtAnyPath(value, SESSION_ENVELOPE_PREFIXES, SESSION_LEAF_KEYS)
424
+ }
425
+
426
+ const STATS_PREFIXES = [
427
+ '', 'result', 'data', 'data.result', 'status', 'status.result',
428
+ 'payload', 'payload.result', 'payload.data', 'payload.data.result',
429
+ 'payload.status', 'payload.status.result'
430
+ ]
431
+
432
+ const STATS_LEAF_KEYS = ['stats', 'sessionUsage', 'usage', 'current', 'session']
433
+
434
+ function looksLikeStatsOrCurrentPayload(parsed) {
435
+ if (!parsed || typeof parsed !== 'object') return false
436
+ return hasTruthyAtAnyPath(parsed, STATS_PREFIXES, STATS_LEAF_KEYS)
437
+ }
438
+
439
+ // Wrapper prefixes for session candidate collection (ordered by priority).
440
+ const COLLECT_PREFIXES = [
441
+ 'sessions', '', 'result.sessions', 'result', 'result.data.sessions', 'result.data',
442
+ 'data.result.sessions', 'data.result', 'data.result.data.sessions', 'data.result.data',
443
+ 'data', 'status.sessions', 'status', 'status.result.sessions', 'status.result',
444
+ 'status.result.data', 'status.data.sessions', 'status.data'
445
+ ]
446
+
447
+ // Session leaf keys to probe under each prefix.
448
+ const COLLECT_LEAVES = ['recent', 'recentSessions', 'activeSessions', 'active', 'session', 'activeSession', 'current', 'currentSession']
449
+
450
+ // Additional fallback paths checked as raw roots (no leaf expansion).
451
+ const COLLECT_FALLBACK_ROOTS = [
452
+ 'result.data', 'result', 'data.result.data', 'data.result', 'data',
453
+ 'status.result.data', 'status.result', 'sessions', 'data'
454
+ ]
455
+
456
+ function collectStatusSessionCandidates(parsed) {
457
+ const coerceOpts = { skipKeys: ['defaults', 'metadata', 'config'] }
458
+
459
+ // First pass: prefix + leaf combinations (specific paths only).
460
+ for (const prefix of COLLECT_PREFIXES) {
461
+ const base = prefix ? deepGet(parsed, prefix) : parsed
462
+ if (base == null || typeof base !== 'object') continue
463
+ for (const leaf of COLLECT_LEAVES) {
464
+ const candidate = base[leaf]
465
+ if (candidate == null) continue
466
+ const normalized = coerceSessionCandidates(candidate, coerceOpts)
467
+ if (normalized && normalized.length > 0) return normalized
468
+ }
469
+ }
470
+
471
+ // Second pass: try wrapper roots themselves as session containers (fallback).
472
+ const fallbackRoots = [
473
+ 'result.data', 'result', 'data.result.data', 'data.result',
474
+ 'data', 'status.result.data', 'status.result', 'status.data',
475
+ 'sessions', 'data'
476
+ ]
477
+ for (const path of fallbackRoots) {
478
+ const candidate = deepGet(parsed, path)
479
+ if (candidate == null) continue
480
+ const normalized = coerceSessionCandidates(candidate, coerceOpts)
481
+ if (normalized && normalized.length > 0) return normalized
482
+ }
483
+
484
+ return null
485
+ }
486
+
487
+
488
+ function parseFromStatusJson(parsed) {
489
+ if (!isExplicitSessionEnvelope(parsed) && looksLikeStatsOrCurrentPayload(parsed)) {
490
+ return null
491
+ }
492
+
493
+ const sessionsRoot = collectStatusSessionCandidates(parsed)
494
+ const defaults = parsed?.sessions?.defaults || parsed?.defaults || parsed?.result?.defaults || parsed?.data?.defaults || parsed?.status?.defaults ||
495
+ (parsed?.result?.defaultModel || parsed?.result?.default_model
496
+ ? { model: parsed.result.defaultModel || parsed.result.default_model }
497
+ : parsed?.result?.data?.defaultModel || parsed?.result?.data?.default_model
498
+ ? { model: parsed.result.data.defaultModel || parsed.result.data.default_model }
499
+ : parsed?.status?.defaultModel || parsed?.status?.default_model
500
+ ? { model: parsed.status.defaultModel || parsed.status.default_model }
501
+ : parsed?.status?.data?.defaultModel || parsed?.status?.data?.default_model
502
+ ? { model: parsed.status.data.defaultModel || parsed.status.data.default_model }
503
+ : parsed?.config?.defaultModel || parsed?.config?.default_model
504
+ ? { model: parsed.config.defaultModel || parsed.config.default_model }
505
+ : {})
506
+ const session = pickBestRecentSession(sessionsRoot)
507
+
508
+ if (!session) {
509
+ if (parsed?.stats || parsed?.usage || parsed?.sessionUsage || parsed?.data?.stats || parsed?.data?.usage || parsed?.data?.sessionUsage) {
510
+ return null
511
+ }
512
+
513
+ const defaultsModel = pickString(
514
+ defaults.model,
515
+ defaults.modelName,
516
+ defaults.defaultModel,
517
+ defaults?.default_model,
518
+ defaults?.model_name,
519
+ parsed?.defaultModel,
520
+ parsed?.default_model,
521
+ parsed?.modelName,
522
+ parsed?.model_name,
523
+ parsed?.status?.defaultModel,
524
+ parsed?.status?.default_model,
525
+ parsed?.status?.modelName,
526
+ parsed?.status?.model_name,
527
+ parsed?.status?.data?.defaultModel,
528
+ parsed?.status?.data?.default_model,
529
+ parsed?.result?.defaultModel,
530
+ parsed?.result?.default_model,
531
+ parsed?.result?.modelName,
532
+ parsed?.result?.model_name,
533
+ parsed?.data?.defaultModel,
534
+ parsed?.data?.default_model,
535
+ parsed?.data?.modelName,
536
+ parsed?.data?.model_name
537
+ )
538
+ if (!defaultsModel) return null
539
+ return {
540
+ model: defaultsModel,
541
+ totalTokens: null,
542
+ tokensPerMin: null,
543
+ sessionId: null,
544
+ agentId: null,
545
+ usageTimestampMs: pickTimestamp(
546
+ parsed?.ts,
547
+ parsed?.time,
548
+ parsed?.timestamp,
549
+ parsed?.tsMs,
550
+ parsed?.timestampMs,
551
+ parsed?.usageTs,
552
+ parsed?.usageTsMs,
553
+ parsed?.usageTimestamp,
554
+ parsed?.usageTimestampMs,
555
+ parsed?.usage_timestamp,
556
+ parsed?.usage_timestamp_ms,
557
+ parsed?.usage_time,
558
+ parsed?.updatedAt,
559
+ parsed?.updated_at,
560
+ parsed?.updatedAtMs,
561
+ parsed?.updated_at_ms
562
+ ),
563
+ integrationStatus: 'partial'
564
+ }
565
+ }
566
+
567
+ const model = pickString(
568
+ session.model,
569
+ session.modelName,
570
+ session.model_name,
571
+ session?.usage?.model,
572
+ session?.usage?.modelName,
573
+ session?.usage?.model_name,
574
+ session?.defaultModel,
575
+ session?.default_model,
576
+ defaults.model,
577
+ defaults.modelName,
578
+ defaults?.model_name,
579
+ parsed?.defaultModel,
580
+ parsed?.default_model,
581
+ parsed?.modelName,
582
+ parsed?.model_name,
583
+ parsed?.status?.model,
584
+ parsed?.status?.modelName,
585
+ parsed?.status?.model_name,
586
+ parsed?.status?.defaultModel,
587
+ parsed?.status?.default_model,
588
+ parsed?.result?.defaultModel,
589
+ parsed?.result?.default_model,
590
+ parsed?.result?.modelName,
591
+ parsed?.result?.model_name,
592
+ parsed?.data?.defaultModel,
593
+ parsed?.data?.default_model,
594
+ parsed?.data?.modelName,
595
+ parsed?.data?.model_name
596
+ )
597
+ const totalTokens = pickNumber(
598
+ session.totalTokens,
599
+ session.total_tokens,
600
+ pickNestedTotalsTokens(session),
601
+ session?.usage?.totalTokens,
602
+ session?.usage?.total_tokens,
603
+ session?.usage?.tokenCount,
604
+ session?.usage?.token_count
605
+ )
606
+ const tokensPerMin = pickNumber(
607
+ session.tokensPerMinute,
608
+ session.tokens_per_minute,
609
+ session.tpm,
610
+ session.rate,
611
+ session?.usage?.tokensPerMinute,
612
+ session?.usage?.tokens_per_minute,
613
+ session?.usage?.tpm,
614
+ session?.usage?.tokenRate,
615
+ deriveTokensPerMinute(session),
616
+ pickNumber(session?.derived?.tokensPerMinute, session?.derived?.tpm)
617
+ )
618
+ const sessionAgeMs = pickNumber(session.age, session.ageMs)
619
+ const usageTimestampMs =
620
+ pickTimestamp(
621
+ session.updatedAt,
622
+ session.updated_at,
623
+ session.updatedAtMs,
624
+ session.updated_at_ms,
625
+ session.timestamp,
626
+ session.time,
627
+ session.usageTs,
628
+ session.usageTimestamp,
629
+ session.usage_timestamp,
630
+ session.usage_timestamp_ms,
631
+ session?.usage_time,
632
+ session.tsMs,
633
+ session?.usage?.updatedAt,
634
+ session?.usage?.updated_at,
635
+ session?.usage?.updatedAtMs,
636
+ session?.usage?.timestamp,
637
+ session?.usage?.ts,
638
+ session?.usage?.tsMs,
639
+ session?.usage?.time,
640
+ session?.usage?.usageTs,
641
+ session?.usage?.usageTimestamp,
642
+ session?.usage?.usage_timestamp,
643
+ session?.usage?.usage_timestamp_ms,
644
+ session?.usage?.usageTsMs,
645
+ parsed?.ts,
646
+ parsed?.time,
647
+ parsed?.timestamp,
648
+ parsed?.tsMs,
649
+ parsed?.timestampMs,
650
+ parsed?.usageTs,
651
+ parsed?.usageTimestamp,
652
+ parsed?.usage_timestamp,
653
+ parsed?.usage_timestamp_ms,
654
+ parsed?.usage_time,
655
+ parsed?.updatedAt,
656
+ parsed?.updated_at,
657
+ parsed?.updatedAtMs,
658
+ parsed?.updated_at_ms,
659
+ parsed?.status?.updatedAt,
660
+ parsed?.status?.updated_at,
661
+ parsed?.status?.updatedAtMs,
662
+ parsed?.status?.updated_at_ms,
663
+ parsed?.status?.timestamp,
664
+ parsed?.status?.time,
665
+ parsed?.status?.ts,
666
+ parsed?.status?.tsMs,
667
+ parsed?.status?.usageTs,
668
+ parsed?.status?.usageTimestamp,
669
+ parsed?.status?.usage_timestamp,
670
+ parsed?.status?.usage_timestamp_ms,
671
+ parsed?.status?.usageTsMs
672
+ ) ??
673
+ (Number.isFinite(sessionAgeMs) && sessionAgeMs >= 0 ? Date.now() - sessionAgeMs : pickTimestamp(parsed?.ts, parsed?.time, parsed?.updatedAt, parsed?.updated_at, parsed?.updatedAtMs, parsed?.timestamp))
674
+
675
+ const hasStrongUsage = model !== null || totalTokens !== null || tokensPerMin !== null
676
+
677
+ return {
678
+ model,
679
+ totalTokens,
680
+ tokensPerMin,
681
+ sessionId: pickString(session.sessionId, session.id, session?.usage?.sessionId, session?.usage?.id, session?.session_id),
682
+ agentId: pickString(session.agentId, session?.usage?.agentId, session?.agent_id),
683
+ usageTimestampMs,
684
+ integrationStatus: hasStrongUsage ? 'ok' : 'partial'
685
+ }
686
+ }
687
+
688
+ function parseGenericUsage(parsed) {
689
+ const usageCandidates = [
690
+ parsed?.usage,
691
+ parsed?.sessionUsage,
692
+ parsed?.stats,
693
+ parsed?.stats?.current,
694
+ parsed?.payload?.usage,
695
+ parsed?.payload?.sessionUsage,
696
+ parsed?.payload?.stats,
697
+ parsed?.payload?.stats?.current,
698
+ parsed?.payload?.stats?.session,
699
+ parsed?.data?.usage,
700
+ parsed?.data?.sessionUsage,
701
+ parsed?.data?.stats,
702
+ parsed?.data?.stats?.current,
703
+ parsed?.data?.stats?.session,
704
+ parsed?.result?.usage,
705
+ parsed?.result?.sessionUsage,
706
+ parsed?.result?.stats,
707
+ parsed?.result?.stats?.current,
708
+ parsed?.result?.stats?.session,
709
+ parsed?.payload?.result?.usage,
710
+ parsed?.payload?.result?.sessionUsage,
711
+ parsed?.payload?.result?.stats,
712
+ parsed?.payload?.result?.stats?.current,
713
+ parsed?.payload?.result?.stats?.session,
714
+ parsed?.data?.result?.usage,
715
+ parsed?.data?.result?.sessionUsage,
716
+ parsed?.data?.result?.stats,
717
+ parsed?.data?.result?.stats?.current,
718
+ parsed?.data?.result?.stats?.session,
719
+ parsed?.status?.usage,
720
+ parsed?.status?.sessionUsage,
721
+ parsed?.status?.stats,
722
+ parsed?.status?.stats?.current,
723
+ parsed?.status?.stats?.session,
724
+ parsed?.status?.result?.usage,
725
+ parsed?.status?.result?.sessionUsage,
726
+ parsed?.status?.result?.stats,
727
+ parsed?.status?.result?.stats?.current,
728
+ parsed?.status?.result?.stats?.session,
729
+ parsed?.current,
730
+ parsed?.session,
731
+ parsed?.result?.current,
732
+ parsed?.result?.session,
733
+ parsed?.result?.data?.current,
734
+ parsed?.result?.data?.session,
735
+ parsed?.result?.data?.stats,
736
+ parsed?.data?.current,
737
+ parsed?.data?.session,
738
+ parsed?.data?.result?.current,
739
+ parsed?.data?.result?.session,
740
+ parsed?.data?.stats,
741
+ parsed?.payload?.current,
742
+ parsed?.payload?.session,
743
+ parsed?.payload?.result?.current,
744
+ parsed?.payload?.result?.session,
745
+ parsed?.payload?.data?.current,
746
+ parsed?.payload?.data?.session,
747
+ parsed?.payload?.data?.stats,
748
+ parsed?.status?.current,
749
+ parsed?.status?.session,
750
+ parsed?.status?.result?.current,
751
+ parsed?.status?.result?.session,
752
+ parsed?.status?.data?.current,
753
+ parsed?.status?.data?.session,
754
+ parsed?.status?.data?.stats,
755
+ parsed
756
+ ]
757
+
758
+ const usage = usageCandidates.reduce((found, candidate) => {
759
+ if (found) return found
760
+ const envelope = extractUsageEnvelope(candidate)
761
+ return envelope && typeof envelope === 'object' ? envelope : null
762
+ }, null)
763
+
764
+ const usageRecord = Array.isArray(usage) ? pickBestRecentSession(usage) : usage
765
+
766
+ const usageTotals = usageRecord?.totals || usageRecord?.summary || usageRecord?.usageTotals || usageRecord?.usage?.totals || usageRecord?.usage?.summary
767
+ const model = pickString(
768
+ parsed?.model,
769
+ parsed?.default_model,
770
+ parsed?.modelName,
771
+ parsed?.model_name,
772
+ parsed?.status?.model,
773
+ parsed?.status?.default_model,
774
+ parsed?.status?.modelName,
775
+ parsed?.status?.model_name,
776
+ usageRecord?.model,
777
+ usageRecord?.modelName,
778
+ usageRecord?.model_name,
779
+ usageRecord?.defaultModel,
780
+ usageRecord?.default_model,
781
+ usageTotals?.model,
782
+ usageTotals?.modelName,
783
+ usageTotals?.model_name,
784
+ usageTotals?.defaultModel,
785
+ usageTotals?.default_model,
786
+ parsed?.result?.model,
787
+ parsed?.result?.modelName,
788
+ parsed?.result?.model_name,
789
+ parsed?.data?.model,
790
+ parsed?.data?.modelName,
791
+ parsed?.data?.model_name,
792
+ parsed?.data?.defaultModel,
793
+ parsed?.data?.default_model,
794
+ parsed?.payload?.model,
795
+ parsed?.payload?.modelName,
796
+ parsed?.payload?.model_name,
797
+ parsed?.payload?.defaultModel,
798
+ parsed?.payload?.default_model
799
+ )
800
+ const totalTokens = pickNumber(
801
+ usageRecord?.totalTokens,
802
+ usageRecord?.total_tokens,
803
+ usageRecord?.tokenCount,
804
+ usageRecord?.token_count,
805
+ usageRecord?.tokens,
806
+ usageRecord?.tokenUsage?.total,
807
+ usageRecord?.token_usage?.total,
808
+ usageRecord?.tokens?.total,
809
+ usageRecord?.tokens?.sum,
810
+ usageRecord?.inputTokens && usageRecord?.outputTokens ? usageRecord.inputTokens + usageRecord.outputTokens : null,
811
+ usageRecord?.input_tokens && usageRecord?.output_tokens ? usageRecord.input_tokens + usageRecord.output_tokens : null,
812
+ usageTotals?.total,
813
+ usageTotals?.totalTokens,
814
+ usageTotals?.total_tokens,
815
+ usageTotals?.tokenCount,
816
+ usageTotals?.token_count,
817
+ usageTotals?.tokens?.total,
818
+ usageTotals?.tokens?.sum,
819
+ usageTotals?.inputTokens && usageTotals?.outputTokens ? usageTotals.inputTokens + usageTotals.outputTokens : null,
820
+ usageTotals?.input_tokens && usageTotals?.output_tokens ? usageTotals.input_tokens + usageTotals.output_tokens : null,
821
+ parsed?.totals?.total,
822
+ parsed?.totals?.tokenCount,
823
+ parsed?.totals?.total_tokens
824
+ )
825
+ const tokensPerMin = pickNumber(
826
+ usageRecord?.tokensPerMinute,
827
+ usageRecord?.tokens_per_minute,
828
+ usageRecord?.tpm,
829
+ usageRecord?.tokenRate,
830
+ usageRecord?.requestsPerMinute,
831
+ usageRecord?.rps,
832
+ usageRecord?.tokens?.perMin,
833
+ usageRecord?.tokens?.perMinute,
834
+ usageTotals?.tokensPerMinute,
835
+ usageTotals?.tokens_per_minute,
836
+ usageTotals?.rps,
837
+ parsed?.rps,
838
+ parsed?.requestsPerMinute
839
+ )
840
+
841
+ if (model === null && totalTokens === null && tokensPerMin === null) return null
842
+
843
+ return {
844
+ model,
845
+ totalTokens,
846
+ tokensPerMin,
847
+ sessionId: pickString(
848
+ parsed?.sessionId,
849
+ parsed?.session_id,
850
+ parsed?.payload?.sessionId,
851
+ parsed?.payload?.session_id,
852
+ usageRecord?.sessionId,
853
+ usageRecord?.session_id,
854
+ usageRecord?.id
855
+ ),
856
+ agentId: pickString(
857
+ parsed?.agentId,
858
+ parsed?.agent_id,
859
+ parsed?.payload?.agentId,
860
+ parsed?.payload?.agent_id,
861
+ usageRecord?.agentId,
862
+ usageRecord?.agent_id
863
+ ),
864
+ usageTimestampMs: pickTimestamp(
865
+ usageRecord?.updatedAt,
866
+ usageRecord?.updated_at,
867
+ usageRecord?.updatedAtMs,
868
+ usageRecord?.ts,
869
+ usageRecord?.time,
870
+ usageRecord?.timestamp,
871
+ usageRecord?.tsMs,
872
+ usageRecord?.timestampMs,
873
+ usageRecord?.usageTs,
874
+ usageRecord?.usageTsMs,
875
+ usageRecord?.usageTimestamp,
876
+ usageRecord?.usage_timestamp,
877
+ usageRecord?.usage_timestamp_ms,
878
+ usageRecord?.usage_time,
879
+ usageRecord?.usageTimestampMs,
880
+ usageTotals?.updatedAt,
881
+ usageTotals?.updated_at,
882
+ usageTotals?.updatedAtMs,
883
+ usageTotals?.updated_at_ms,
884
+ usageTotals?.ts,
885
+ usageTotals?.time,
886
+ usageTotals?.timestamp,
887
+ usageTotals?.timestampMs,
888
+ usageTotals?.tsMs,
889
+ usageTotals?.usageTs,
890
+ usageTotals?.usageTsMs,
891
+ usageTotals?.usageTimestamp,
892
+ usageTotals?.usage_timestamp,
893
+ usageTotals?.usage_timestamp_ms,
894
+ usageTotals?.usage_time,
895
+ parsed?.payload?.updatedAt,
896
+ parsed?.payload?.updated_at,
897
+ parsed?.payload?.updatedAtMs,
898
+ parsed?.payload?.ts,
899
+ parsed?.payload?.time,
900
+ parsed?.payload?.timestamp,
901
+ parsed?.payload?.tsMs,
902
+ parsed?.payload?.usageTs,
903
+ parsed?.payload?.usageTsMs,
904
+ parsed?.payload?.usageTimestamp,
905
+ parsed?.payload?.usage_timestamp,
906
+ parsed?.payload?.usage_timestamp_ms,
907
+ parsed?.payload?.usage_time,
908
+ parsed?.payload?.usageTimestampMs,
909
+ parsed?.ts,
910
+ parsed?.time,
911
+ parsed?.timestamp,
912
+ parsed?.tsMs,
913
+ parsed?.timestampMs,
914
+ parsed?.usageTs,
915
+ parsed?.usageTimestamp,
916
+ parsed?.usage_timestamp,
917
+ parsed?.usage_timestamp_ms,
918
+ parsed?.usage_time,
919
+ parsed?.updatedAt,
920
+ parsed?.updated_at,
921
+ parsed?.updatedAtMs,
922
+ parsed?.updated_at_ms,
923
+ parsed?.status?.updatedAt,
924
+ parsed?.status?.updated_at,
925
+ parsed?.status?.updatedAtMs,
926
+ parsed?.status?.updated_at_ms,
927
+ parsed?.status?.timestamp,
928
+ parsed?.status?.time,
929
+ parsed?.status?.ts,
930
+ parsed?.status?.tsMs,
931
+ parsed?.status?.usageTs,
932
+ parsed?.status?.usageTimestamp,
933
+ parsed?.status?.usage_timestamp,
934
+ parsed?.status?.usage_timestamp_ms,
935
+ parsed?.status?.usage_time,
936
+ parsed?.status?.usageTsMs
937
+ ),
938
+ integrationStatus: 'ok'
939
+ }
940
+ }
941
+
942
+ function usageCandidateScore(usage) {
943
+ if (!usage) return -1
944
+
945
+ let score = usage.integrationStatus === 'ok' ? 8 : 1
946
+
947
+ if (usage.model !== null) score += 2
948
+ if (usage.totalTokens !== null) score += 2
949
+ if (usage.tokensPerMin !== null) score += 1
950
+ if (usage.sessionId !== null) score += 1
951
+ if (usage.agentId !== null) score += 1
952
+ if (usage.usageTimestampMs !== null) score += 1
953
+
954
+ return score
955
+ }
956
+
957
+ export function parseOpenClawUsage(raw) {
958
+ if (!raw) return null
959
+
960
+ const candidates = extractJsonCandidates(raw)
961
+ if (candidates.length === 0) return null
962
+
963
+ let bestMatch = null
964
+ let bestScore = -1
965
+ let bestSource = null
966
+ let hasError = false
967
+
968
+ const compareByRecency = (a, b) => {
969
+ if (!a || !b) return 0
970
+ const aTs = a.usageTimestampMs
971
+ const bTs = b.usageTimestampMs
972
+ if (aTs === null && bTs === null) return 0
973
+ if (aTs === null) return -1
974
+ if (bTs === null) return 1
975
+ if (aTs === bTs) return 0
976
+ return aTs > bTs ? 1 : -1
977
+ }
978
+
979
+ for (const candidate of candidates) {
980
+ let parsed
981
+ try {
982
+ parsed = JSON.parse(candidate)
983
+ } catch {
984
+ hasError = true
985
+ continue
986
+ }
987
+
988
+ const normalized = normalizeOpenClawAliases(parsed)
989
+ const fromStatus = parseFromStatusJson(normalized)
990
+ if (fromStatus) {
991
+ const score = usageCandidateScore(fromStatus)
992
+ if (
993
+ score > bestScore ||
994
+ (score === bestScore && compareByRecency(fromStatus, bestMatch) > 0) ||
995
+ (score === bestScore && bestSource === 'generic' && compareByRecency(fromStatus, bestMatch) === 0)
996
+ ) {
997
+ bestMatch = fromStatus
998
+ bestScore = score
999
+ bestSource = 'status'
1000
+ }
1001
+ }
1002
+
1003
+ const fromGeneric = parseGenericUsage(normalized)
1004
+ if (fromGeneric) {
1005
+ const score = usageCandidateScore(fromGeneric)
1006
+ if (
1007
+ score > bestScore ||
1008
+ (score === bestScore && compareByRecency(fromGeneric, bestMatch) > 0) ||
1009
+ (score === bestScore && compareByRecency(fromGeneric, bestMatch) === 0 && bestSource !== 'generic')
1010
+ ) {
1011
+ bestMatch = fromGeneric
1012
+ bestScore = score
1013
+ bestSource = 'generic'
1014
+ }
1015
+ }
1016
+
1017
+ }
1018
+
1019
+ return hasError && bestMatch === null ? null : bestMatch
1020
+ }