free-coding-models 0.5.59 → 0.5.60

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.
@@ -0,0 +1,541 @@
1
+ /**
2
+ * @file runtime-telemetry.js
3
+ * @description Per-model runtime metrics captured by the daemon on every routed request.
4
+ *
5
+ * @details
6
+ * 📖 Why this exists:
7
+ * 📖 SWE-bench / tier scores are static and self-reported by providers — a model
8
+ * 📖 can advertise 82% on SWE-bench and still fail 50% of real requests through a
9
+ * 📖 flaky free-tier gateway. We track what *actually* happens on the wire:
10
+ * 📖 - real success rate (success vs error calls)
11
+ * 📖 - real avg tokens/second (throughput under our real traffic)
12
+ * 📖 - recent calls (last 50, capped) for debugging
13
+ *
14
+ * 📖 Distinct from src/core/telemetry.js (which is product analytics sent
15
+ * 📖 upstream to PostHog). This file is local-only by default and never leaves
16
+ * 📖 the user's machine.
17
+ *
18
+ * 📖 Persistence: ~/.free-coding-models/runtime-telemetry.json (separate from
19
+ * 📖 the probe-cache at probe-cache.json). Atomic write via shared-helpers.
20
+ *
21
+ * 📖 Derived fields (computed on read, never persisted):
22
+ * 📖 avgLatencyMs = totalLatencyMs / totalCalls
23
+ * 📖 avgTokensPerSecond = totalCompletionTokens / (totalLatencyMs / 1000)
24
+ * 📖 successRate = successCalls / totalCalls
25
+ *
26
+ * @functions
27
+ * → getRuntimeTelemetryPath() — Resolves the JSON file path
28
+ * → loadRuntimeTelemetry({ path, now }?) — Reads + validates the file
29
+ * → flushRuntimeTelemetry({ path, cache, now }?) — Atomic write
30
+ * → clearRuntimeTelemetry({ path }?) — Nuke the file (for --clear-runtime)
31
+ * → recordModelCall(providerKey, modelId, callResult, opts?) → mutates in-memory cache
32
+ * → getModelTelemetry(providerKey, modelId, opts?) → ModelTelemetry | null
33
+ * → getAllModelTelemetry(opts?) → Record<key, ModelTelemetry>
34
+ * → getRealWorldScore(providerKey, modelId, opts?) → 0..100 | null (null below MIN_CALLS)
35
+ * → getCacheStats(opts?) → aggregate counts for footer
36
+ * → pruneStaleEntries(maxAgeMs, opts?) → drop models not seen recently
37
+ *
38
+ * @exports getRuntimeTelemetryPath, loadRuntimeTelemetry, flushRuntimeTelemetry,
39
+ * clearRuntimeTelemetry, recordModelCall, getModelTelemetry,
40
+ * getAllModelTelemetry, getRealWorldScore, getCacheStats,
41
+ * pruneStaleEntries, DEFAULT_MIN_CALLS_FOR_SCORE, DEFAULT_REAL_WORLD_WEIGHTS,
42
+ * MAX_RECENT_CALLS
43
+ *
44
+ * @see src/core/telemetry.js — product analytics (separate concern)
45
+ * @see src/core/probe-cache.js — persistent probe-cache (t1, separate file)
46
+ * @see src/core/shared-helpers.js — atomicWriteJson (used by flushRuntimeTelemetry)
47
+ */
48
+
49
+ import fs from 'node:fs'
50
+ import os from 'node:os'
51
+ import path from 'node:path'
52
+ import { atomicWriteJson } from './shared-helpers.js'
53
+
54
+ // ─── Constants ────────────────────────────────────────────────────────────────
55
+
56
+ /** 📖 Below this many routed calls, getRealWorldScore returns null (not enough signal). */
57
+ export const DEFAULT_MIN_CALLS_FOR_SCORE = 5
58
+
59
+ /** 📖 recentCalls FIFO cap. Worst-case size: 238 models × 50 × ~200B ≈ 2.4 MB. */
60
+ export const MAX_RECENT_CALLS = 50
61
+
62
+ /** 📖 Composite-score weights. Tune with vava before shipping wider. */
63
+ export const DEFAULT_REAL_WORLD_WEIGHTS = Object.freeze({
64
+ success: 0.60,
65
+ speed: 0.25,
66
+ recency: 0.15,
67
+ })
68
+
69
+ /** 📖 Filename lives in the same dir as the probe-cache for discoverability. */
70
+ const TELEMETRY_FILENAME = 'runtime-telemetry.json'
71
+ const STATE_DIRNAME = 'free-coding-models'
72
+
73
+ // ─── Module-level state ──────────────────────────────────────────────────────
74
+
75
+ /**
76
+ * 📖 In-memory mirror of the on-disk file. Loaded lazily by the first function
77
+ * 📖 that needs it (recordModelCall / getModelTelemetry / etc.).
78
+ */
79
+ let _cache = null
80
+ let _cacheLoadedFrom = null
81
+
82
+ // ─── Path resolution ──────────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * 📖 Resolves where the runtime-telemetry JSON lives. Honours XDG_CACHE_HOME
86
+ * 📖 when set, else falls back to ~/.free-coding-models/. The schema is local-only
87
+ * 📖 so we put it under ~/.cache (the same dir as the probe-cache) — not under
88
+ * 📖 ~/.config which is reserved for shipped config the user expects to back up.
89
+ *
90
+ * @returns {string} Absolute path to the file (may not exist yet).
91
+ */
92
+ export function getRuntimeTelemetryPath() {
93
+ const xdg = process.env.XDG_CACHE_HOME
94
+ const baseDir = xdg && xdg.trim()
95
+ ? path.join(xdg, STATE_DIRNAME)
96
+ : path.join(os.homedir(), `.${STATE_DIRNAME}`)
97
+ return path.join(baseDir, TELEMETRY_FILENAME)
98
+ }
99
+
100
+ // ─── Low-level load / flush / clear ──────────────────────────────────────────
101
+
102
+ function emptyCache() {
103
+ return {
104
+ version: 1,
105
+ models: {},
106
+ lastUpdated: 0,
107
+ }
108
+ }
109
+
110
+ /**
111
+ * 📖 Read the runtime-telemetry JSON from disk. Returns an empty cache on any
112
+ * 📖 I/O or parse error — never crashes the daemon on a corrupt file.
113
+ *
114
+ * @param {object} [opts]
115
+ * @param {string} [opts.path] — Override the file path (mainly for tests).
116
+ * @returns {object} The loaded cache.
117
+ */
118
+ export function loadRuntimeTelemetry({ path: telemetryPath } = {}) {
119
+ const target = telemetryPath ?? getRuntimeTelemetryPath()
120
+ let raw
121
+ try {
122
+ raw = fs.readFileSync(target, 'utf-8')
123
+ } catch (err) {
124
+ if (err && err.code === 'ENOENT') return emptyCache()
125
+ return emptyCache()
126
+ }
127
+
128
+ let parsed
129
+ try {
130
+ parsed = JSON.parse(raw)
131
+ } catch {
132
+ return emptyCache()
133
+ }
134
+
135
+ if (!parsed || typeof parsed !== 'object') return emptyCache()
136
+ if (typeof parsed.version !== 'number') parsed.version = 1
137
+ if (!parsed.models || typeof parsed.models !== 'object') parsed.models = {}
138
+ if (typeof parsed.lastUpdated !== 'number') parsed.lastUpdated = 0
139
+
140
+ // 📖 Walk every model entry and normalise.
141
+ for (const [key, entry] of Object.entries(parsed.models)) {
142
+ if (!entry || typeof entry !== 'object') {
143
+ delete parsed.models[key]
144
+ continue
145
+ }
146
+ entry.totalCalls = Number(entry.totalCalls) || 0
147
+ entry.successCalls = Number(entry.successCalls) || 0
148
+ entry.errorCalls = Number(entry.errorCalls) || 0
149
+ entry.totalTokens = Number(entry.totalTokens) || 0
150
+ entry.totalPromptTokens = Number(entry.totalPromptTokens) || 0
151
+ entry.totalCompletionTokens = Number(entry.totalCompletionTokens) || 0
152
+ entry.totalLatencyMs = Number(entry.totalLatencyMs) || 0
153
+ entry.totalCost = Number(entry.totalCost) || 0
154
+ if (!Array.isArray(entry.recentCalls)) entry.recentCalls = []
155
+ if (typeof entry.lastUpdated !== 'number') entry.lastUpdated = 0
156
+ // 📖 Backfill derived: split totalCalls into success+error if they're 0.
157
+ if (entry.totalCalls > 0 && entry.successCalls === 0 && entry.errorCalls === 0) {
158
+ // 📖 Unknown break-down — treat all as success to preserve the count signal.
159
+ entry.successCalls = entry.totalCalls
160
+ }
161
+ }
162
+
163
+ _cache = parsed
164
+ _cacheLoadedFrom = target
165
+ return parsed
166
+ }
167
+
168
+ /**
169
+ * 📖 Persist the in-memory cache to disk atomically (tmp + rename). Uses the
170
+ * 📖 same read-merge-write pattern as probe-cache.flushCache so the daemon +
171
+ * 📖 CLI can share the file safely across processes.
172
+ *
173
+ * @param {object} [opts]
174
+ * @param {string} [opts.path]
175
+ * @param {object} [opts.cache]
176
+ * @returns {boolean} true on success, false on I/O error.
177
+ */
178
+ export function flushRuntimeTelemetry({ path: telemetryPath, cache } = {}) {
179
+ const target = telemetryPath ?? _cacheLoadedFrom ?? getRuntimeTelemetryPath()
180
+ const localData = cache ?? _cache ?? emptyCache()
181
+ localData.lastUpdated = Date.now()
182
+
183
+ let onDisk = null
184
+ try {
185
+ const raw = fs.readFileSync(target, 'utf-8')
186
+ onDisk = JSON.parse(raw)
187
+ if (!onDisk || typeof onDisk !== 'object') onDisk = null
188
+ } catch {
189
+ onDisk = null
190
+ }
191
+
192
+ // 📖 Merge: per-model, our deltas win on key collision.
193
+ const merged = onDisk && typeof onDisk === 'object' ? onDisk : { version: 1, models: {}, lastUpdated: 0 }
194
+ if (!merged.models || typeof merged.models !== 'object') merged.models = {}
195
+ for (const [key, entry] of Object.entries(localData.models)) {
196
+ const base = merged.models[key]
197
+ if (base && typeof base === 'object') {
198
+ // 📖 Merge counters + append recent calls (caller-side already FIFO'd).
199
+ merged.models[key] = {
200
+ ...base,
201
+ ...entry,
202
+ recentCalls: entry.recentCalls, // 📖 entry already has the full new FIFO list
203
+ lastUpdated: Math.max(base.lastUpdated || 0, entry.lastUpdated || 0),
204
+ }
205
+ } else {
206
+ merged.models[key] = entry
207
+ }
208
+ }
209
+ merged.lastUpdated = Date.now()
210
+
211
+ try {
212
+ atomicWriteJson(target, merged, 0o600)
213
+ _cacheLoadedFrom = target
214
+ _cache = merged
215
+ return true
216
+ } catch {
217
+ return false
218
+ }
219
+ }
220
+
221
+ /**
222
+ * 📖 Delete the runtime-telemetry file. Used by `--clear-runtime` and tests.
223
+ *
224
+ * @param {object} [opts]
225
+ * @param {string} [opts.path]
226
+ * @returns {boolean}
227
+ */
228
+ export function clearRuntimeTelemetry({ path: telemetryPath } = {}) {
229
+ const target = telemetryPath ?? getRuntimeTelemetryPath()
230
+ _cache = null
231
+ _cacheLoadedFrom = null
232
+ try {
233
+ fs.unlinkSync(target)
234
+ return true
235
+ } catch (err) {
236
+ if (err && err.code === 'ENOENT') return true
237
+ return false
238
+ }
239
+ }
240
+
241
+ // ─── Module-state accessor ───────────────────────────────────────────────────
242
+
243
+ function getCache(opts) {
244
+ if (opts && Object.prototype.hasOwnProperty.call(opts, 'cache')) return opts.cache
245
+ if (_cache) return _cache
246
+ return loadRuntimeTelemetry()
247
+ }
248
+
249
+ // ─── Write path ───────────────────────────────────────────────────────────────
250
+
251
+ /**
252
+ * 📖 Validate + normalise a single callResult into a recordCall shape.
253
+ * 📖 Throws on garbage so recordModelCall can drop it cleanly.
254
+ */
255
+ function normaliseCallResult(r) {
256
+ if (!r || typeof r !== 'object') throw new Error('callResult must be an object')
257
+ const out = {
258
+ success: r.success === true,
259
+ latencyMs: typeof r.latencyMs === 'number' && Number.isFinite(r.latencyMs) ? r.latencyMs : 0,
260
+ promptTokens: typeof r.promptTokens === 'number' && Number.isFinite(r.promptTokens) ? Math.max(0, Math.floor(r.promptTokens)) : 0,
261
+ completionTokens: typeof r.completionTokens === 'number' && Number.isFinite(r.completionTokens) ? Math.max(0, Math.floor(r.completionTokens)) : 0,
262
+ stopReason: typeof r.stopReason === 'string' ? r.stopReason : null,
263
+ error: r.success === true ? null : (typeof r.error === 'string' ? r.error : 'unknown'),
264
+ }
265
+ out.totalTokens = out.promptTokens + out.completionTokens
266
+ return out
267
+ }
268
+
269
+ /**
270
+ * 📖 recordModelCall: append one routed request's outcome to the per-model store.
271
+ * 📖 Updates the in-memory cache (caller schedules a flush). The recentCalls FIFO
272
+ * 📖 is trimmed to MAX_RECENT_CALLS to bound the file size.
273
+ *
274
+ * @param {string} providerKey
275
+ * @param {string} modelId
276
+ * @param {{ success: boolean, latencyMs?: number, promptTokens?: number, completionTokens?: number, stopReason?: string, error?: string }} callResult
277
+ * @param {object} [opts]
278
+ * @param {number} [opts.now=Date.now()]
279
+ * @param {object} [opts.cache] — Optional explicit cache to mutate (skips module state).
280
+ * @returns {{ written: boolean, error?: string }}
281
+ */
282
+ export function recordModelCall(providerKey, modelId, callResult, opts = {}) {
283
+ if (!providerKey || typeof providerKey !== 'string') return { written: false, error: 'invalid providerKey' }
284
+ if (!modelId || typeof modelId !== 'string') return { written: false, error: 'invalid modelId' }
285
+ const now = opts.now ?? Date.now()
286
+ const cache = (opts && Object.prototype.hasOwnProperty.call(opts, 'cache')) ? opts.cache : getCache(opts)
287
+ const key = `${providerKey}/${modelId}`
288
+
289
+ let r
290
+ try {
291
+ r = normaliseCallResult(callResult)
292
+ } catch (err) {
293
+ return { written: false, error: err?.message || 'invalid callResult' }
294
+ }
295
+
296
+ let entry = cache.models[key]
297
+ if (!entry) {
298
+ entry = {
299
+ providerKey,
300
+ modelId,
301
+ totalCalls: 0,
302
+ successCalls: 0,
303
+ errorCalls: 0,
304
+ totalTokens: 0,
305
+ totalPromptTokens: 0,
306
+ totalCompletionTokens: 0,
307
+ totalLatencyMs: 0,
308
+ totalCost: 0,
309
+ recentCalls: [],
310
+ lastUpdated: 0,
311
+ }
312
+ cache.models[key] = entry
313
+ }
314
+
315
+ entry.totalCalls += 1
316
+ if (r.success) entry.successCalls += 1
317
+ else entry.errorCalls += 1
318
+ entry.totalTokens += r.totalTokens
319
+ entry.totalPromptTokens += r.promptTokens
320
+ entry.totalCompletionTokens += r.completionTokens
321
+ entry.totalLatencyMs += r.latencyMs
322
+ entry.lastUpdated = now
323
+
324
+ // 📖 FIFO trim — push to the front so recentCalls[0] is the newest.
325
+ const call = {
326
+ timestamp: now,
327
+ provider: providerKey,
328
+ model: modelId,
329
+ success: r.success,
330
+ latencyMs: r.latencyMs,
331
+ promptTokens: r.promptTokens,
332
+ completionTokens: r.completionTokens,
333
+ totalTokens: r.totalTokens,
334
+ tokensPerSecond: r.latencyMs > 0 ? r.completionTokens / (r.latencyMs / 1000) : 0,
335
+ stopReason: r.stopReason,
336
+ error: r.error,
337
+ }
338
+ entry.recentCalls.unshift(call)
339
+ if (entry.recentCalls.length > MAX_RECENT_CALLS) {
340
+ entry.recentCalls.length = MAX_RECENT_CALLS // 📖 truncate to cap
341
+ }
342
+
343
+ if (!opts || !Object.prototype.hasOwnProperty.call(opts, 'cache')) {
344
+ _cache = cache
345
+ }
346
+
347
+ return { written: true }
348
+ }
349
+
350
+ // ─── Read path ────────────────────────────────────────────────────────────────
351
+
352
+ /**
353
+ * 📖 Derived metrics computed on read (never persisted).
354
+ * @typedef {object} ModelTelemetry
355
+ * @property {string} providerKey
356
+ * @property {string} modelId
357
+ * @property {number} totalCalls
358
+ * @property {number} successCalls
359
+ * @property {number} errorCalls
360
+ * @property {number} successRate 0..1
361
+ * @property {number} totalTokens
362
+ * @property {number} totalPromptTokens
363
+ * @property {number} totalCompletionTokens
364
+ * @property {number} totalLatencyMs
365
+ * @property {number} avgLatencyMs 0 if totalCalls===0
366
+ * @property {number} avgTokensPerSecond 0 if totalLatencyMs===0
367
+ * @property {Array} recentCalls most-recent first, capped at MAX_RECENT_CALLS
368
+ * @property {number} lastUpdated ms epoch
369
+ */
370
+
371
+ /**
372
+ * 📖 getModelTelemetry: read the per-model telemetry snapshot + derived metrics.
373
+ *
374
+ * @param {string} providerKey
375
+ * @param {string} modelId
376
+ * @param {object} [opts]
377
+ * @returns {ModelTelemetry | null}
378
+ */
379
+ export function getModelTelemetry(providerKey, modelId, opts = {}) {
380
+ if (!providerKey || !modelId) return null
381
+ const cache = getCache(opts)
382
+ const key = `${providerKey}/${modelId}`
383
+ const entry = cache.models[key]
384
+ if (!entry) return null
385
+ return deriveMetrics(entry)
386
+ }
387
+
388
+ /**
389
+ * 📖 Same as getModelTelemetry but for every model at once.
390
+ * 📖 Returns an object keyed by `${providerKey}/${modelId}` for cheap lookup.
391
+ *
392
+ * @param {object} [opts]
393
+ * @returns {Record<string, ModelTelemetry>}
394
+ */
395
+ export function getAllModelTelemetry(opts = {}) {
396
+ const cache = getCache(opts)
397
+ const out = {}
398
+ for (const [key, entry] of Object.entries(cache.models)) {
399
+ if (!entry) continue
400
+ out[key] = deriveMetrics(entry)
401
+ }
402
+ return out
403
+ }
404
+
405
+ /**
406
+ * 📖 Internal: derive successRate / avgLatencyMs / avgTokensPerSecond from raw counters.
407
+ */
408
+ function deriveMetrics(entry) {
409
+ const totalCalls = entry.totalCalls || 0
410
+ const totalLatencyMs = entry.totalLatencyMs || 0
411
+ const totalCompletionTokens = entry.totalCompletionTokens || 0
412
+ return {
413
+ providerKey: entry.providerKey,
414
+ modelId: entry.modelId,
415
+ totalCalls,
416
+ successCalls: entry.successCalls || 0,
417
+ errorCalls: entry.errorCalls || 0,
418
+ successRate: totalCalls > 0 ? (entry.successCalls || 0) / totalCalls : 0,
419
+ totalTokens: entry.totalTokens || 0,
420
+ totalPromptTokens: entry.totalPromptTokens || 0,
421
+ totalCompletionTokens,
422
+ totalLatencyMs,
423
+ avgLatencyMs: totalCalls > 0 ? totalLatencyMs / totalCalls : 0,
424
+ avgTokensPerSecond: totalLatencyMs > 0 ? totalCompletionTokens / (totalLatencyMs / 1000) : 0,
425
+ recentCalls: Array.isArray(entry.recentCalls) ? entry.recentCalls : [],
426
+ lastUpdated: entry.lastUpdated || 0,
427
+ }
428
+ }
429
+
430
+ // ─── Real-world score (the ranking signal) ───────────────────────────────────
431
+
432
+ /**
433
+ * 📖 sigmoid01: squashes (0, +inf) into (0, 1) so 50 tok/s = 0.5, 200 tok/s ≈ 0.95.
434
+ * 📖 Tuned so that the typical free-tier sweet spot (30-80 tok/s) maps to 0.4-0.7.
435
+ */
436
+ function sigmoid01(x, midpoint = 50) {
437
+ // 📖 Logistic curve shifted so midpoint -> 0.5.
438
+ const k = 0.04 // 📖 steepness — 50 -> 0.5, 100 -> ~0.88, 200 -> ~0.99
439
+ return 1 / (1 + Math.exp(-k * (x - midpoint)))
440
+ }
441
+
442
+ /**
443
+ * 📖 recencyDecay: 1.0 today, ~0.5 after 7 days, ~0.0 after 30 days.
444
+ */
445
+ function recencyDecay(lastUpdatedMs, now = Date.now()) {
446
+ if (!lastUpdatedMs) return 0
447
+ const ageDays = (now - lastUpdatedMs) / (24 * 60 * 60 * 1000)
448
+ if (ageDays < 0) return 1
449
+ if (ageDays >= 30) return 0
450
+ return Math.max(0, 1 - ageDays / 30)
451
+ }
452
+
453
+ /**
454
+ * 📖 getRealWorldScore: composite 0..100 score for ranking, or null when below
455
+ * 📖 the minimum-call threshold (so brand-new models don't get punished).
456
+ *
457
+ * 📖 Formula: successRate * 0.60 + speedScore * 0.25 + recencyBonus * 0.15
458
+ * 📖 - successRate: 0..1 (straightforward)
459
+ * 📖 - speedScore: sigmoid01(avgTokensPerSecond) maps 50 tok/s -> 0.5
460
+ * 📖 - recencyBonus: recencyDecay(lastUpdatedMs) -> 1.0 today, ~0 after 30d
461
+ *
462
+ * @param {string} providerKey
463
+ * @param {string} modelId
464
+ * @param {object} [opts]
465
+ * @param {number} [opts.minCalls=DEFAULT_MIN_CALLS_FOR_SCORE]
466
+ * @param {object} [opts.weights=DEFAULT_REAL_WORLD_WEIGHTS]
467
+ * @returns {number | null}
468
+ */
469
+ export function getRealWorldScore(providerKey, modelId, opts = {}) {
470
+ const minCalls = opts.minCalls ?? DEFAULT_MIN_CALLS_FOR_SCORE
471
+ const weights = opts.weights ?? DEFAULT_REAL_WORLD_WEIGHTS
472
+ const m = getModelTelemetry(providerKey, modelId, opts)
473
+ if (!m || m.totalCalls < minCalls) return null
474
+ const now = opts.now ?? Date.now()
475
+ const successRate = m.successRate
476
+ const speedScore = sigmoid01(m.avgTokensPerSecond)
477
+ const recencyBonus = recencyDecay(m.lastUpdated, now)
478
+ const score = (successRate * weights.success) + (speedScore * weights.speed) + (recencyBonus * weights.recency)
479
+ return Math.round(Math.max(0, Math.min(1, score)) * 100)
480
+ }
481
+
482
+ // ─── Stats + pruning ──────────────────────────────────────────────────────────
483
+
484
+ /**
485
+ * 📖 getCacheStats: aggregate counts for the TUI footer + /stats endpoint.
486
+ *
487
+ * @param {object} [opts]
488
+ * @returns {{
489
+ * modelsTracked: number,
490
+ * totalCalls: number,
491
+ * successCalls: number,
492
+ * errorCalls: number,
493
+ * modelsWithSignal: number, // totalCalls >= DEFAULT_MIN_CALLS_FOR_SCORE
494
+ * }}
495
+ */
496
+ export function getCacheStats(opts = {}) {
497
+ const cache = getCache(opts)
498
+ const minCalls = opts.minCalls ?? DEFAULT_MIN_CALLS_FOR_SCORE
499
+ let totalCalls = 0, successCalls = 0, errorCalls = 0, modelsWithSignal = 0
500
+ for (const entry of Object.values(cache.models)) {
501
+ if (!entry) continue
502
+ totalCalls += entry.totalCalls || 0
503
+ successCalls += entry.successCalls || 0
504
+ errorCalls += entry.errorCalls || 0
505
+ if ((entry.totalCalls || 0) >= minCalls) modelsWithSignal++
506
+ }
507
+ return {
508
+ modelsTracked: Object.keys(cache.models).length,
509
+ totalCalls,
510
+ successCalls,
511
+ errorCalls,
512
+ modelsWithSignal,
513
+ }
514
+ }
515
+
516
+ /**
517
+ * 📖 pruneStaleEntries: drop entries not updated within maxAgeMs. Called on
518
+ * 📖 daemon boot to keep the file bounded as the catalog evolves.
519
+ *
520
+ * @param {number} maxAgeMs
521
+ * @param {object} [opts]
522
+ * @param {object} [opts.cache]
523
+ * @param {number} [opts.now=Date.now()]
524
+ * @returns {number} Number of entries pruned.
525
+ */
526
+ export function pruneStaleEntries(maxAgeMs, opts = {}) {
527
+ const cache = getCache(opts)
528
+ const now = opts.now ?? Date.now()
529
+ let pruned = 0
530
+ for (const [key, entry] of Object.entries(cache.models)) {
531
+ if (!entry) continue
532
+ if (!entry.lastUpdated || now - entry.lastUpdated > maxAgeMs) {
533
+ delete cache.models[key]
534
+ pruned++
535
+ }
536
+ }
537
+ if (!opts || !Object.prototype.hasOwnProperty.call(opts, 'cache')) {
538
+ _cache = cache
539
+ }
540
+ return pruned
541
+ }
package/src/core/utils.js CHANGED
@@ -380,6 +380,15 @@ export const sortResults = (results, sortColumn, sortDirection, { benchmarkResul
380
380
  // 📖 via JS stable sort preserving original order when values are equal
381
381
  cmp = (a.usagePercent ?? 0) - (b.usagePercent ?? 0)
382
382
  break
383
+ case 'realworld':
384
+ // 📖 Sort by real-world score (t3) — composite of success rate, throughput,
385
+ // 📖 and recency. Models with insufficient data (r.realWorldScore === null)
386
+ // 📖 sort to the bottom in BOTH directions (treat null as -Infinity for asc,
387
+ // 📖 so high-to-low puts the score-havers first regardless of null).
388
+ const aRW = typeof a.realWorldScore === 'number' ? a.realWorldScore : -Infinity
389
+ const bRW = typeof b.realWorldScore === 'number' ? b.realWorldScore : -Infinity
390
+ cmp = aRW - bRW
391
+ break
383
392
  }
384
393
 
385
394
  // 📖 Flip comparison for descending order
@@ -586,6 +595,10 @@ export function parseArgs(argv) {
586
595
  // 📖 --recommend — launch directly into Smart Recommend mode (Q key equivalent)
587
596
  const recommendMode = flags.includes('--recommend')
588
597
 
598
+ // 📖 --clear-runtime — wipe ~/.free-coding-models/runtime-telemetry.json (t3).
599
+ // 📖 Useful when the user wants to reset the real-world-score baseline.
600
+ const clearRuntimeMode = flags.includes('--clear-runtime')
601
+
589
602
  // 📖 Probe-cache flags (t1): --reprobe / --no-cache force a fresh probe pass;
590
603
  // 📖 --probe-ttl overrides the 24h default; --show-broken un-hides broken models for this run.
591
604
  const reprobeMode = flags.includes('--reprobe') || flags.includes('--no-cache')
@@ -644,6 +657,8 @@ export function parseArgs(argv) {
644
657
  reprobeMode,
645
658
  probeTtlMs: Number.isFinite(probeTtlMs) && probeTtlMs > 0 ? probeTtlMs : null,
646
659
  showBrokenMode,
660
+ // 📖 Runtime telemetry flag (t3) — see src/core/runtime-telemetry.js
661
+ clearRuntimeMode,
647
662
  }
648
663
  }
649
664
 
package/src/tui/app.js CHANGED
@@ -507,6 +507,23 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
507
507
  state.probeCacheMisses = probeCacheMisses
508
508
  state.probeCacheBrokenHidden = state.results.filter(r => r.cachedBroken && r.hidden).length
509
509
 
510
+ // 📖 Runtime telemetry (t3): load the per-model metrics file + compute the
511
+ // 📖 real-world score for each result so the table can sort/display it.
512
+ // 📖 The file may not exist yet (no daemon traffic) — that's fine, every
513
+ // 📖 r.realWorldScore stays null and we fall back to SWE-bench.
514
+ try {
515
+ const { loadRuntimeTelemetry, getRealWorldScore } = await import('../core/runtime-telemetry.js')
516
+ loadRuntimeTelemetry() // 📖 populate module-level cache
517
+ for (const r of state.results) {
518
+ const score = getRealWorldScore(r.providerKey, r.modelId)
519
+ r.realWorldScore = score // null when below MIN_CALLS_FOR_SCORE
520
+ }
521
+ state.runtimeTelemetryLoaded = true
522
+ } catch (err) {
523
+ state.runtimeTelemetryLoaded = false
524
+ state.runtimeTelemetryError = err?.message || String(err)
525
+ }
526
+
510
527
  // 📖 Define pingModel before JSON mode so `--json` can reuse the same provider-aware
511
528
  // 📖 ping path as the interactive TUI without waiting for the PTY/render loop setup.
512
529
  pingModel = async (r) => {