modelstat 0.0.38 → 0.0.41

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.38",
3
+ "version": "0.0.41",
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",
@@ -69,6 +69,7 @@
69
69
  "pack:tarball": "pnpm run build && npm pack --pack-destination ../..",
70
70
  "install:local": "bash ./scripts/install-local.sh",
71
71
  "postinstall": "node ./scripts/postinstall.mjs",
72
- "typecheck": "tsc --noEmit"
72
+ "typecheck": "tsc --noEmit",
73
+ "test": "node --import tsx --test src/**/*.test.ts"
73
74
  }
74
75
  }
@@ -132,7 +132,6 @@ async function main() {
132
132
 
133
133
  async function rebootServiceIfInstalled() {
134
134
  const stateDir = join(homedir(), ".modelstat");
135
- const installedBundle = join(stateDir, "bin", "modelstat.mjs");
136
135
  const launchdPlist = join(
137
136
  homedir(),
138
137
  "Library",
@@ -186,31 +185,29 @@ async function rebootServiceIfInstalled() {
186
185
  // first, escalate to SIGKILL if it's still around 2 s later.
187
186
  await killStaleDaemon(stateDir);
188
187
 
189
- // Copy the new bundle over the old `~/.modelstat/bin/modelstat.mjs`
190
- // so the service supervisor (launchd/systemd) loads the new code on
191
- // next launch. The fresh bundle's `connect` / `start` does this on
192
- // its own first run, but we want the service to come back alive
193
- // immediately not wait for a manual `modelstat connect`.
194
- const { copyFileSync, mkdirSync } = await import("node:fs");
195
- try {
196
- mkdirSync(dirname(installedBundle), { recursive: true });
197
- copyFileSync(freshBundle, installedBundle);
198
- } catch (err) {
188
+ // Re-stage the installed bundle AND its native summariser runtime in
189
+ // ONE canonical step by spawning the freshly-unpacked bundle's own
190
+ // `_setup-runtime` command. That runs the SAME service.ts staging the
191
+ // `modelstat connect` path uses: it copies dist/cli.mjs over
192
+ // ~/.modelstat/bin/modelstat.mjs and `npm install`s the complete
193
+ // node-llama-cpp closure (incl. this platform's prebuilt binary) into
194
+ // ~/.modelstat/bin/node_modules. freshBundle runs from the just-
195
+ // unpacked npm tree, so its installNativeRuntime() pins the version.
196
+ //
197
+ // This replaces the old hand-copy + symlink-the-npm-cache approach,
198
+ // whose symlinks dangled the moment the cache/global was pruned — the
199
+ // opposite of self-contained. One mechanism, every platform.
200
+ console.log("[modelstat] staging new bundle + native runtime…");
201
+ const setup = spawnSync(process.execPath, [freshBundle, "_setup-runtime"], {
202
+ stdio: "inherit",
203
+ timeout: 300_000,
204
+ });
205
+ if (setup.status !== 0) {
199
206
  console.warn(
200
- `[modelstat] couldn't refresh installed bundle: ${err && err.message ? err.message : err}`,
207
+ `[modelstat] couldn't stage the new bundle/runtime (exit ${setup.status ?? "?"}); the service may still be on the previous build`,
201
208
  );
202
209
  }
203
210
 
204
- // The bundle is portable EXCEPT for `node-llama-cpp` and its
205
- // platform-specific native sub-packages — they're marked external
206
- // in tsup so they're resolved at runtime via Node's `node_modules`
207
- // walk-up. From `~/.modelstat/bin/modelstat.mjs` there's no parent
208
- // `node_modules` to find them in, so the bundled summariser dies
209
- // at preflight with "Cannot find package 'node-llama-cpp'". Set up
210
- // a sibling `node_modules` directory containing the runtime
211
- // dependencies, sourced from this package's own install location.
212
- await setupNativeNodeModules(installedBundle);
213
-
214
211
  // Start back up. `modelstat start` re-runs preflight (incl. the
215
212
  // processing-version reconcile that wipes cursors when we ship a
216
213
  // new pipeline), so the upgrade picks up the new behaviour
@@ -224,98 +221,6 @@ async function rebootServiceIfInstalled() {
224
221
  console.log("[modelstat] ✓ background service restarted with new build");
225
222
  }
226
223
 
227
- /**
228
- * Make `node-llama-cpp` and its platform-specific native sibling
229
- * packages reachable from `~/.modelstat/bin/modelstat.mjs` so the
230
- * bundle's runtime `import("node-llama-cpp")` resolves.
231
- *
232
- * Strategy:
233
- * - Find the source `node_modules` directory in this package's
234
- * install (npm flat layout puts it at <pkg>/.. for global
235
- * installs; pnpm/bun layouts vary but the same walk-up works
236
- * since postinstall always runs from the package root).
237
- * - For each runtime dep we care about (`node-llama-cpp` plus
238
- * every `@node-llama-cpp/*` and `@reflink/*` sub-package),
239
- * symlink it into `~/.modelstat/bin/node_modules/<name>`.
240
- * Symlinks are free + fast; if symlinking fails (filesystem
241
- * boundary, permissions) fall back to a recursive copy with
242
- * `dereference:true` so the resulting tree has no dangling
243
- * refs into the source location.
244
- *
245
- * Idempotent: each upgrade clears the destination first.
246
- */
247
- async function setupNativeNodeModules(installedBundle) {
248
- const here = dirname(fileURLToPath(import.meta.url));
249
- // Find a parent `node_modules` containing `node-llama-cpp`. The
250
- // walk handles npm flat (<prefix>/lib/node_modules), pnpm flat
251
- // (<prefix>/lib/node_modules with peer hoisting), and the npx
252
- // cache layout (~/.npm/_npx/<hash>/node_modules).
253
- let srcNodeModules = null;
254
- for (let dir = here, i = 0; i < 8; i++) {
255
- const probe = join(dir, "node_modules", "node-llama-cpp", "package.json");
256
- if (existsSync(probe)) {
257
- srcNodeModules = join(dir, "node_modules");
258
- break;
259
- }
260
- const up = resolve(dir, "..");
261
- if (up === dir) break;
262
- dir = up;
263
- }
264
- if (!srcNodeModules) {
265
- console.warn(
266
- "[modelstat] couldn't locate node-llama-cpp in this install — bundled summariser will fail at preflight. Re-install with `npm i -g modelstat` or report a bug.",
267
- );
268
- return;
269
- }
270
-
271
- const fs = await import("node:fs/promises");
272
- const destDir = join(dirname(installedBundle), "node_modules");
273
- await fs.mkdir(destDir, { recursive: true });
274
-
275
- // Discover every native package we need: the main one + every
276
- // platform-specific sibling (the binary blob is in one of the
277
- // `@node-llama-cpp/*` packages chosen by node-llama-cpp's
278
- // optional-dependencies resolution at install time).
279
- const wanted = new Set(["node-llama-cpp"]);
280
- const all = await fs
281
- .readdir(srcNodeModules, { withFileTypes: true })
282
- .catch(() => []);
283
- for (const e of all) {
284
- if (!e.isDirectory()) continue;
285
- if (!e.name.startsWith("@")) continue;
286
- if (
287
- e.name === "@node-llama-cpp" ||
288
- e.name === "@reflink"
289
- ) {
290
- // Scoped dirs contain multiple packages — copy/symlink the
291
- // whole scope dir so all platform variants are reachable.
292
- wanted.add(e.name);
293
- }
294
- }
295
-
296
- for (const pkg of wanted) {
297
- const src = join(srcNodeModules, pkg);
298
- const dest = join(destDir, pkg);
299
- if (!existsSync(src)) continue;
300
- try {
301
- await fs.rm(dest, { recursive: true, force: true });
302
- } catch {
303
- /* ignore */
304
- }
305
- try {
306
- await fs.symlink(src, dest, "dir");
307
- } catch {
308
- try {
309
- await fs.cp(src, dest, { recursive: true, dereference: true });
310
- } catch (err2) {
311
- console.warn(
312
- `[modelstat] couldn't link ${pkg}: ${err2 && err2.message ? err2.message : err2}`,
313
- );
314
- }
315
- }
316
- }
317
- }
318
-
319
224
  /**
320
225
  * Kill any modelstat-related process the service supervisor didn't
321
226
  * reap. Two passes:
@@ -95,6 +95,12 @@ struct LocalStatsCounters: Decodable {
95
95
  let files_unchanged: Int?
96
96
  let events_uploaded: Int?
97
97
  let batches_uploaded: Int?
98
+ /// Lifetime count of cognition segments uploaded from this machine
99
+ /// (persisted by the daemon, so it survives restarts).
100
+ let segments_sent: Int?
101
+ /// Segments in the batch being uploaded right now — a gauge that
102
+ /// drops back to 0 between bursts. Usually 0 when idle.
103
+ let segments_sending: Int?
98
104
  }
99
105
 
100
106
  @MainActor
@@ -105,7 +111,15 @@ final class TrayController: NSObject {
105
111
  private var daemon: Process?
106
112
  private var paused = false
107
113
  private var latest: AgentStats?
108
- private var pollTimer: Timer?
114
+ /// Live local heartbeat, read straight from ~/.modelstat/last-status.json
115
+ /// on the fast timer. Decoupled from `latest` (the slower, network-backed
116
+ /// `stats --json` shell-out) so the menu's numbers move every second.
117
+ private var localLatest: LocalStatus?
118
+ /// Advances once per fast tick to drive the "alive" pulse on the status
119
+ /// line — a cheap, honest signal that the agent is doing work right now.
120
+ private var spinnerTick = 0
121
+ private var fastTimer: Timer?
122
+ private var slowTimer: Timer?
109
123
 
110
124
  // Menu items we update on every poll
111
125
  private let statusMI = NSMenuItem(title: "Loading…", action: nil, keyEquivalent: "")
@@ -128,9 +142,28 @@ final class TrayController: NSObject {
128
142
  buildMenu()
129
143
  startDaemon()
130
144
  refreshStats()
131
- pollTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
132
- Task { @MainActor in self?.refreshStats() }
145
+ tickLocal()
146
+
147
+ // Two cadences, both registered in `.common` mode so they keep firing
148
+ // while the menu is open and being tracked — a plain
149
+ // `Timer.scheduledTimer` only runs in `.default` mode, so the dropdown
150
+ // froze the moment you opened it. That alone made a working agent look
151
+ // stuck.
152
+ // · fast (1s): read last-status.json directly — cheap, no subprocess
153
+ // — and re-render so phase/segment numbers tick live.
154
+ // · slow (15s): shell out to `modelstat stats --json` for the
155
+ // network-backed paired/claimed/device/analyzed data that barely
156
+ // changes (and, for claimed devices, costs a 404 round-trip).
157
+ let fast = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
158
+ MainActor.assumeIsolated { self?.tickLocal() }
133
159
  }
160
+ RunLoop.main.add(fast, forMode: .common)
161
+ fastTimer = fast
162
+ let slow = Timer(timeInterval: 15.0, repeats: true) { [weak self] _ in
163
+ MainActor.assumeIsolated { self?.refreshStats() }
164
+ }
165
+ RunLoop.main.add(slow, forMode: .common)
166
+ slowTimer = slow
134
167
  }
135
168
 
136
169
  private func configureStatusItem() {
@@ -148,13 +181,23 @@ final class TrayController: NSObject {
148
181
  statusItem.button?.toolTip = "modelstat"
149
182
  }
150
183
 
184
+ /// The five non-clickable info rows at the top of the menu, in order.
185
+ private var infoItems: [NSMenuItem] {
186
+ [statusMI, deviceMI, analyzedMI, pipelineMI, detectedMI]
187
+ }
188
+
189
+ /// Set an info row's title, hiding the row when the title is empty.
190
+ /// A disabled `NSMenuItem` with an empty title still takes a full
191
+ /// row of height — that was the stray blank space under "Claimed ✓".
192
+ private func setInfo(_ item: NSMenuItem, _ title: String) {
193
+ item.title = title
194
+ item.isHidden = title.isEmpty
195
+ }
196
+
151
197
  private func buildMenu() {
152
- statusMI.isEnabled = false
153
- deviceMI.isEnabled = false
154
- analyzedMI.isEnabled = false
155
- pipelineMI.isEnabled = false
156
- detectedMI.isEnabled = false
157
- for mi in [statusMI, deviceMI, analyzedMI, pipelineMI, detectedMI] {
198
+ for mi in infoItems {
199
+ mi.isEnabled = false
200
+ mi.isHidden = mi.title.isEmpty
158
201
  menu.addItem(mi)
159
202
  }
160
203
  menu.addItem(NSMenuItem.separator())
@@ -231,6 +274,26 @@ final class TrayController: NSObject {
231
274
  daemon = nil
232
275
  }
233
276
 
277
+ // ── Live local heartbeat (fast path) ────────────────────────────
278
+
279
+ /// Read ~/.modelstat/last-status.json directly and re-render. Runs every
280
+ /// second from a `.common`-mode timer, so it updates the dropdown even
281
+ /// while it's open. No subprocess, no network — just a few-KB JSON read.
282
+ /// The file's top-level shape is the daemon's heartbeat body, which
283
+ /// matches `LocalStatus`, so we decode it straight.
284
+ private func tickLocal() {
285
+ guard !paused else { return }
286
+ spinnerTick &+= 1
287
+ let path = ("~/.modelstat/last-status.json" as NSString).expandingTildeInPath
288
+ if let data = FileManager.default.contents(atPath: path),
289
+ let ls = try? JSONDecoder().decode(LocalStatus.self, from: data)
290
+ {
291
+ localLatest = ls
292
+ }
293
+ // Re-render even if the read failed so the pulse keeps moving.
294
+ renderStats()
295
+ }
296
+
234
297
  // ── Polling `modelstat stats --json` ────────────────────────────
235
298
 
236
299
  private func refreshStats() {
@@ -268,16 +331,19 @@ final class TrayController: NSObject {
268
331
  }
269
332
 
270
333
  private func renderStats() {
334
+ // Paused: togglePaused() owns the status line ("Paused"); don't let the
335
+ // fast tick clobber it with a stale phase from the file.
336
+ guard !paused else { return }
271
337
  guard let s = latest else {
272
- statusMI.title = "Loading…"
338
+ setInfo(statusMI, "Loading…")
273
339
  return
274
340
  }
341
+ // Prefer the live file read (fast timer) over the network shell-out's
342
+ // embedded copy, which can be up to 15s stale.
343
+ let local = localLatest ?? s.local
275
344
  if s.paired == false {
276
- statusMI.title = "Not paired — run `npx modelstat@latest`"
277
- deviceMI.title = ""
278
- analyzedMI.title = ""
279
- pipelineMI.title = ""
280
- detectedMI.title = ""
345
+ setInfo(statusMI, "Not paired — run `npx modelstat@latest`")
346
+ for mi in [deviceMI, analyzedMI, pipelineMI, detectedMI] { setInfo(mi, "") }
281
347
  claimMI.title = "Open modelstat.ai"
282
348
  copyClaimMI.isHidden = true
283
349
  return
@@ -287,26 +353,36 @@ final class TrayController: NSObject {
287
353
  // Falls back to the device-view's reported agent_status. If
288
354
  // both are missing we say "running" rather than "starting" so
289
355
  // 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
356
+ let phase = local?.status ?? s.device?.agent_status ?? "running"
357
+ let phaseMsg = local?.message
358
+ // Pulse the leading dot while the agent is actively working so the
359
+ // menu reads as alive even on the rare beat where the numbers don't
360
+ // change. Steady dot when idle/watching/offline.
361
+ let dot = isActivePhase(phase) ? (spinnerTick % 2 == 0 ? "●" : "○") : "●"
292
362
  if let m = phaseMsg, !m.isEmpty {
293
- statusMI.title = " \(phase) — \(m)"
363
+ setInfo(statusMI, "\(dot) \(phase) — \(m)")
294
364
  } else {
295
- statusMI.title = " \(phase)"
365
+ setInfo(statusMI, "\(dot) \(phase)")
296
366
  }
297
367
 
298
368
  if s.claimed == true {
299
369
  // Claimed device: device-view 404s for the tray (no auth) so
300
370
  // 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"
371
+ // numbers, plus point the menu items at the dashboard. Keep the
372
+ // reassuring "Claimed ✓" and append the agent version when the
373
+ // local snapshot carries it.
374
+ if let v = local?.agent_version, !v.isEmpty {
375
+ setInfo(deviceMI, "Claimed ✓ · \(v)")
376
+ } else {
377
+ setInfo(deviceMI, "Claimed ✓ — synced to your account")
378
+ }
303
379
  claimMI.title = "Open dashboard"
304
380
  copyClaimMI.isHidden = true
305
381
  } else {
306
382
  // Unclaimed: device-view fills in the rich numbers.
307
383
  let host = s.device?.hostname ?? "unknown"
308
384
  let os = s.device?.os_family ?? ""
309
- deviceMI.title = "\(host) · \(os)"
385
+ setInfo(deviceMI, "\(host) · \(os)")
310
386
  claimMI.title = "Open device page"
311
387
  copyClaimMI.isHidden = (s.claim_url == nil || s.claim_url?.isEmpty == true)
312
388
  }
@@ -321,43 +397,58 @@ final class TrayController: NSObject {
321
397
  let proc = a.processing ?? 0
322
398
  let done = a.finished ?? cnt
323
399
  let breakdown = proc > 0 ? " (\(done) finished · \(proc) processing)" : ""
324
- analyzedMI.title = "\(cnt) sessions\(breakdown) · \(fmtTokens(tok)) tokens · $\(usd)"
400
+ setInfo(analyzedMI, "\(cnt) sessions\(breakdown) · \(fmtTokens(tok)) tokens · $\(usd)")
325
401
  } else {
326
- analyzedMI.title = ""
402
+ setInfo(analyzedMI, "")
327
403
  }
328
404
 
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 {
405
+ // Pipeline activity — segments are the headline (what the user
406
+ // asked to see): how many are uploading right now, and how many
407
+ // have been sent in total. Events / files trail as context. All
408
+ // sourced from the local heartbeat mirror so it works for both
409
+ // claimed and unclaimed devices.
410
+ if let c = local?.stats {
411
+ let sending = c.segments_sending ?? 0
412
+ let sent = c.segments_sent ?? 0
333
413
  let events = c.events_uploaded ?? 0
334
- let batches = c.batches_uploaded ?? 0
335
414
  let scanned = c.files_scanned ?? 0
336
- let queue = s.local?.queue_size ?? 0
415
+ let queue = local?.queue_size ?? 0
337
416
  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") }
417
+ if sending > 0 { bits.append("↑ \(sending) sending") }
418
+ if sent > 0 { bits.append("\(fmtCount(sent)) segments sent") }
419
+ if events > 0 { bits.append("\(fmtCount(events)) events") }
420
+ if scanned > 0 { bits.append("\(scanned) files") }
342
421
  if queue > 0 { bits.append("\(queue) in queue") }
343
- pipelineMI.title = bits.isEmpty ? "" : bits.joined(separator: " · ")
422
+ setInfo(pipelineMI, bits.joined(separator: " · "))
344
423
  } else {
345
- pipelineMI.title = ""
424
+ setInfo(pipelineMI, "")
346
425
  }
347
426
 
348
427
  // What the agent found on this machine — installations +
349
428
  // identities (Claude Keychain, Codex JWT, …). Mirror of the
350
429
  // discover() output the daemon ran at startup.
351
- if let c = s.local?.stats {
430
+ if let c = local?.stats {
352
431
  let installs = c.installations_detected ?? 0
353
432
  let ids = c.identities_detected ?? 0
354
433
  if installs > 0 || ids > 0 {
355
- detectedMI.title = "\(installs) tools · \(ids) accounts detected"
434
+ setInfo(detectedMI, "\(installs) tools · \(ids) accounts detected")
356
435
  } else {
357
- detectedMI.title = ""
436
+ setInfo(detectedMI, "")
358
437
  }
359
438
  } else {
360
- detectedMI.title = ""
439
+ setInfo(detectedMI, "")
440
+ }
441
+ }
442
+
443
+ /// Phases where the agent is doing visible work right now — drives the
444
+ /// pulsing status dot. "watching"/"idle" are healthy-but-quiet (steady
445
+ /// dot); "offline"/"error" are problems (steady dot, not a busy pulse).
446
+ private func isActivePhase(_ phase: String) -> Bool {
447
+ switch phase {
448
+ case "starting", "discovering", "scanning", "processing", "uploading":
449
+ return true
450
+ default:
451
+ return false
361
452
  }
362
453
  }
363
454
 
@@ -427,7 +518,8 @@ final class TrayController: NSObject {
427
518
 
428
519
  @objc private func quit() {
429
520
  stopDaemon()
430
- pollTimer?.invalidate()
521
+ fastTimer?.invalidate()
522
+ slowTimer?.invalidate()
431
523
  NSApp.terminate(nil)
432
524
  }
433
525
  }