modelstat 0.0.29 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelstat",
3
- "version": "0.0.29",
3
+ "version": "0.0.30",
4
4
  "description": "modelstat companion — reads local AI-tool usage and ships tokenised events to modelstat.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -54,6 +54,12 @@ struct AgentStats: Decodable {
54
54
  let agent_url: String?
55
55
  let device: DeviceInfo?
56
56
  let analyzed: AnalyzedInfo?
57
+ /// Daemon heartbeat snapshot mirrored to ~/.modelstat/last-status.json.
58
+ /// Present on both unclaimed and claimed responses post-0.0.30 so the
59
+ /// tray can show real numbers even when the public device-view 404s
60
+ /// (claimed devices). Older daemons that don't write the file leave
61
+ /// this nil.
62
+ let local: LocalStatus?
57
63
  }
58
64
 
59
65
  struct DeviceInfo: Decodable {
@@ -65,10 +71,32 @@ struct DeviceInfo: Decodable {
65
71
 
66
72
  struct AnalyzedInfo: Decodable {
67
73
  let count: Int?
74
+ /// `processing` = sessions with at least one un-classified segment;
75
+ /// `finished` = every segment classified. From device-view endpoint.
76
+ let processing: Int?
77
+ let finished: Int?
68
78
  let totalTokens: String?
69
79
  let totalCostUsd: Double?
70
80
  }
71
81
 
82
+ struct LocalStatus: Decodable {
83
+ let status: String?
84
+ let message: String?
85
+ let queue_size: Int?
86
+ let last_event_at: String?
87
+ let agent_version: String?
88
+ let stats: LocalStatsCounters?
89
+ }
90
+
91
+ struct LocalStatsCounters: Decodable {
92
+ let installations_detected: Int?
93
+ let identities_detected: Int?
94
+ let files_scanned: Int?
95
+ let files_unchanged: Int?
96
+ let events_uploaded: Int?
97
+ let batches_uploaded: Int?
98
+ }
99
+
72
100
  @MainActor
73
101
  final class TrayController: NSObject {
74
102
  private let statusItem: NSStatusItem
@@ -80,9 +108,13 @@ final class TrayController: NSObject {
80
108
  private var pollTimer: Timer?
81
109
 
82
110
  // Menu items we update on every poll
83
- private let statusMI = NSMenuItem(title: "Starting…", action: nil, keyEquivalent: "")
111
+ private let statusMI = NSMenuItem(title: "Loading…", action: nil, keyEquivalent: "")
84
112
  private let deviceMI = NSMenuItem(title: "", action: nil, keyEquivalent: "")
85
113
  private let analyzedMI = NSMenuItem(title: "", action: nil, keyEquivalent: "")
114
+ /// Pipeline activity — sessions processing/finished + events uploaded.
115
+ private let pipelineMI = NSMenuItem(title: "", action: nil, keyEquivalent: "")
116
+ /// What the agent has discovered on this machine — installations + identities.
117
+ private let detectedMI = NSMenuItem(title: "", action: nil, keyEquivalent: "")
86
118
  private let claimMI = NSMenuItem(title: "Open device page", action: #selector(openDashboard), keyEquivalent: "o")
87
119
  private let copyClaimMI = NSMenuItem(title: "Copy claim URL", action: #selector(copyClaimUrl), keyEquivalent: "c")
88
120
  private let jobsMI = NSMenuItem(title: "View pipeline…", action: #selector(openJobs), keyEquivalent: "j")
@@ -120,7 +152,11 @@ final class TrayController: NSObject {
120
152
  statusMI.isEnabled = false
121
153
  deviceMI.isEnabled = false
122
154
  analyzedMI.isEnabled = false
123
- for mi in [statusMI, deviceMI, analyzedMI] { menu.addItem(mi) }
155
+ pipelineMI.isEnabled = false
156
+ detectedMI.isEnabled = false
157
+ for mi in [statusMI, deviceMI, analyzedMI, pipelineMI, detectedMI] {
158
+ menu.addItem(mi)
159
+ }
124
160
  menu.addItem(NSMenuItem.separator())
125
161
  claimMI.target = self
126
162
  copyClaimMI.target = self
@@ -233,41 +269,102 @@ final class TrayController: NSObject {
233
269
 
234
270
  private func renderStats() {
235
271
  guard let s = latest else {
236
- statusMI.title = "pairing…"
272
+ statusMI.title = "Loading…"
237
273
  return
238
274
  }
239
275
  if s.paired == false {
240
- statusMI.title = "not paired — run `modelstat connect`"
276
+ statusMI.title = "Not paired — run `npx modelstat@latest`"
241
277
  deviceMI.title = ""
242
278
  analyzedMI.title = ""
279
+ pipelineMI.title = ""
280
+ detectedMI.title = ""
243
281
  claimMI.title = "Open modelstat.ai"
244
282
  copyClaimMI.isHidden = true
245
283
  return
246
284
  }
285
+
286
+ // Live agent phase comes from the local heartbeat mirror.
287
+ // Falls back to the device-view's reported agent_status. If
288
+ // both are missing we say "running" rather than "starting" so
289
+ // the menu doesn't lie about the daemon's state.
290
+ let phase = s.local?.status ?? s.device?.agent_status ?? "running"
291
+ let phaseMsg = s.local?.message
292
+ if let m = phaseMsg, !m.isEmpty {
293
+ statusMI.title = "● \(phase) — \(m)"
294
+ } else {
295
+ statusMI.title = "● \(phase)"
296
+ }
297
+
247
298
  if s.claimed == true {
248
- statusMI.title = "Claimed ✓"
249
- deviceMI.title = "Synced to your account"
250
- analyzedMI.title = ""
299
+ // Claimed device: device-view 404s for the tray (no auth) so
300
+ // we lean entirely on the local heartbeat snapshot for live
301
+ // numbers, plus point the menu items at the dashboard.
302
+ deviceMI.title = s.local?.agent_version ?? "Claimed ✓ — synced to your account"
251
303
  claimMI.title = "Open dashboard"
252
304
  copyClaimMI.isHidden = true
253
- return
305
+ } else {
306
+ // Unclaimed: device-view fills in the rich numbers.
307
+ let host = s.device?.hostname ?? "unknown"
308
+ let os = s.device?.os_family ?? ""
309
+ deviceMI.title = "\(host) · \(os)"
310
+ claimMI.title = "Open device page"
311
+ copyClaimMI.isHidden = (s.claim_url == nil || s.claim_url?.isEmpty == true)
254
312
  }
255
- // Unclaimed: show the rich summary from the stats payload.
256
- let status = s.device?.agent_status ?? "ready"
257
- statusMI.title = "Agent: \(status)"
258
- let host = s.device?.hostname ?? "unknown"
259
- let os = s.device?.os_family ?? ""
260
- deviceMI.title = "\(host) · \(os)"
313
+
314
+ // Sessions / tokens / cost (only available for unclaimed since
315
+ // the device-view exposes them). Claimed devices show pipeline
316
+ // + detected counts instead — see below.
261
317
  if let a = s.analyzed {
262
318
  let tok = a.totalTokens ?? "0"
263
319
  let cnt = a.count ?? 0
264
320
  let usd = String(format: "%.2f", a.totalCostUsd ?? 0.0)
265
- analyzedMI.title = "\(cnt) sessions · \(fmtTokens(tok)) tokens · $\(usd)"
321
+ let proc = a.processing ?? 0
322
+ let done = a.finished ?? cnt
323
+ let breakdown = proc > 0 ? " (\(done) finished · \(proc) processing)" : ""
324
+ analyzedMI.title = "\(cnt) sessions\(breakdown) · \(fmtTokens(tok)) tokens · $\(usd)"
325
+ } else {
326
+ analyzedMI.title = ""
327
+ }
328
+
329
+ // Pipeline activity — events + batches uploaded since daemon
330
+ // started. Sourced from the local heartbeat mirror so it works
331
+ // for both claimed and unclaimed devices.
332
+ if let c = s.local?.stats {
333
+ let events = c.events_uploaded ?? 0
334
+ let batches = c.batches_uploaded ?? 0
335
+ let scanned = c.files_scanned ?? 0
336
+ let queue = s.local?.queue_size ?? 0
337
+ var bits: [String] = []
338
+ if events > 0 || batches > 0 {
339
+ bits.append("\(fmtCount(events)) events · \(batches) batches uploaded")
340
+ }
341
+ if scanned > 0 { bits.append("\(scanned) files scanned") }
342
+ if queue > 0 { bits.append("\(queue) in queue") }
343
+ pipelineMI.title = bits.isEmpty ? "" : bits.joined(separator: " · ")
266
344
  } else {
267
- analyzedMI.title = "no activity yet"
345
+ pipelineMI.title = ""
268
346
  }
269
- claimMI.title = "Open device page"
270
- copyClaimMI.isHidden = (s.claim_url == nil || s.claim_url?.isEmpty == true)
347
+
348
+ // What the agent found on this machine — installations +
349
+ // identities (Claude Keychain, Codex JWT, …). Mirror of the
350
+ // discover() output the daemon ran at startup.
351
+ if let c = s.local?.stats {
352
+ let installs = c.installations_detected ?? 0
353
+ let ids = c.identities_detected ?? 0
354
+ if installs > 0 || ids > 0 {
355
+ detectedMI.title = "\(installs) tools · \(ids) accounts detected"
356
+ } else {
357
+ detectedMI.title = ""
358
+ }
359
+ } else {
360
+ detectedMI.title = ""
361
+ }
362
+ }
363
+
364
+ private func fmtCount(_ n: Int) -> String {
365
+ if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) }
366
+ if n >= 1_000 { return String(format: "%.1fK", Double(n) / 1_000) }
367
+ return String(n)
271
368
  }
272
369
 
273
370
  private func fmtTokens(_ raw: String) -> String {